Initial patt implementation

This commit is contained in:
Dirk Alders 2020-01-26 21:04:45 +01:00
parent ad633124f0
commit c834976e79
80 changed files with 3150 additions and 0 deletions

105
__init__.py Normal file
View File

@ -0,0 +1,105 @@
from django.urls.base import reverse
from django.utils.translation import gettext as _
def back_url(request, addition):
return request.path + addition
def url_current(request):
return request.GET.get('next', request.get_full_path())
def url_tasklist(request, user_filter_id=None, search_txt=None, common_filter_id=None):
if user_filter_id is not None:
return reverse('patt-userfilter', kwargs={'user_filter_id': user_filter_id})
elif search_txt is not None:
return reverse('search') + '?q=%s' % search_txt
elif common_filter_id is not None:
return reverse('patt-commonfilter', kwargs={'common_filter_id': common_filter_id})
else:
return reverse('patt-tasklist')
def url_projectlist(request):
return reverse('patt-projectlist')
def url_helpview(request, page):
return reverse('patt-helpview', kwargs={'page': page})
def url_tasknew(request, project_id):
nxt = url_current(request)
return reverse('patt-tasknew') + '?next=%s' % nxt + ('&project_id=%s' % project_id if project_id is not None else '')
def url_projectnew(request):
nxt = url_current(request)
return reverse('patt-projectnew') + '?next=%s' % nxt
def url_taskedit(request, task_id):
nxt = url_current(request)
return reverse('patt-taskedit', kwargs={'task_id': task_id}) + '?do=edit&next=%s' % nxt
def url_projectedit(request, project_id):
nxt = url_current(request)
return reverse('patt-projectedit', kwargs={'project_id': project_id}) + '?next=%s' % nxt
def url_commentnew(request, task_id):
nxt = url_current(request)
return reverse('patt-commentnew', kwargs={'task_id': task_id}) + '?next=%s' % nxt
def url_easysearch(request):
return reverse('patt-easysearch')
def url_taskset(request, task_id, **kwargs):
nxt = url_current(request)
if kwargs.get('priority') is not None:
return reverse('patt-taskedit', kwargs={'task_id': task_id}) + '?do=set_priority&priority=%s&next=%s' % (kwargs.get('priority'), nxt)
elif kwargs.get('state') is not None:
return reverse('patt-taskedit', kwargs={'task_id': task_id}) + '?do=set_state&state=%s&next=%s' % (kwargs.get('state'), nxt)
else:
raise Exception('Required keyword missing. One of "priority", "state" is not in %s.' % repr(kwargs.keys()))
def url_filteredit(request, search_id=None):
if search_id is None:
if get_search_query(request) is None:
return reverse('patt-filternew')
else:
return reverse('patt-filternew') + '?q=%s' % get_search_query(request)
else:
return reverse('patt-filteredit', kwargs={'search_id': search_id})
def url_printview(request):
if not is_printview(request):
return '?printview'
else:
return request.path
def get_search_query(request):
return request.GET.get('q')
def is_printview(request):
return 'printview' in request.GET
def is_projectlistview(request):
return request.META['PATH_INFO'].startswith(reverse('patt-projectlist'))
def is_tasklistview(request, search_id=None):
if request.META['PATH_INFO'].startswith(url_tasklist('patt-tasklist', search_id)):
return True
if search_id is None and get_search_query(request) is not None:
return True
return False

208
access.py Normal file
View File

@ -0,0 +1,208 @@
import logging
from .models import Task, Project, Comment, TASKSTATE_CHOICES, TASKS_IN_WORK, PROJECTS_IN_WORK, PRIO_CHOICES
logger = logging.getLogger('ACC')
def read_attachment(request, rel_path):
item_type, item_id = rel_path.split('/')[1:3]
try:
item_id = int(item_id)
except ValueError:
return False
if item_type == 'task':
acc = acc_task(Task.objects.get(id=item_id), request.user)
return acc.read
elif item_type == 'comment':
acc = acc_task(Comment.objects.get(id=item_id).task, request.user)
return acc.read_comments
elif item_type == 'project':
acc = acc_project(Project.objects.get(id=item_id), request.user)
return acc.read
else:
return False
def modify_attachment(request, rel_path):
item_type, item_id = rel_path.split('/')[1:3]
try:
item_id = int(item_id)
except ValueError:
return False
if item_type == 'task':
acc = acc_task(Task.objects.get(id=item_id), request.user)
return acc.modify or acc.modify_limited
elif item_type == 'comment':
comment = Comment.objects.get(id=item_id)
acc = acc_task(comment.task, request.user)
return request.user == comment.user or acc.modify_comment
elif item_type == 'project':
acc = acc_project(Project.objects.get(id=item_id), request.user)
return acc.modify or acc.modify_limited
else:
return False
class acc_task(object):
def __init__(self, task, user):
self.task = task
self.user = user
self.__read__ = None
self.__modify__ = None
self.__modify_limited__ = None
self.__add_comment__ = None
self.__modify_comment__ = None
self.user_has_leader_rights = user in task.project.role_leader.all() and user.is_staff
self.user_has_memeber_rights = user in task.project.role_member.all() and user.is_staff
self.user_has_visitor_rights = user in task.project.role_visitor.all() and user.is_staff
self.user_has_role_rights = self.user_has_leader_rights or self.user_has_memeber_rights or self.user_has_visitor_rights
self.user_is_assigned_user = user == task.assigned_user
@property
def read(self):
if self.__read__ is None:
if self.user.is_superuser:
logger.debug('acc_task.read: Access granted (Task #%d). User is Superuser.', self.task.id)
self.__read__ = True
elif self.user_is_assigned_user and self.task.state in TASKS_IN_WORK:
logger.debug('acc_task.read: Access granted (Task #%d). User is Taskowner and taskstate is open or finished.', self.task.id)
self.__read__ = True
elif self.user_has_role_rights:
logger.debug('acc_task.read: Access granted (Task #%d). User has a role and is Staff.', self.task.id)
self.__read__ = True
else:
logger.debug('acc_task.read: Access denied (Task #%d).', self.task.id)
self.__read__ = False
return self.__read__
@property
def read_comments(self):
return self.read
@property
def modify_limited(self):
if self.__modify_limited__ is None:
if self.user_is_assigned_user and self.user.is_staff and self.task.state in TASKS_IN_WORK:
logger.debug('acc_task.modify_limited: Access granted (Task #%d). User is Taskowner and taskstate is open or finished.', self.task.id)
self.__modify_limited__ = True
else:
logger.debug('acc_task.modify_limited: Access denied (Task #%d).', self.task.id)
self.__modify_limited__ = False
return self.__modify_limited__
@property
def modify(self):
if self.__modify__ is None:
if self.user.is_superuser:
logger.debug('acc_task.modify: Access granted (Task #%d). User is Superuser.', self.task.id)
self.__modify__ = True
elif self.user_has_leader_rights:
logger.debug('acc_task.modify: Access granted (Task #%d). User is Projectleader and staff.', self.task.id)
self.__modify__ = True
else:
logger.debug('acc_task.modify: Access denied (Task #%d).', self.task.id)
self.__modify__ = False
return self.__modify__
@property
def add_comments(self):
if self.__add_comment__ is None:
if self.user.is_superuser:
logger.debug('acc_task.add_comments: Access granted (Task #%d). User is Superuser.', self.task.id)
self.__add_comment__ = True
elif (self.user_has_leader_rights or self.user_has_memeber_rights) and self.task.state in TASKS_IN_WORK:
logger.debug('acc_task.add_comments: Access granted (Task #%d). User is Staff, has role in the project and the task state is open or finished.', self.task.id)
self.__add_comment__ = True
else:
logger.debug('acc_task.add_comments: Access denied (Task #%d).', self.task.id)
self.__add_comment__ = False
return self.__add_comment__
@property
def modify_comment(self):
if self.__modify_comment__ is None:
if self.user.is_superuser:
logger.debug('acc_task.modify_comment: Access granted (Task #%d). User is Superuser.', self.task.id)
self.__modify_comment__ = True
elif self.user_has_leader_rights:
logger.debug('acc_task.modify_comment: Access granted (Task #%d). User is Projectleader.', self.task.id)
self.__modify_comment__ = True
else:
logger.debug('acc_task.modify_comment: Access denied (Task #%d).', self.task.id)
self.__modify_comment__ = False
return self.__modify_comment__
@property
def allowed_targetstates(self):
if self.modify:
rv = [state[0] for state in TASKSTATE_CHOICES]
elif self.modify_limited:
rv = list(TASKS_IN_WORK)
else:
return []
rv.pop(rv.index(self.task.state))
rv.sort()
rv.reverse()
return rv
@property
def allowed_targetpriority(self):
if self.modify:
rv = [prio[0] for prio in PRIO_CHOICES]
rv.pop(rv.index(self.task.priority))
rv.sort()
rv.reverse()
return rv
return []
class acc_project(object):
def __init__(self, project, user):
self.project = project
self.user = user
self.__modify__ = None
self.user_has_leader_rights = user in project.role_leader.all() and user.is_staff
self.user_has_memeber_rights = user in project.role_member.all() and user.is_staff
self.user_has_visitor_rights = user in project.role_visitor.all() and user.is_staff
self.user_has_role_rights = self.user_has_leader_rights or self.user_has_memeber_rights or self.user_has_visitor_rights
@property
def read(self):
if self.user.is_superuser:
logger.debug('acc_project.read: Access granted (Project #%d). User is Superuser.', self.project.id)
return True
elif self.user_has_leader_rights:
logger.debug('acc_project.read: Access granted (Project #%d). User is projectleader.', self.project.id)
return True
elif self.user_has_role_rights and self.project.state in PROJECTS_IN_WORK:
logger.debug('acc_project.read: Access granted (Project #%d). User has a role and project is in work.', self.project.id)
return True
elif len(self.project.task_set.filter(assigned_user=self.user, state__in=TASKS_IN_WORK)) > 0:
logger.debug('acc_project.read: Access granted (Project #%d). User has open tasks.', self.project.id)
return True
else:
logger.debug('acc_project.read: Access denied (Project #%d). User is not authenticated.', self.project.id)
return False
@property
def modify(self):
if self.__modify__ is None:
if self.user.is_superuser:
logger.debug('acc_project.modify: Access granted (Project #%d). User is Superuser.', self.project.id)
self.__modify__ = True
elif self.user in self.project.role_leader.all() and self.user.is_staff:
logger.debug('acc_project.modify: Access granted (Project #%d). User is Projectleader.', self.project.id)
self.__modify__ = True
else:
logger.debug('acc_project.modify: Access denied (Project #%d).', self.project.id)
self.__modify__ = False
return self.__modify__
def create_task_possible(user):
return len(Project.objects.filter(role_leader__in=[user])) + len(Project.objects.filter(role_member__in=[user])) > 0 and user.is_staff
def create_project_possible(user):
return user.is_superuser

65
admin.py Normal file
View File

@ -0,0 +1,65 @@
from django.contrib import admin
from .models import Project, Task, Comment, Search
from simple_history.admin import SimpleHistoryAdmin
class ProjectAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'id', )
search_fields = ('name', 'description', 'id', )
list_filter = (
('state', admin.ChoicesFieldListFilter),
('role_leader', admin.RelatedFieldListFilter),
('role_member', admin.RelatedFieldListFilter),
)
class TaskAdmin(SimpleHistoryAdmin):
list_display = ('name', 'description', 'id', )
history_list_display = ('name', 'description', 'state', )
search_fields = ('name', 'description', 'id', )
list_filter = (
('state', admin.ChoicesFieldListFilter),
('priority', admin.ChoicesFieldListFilter),
('assigned_user', admin.RelatedFieldListFilter),
('project', admin.RelatedFieldListFilter),
)
class CommentAdmin(SimpleHistoryAdmin):
list_display = ('task', 'user', 'comment', )
history_list_display = ('comment', 'type', )
search_fields = ('comment', )
list_filter = (
('type', admin.ChoicesFieldListFilter),
('user', admin.RelatedFieldListFilter),
)
class ViewSettingAdmin(admin.ModelAdmin):
list_display = ('profile', 'view', )
search_fields = ('profile', 'view', )
list_filter = (
('profile', admin.RelatedFieldListFilter),
)
class PattProfileAdmin(admin.ModelAdmin):
list_display = ('user', )
search_fields = ('user', )
list_filter = (
('user', admin.RelatedFieldListFilter),
)
class SearchAdmin(admin.ModelAdmin):
list_display = ('user', 'name', )
search_fields = ('user', 'name', )
list_filter = (
('user', admin.RelatedFieldListFilter),
)
admin.site.register(Project, ProjectAdmin)
admin.site.register(Task, TaskAdmin)
admin.site.register(Comment, CommentAdmin)
admin.site.register(Search, SearchAdmin)

8
apps.py Normal file
View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class PattConfig(AppConfig):
name = 'patt'
def ready(self):
import patt.signals

247
context.py Normal file
View File

@ -0,0 +1,247 @@
from .access import create_project_possible, create_task_possible, acc_task
from django.db.models.functions import Lower
from django.utils.translation import gettext as _
from .help import actionbar as actionbar_add_help
import inspect
import mycreole
import patt
from .search import common_searches
from themes import empty_entry_parameters, color_icon_url, gray_icon_url
from users.context import menubar as menubar_users
ATTACHMENT_UID = 'attachment'
BACK_UID = 'back'
COMMENTNEW_UID = 'commentnew'
CREATE_PROJECT_UID = 'create-project'
CREATE_TASK_UID = 'create-task'
HELP_UID = 'help'
PRINTVIEW_UID = 'printview'
TASKEDIT_UID = 'taskedit'
VIEW_PROJECTLIST_UID = 'view-projectlist'
VIEW_TASKLIST_UID = 'view-tasklist'
def context_adaption(context, request, **kwargs):
caller_name = inspect.currentframe().f_back.f_code.co_name
try:
context.set_additional_title(kwargs.pop('title'))
except KeyError:
pass # no title in kwargs
menubar_users(context[context.MENUBAR], request)
menubar(context, request, caller_name, **kwargs)
actionbar(context, request, caller_name, **kwargs)
navigationbar(context, request)
for key in kwargs:
context[key] = kwargs[key]
def menubar(context, request, caller_name, **kwargs):
bar = context[context.MENUBAR]
add_help_menu(request, bar)
add_tasklist_menu(request, bar)
add_filter_submenu(request, bar, VIEW_TASKLIST_UID)
add_projectlist_menu(request, bar)
add_printview_menu(request, bar)
finalise_bar(request, bar)
def navigationbar(context, request):
bar = context[context.NAVIGATIONBAR]
add_back_menu(request, bar)
finalise_bar(request, bar)
def actionbar(context, request, caller_name, **kwargs):
bar = context[context.ACTIONBAR]
if caller_name == 'patt_tasklist':
if create_task_possible(request.user):
add_newtask_menu(request, bar, kwargs.get('project_id'))
elif caller_name == 'patt_projectlist':
if create_project_possible(request.user):
add_newproject_menu(request, bar)
elif caller_name == 'patt_taskview':
acc = acc_task(kwargs['task'], request.user)
if acc.modify or acc.modify_limited:
add_edittask_menu(request, bar, kwargs['task'].id)
if acc.add_comments:
add_newcomment_menu(request, bar, kwargs['task'].id)
add_manageupload_menu(request, bar, kwargs['task'])
elif caller_name == 'patt_helpview':
actionbar_add_help(context, request, **kwargs)
finalise_bar(request, bar)
def finalise_bar(request, bar):
if len(bar) == 0:
bar.append_entry(*empty_entry_parameters(request))
def add_help_menu(request, bar):
bar.append_entry(
HELP_UID, # uid
_('Help'), # name
color_icon_url(request, 'help.png'), # icon
patt.url_helpview(request, 'main'), # url
True, # left
False # active
)
def add_tasklist_menu(request, bar):
bar.append_entry(
VIEW_TASKLIST_UID, # uid
_('Tasklist'), # name
color_icon_url(request, 'task.png'), # icon
patt.url_tasklist(request), # url
True, # left
patt.is_tasklistview(request) # active
)
def add_projectlist_menu(request, bar):
bar.append_entry(
VIEW_PROJECTLIST_UID, # uid
_('Projectlist'), # name
color_icon_url(request, 'folder.png'), # icon
patt.url_projectlist(request), # url
True, # left
patt.is_projectlistview(request) # active
)
def add_printview_menu(request, bar):
bar.append_entry(
PRINTVIEW_UID, # uid
_('Printview'), # name
color_icon_url(request, 'print.png'), # icon
patt.url_printview(request), # url
True, # left
patt.is_printview(request) # active
)
def add_newtask_menu(request, bar, project_id):
bar.append_entry(
CREATE_TASK_UID, # uid
_('New Task'), # name
color_icon_url(request, 'plus.png'), # icon
patt.url_tasknew(request, project_id), # url
True, # left
False # active
)
def add_edittask_menu(request, bar, task_id):
bar.append_entry(
TASKEDIT_UID, # uid
_('Edit'), # name
color_icon_url(request, 'edit.png'), # icon
patt.url_taskedit(request, task_id), # url
True, # left
False # active
)
def add_newcomment_menu(request, bar, task_id):
bar.append_entry(
COMMENTNEW_UID, # uid
_('Add Comment'), # name
color_icon_url(request, 'edit2.png'), # icon
patt.url_commentnew(request, task_id), # url
True, # left
False # active
)
def add_newproject_menu(request, bar):
bar.append_entry(
CREATE_PROJECT_UID, # uid
_('New Project'), # name
color_icon_url(request, 'plus.png'), # icon
patt.url_projectnew(request), # url
True, # left
False # active
)
def add_manageupload_menu(request, bar, task):
bar.append_entry(
ATTACHMENT_UID, # uid
_("Attachments"), # name
color_icon_url(request, 'upload.png'), # icon
mycreole.url_manage_uploads(request, task.attachment_target_path), # url
True, # left
False, # active
)
def add_back_menu(request, bar):
bar.append_entry(
BACK_UID, # uid
_('Back'), # name
gray_icon_url(request, 'back.png'), # icon
'javascript:history.back()', # url
True, # left
False # active
)
def add_filter_submenu(request, bar, menu_uid):
bar.append_entry_to_entry(
menu_uid,
menu_uid + '-easysearch', # uid
_('Easysearch'), # name
gray_icon_url(request, 'search.png'), # icon
patt.url_easysearch(request), # url
True, # left
False # active
)
if patt.get_search_query(request) is not None:
bar.append_entry_to_entry(
menu_uid,
menu_uid + '-save', # uid
_('Save Search as Filter'), # name
gray_icon_url(request, 'save.png'), # icon
patt.url_filteredit(request), # url
True, # left
False # active
)
bar.append_entry_to_entry(
menu_uid,
menu_uid + '-all', # uid
_('All Tasks'), # name
gray_icon_url(request, 'task.png'), # icon
patt.url_tasklist(request), # url
True, # left
False # active
)
cs = common_searches(request)
for common_filter_id in cs:
bar.append_entry_to_entry(
menu_uid,
menu_uid + '-common', # uid
_(cs[common_filter_id][0]), # name
gray_icon_url(request, 'filter.png'), # icon
patt.url_tasklist(request, common_filter_id=common_filter_id), # url
True, # left
False # active
)
for s in request.user.search_set.order_by(Lower('name')):
active = patt.is_tasklistview(request, s.id)
if active is True:
url = patt.url_filteredit(request, s.id)
else:
url = patt.url_tasklist(request, user_filter_id=s.id)
if active:
icon = 'settings.png'
else:
icon = 'favourite.png'
bar.append_entry_to_entry(
menu_uid,
menu_uid + '-sub', # uid
s.name, # name
gray_icon_url(request, icon), # icon
url, # url
True, # left
active # active
)

45
creole.py Normal file
View File

@ -0,0 +1,45 @@
from django.urls.base import reverse
from .models import Project
def task_link_filter(text):
render_txt = ''
while len(text) > 0:
try:
pos = text.index('[[task:')
except ValueError:
pos = len(text)
render_txt += text[:pos]
text = text[pos + 7:]
if len(text):
pos = text.index(']]')
try:
task_id = int(text[:pos])
except ValueError:
render_txt += "[[task:" + text[:pos + 2]
else:
render_txt += '[[%s|#%d]]' % (reverse('patt-taskview', kwargs={'task_id': task_id}), task_id)
text = text[pos + 2:]
return render_txt
def tasklist_link_filter(text):
render_txt = ''
while len(text) > 0:
try:
pos = text.index('[[tasklist:')
except ValueError:
pos = len(text)
render_txt += text[:pos]
text = text[pos + 11:]
if len(text):
pos = text.index(']]')
try:
project_id = int(text[:pos])
except ValueError:
render_txt += "[[tasklist:" + text[:pos + 2]
else:
p = Project.objects.get(id=project_id)
render_txt += '[[%s|%s]]' % (reverse('patt-tasklist-prj', kwargs={'project_id': project_id}), p.name)
text = text[pos + 2:]
return render_txt

137
forms.py Normal file
View File

@ -0,0 +1,137 @@
from django import forms
from django.contrib.auth.models import User
from django.db.models import Q
from django.utils.translation import gettext as _
from .models import Task, Project, Comment, Search, TASKSTATE_CHOICES, PROJECTSTATE_OPEN, ModelList
from .search import INDEX_STATES
class TaskForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
# get request from kwargs
try:
self.request = kwargs.pop('request')
except KeyError:
raise TypeError("needed request object is missing in kwargs")
plist = self.__projectlist_taskedit__(self.request.user)
if self.request.POST:
project = Project.objects.get(id=self.request.POST.get('project'))
else:
try:
project = kwargs.get('instance').project
except Task.project.RelatedObjectDoesNotExist:
if len(plist) > 0:
project = plist[0]
# init TaskForm
super(TaskForm, self).__init__(*args, **kwargs)
# set projectchoice for project
self.fields['project'].queryset = plist
self.fields['project'].empty_label = None
# set userlist (projectdepending)
self.fields['assigned_user'].empty_label = _('----- choose a user -----')
if project is not None:
self.fields['assigned_user'].queryset = self.__userlist_taskedit__(self.request.user, project)
def __projectlist_taskedit__(self, user):
if user.is_superuser:
rv = Project.objects.all()
else:
rv = Project.objects.filter(
Q(role_leader__id__exact=user.id) | Q(role_member__id__exact=user.id),
state=PROJECTSTATE_OPEN,
).distinct()
rv = ModelList(rv)
rv.sort()
return Project.objects.filter(pk__in=[p.pk for p in rv])
def __userlist_taskedit__(self, user, project=None):
if user.is_superuser:
return User.objects.all().order_by('username')
elif user in project.role_leader.all():
uids = [u.id for u in project.role_leader.all() | project.role_member.all()]
else:
uids = [user.id]
return User.objects.filter(id__in=set(uids)).order_by('username')
class Meta:
model = Task
fields = ['project', 'assigned_user', 'name', 'state', 'priority', 'targetdate', 'progress', 'description']
widgets = {
'assigned_user': forms.Select(attrs={'required': True}),
'project': forms.Select(attrs={'onchange': 'submit()'}),
'state': forms.Select(choices=TASKSTATE_CHOICES),
'targetdate': forms.DateInput(format="%Y-%m-%d"),
'description': forms.Textarea(attrs={'rows': 5}),
}
class TaskFormLimited(forms.ModelForm):
def __init__(self, *args, **kwargs):
# remove request from kwargs
kwargs.pop('request')
super(TaskFormLimited, self).__init__(*args, **kwargs)
class Meta:
model = Task
fields = ['state', 'progress', 'description']
widgets = {
'state': forms.Select(choices=TASKSTATE_CHOICES[:2]),
'description': forms.Textarea(attrs={'rows': 5}),
}
class ProjectForm(forms.ModelForm):
class Meta:
model = Project
fields = ['name', 'state', 'role_leader', 'role_member', 'role_visitor', 'description', 'days_late', 'days_very_soon', 'days_soon']
widgets = {
'description': forms.Textarea(attrs={'rows': 5}),
}
labels = {
'days_late': _('Days to deadline (late)'),
'days_very_soon': _('Days to deadline (very soon)'),
'days_soon': _('Days to deadline (soon)'),
}
def __init__(self, *args, **kwargs):
super(ProjectForm, self).__init__(*args, **kwargs)
self.fields['role_leader'].queryset = User.objects.order_by('username')
self.fields['role_member'].queryset = User.objects.order_by('username')
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ['comment']
class TaskCommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ['comment']
def __init__(self, *args, **kwargs):
super(forms.ModelForm, self).__init__(*args, **kwargs)
self.fields['comment'].required = False
class SearchForm(forms.ModelForm):
class Meta:
model = Search
fields = ['name', 'search_txt']
labels = {
'search_txt': _('Search Text'),
}
class EasySearchForm(forms.Form):
user_ids = forms.MultipleChoiceField(required=False, label=_('Assigned User(s)'), widget=forms.widgets.SelectMultiple(attrs={'size': 6}))
states = forms.MultipleChoiceField(required=False, label=_('State(s)'))
prj_ids = forms.MultipleChoiceField(required=False, label=_('Project(s)'), widget=forms.widgets.SelectMultiple(attrs={'size': 10}))
def __init__(self, *args, **kwargs):
super(EasySearchForm, self).__init__(*args, **kwargs)
self.fields['user_ids'].choices = [(u.id, u.username) for u in User.objects.order_by('username')]
self.fields['states'].choices = [(INDEX_STATES.get(task_num), task_name) for task_num, task_name in TASKSTATE_CHOICES]
self.fields['prj_ids'].choices = [(p.id, p.name) for p in Project.objects.order_by('state', 'name')]

153
help.py Normal file
View File

@ -0,0 +1,153 @@
from django.utils.translation import gettext as _
import mycreole
import patt
from themes import color_icon_url
# TODO: Search: Describe search fields
# TODO: Search: Describe logic operator order and brackets if possible
# TODO: Search: Extend Examples with useful features.
# TODO: Search for specific content: Describe search possibilities (also in pygal)
HELP_UID = 'help'
MAIN = mycreole.render_simple(_("""
= PaTT
**PaTT** is a **P**roject **a**nd **T**eamorganisation **T**ool.
It is designed to store Tasks in relation to Projects and Users.
== Help
* [[creole|Creole Markup Language]]
* [[access|Access Control for the site content]]
* [[search|Help on Search]]
== Items
=== Task properties:
* State
* Targetdate
* Priority
* Progress
* Name
* Description
=== Project properties:
* Project Leaders
* Project Members
* State
* Name
* Description
"""))
CREOLE = mycreole.mycreole_help_pagecontent()
CREOLE += mycreole.render_simple("""
= PaTT Markup
{{{[[task:number]]}}} will result in a Link to the given tasknumber.
{{{[[tasklist:number]]}}} will result in a Link to the tasklist of the given projectnumber.
""")
ACCESS = mycreole.render_simple(_("""
= Superuser(s)
* Are able to view, create and edit everything!
* Only a Superuser is able to create a project.
= Non-Staff-Users
* Are able to read their own tasks, which are in the state "Open" or "Finished" and the related project(s) to these tasks.
* They don't get project role permissions (Projectleader, -member, ...), even if they have a role.
* They don't get permission to change any content.
= Projectleader(s)
* Are able to view and edit everything related to the project.
* They are able to create tasks for the project for any user with a projectrole.
= Projectmember(s)
* Are able to view everything related to the project.
* They are have limited modify permission to their own task related to that project.
* They are able to leave taskcomments at every task related to the project.
* They are able to create tasks related to the project for themselves.
= Projectvisitor(s)
* Are able to view everything related to the project.
* They are have limited modify permission to their own task related to that project.
"""))
SEARCH = mycreole.render_simple(_("""
= Search
The search looks up full words in //Tasknames (name)// and //Taskdescriptions (description)// without giving \
special search commands in the search string. The search will result in a tasklist.
=== Task search fields
* task_id (NUMERIC):
* assigned_user (TEXT):
* assigned_user_missing (BOOLEAN):
* name (TEXT):
* description (TEXT):
* state (TEXT):
** The state of a Task. It is one of the following states: Open, Finished, Closed, Cancelled
* targetdate (DATETIME):
=== Project related fields
* project_id (NUMERIC):
* project_name (TEXT):
* project_description (TEXT):
=== Comment related field
* comment (TEXT):
== Search syntax (Whoosh)
=== Logic operators
* AND
** **Example:** "foo AND bar" - Search will find all items with foo and bar.
* OR
** **Example:** "foo OR bar" - Search will find all items with foo, bar or with foo and bar.
* NOT
** **Example:** "foo NOT bar" - Search will find all items with foo and no bar.
=== Search in specific fields
A search pattern like //foo:bar// does look for //bar// in the field named //foo//.
This search pattern can also be combined with other search text via logical operators.
=== Search for specific content
* **Wildcards:**
* **Range:**
** From To:
** Above:
** Below:
* **Named constants:**
** //now//: Current date
** //-[num]y//: Current date minus [num] years
** //+[num]mo//: Current date plus [num] months
** //-[num]d//: Current date minus [num] days
** ...
== Examples
* [[/patt/tasklist/search?q=project_id:1|project_id:1]] gives results with all tasks of project number #1.
* [[/patt/tasklist/search?q=project_id:1 AND assigned_user:dirk|project_id:1 AND assigned_user:dirk]] gives results with all tasks of project number #1 which are assigned to 'dirk'.
* [[/patt/tasklist/search?q=assigned_user:dirk+AND+targetdate:[2000+to+%2b5d]|assigned_user:dirk AND targetdate:[2000 to +5d] ]] gives results with tasks having a targetdate within the next 5 days and assigned to 'dirk'.
"""))
help_pages = {
'main': MAIN,
'creole': CREOLE,
'access': ACCESS,
'search': SEARCH,
}
def actionbar(context, request, current_help_page=None, **kwargs):
actionbar_entries = (
('1', 'Main'),
('2', 'Creole'),
('3', 'Access'),
('4', 'Search'),
)
for num, name in actionbar_entries:
context[context.ACTIONBAR].append_entry(
HELP_UID + '-%s' % name.lower(), # uid
_(name), # name
color_icon_url(request, num + '.png'), # icon
patt.url_helpview(request, name.lower()), # url
True, # left
name.lower() == current_help_page, # active
)

0
management/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,9 @@
from django.core.management.base import BaseCommand
from patt.search import create_index, rebuild_index
class Command(BaseCommand):
def handle(self, *args, **options):
ix = create_index()
n = rebuild_index(ix)
self.stdout.write(self.style.SUCCESS('Search index for %d items created.') % n)

View File

@ -0,0 +1,76 @@
# Generated by Django 2.2.5 on 2019-09-30 16:58
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Project',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128)),
('description', models.TextField(blank=True, default='')),
('state', models.IntegerField(choices=[(0, 'Open'), (1, 'Closed')], default=0)),
('role_leader', models.ManyToManyField(related_name='role_leader', to=settings.AUTH_USER_MODEL)),
('role_member', models.ManyToManyField(blank=True, related_name='role_member', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='TaskListSetting',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='All', max_length=50)),
('projectfilter', models.CharField(default='all', max_length=50)),
('userfilter', models.CharField(default='all', max_length=50)),
('statefilter', models.CharField(default='0,1,2,3', max_length=50)),
('displaytype', models.CharField(default='short', max_length=50)),
('sorttype', models.CharField(default='date', max_length=50)),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('current_tasklistsetting', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='patt.TaskListSetting')),
('my_tasklistsettings', models.ManyToManyField(related_name='my_tasklistsettings', to='patt.TaskListSetting')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Task',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state', models.IntegerField(choices=[(0, 'Open'), (1, 'Finished'), (2, 'Closed'), (3, 'Canceled')], default=0)),
('priority', models.IntegerField(choices=[(1, '1 - Highest'), (2, '2 - High'), (3, '3 - Above-Average'), (4, '4 - Average'), (5, '5 - Below-Average'), (6, '6 - Low'), (7, '7 - Lowest')], default=4)),
('targetdate', models.DateField(blank=True, null=True)),
('progress', models.IntegerField(choices=[(0, '0 %'), (10, '10 %'), (20, '20 %'), (30, '30 %'), (40, '40 %'), (50, '50 %'), (60, '60 %'), (70, '70 %'), (80, '80 %'), (90, '90 %'), (100, '100 %')], default=0)),
('name', models.CharField(default='', max_length=150)),
('description', models.TextField(blank=True, default='')),
('creation_date', models.DateTimeField(auto_now_add=True)),
('assigned_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='patt.Project')),
],
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.IntegerField(choices=[(1, 'Appraisal'), (0, 'Comment')], default=0)),
('creation_date', models.DateTimeField(auto_now_add=True)),
('description', models.TextField()),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patt.Task')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 2.2.5 on 2019-10-03 16:14
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('patt', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='project',
name='state',
field=models.IntegerField(choices=[(0, 'Open'), (2, 'Closed')], default=0),
),
migrations.AlterField(
model_name='task',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patt.Project'),
),
migrations.AlterField(
model_name='task',
name='state',
field=models.IntegerField(default=0),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 2.2.5 on 2019-10-03 18:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('patt', '0002_auto_20191003_1614'),
]
operations = [
migrations.RemoveField(
model_name='tasklistsetting',
name='sorttype',
),
]

View File

@ -0,0 +1,45 @@
# Generated by Django 2.2.5 on 2019-10-04 11:25
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('patt', '0003_remove_tasklistsetting_sorttype'),
]
operations = [
migrations.CreateModel(
name='ViewSetting',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('view', models.CharField(max_length=50)),
('userfilter', models.CharField(default='all', max_length=50)),
('statefilter', models.CharField(default='1,0', max_length=50)),
('displaytype', models.CharField(default='short', max_length=50)),
],
),
migrations.RemoveField(
model_name='userprofile',
name='current_tasklistsetting',
),
migrations.RemoveField(
model_name='userprofile',
name='my_tasklistsettings',
),
migrations.AddField(
model_name='userprofile',
name='viewsettings',
field=models.TextField(default='{}'),
),
migrations.DeleteModel(
name='TaskListSetting',
),
migrations.AddField(
model_name='viewsetting',
name='profile',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patt.UserProfile'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 2.2.5 on 2019-10-05 11:48
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('patt', '0004_auto_20191004_1125'),
]
operations = [
migrations.AddField(
model_name='project',
name='creation_date',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.5 on 2019-10-06 18:24
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('patt', '0005_project_creation_date'),
]
operations = [
migrations.RenameField(
model_name='comment',
old_name='description',
new_name='comment',
),
]

View File

@ -0,0 +1,65 @@
# Generated by Django 2.2.5 on 2019-10-09 19:38
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import simple_history.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('patt', '0006_auto_20191006_1824'),
]
operations = [
migrations.CreateModel(
name='HistoricalTask',
fields=[
('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('state', models.IntegerField(default=0)),
('priority', models.IntegerField(choices=[(1, '1 - Highest'), (2, '2 - High'), (3, '3 - Above-Average'), (4, '4 - Average'), (5, '5 - Below-Average'), (6, '6 - Low'), (7, '7 - Lowest')], default=4)),
('targetdate', models.DateField(blank=True, null=True)),
('progress', models.IntegerField(choices=[(0, '0 %'), (10, '10 %'), (20, '20 %'), (30, '30 %'), (40, '40 %'), (50, '50 %'), (60, '60 %'), (70, '70 %'), (80, '80 %'), (90, '90 %'), (100, '100 %')], default=0)),
('name', models.CharField(default='', max_length=150)),
('description', models.TextField(blank=True, default='')),
('creation_date', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('assigned_user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='patt.Project')),
],
options={
'verbose_name': 'historical task',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': 'history_date',
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalComment',
fields=[
('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('type', models.IntegerField(choices=[(1, 'Appraisal'), (0, 'Comment')], default=0)),
('creation_date', models.DateTimeField(blank=True, editable=False)),
('comment', models.TextField()),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('task', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='patt.Task')),
('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical comment',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': 'history_date',
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 2.2.5 on 2019-10-10 18:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('patt', '0007_historicalcomment_historicaltask'),
]
operations = [
migrations.RemoveField(
model_name='viewsetting',
name='displaytype',
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 2.2.5 on 2019-11-02 19:25
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('patt', '0008_remove_viewsetting_displaytype'),
]
operations = [
migrations.CreateModel(
name='PattProfile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='project',
name='days_highlight',
field=models.IntegerField(choices=[(0, 'Off'), (1, 1), (2, 2), (3, 3), (5, 5), (7, 7), (10, 10), (14, 14)], default=7),
),
migrations.AddField(
model_name='project',
name='days_warn',
field=models.IntegerField(choices=[(0, 'Off'), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (7, 7), (9, 9)], default=3),
),
migrations.AlterField(
model_name='viewsetting',
name='profile',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patt.PattProfile'),
),
migrations.DeleteModel(
name='UserProfile',
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 2.2.5 on 2019-11-17 11:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('patt', '0009_auto_20191102_1925'),
]
operations = [
migrations.RemoveField(
model_name='project',
name='days_highlight',
),
migrations.RemoveField(
model_name='project',
name='days_warn',
),
migrations.AddField(
model_name='project',
name='days_late',
field=models.IntegerField(choices=[(-1, 'Off'), (0, 0)], default=0),
),
migrations.AddField(
model_name='project',
name='days_soon',
field=models.IntegerField(choices=[(-1, 'Off'), (1, 1), (2, 2), (3, 3), (5, 5), (7, 7), (10, 10), (14, 14)], default=7),
),
migrations.AddField(
model_name='project',
name='days_very_soon',
field=models.IntegerField(choices=[(-1, 'Off'), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (7, 7), (9, 9)], default=3),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 2.2.9 on 2020-01-09 10:53
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('patt', '0010_auto_20191117_1157'),
]
operations = [
migrations.AddField(
model_name='project',
name='role_visitor',
field=models.ManyToManyField(blank=True, related_name='role_visitor', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,35 @@
# Generated by Django 2.2.9 on 2020-01-14 10:35
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('patt', '0011_project_role_visitor'),
]
operations = [
migrations.CreateModel(
name='Search',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=48)),
('search_txt', models.TextField(blank=True, default='')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.RemoveField(
model_name='viewsetting',
name='profile',
),
migrations.DeleteModel(
name='PattProfile',
),
migrations.DeleteModel(
name='ViewSetting',
),
]

0
migrations/__init__.py Normal file
View File

281
models.py Normal file
View File

@ -0,0 +1,281 @@
import datetime
from django.contrib.auth.models import User
from django.db import models
from django.utils.dateformat import format
from django.utils.translation import gettext as _
from simple_history.models import HistoricalRecords
# PROJECTSTATE (REQ-??)
#
PROJECTSTATE_OPEN = 0
PROJECTSTATE_CLOSED = 2
PROJECTS_IN_WORK = (PROJECTSTATE_OPEN, )
#
PROJECTSTATE_CHOICES = (
(PROJECTSTATE_OPEN, _('Open')),
(PROJECTSTATE_CLOSED, _('Closed')),
)
# TASKSTATE (REQ-14)
#
TASKSTATE_OPEN = 0
TASKSTATE_FINISHED = 1
TASKSTATE_CLOSED = 2
TASKSTATE_CANCELED = 3
TASKS_IN_WORK = (TASKSTATE_OPEN, TASKSTATE_FINISHED, )
#
TASKSTATE_CHOICES = (
(TASKSTATE_OPEN, _('Open')),
(TASKSTATE_FINISHED, _('Finished')),
(TASKSTATE_CLOSED, _('Closed')),
(TASKSTATE_CANCELED, _('Cancelled')),
)
# TASKPRIORITY (REQ-??)
#
PRIO_CHOICES = (
(1, _('1 - Highest')),
(2, _('2 - High')),
(3, _('3 - Above-Average')),
(4, _('4 - Average')),
(5, _('5 - Below-Average')),
(6, _('6 - Low')),
(7, _('7 - Lowest')),
)
# TASKPROGRESS (REQ-??)
#
PRORGRESS_CHOICES = (
(0, '0 %'),
(10, '10 %'),
(20, '20 %'),
(30, '30 %'),
(40, '40 %'),
(50, '50 %'),
(60, '60 %'),
(70, '70 %'),
(80, '80 %'),
(90, '90 %'),
(100, '100 %'),
)
# COMMENTTYPE (REQ-??)
#
COMMENTTYPE_COMMENT = 0
COMMENTTYPE_APPRAISAL = 1
#
COMMENTTYPE_CHOICES = (
(COMMENTTYPE_APPRAISAL, _('Appraisal')),
(COMMENTTYPE_COMMENT, _('Comment')),
)
# GENERAL Methods and Classes
#
class ModelList(list):
def __init__(self, lst, chk=None, user=None):
if chk is None:
list.__init__(self, lst)
else:
list.__init__(self)
for e in lst:
acc = chk(e, user)
if acc.read:
self.append(e)
def sort(self, **kwargs):
list.sort(self, key=lambda entry: entry.sort_string(), reverse=kwargs.get('reverse', False))
# PROCECT Model
#
class Project(models.Model):
name = models.CharField(max_length=128) # REQ-7
description = models.TextField(default='', blank=True) # REQ-8
state = models.IntegerField(choices=PROJECTSTATE_CHOICES, default=PROJECTSTATE_OPEN) # REQ-26
role_leader = models.ManyToManyField(User, related_name='role_leader') # REQ-9
role_member = models.ManyToManyField(User, related_name='role_member', blank=True) # REQ-12
role_visitor = models.ManyToManyField(User, related_name='role_visitor', blank=True) # REQ-33
creation_date = models.DateTimeField(auto_now_add=True)
days_late = models.IntegerField(default=0, choices=((-1, _('Off')), (0, 0), ))
days_very_soon = models.IntegerField(default=3, choices=((-1, _('Off')), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (7, 7), (9, 9), ))
days_soon = models.IntegerField(default=7, choices=((-1, _('Off')), (1, 1), (2, 2), (3, 3), (5, 5), (7, 7), (10, 10), (14, 14), ))
@property
def attachment_target_path(self):
if self.id:
return 'patt/project/%d' % self.id
else:
return 'patt/project/new'
@property
def formatted_leaders(self):
if self.id: # in preview (before saving), the ManyToManyFields are not available
return ', '.join([user.username for user in self.role_leader.all()]) or '-'
else:
return '...'
@property
def formatted_members(self):
if self.id: # in preview (before saving), the ManyToManyFields are not available
return ', '.join([user.username for user in self.role_member.all()]) or '-'
else:
return '...'
@property
def formatted_visitors(self):
if self.id: # in preview (before saving), the ManyToManyFields are not available
return ', '.join([user.username for user in self.role_visitor.all()]) or '-'
else:
return '...'
def sort_string(self):
return (self.state, self.name)
def __str__(self):
return 'Project: %s' % self.name
# TASK Model
#
class Task(models.Model):
FUSION_STATE_FINISHED = 0
FUSION_STATE_NORMAL = 1
FUSION_STATE_SOON = 2
FUSION_STATE_VERY_SOON = 3
FUSION_STATE_LATE = 4
#
SAVE_ON_CHANGE_FIELDS = ['state', 'priority', 'targetdate', 'progress', 'name', 'description', 'assigned_user', 'project']
#
state = models.IntegerField(default=TASKSTATE_OPEN) # REQ-14
priority = models.IntegerField(choices=PRIO_CHOICES, default=4) # REQ-15
targetdate = models.DateField(null=True, blank=True) # REQ-16
progress = models.IntegerField(default=0, choices=PRORGRESS_CHOICES) # REQ-17
name = models.CharField(max_length=150, default='') # REQ-?? (18)
description = models.TextField(default='', blank=True) # REQ-??
assigned_user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) # REQ-31
project = models.ForeignKey(Project, on_delete=models.CASCADE) # REQ-??
creation_date = models.DateTimeField(auto_now_add=True)
#
history = HistoricalRecords()
@property
def taskname_prefix(self):
if self.assigned_user:
return '**#%d //(%s)//:** ' % (self.id, self.assigned_user.username)
else:
return '**#%d:** ' % self.id
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
if self.id and not force_update:
orig = Task.objects.get(id=self.id)
for key in self.SAVE_ON_CHANGE_FIELDS:
if getattr(self, key) != getattr(orig, key):
break
else:
self.save_needed = False
return False
self.save_needed = True
return models.Model.save(self, force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
@property
def attachment_target_path(self):
if self.id:
return 'patt/task/%d' % self.id
else:
return 'patt/task/new'
@property
def comments(self):
rv = ModelList(self.comment_set.all())
rv.sort(reverse=True)
return rv
def datafusion_state(self):
if self.state in [TASKSTATE_CANCELED, TASKSTATE_CLOSED, TASKSTATE_FINISHED]:
return self.FUSION_STATE_FINISHED
else:
if self.targetdate is not None:
if type(self.targetdate) == datetime.date:
targetdate = self.targetdate
else:
targetdate = datetime.datetime.strptime(self.targetdate, '%Y-%m-%d').date()
if targetdate - datetime.date.today() <= datetime.timedelta(self.project.days_late) and self.project.days_late >= 0:
return self.FUSION_STATE_LATE
elif targetdate - datetime.date.today() <= datetime.timedelta(self.project.days_very_soon) and self.project.days_very_soon >= 0:
return self.FUSION_STATE_VERY_SOON
elif targetdate - datetime.date.today() <= datetime.timedelta(self.project.days_soon) and self.project.days_soon >= 0:
return self.FUSION_STATE_SOON
return self.FUSION_STATE_NORMAL
@property
def class_by_state(self):
return {
self.FUSION_STATE_FINISHED: 'task-finished',
self.FUSION_STATE_NORMAL: 'task-normal',
self.FUSION_STATE_VERY_SOON: 'task-very-soon',
self.FUSION_STATE_SOON: 'task-soon',
self.FUSION_STATE_LATE: 'task-late',
}.get(self.datafusion_state())
def sort_string(self):
if self.targetdate:
td = int(format(self.targetdate, 'U'))
else:
td = 999999999999
return (100 - self.datafusion_state(), self.state, self.priority, td, self.progress, self.name)
def __str__(self):
if self.project:
return 'Task: %s - %s' % (self.project.name, self.name)
else:
return 'Task: %s' % (self.name)
# COMMENT Model
#
class Comment(models.Model): # REQ-19 and REQ-20
SAVE_ON_CHANGE_FIELDS = ['task', 'user', 'type', 'comment']
task = models.ForeignKey(Task, on_delete=models.CASCADE)
user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)
type = models.IntegerField(choices=COMMENTTYPE_CHOICES, default=COMMENTTYPE_COMMENT)
creation_date = models.DateTimeField(auto_now_add=True)
comment = models.TextField()
#
history = HistoricalRecords()
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
if self.id and not force_update:
orig = Comment.objects.get(id=self.id)
for key in self.SAVE_ON_CHANGE_FIELDS:
if getattr(self, key) != getattr(orig, key):
break
else:
self.save_needed = False
return False
self.save_needed = True
return models.Model.save(self, force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
@property
def attachment_target_path(self):
if self.id:
return 'patt/comment/%d' % self.id
else:
return 'patt/comment/new'
def sort_string(self):
return (self.creation_date, self.type, self.comment)
def __str__(self):
return 'Comment: %s - %d' % (self.task.name, self.id)
# SEARCH Model
#
class Search(models.Model):
name = models.CharField(max_length=48)
search_txt = models.TextField(default='', blank=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)

167
search.py Normal file
View File

@ -0,0 +1,167 @@
import datetime
from django.conf import settings
import fstools
import logging
from .models import Task, TASKSTATE_OPEN, TASKSTATE_FINISHED, TASKSTATE_CLOSED, TASKSTATE_CANCELED
import os
import re
from whoosh.fields import Schema, ID, TEXT, NUMERIC, DATETIME, BOOLEAN
from whoosh.qparser.dateparse import DateParserPlugin
from whoosh import index, qparser
logger = logging.getLogger("WHOOSH")
SEARCH_MY_OPEN_TASKS = 1
SEARCH_LOST_SOULS = 2
def common_searches(request):
cs = {}
if request.user.is_authenticated:
cs[SEARCH_MY_OPEN_TASKS] = ('My Open Tasks', mk_search_pattern(user_ids=[request.user.id]))
cs[SEARCH_LOST_SOULS] = ('Lost Souls (no user)', 'assigned_user_missing:1')
return cs
# INDEX_STATES
#
INDEX_STATES = {
TASKSTATE_OPEN: 'Open',
TASKSTATE_FINISHED: 'Finished',
TASKSTATE_CLOSED: 'Closed',
TASKSTATE_CANCELED: 'Canselled'
}
SCHEMA = Schema(
id=ID(unique=True, stored=True),
# Task
task_id=NUMERIC,
assigned_user=TEXT,
assigned_user_missing=BOOLEAN,
name=TEXT,
description=TEXT,
state=TEXT,
targetdate=DATETIME,
# Related Project
project_id=NUMERIC,
project_name=TEXT,
project_description=TEXT,
# Related Comments
comment=TEXT,
)
def mk_whooshpath_if_needed():
if not os.path.exists(settings.WHOOSH_PATH):
fstools.mkdir(settings.WHOOSH_PATH)
def create_index():
mk_whooshpath_if_needed()
logger.debug('Search Index created.')
return index.create_in(settings.WHOOSH_PATH, schema=SCHEMA)
def load_index():
mk_whooshpath_if_needed()
try:
ix = index.open_dir(settings.WHOOSH_PATH)
except index.EmptyIndexError:
ix = create_index()
else:
logger.debug('Search Index opened.')
return ix
def add_item(ix, item):
# Define Standard data
#
data = dict(
id='%d' % item.id,
# Task
task_id=item.id,
name=item.name,
description=item.description,
state=INDEX_STATES.get(item.state),
# Related Project
project_id=item.project.id,
project_name=item.project.name,
project_description=item.project.description,
# Related Comments
comment=' '.join([c.comment for c in item.comment_set.all()]),
)
# Add Optional data
#
if item.assigned_user is not None:
data['assigned_user'] = item.assigned_user.username
data['assigned_user_missing'] = False
else:
data['assigned_user_missing'] = True
if item.targetdate is not None:
data['targetdate'] = datetime.datetime.combine(item.targetdate, datetime.datetime.min.time())
# Write data to the index
#
with ix.writer() as w:
logger.info('Adding document with id=%d to the search index.', data.get('task_id'))
w.add_document(**data)
for key in data:
logger.debug(' - Adding %s=%s', key, repr(data[key]))
def delete_item(ix, item):
with ix.writer() as w:
logger.info('Removing document with id=%d from the search index.', item.id)
w.delete_by_term("task_id", item.id)
def update_item(ix, item):
delete_item(ix, item)
add_item(ix, item)
def rebuild_index(ix):
for t in Task.objects.all():
add_item(ix, t)
return len(Task.objects.all())
def search(ix, search_txt):
qp = qparser.MultifieldParser(['name', 'description'], ix.schema)
qp.add_plugin(DateParserPlugin(free=True))
try:
q = qp.parse(search_txt)
except AttributeError:
return None
except Exception:
return None
with ix.searcher() as s:
results = s.search(q, limit=None)
rpl = []
for hit in results:
rpl.append(hit['id'])
return Task.objects.filter(id__in=rpl)
def mk_search_pattern(**kwargs):
prj_ids = kwargs.get('prj_ids', [])
user_ids = kwargs.get('user_ids', [])
states = kwargs.get('states', [INDEX_STATES.get(TASKSTATE_OPEN), INDEX_STATES.get(TASKSTATE_FINISHED)])
rule_parts = []
if prj_ids is not None and len(prj_ids) > 0:
rule_parts.append(' OR '.join(['project_id:%s' % pid for pid in prj_ids]))
if user_ids is not None and len(user_ids) > 0:
from django.contrib.auth.models import User
rule_parts.append(' OR '.join(['assigned_user:%s' % User.objects.get(id=uid).username for uid in user_ids]))
if states is not None and len(states) > 0:
rule_parts.append(' OR '.join(['state:%s' % state for state in states]))
return ' AND '.join('(%s)' % rule for rule in rule_parts)
def get_project_ids_from_search_pattern(search_txt):
try:
return re.findall('project_id:(\d+)', search_txt)
except AttributeError:
return None
except TypeError:
return None

31
signals.py Normal file
View File

@ -0,0 +1,31 @@
from django.db.models.signals import pre_delete, post_delete, post_save
from django.dispatch import receiver
from .models import Task, Project, Comment
from mycreole import delete_attachment_target_path
from .search import load_index, delete_item, update_item
@receiver(pre_delete, sender=Task)
@receiver(pre_delete, sender=Project)
@receiver(pre_delete, sender=Comment)
def item_cache_delete(instance, **kwargs):
delete_attachment_target_path(instance.attachment_target_path)
@receiver(post_delete, sender=Task)
def search_index_item_delete(instance, **kwargs):
# delete index entry
ix = load_index()
delete_item(ix, instance)
@receiver(post_save, sender=Task)
@receiver(post_save, sender=Comment)
@receiver(post_delete, sender=Comment)
def search_index_item_update(instance, **kwargs):
if type(instance) == Comment:
task = instance.task
else:
task = instance
ix = load_index()
update_item(ix, task)

9
static/patt/datepicker.min.css vendored Normal file
View File

@ -0,0 +1,9 @@
/*!
* Datepicker v0.6.5
* https://github.com/fengyuanchen/datepicker
*
* Copyright (c) 2014-2018 Chen Fengyuan
* Released under the MIT license
*
* Date: 2018-03-31T06:16:43.444Z
*/.datepicker-container{background-color:#fff;direction:ltr;font-size:12px;left:0;line-height:30px;position:fixed;top:0;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;width:210px;z-index:-1;-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none}.datepicker-container:after,.datepicker-container:before{border:5px solid transparent;content:" ";display:block;height:0;position:absolute;width:0}.datepicker-dropdown{border:1px solid #ccc;box-shadow:0 3px 6px #ccc;box-sizing:content-box;position:absolute;z-index:1}.datepicker-inline{position:static}.datepicker-top-left,.datepicker-top-right{border-top-color:#39f}.datepicker-top-left:after,.datepicker-top-left:before,.datepicker-top-right:after,.datepicker-top-right:before{border-top:0;left:10px;top:-5px}.datepicker-top-left:before,.datepicker-top-right:before{border-bottom-color:#39f}.datepicker-top-left:after,.datepicker-top-right:after{border-bottom-color:#fff;top:-4px}.datepicker-bottom-left,.datepicker-bottom-right{border-bottom-color:#39f}.datepicker-bottom-left:after,.datepicker-bottom-left:before,.datepicker-bottom-right:after,.datepicker-bottom-right:before{border-bottom:0;bottom:-5px;left:10px}.datepicker-bottom-left:before,.datepicker-bottom-right:before{border-top-color:#39f}.datepicker-bottom-left:after,.datepicker-bottom-right:after{border-top-color:#fff;bottom:-4px}.datepicker-bottom-right:after,.datepicker-bottom-right:before,.datepicker-top-right:after,.datepicker-top-right:before{left:auto;right:10px}.datepicker-panel>ul{margin:0;padding:0;width:102%}.datepicker-panel>ul:after,.datepicker-panel>ul:before{content:" ";display:table}.datepicker-panel>ul:after{clear:both}.datepicker-panel>ul>li{background-color:#fff;cursor:pointer;float:left;height:30px;list-style:none;margin:0;padding:0;text-align:center;width:30px}.datepicker-panel>ul>li:hover{background-color:#e5f2ff}.datepicker-panel>ul>li.muted,.datepicker-panel>ul>li.muted:hover{color:#999}.datepicker-panel>ul>li.highlighted{background-color:#e5f2ff}.datepicker-panel>ul>li.highlighted:hover{background-color:#cce5ff}.datepicker-panel>ul>li.picked,.datepicker-panel>ul>li.picked:hover{color:#39f}.datepicker-panel>ul>li.disabled,.datepicker-panel>ul>li.disabled:hover{background-color:#fff;color:#ccc;cursor:default}.datepicker-panel>ul>li.disabled.highlighted,.datepicker-panel>ul>li.disabled:hover.highlighted{background-color:#e5f2ff}.datepicker-panel>ul>li[data-view="month next"],.datepicker-panel>ul>li[data-view="month prev"],.datepicker-panel>ul>li[data-view="year next"],.datepicker-panel>ul>li[data-view="year prev"],.datepicker-panel>ul>li[data-view="years next"],.datepicker-panel>ul>li[data-view="years prev"],.datepicker-panel>ul>li[data-view=next]{font-size:18px}.datepicker-panel>ul>li[data-view="month current"],.datepicker-panel>ul>li[data-view="year current"],.datepicker-panel>ul>li[data-view="years current"]{width:150px}.datepicker-panel>ul[data-view=months]>li,.datepicker-panel>ul[data-view=years]>li{height:52.5px;line-height:52.5px;width:52.5px}.datepicker-panel>ul[data-view=week]>li,.datepicker-panel>ul[data-view=week]>li:hover{background-color:#fff;cursor:default}.datepicker-hide{display:none}

10
static/patt/datepicker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
static/patt/draft.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

BIN
static/patt/icons/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

BIN
static/patt/icons/pg_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

BIN
static/patt/icons/pg_10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

BIN
static/patt/icons/pg_20.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

BIN
static/patt/icons/pg_30.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

BIN
static/patt/icons/pg_40.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

BIN
static/patt/icons/pg_50.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

BIN
static/patt/icons/pg_60.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

BIN
static/patt/icons/pg_70.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

BIN
static/patt/icons/pg_80.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

BIN
static/patt/icons/pg_90.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B

BIN
static/patt/icons/prio1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

BIN
static/patt/icons/prio2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 B

BIN
static/patt/icons/prio3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 B

BIN
static/patt/icons/prio4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

BIN
static/patt/icons/prio5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 B

BIN
static/patt/icons/prio6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 942 B

BIN
static/patt/icons/prio7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

4
static/patt/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

12
templates/patt/help.html Normal file
View File

@ -0,0 +1,12 @@
{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
{% block head_extensions %}
<style>
{% include 'patt/patt.css' %}
</style>
{% endblock head_extensions %}
{% block content %}
{{ help_content|safe }}
{% endblock content %}

131
templates/patt/patt.css Normal file
View File

@ -0,0 +1,131 @@
{% load static %}
.tasklist, .projectlist {
padding-top: 75px;
}
.tasklist-first, .projectlist-first {
padding-top: 16px;
}
.taskheadline, .projectheadline {
margin: 0px;
display: block;
align-items: center;
padding-top: 4px;
padding-bottom: 4px;
padding-left: 15px;
padding-right: 15px;
font-size: 16px;
border-radius: 10px;
font-size: 13px;
}
.taskname, .projectname, .projectusers, .projectuserlabel {
font-size: 18px;
}
.projectname {
padding-left: 40px;
}
.projectuserlabel {
font-weight: bold;
}
.taskdetails, .projectdetails {
padding: 16px;
}
.taskcomment {
margin: 16px;
border-left: 6px solid #323232;
}
.taskcomment-date {
padding: 16px;
font-size: 18px;
padding-bottom: 8px;
font-weight: bold;
}
.taskcomment-description {
padding: 0 50px;
padding-right: 16px;
}
.task-finished {
background-color: #efffef;
}
.task-normal {
background-color: #dfefff;
}
.task-soon {
background-color: #ffffe4;
}
.task-very-soon {
background-color: #ffe6cf;
}
.task-late {
background-color: #ffe4e4;
}
.project-normal {
background-color: #efefef;
}
.spacer {
padding-left: 6px;
padding-right: 6px;
}
.taskicon, .projecticon {
padding-left: 1px;
padding-right: 1px;
}
.projecticon {
position: absolute;
top: 0;
left: 0;
}
.taskicon:hover {
background: none;
}
/* When the screen is less than 700px wide, reduce content to be shown */
@media screen and (max-width: 700px) {
.prio_icons_hide {
display: none
}
}
/* When the screen is less than 500px wide, reduce content to be shown */
@media screen and (max-width: 500px) {
.target_date_hide {
display: none
}
}
/* When the screen is less than 375px wide, reduce content to be shown */
@media screen and (max-width: 375px) {
.state_icons_hide {
display: none
}
}
.preview {
background-image: url("{% static 'patt/draft.png' %}");
padding: 40px;
padding-top: 75px;
min-height: 300px;
}
.preview-spacer {
min-height: 35px;
}

View File

@ -0,0 +1,3 @@
{% load mycreole %}
{% if project.description %}<div class="projectdetails">{% render_creole project.description project.attachment_target_path next_anchor %}</div>{% endif %}

View File

@ -0,0 +1,28 @@
{% load static %}
{% load i18n %}
{% load mycreole %}
{% load access %}
{% load patt_urls %}
{% may_modify project as user_may_modify %}
<div class="projectheadline project-normal" ondblclick="location.href='{% url_tasklist_for_project project %}';" style="cursor:pointer;">
<div class="container">
<div class="projectname">{% render_creole "**Project #"|add:project_id|add:":** "|add:project.name project.attachment_target_path next_anchor %}</div>
<img class="projecticon" src="{% static 'patt/icons/state'|add:state|add:'.png' %}">
{% if user_may_modify %}<a href="{% url_projectedit project %}"><img class="projecticon" style="left: unset; right: 0;" src="{% static 'patt/icons/edit.png' %}" style="float:right"></a>{% endif %}
</div>
<p>
<span class="projectuserlabel">{% trans 'Projectleader(s)' %}:</span>
<span class="projectusers">{{ project.formatted_leaders }}</span>
</p>
<p>
<span class="projectuserlabel">{% trans 'Projectmember(s)' %}:</span>
<span class="projectusers">{{ project.formatted_members }}</span>
</p>
<p>
<span class="projectuserlabel">{% trans 'Projectvisitor(s)' %}:</span>
<span class="projectusers">{{ project.formatted_visitors }}</span>
</p>
</div>

View File

@ -0,0 +1,4 @@
{% with state=project.state|stringformat:"s" project_id=project.id|stringformat:"s" %}
{% include 'patt/project/head.html' %}
{% include 'patt/project/details.html' %}
{% endwith %}

View File

@ -0,0 +1,20 @@
{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
{% block head_extensions %}
<style>
{% include 'patt/patt.css' %}
</style>
{% endblock head_extensions %}
{% block content %}
{% with link=True %}
{% for project in projectlist %}
<div {% if forloop.counter != 1 %} id="project-{{ project.id }}"{% endif %}>
<div class="projectlist{% if forloop.counter == 1 %}-first{% endif %}">
{% include 'patt/project/project.html' %}
</div>
</div>
{% endfor %}
{% endwith %}
{% endblock content %}

View File

@ -0,0 +1,31 @@
{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
{% load static %}
{% load i18n %}
{% block head_extensions %}
{{ form.media }}
<style>
{% include 'patt/patt.css' %}
</style>
{% endblock head_extensions %}
{% block content %}
<form class="form" method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="hidden" name="next" value="{{ next }}">
<input type="hidden" name="item_id" value="{{ item_id }}">
{% if not disable_save %}<input type="submit" name="save" value="{% trans "Save" %}" class="button" />{% endif %}
{% if not disable_search %}<input type="submit" formaction="" name="search" value="{% trans "Search" %}" class="button" />{% endif %}
{% if not disable_preview %}<input type="submit" formaction="#preview" name="preview" value="{% trans "Preview" %}" class="button" />{% endif %}
{% if not disable_delete %}<input type="submit" formaction="" name="delete" value="{% trans "Delete" %}" class="button" />{% endif %}
</form>
{% if template %}
<div class="preview-spacer" id="preview"></div>
<div class="preview">
{% include template %}
</div>
{% endif %}
{% endblock content %}

View File

@ -0,0 +1,9 @@
{% load mycreole %}
{% load access %}
{% may_modify_comment comment as user_may_modify_comment %}
<div class="taskcomment"{% if printview != True and user_may_modify_comment %} onclick="location.href='{% url 'patt-commentedit' task_id=task.id comment_id=comment.id %}{{"?next="|add:request.path }}';" style="cursor:pointer;"{% endif %}>
<div class="taskcomment-date">{{ comment.creation_date }}{% if comment.user %} ({{ comment.user.username }}){% endif %}:</div>
<div class="taskcomment-description">{% render_creole comment.comment comment.attachment_target_path next_anchor %}</div>
</div>

View File

@ -0,0 +1,19 @@
{% load i18n %}
{% load mycreole %}
<div class="taskdetails">
<strong>{% trans 'Created' %}:</strong> {{ task.creation_date }}<br>
<strong>{% trans 'Project' %}:</strong> {{ task.project.name }}
</div>
{% if task.description %}<div class="taskdetails">{% render_creole task.description task.attachment_target_path next_anchor %}</div>{% endif %}
{% if comment_new %}
{% if comment_new.comment != "" %}
{% with comment=comment_new %}
{% include 'patt/task/comment.html' %}
{% endwith %}
{% endif %}
{% endif %}
{% for comment in task.comments %}
{% include 'patt/task/comment.html' %}
{% endfor %}

View File

@ -0,0 +1,41 @@
{% load static %}
{% load mycreole %}
{% load access %}
{% load patt_urls %}
{% may_add_comments task as user_may_add_comments %}
{% may_modify task as user_may_modify %}
{% may_modify_limited task as user_may_modify_limited %}
{% targetstates task as possible_targetstates %}
{% targetpriority task as possible_targetpriority %}
<div class="taskheadline {{ task.class_by_state }}"{% if target_head %} ondblclick="location.href='{% url target_head task_id=task.id %}{% if target_head == 'patt-taskedit' %}{{"?do=edit&next="|add:request.path }}{% endif %}';" style="cursor:pointer;"{% endif %}>
<div class="taskname">{% render_creole task.taskname_prefix|add:task.name task.attachment_target_path next_anchor %}</div>
<p>
{% if printview != True and taskview != True %}
<span class="toggle-button" style="cursor:pointer; padding-right: 10px;">
<img class="minus" src="{% static 'patt/icons/collapse.png' %}">
<img class="plus" src="{% static 'patt/icons/expand.png' %}">
</span>
{% endif %}
<img class="taskicon" src="{% static 'patt/icons/prio'|add:priority|add:'.png' %}">
<img class="taskicon" src="{% static 'patt/icons/state'|add:state|add:'.png' %}">
<img class="taskicon" src="{% static 'patt/icons/pg_'|add:progress|add:'.png' %}">
{% if task.targetdate %}<span class="target_date_hide">{{ task.targetdate }}</span>{% endif %}
{% if printview != True %}
{% if user_may_add_comments %}<a href="{% url_commentnew task %}"><img src="{% static 'patt/icons/edit_comment.png' %}" style="float:right"></a>{% endif %}
{% if user_may_modify or user_may_modify_limited %}<a href="{% url_taskedit task %}"><img src="{% static 'patt/icons/edit.png' %}" style="float:right"></a>{% endif %}
{% if user_may_modify or user_may_add_comments or user_may_modify_limited %}<img class="spacer prio_icons_hide" src="{% static 'patt/icons/spacer.png' %}" style="float:right">{% endif %}
{% for i in possible_targetpriority %}
<a class="taskicon prio_icons_hide" href="{% url_taskset_priority task i %}"><img src="{% static 'patt/icons/prio'|add:i|add:'.png' %}" style="float:right"></a>
{% endfor %}
{% if possible_targetpriority %}
<img class="spacer state_icons_hide" src="{% static 'patt/icons/spacer.png' %}" style="float:right">
{% endif %}
{% for i in possible_targetstates %}
<a class="taskicon state_icons_hide" href="{% url_taskset_state task i %}"><img src="{% static 'patt/icons/state'|add:i|add:'.png' %}" style="float:right"></a>
{% endfor %}
</p>
{% endif %}
</div>

View File

@ -0,0 +1,6 @@
{% with priority=task.priority|stringformat:"s" state=task.state|stringformat:"s" progress=task.progress|stringformat:"s" task_id=task.id|stringformat:"s" %}
{% include 'patt/task/head.html' %}
<div class="toggle">
{% include 'patt/task/details.html' %}
</div>
{% endwith %}

View File

@ -0,0 +1,37 @@
{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
{% load static %}
{% load i18n %}
{% block head_extensions %}
<script src="{% static 'patt/jquery.min.js' %}"></script>
<link rel="stylesheet" href="{% static 'patt/datepicker.min.css' %}">
<script src="{% static 'patt/datepicker.min.js' %}"></script>
{{ form.media }}
<style>
{% include 'patt/patt.css' %}
</style>
<script>
$(function () {
$("#id_targetdate").datepicker({format:'yyyy-mm-dd', });
});
</script>
{% endblock head_extensions %}
{% block content %}
<form class="form" method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="hidden" name="next" value="{{ next }}">
<input type="submit" name="save" value="{% trans "Save" %}" class="button" />
{% if not disable_preview %}<input type="submit" formaction="#preview" name="preview" value="{% trans "Preview" %}" class="button" />{% endif %}
{{ form_comment.as_p }}
</form>
{% if template %}
<div class="preview-spacer" id="preview"></div>
<div class="preview">
{% include template %}
</div>
{% endif %}
{% endblock content %}

View File

@ -0,0 +1,36 @@
{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
{% load static %}
{% block head_extensions %}
<script src="{% static 'patt/jquery.min.js' %}"></script>
<style>
{% include 'patt/patt.css' %}
</style>
{% endblock head_extensions %}
{% block content %}
{% for task in tasklist %}
{% with task_id=task.id|stringformat:"s" %}
{% with next_anchor='task-'|add:task_id %}
<div {% if forloop.counter != 1 %} id="task-{{ task.id }}"{% endif %}>
<div class="tasklist{% if forloop.counter == 1 %}-first{% endif %}">
{% include 'patt/task/task.html' %}
</div>
</div>
{% endwith %}
{% endwith %}
{% endfor %}
<script>
$(function() {
$('div.toggle').hide();
$('img.minus').hide();
$('.toggle-button').click(function(){
$(this).children().first().toggle()
$(this).children().last().toggle()
$(this).closest('div').next('div.toggle').toggle();
});
});
</script>
{% endblock content %}

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
{% include 'patt/patt.css' %}
</style>
{% for task in tasklist %}
<div {% if forloop.counter != 1 %} id="task-{{ task.id }}"{% endif %}>
<div class="tasklist{% if forloop.counter == 1 %}-first{% endif %}">
{% include 'patt/task/task.html' %}
</div>
</div>
{% endfor %}
</body>
</html>

View File

@ -0,0 +1,12 @@
{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
{% block head_extensions %}
<style>
{% include 'patt/patt.css' %}
</style>
{% endblock head_extensions %}
{% block content %}
{% include 'patt/task/task.html' %}
{% endblock content %}

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
{% include 'patt/patt.css' %}
</style>
{% include 'patt/task/task.html' %}
</body>
</html>

0
templatetags/__init__.py Normal file
View File

64
templatetags/access.py Normal file
View File

@ -0,0 +1,64 @@
from ..access import acc_task, acc_project
from django import template
from ..models import Task, Project, Comment
from ..views import TaskLike, ProjectLike, CommentLike
register = template.Library()
class xlike_acc(object):
add_comments = False
modify = False
modify_comment = False
modify_limited = False
allowed_targetstates = []
allowed_targetpriority = []
def get_acc(obj, user):
if type(obj) is Comment:
return acc_task(obj.task, user)
elif type(obj) is Task:
return acc_task(obj, user)
elif type(obj) is Project:
return acc_project(obj, user)
elif type(obj) in [TaskLike, ProjectLike, CommentLike]:
return xlike_acc()
else:
raise AttributeError("Unknown obj of type", type(obj))
@register.simple_tag(name='may_add_comments', takes_context=True)
def may_add_comments(context, obj):
acc = get_acc(obj, context['request'].user)
return acc.add_comments
@register.simple_tag(name='may_modify', takes_context=True)
def may_modify(context, obj):
acc = get_acc(obj, context['request'].user)
return acc.modify
@register.simple_tag(name='may_modify_comment', takes_context=True)
def may_modify_comment(context, obj):
acc = get_acc(obj, context['request'].user)
return acc.modify_comment
@register.simple_tag(name='may_modify_limited', takes_context=True)
def may_modify_limited(context, obj):
acc = get_acc(obj, context['request'].user)
return acc.modify_limited
@register.simple_tag(name='targetstates', takes_context=True)
def targetstates(context, obj):
acc = get_acc(obj, context['request'].user)
return [str(i) for i in acc.allowed_targetstates]
@register.simple_tag(name='targetpriority', takes_context=True)
def targetpriority(context, obj):
acc = get_acc(obj, context['request'].user)
return [str(i) for i in acc.allowed_targetpriority]

37
templatetags/patt_urls.py Normal file
View File

@ -0,0 +1,37 @@
from django import template
import patt
from ..search import mk_search_pattern
register = template.Library()
@register.simple_tag(name='url_taskedit', takes_context=True)
def url_taskedit(context, task):
return patt.url_taskedit(context['request'], task.id)
@register.simple_tag(name='url_taskset_priority', takes_context=True)
def url_taskset_priority(context, task, target_priority):
return patt.url_taskset(context['request'], task.id, priority=target_priority)
@register.simple_tag(name='url_taskset_state', takes_context=True)
def url_taskset_state(context, task, target_state):
return patt.url_taskset(context['request'], task.id, state=target_state)
@register.simple_tag(name='url_commentnew', takes_context=True)
def url_commentnew(context, task):
return patt.url_commentnew(context['request'], task.id)
@register.simple_tag(name='url_projectedit', takes_context=True)
def url_projectedit(context, project):
return patt.url_projectedit(context['request'], project.id)
@register.simple_tag(name='url_tasklist_for_project', takes_context=True)
def url_tasklist_for_project(context, project):
request = context['request']
return patt.url_tasklist(request, search_txt=mk_search_pattern(prj_ids=[project.id]))

3
tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

30
urls.py Normal file
View File

@ -0,0 +1,30 @@
from django.shortcuts import redirect
from django.urls import path
from django.urls.base import reverse
from .search import SEARCH_MY_OPEN_TASKS
from . import views
urlpatterns = [
path('', lambda request: redirect(reverse('patt-commonfilter', kwargs={'common_filter_id': SEARCH_MY_OPEN_TASKS}), permanent=False)),
#
# views and urls
#
path('tasklist/', views.patt_tasklist, name='patt-tasklist'),
path('projectlist/', views.patt_projectlist, name='patt-projectlist'),
path('taskview/<int:task_id>/', views.patt_taskview, name='patt-taskview'),
path('helpview/', views.patt_helpview, name='patt-helpview'),
path('helpview/<str:page>', views.patt_helpview, name='patt-helpview'),
path('tasknew/', views.patt_tasknew, name='patt-tasknew'),
path('projectnew/', views.patt_projectnew, name='patt-projectnew'),
path('taskedit/<int:task_id>/', views.patt_taskedit, name='patt-taskedit'),
path('projectedit/<int:project_id>/', views.patt_projectedit, name='patt-projectedit'),
path('commentnew/<int:task_id>/', views.patt_commentnew, name='patt-commentnew'),
path('commentedit/<int:task_id>/<int:comment_id>/', views.patt_commentedit, name='patt-commentedit'),
path('filteredit/', views.patt_filteredit, name='patt-filternew'),
path('filteredit/<int:search_id>/', views.patt_filteredit, name='patt-filteredit'),
path('easysearch/', views.patt_easysearch, name='patt-easysearch'),
path('tasklist/user_filter/<int:user_filter_id>/', views.patt_tasklist, name='patt-userfilter'),
path('tasklist/common_filter/<int:common_filter_id>', views.patt_tasklist, name='patt-commonfilter'),
#
path('tasklist/search/', views.patt_tasklist, name='search'),
]

690
views.py Normal file
View File

@ -0,0 +1,690 @@
from .access import acc_task, acc_project, create_project_possible, create_task_possible
from .context import context_adaption
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import User
from django.shortcuts import render, redirect
from django.urls.base import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from .forms import TaskForm, TaskFormLimited, ProjectForm, CommentForm, TaskCommentForm, SearchForm, EasySearchForm
from .help import help_pages
from .models import Task, ModelList, Comment, TASKSTATE_CHOICES, PRIO_CHOICES, TASKS_IN_WORK
from .models import Project, Search
import patt
from .search import load_index, search, mk_search_pattern, get_project_ids_from_search_pattern, common_searches
from themes import Context
class Like(object):
DATA = []
def __init__(self, request, obj):
for key in self.DATA:
if key in request.POST:
setattr(self, key, request.POST.get(key))
else:
try:
setattr(self, key, getattr(obj, key))
except AttributeError:
setattr(self, key, None)
self.__init_additions__(request, obj)
if self.creation_date is None:
self.creation_date = timezone.now()
if self.id is None:
self.id = 'xxx'
def __init_additions__(self, request, obj):
pass
class TaskLike(Like):
DATA = ['id', 'state', 'priority', 'targetdate', 'progress', 'name', 'description', 'creation_date', 'comments']
def __init_additions__(self, request, obj):
if 'project' in request.POST:
self.project = Project.objects.get(id=request.POST.get('project'))
else:
self.project = getattr(obj, 'project')
if 'assigned_user' in request.POST:
try:
self.assigned_user = User.objects.get(id=request.POST.get('assigned_user'))
except ValueError:
self.assigned_user = None
else:
self.assigned_user = getattr(obj, 'assigned_user')
#
self.class_by_state = 'task-normal' if self.state == '0' else 'task-finished'
#
if obj is None:
self.attachment_target_path = Task().attachment_target_path
else:
self.attachment_target_path = obj.attachment_target_path
if self.assigned_user and self.id:
self.taskname_prefix = '**#%d //(%s)//:** ' % (self.id, self.assigned_user.username)
elif self.id:
self.taskname_prefix = '**#%d:** ' % self.id
elif self.assigned_user:
self.taskname_prefix = '**//(%s)//:** ' % self.assigned_user.username
else:
self.taskname_prefix = ''
class CommentLike(Like):
DATA = ['id', 'type', 'creation_date', 'comment']
def __init_additions__(self, request, obj):
if 'user' in request.POST:
self.user = User.objects.get(id=request.POST.get('user'))
else:
try:
self.user = getattr(obj, 'user')
except AttributeError:
self.user = request.user
#
if obj is None:
self.attachment_target_path = Comment().attachment_target_path
else:
self.attachment_target_path = obj.attachment_target_path
class ProjectLike(Like):
DATA = ['id', 'name', 'description', 'state', 'creation_date']
def __init_additions__(self, request, obj):
self.role_leader = [User.objects.get(id=user_id) for user_id in request.POST.getlist('role_leader', [])]
self.role_member = [User.objects.get(id=user_id) for user_id in request.POST.getlist('role_member', [])]
self.creation_date = '-'
self.formatted_leaders = ', '.join([user.username for user in self.role_leader])
self.formatted_members = ', '.join([user.username for user in self.role_member])
#
if obj is None:
self.attachment_target_path = Project().attachment_target_path
else:
self.attachment_target_path = obj.attachment_target_path
def permission_denied_msg_project(request, project_id):
if project_id is None:
messages.error(request, _('Permission denied to create a new Project.'))
else:
messages.error(request, _('Permission denied to Project #%(project_id)d') % {'project_id': project_id})
def permission_denied_msg_task(request, task_id):
if task_id is None:
messages.error(request, _('Permission denied to create a new Task'))
else:
messages.error(request, _('Permission denied to Task #%(task_id)d') % {'task_id': task_id})
def permission_denied_msg_comment(request, task_id):
messages.error(request, _('Permission denied to add or edit a comment of Task #%(task_id)d') % {'task_id': task_id})
def no_change_msg(request):
messages.info(request, _("Nothing changed, no storage needed."))
def does_not_exist_msg_task(request, task_id):
messages.error(request, _('Task #%(id)d does not exist!') % {'id': task_id})
def does_not_exist_msg_search(request, search_id):
messages.error(request, _('Search #%(id)d does not exist!') % {'id': search_id})
def get_next(request):
if not request.POST:
return request.GET.get('next', '/')
else:
return request.POST.get('next', '/')
@login_required
def patt_tasklist(request, user_filter_id=None, common_filter_id=None):
context = Context(request) # needs to be executed first because of time mesurement
is_printview = patt.is_printview(request)
search_txt = patt.get_search_query(request)
if user_filter_id is not None:
try:
s = Search.objects.get(id=user_filter_id)
except Search.DoesNotExist:
does_not_exist_msg_search(request, user_filter_id)
return redirect(patt.url_tasklist(request))
search_txt = s.search_txt
title = s.name
ix = load_index()
sr = search(ix, search_txt)
if sr is None:
messages.error(request, _('Invalid search pattern: %s') % repr(search_txt))
sr = []
tasklist = ModelList(sr, acc_task, request.user)
elif search_txt is not None:
ix = load_index()
sr = search(ix, search_txt)
if sr is None:
messages.error(request, _('Invalid search pattern: %s') % repr(search_txt))
sr = []
tasklist = ModelList(sr, acc_task, request.user)
max_len = 25
if len(search_txt) > max_len:
title = _('Searchresults for %s') % repr(search_txt[:max_len - 3] + '...')
else:
title = _('Searchresults for %s') % repr(search_txt)
elif common_filter_id is not None:
try:
title, search_txt = common_searches(request)[common_filter_id]
except KeyError:
messages.error(request, _('Invalid common search: %s') % repr(common_filter_id))
sr = []
title = _('Common Search Error')
else:
ix = load_index()
sr = search(ix, search_txt)
if sr is None:
messages.error(request, _('Invalid search pattern: %s') % repr(search_txt))
sr = []
tasklist = ModelList(sr, acc_task, request.user)
else:
tasklist = ModelList(Task.objects.filter(state__in=TASKS_IN_WORK), acc_task, request.user)
title = _("All Tasks in work")
try:
project_id = get_project_ids_from_search_pattern(search_txt)[0]
except IndexError:
project_id = None
except TypeError:
project_id = None
tasklist.sort()
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
tasklist=tasklist, # the tasklist to be shown (template)
printview=is_printview, # Show reduced view and functionality in printview
target_head=None if is_printview else 'patt-taskview', # target-link for head (template)
project_id=project_id, # as default Value for New Task entry
title=title, # the title for the page (template)
)
if patt.is_printview(request):
return render(request, 'patt/tasklist_print.html', context=context)
else:
return render(request, 'patt/tasklist.html', context=context)
@login_required
def patt_projectlist(request):
context = Context(request) # needs to be executed first because of time mesurement
projectlist = ModelList(Project.objects.all(), acc_project, request.user)
projectlist.sort()
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
projectlist=projectlist, # the projectlist to be shown (template)
target_head='patt-projectedit', # target-link for head (template)
title=_("Projectlist"), # the title for the page (template)
)
return render(request, 'patt/projectlist.html', context=context)
@login_required
def patt_taskview(request, task_id):
context = Context(request) # needs to be executed first because of time mesurement
is_printview = patt.is_printview(request)
try:
task = Task.objects.get(id=task_id)
except Task.DoesNotExist:
does_not_exist_msg_task(request, task_id)
else:
acc = acc_task(task, request.user)
if acc.read:
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
task=task, # the task to be shown (template)
printview=is_printview, # Show reduced view and functionality in printview
taskview=True, # deactivate collapse and expand buttons
target_head=None if is_printview else 'patt-taskedit', # target-link for head (template)
project_id=task.project.id, # as default Value for New Task entry
title=_('Task #%d') % task.id # the title for the page (template)
)
if patt.is_printview(request):
return render(request, 'patt/taskview_print.html', context=context)
else:
return render(request, 'patt/taskview.html', context=context)
else:
permission_denied_msg_task(request, task.id)
return redirect(reverse('patt-tasklist'))
@login_required
def patt_tasknew(request):
context = Context(request) # needs to be executed first because of time mesurement
nxt = get_next(request)
if create_task_possible(request.user):
if not request.POST: # Initial Form...
task = Task(project_id=request.GET.get('project_id'))
form = TaskForm(instance=task, request=request)
form_comment = TaskCommentForm()
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the task form (template)
form_comment=form_comment, # the form object to create the comment form (template)
next=nxt, # the url for redirection
title=_('New Task') # the title for the page (template)
)
return render(request, 'patt/task_form.html', context=context)
else:
task = Task()
form = TaskForm(request.POST, instance=task, request=request)
form_comment = TaskCommentForm(request.POST)
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the task form (template)
form_comment=form_comment, # the form object to create the comment form (template)
next=nxt, # the url for redirection
title=_('New Task') # the title for the page (template)
)
if request.POST.get('save'): # Save form content...
if form.is_valid() and form_comment.is_valid():
form.save()
if request.POST.get('comment'):
form_comment = TaskCommentForm(request.POST, instance=Comment(task_id=form.instance.id, user=request.user))
form_comment.save()
messages.success(request, _('Thanks for adding Task #%(task_id)d.') % {'task_id': form.instance.id})
return redirect(nxt + '#task-%d' % form.instance.id)
elif request.POST.get('preview'): # Create a preview
context['comment_new'] = CommentLike(request, None) # the new comment to be used in the preview
context['template'] = 'patt/task/task.html' # the template for preview (template)
context['task'] = TaskLike(request, task) # the object to be used in the preview template
context['printview'] = True # Show reduced view and functionality like in printview
return render(request, 'patt/task_form.html', context=context)
else:
permission_denied_msg_task(request, None)
return redirect(nxt)
@login_required
def patt_projectnew(request):
context = Context(request) # needs to be executed first because of time mesurement
nxt = get_next(request)
if create_project_possible(request.user):
project = Project()
if not request.POST:
form = ProjectForm(instance=project)
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the form (template)
next=nxt, # the url for redirection
disable_delete=True, # disable delete button
disable_search=True, # disable search button
title=_('New Project') # the title for the page (template)
)
return render(request, 'patt/raw_single_form.html', context=context)
else:
form = ProjectForm(request.POST, instance=project)
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the form (template)
next=nxt, # the url for redirection
disable_delete=True, # disable delete button
disable_search=True, # disable search button
title=_('New Project') # the title for the page (template)
)
#
if request.POST.get('save'):
if form.is_valid():
form.save()
messages.success(request, _('Thanks for adding Project #%(project_id)d.') % {'project_id': form.instance.id})
return redirect(nxt + '#project-%d' % form.instance.id)
elif request.POST.get('preview'):
context['template'] = 'patt/project/project.html' # the template for preview (template)
context['project'] = ProjectLike(request, None) # the object to be used in the preview template
return render(request, 'patt/raw_single_form.html', context=context)
else:
permission_denied_msg_project(request, None)
return redirect(nxt)
@login_required
def patt_taskedit(request, task_id, **kwargs):
context = Context(request) # needs to be executed first because of time mesurement
nxt = get_next(request)
try:
task = Task.objects.get(id=task_id)
except Task.DoesNotExist:
does_not_exist_msg_task(request, task_id)
return redirect(nxt)
else:
acc = acc_task(task, request.user)
if acc.modify or acc.modify_limited:
if acc.modify:
ThisForm = TaskForm
else:
ThisForm = TaskFormLimited
if not request.POST:
# get request parameters
do = request.GET.get('do')
state = request.GET.get('state')
if state:
state = int(state)
priority = request.GET.get('priority')
if priority:
priority = int(priority)
#
if do == 'edit':
form = ThisForm(instance=task, request=request)
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the task form (template)
form_comment=TaskCommentForm(), # the form object to create the comment form (template)
next=nxt, # the url for redirection
next_anchor='task-%d' % task.id, # the anchor for redirection
title=_('Edit Task #%d') % task.id # the title for the page (template)
)
return render(request, 'patt/task_form.html', context=context)
elif do == 'set_state':
if state in acc.allowed_targetstates:
task.state = state
task.save()
messages.success(request, _('State of Task #%(task_id)d set to %(name)s') % {'task_id': task.id, 'name': dict(TASKSTATE_CHOICES)[state]})
else:
permission_denied_msg_task(request, task_id)
elif do == 'set_priority':
if priority in acc.allowed_targetpriority:
task.priority = priority
task.save()
messages.success(request, _('Priority of Task #%(task_id)d set to %(name)s') % {'task_id': task.id, 'name': dict(PRIO_CHOICES)[priority]})
else:
permission_denied_msg_task(request, task_id)
else:
messages.error(request, 'Edit with do="%s" not yet implemented!' % do)
return redirect(nxt + '#task-%d' % task_id)
else:
comment = Comment(task_id=task_id, user=request.user)
form = ThisForm(request.POST, instance=task, request=request)
form_comment = TaskCommentForm(request.POST, instance=comment)
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the task form (template)
form_comment=form_comment, # the form object to create the comment form (template)
next=nxt, # the url for redirection
next_anchor='task-%d' % task.id, # the anchor for redirection
title=_('Edit Task #%d') % task.id # the title for the page (template)
)
if request.POST.get('save'):
if form.is_valid() and form_comment.is_valid(): # TODO: Validator depending on modify and modify_limited (to ensure content is okay for this user)!
save_needed = form.save().save_needed
if request.POST.get('comment'):
save_needed |= form_comment.save().save_needed
if save_needed:
messages.success(request, _('Thanks for editing Task #%(task_id)d.') % {'task_id': task.id})
else:
no_change_msg(request)
return redirect(nxt + '#task-%d' % task_id)
elif request.POST.get('preview'):
context['comment_new'] = CommentLike(request, comment) # the new comment to be used in the preview
context['template'] = 'patt/task/task.html' # the template for preview (template)
context['task'] = TaskLike(request, task) # the object to be used in the preview template
context['printview'] = True # Show reduced view and functionality like in printview
return render(request, 'patt/task_form.html', context=context)
else:
permission_denied_msg_task(request, task_id)
return redirect(nxt + '#task-%d' % task_id)
@login_required
def patt_projectedit(request, project_id):
context = Context(request) # needs to be executed first because of time mesurement
nxt = get_next(request)
try:
project = Project.objects.get(id=project_id)
except Task.DoesNotExist:
messages.error(request, _('Project with id %(project_id)d does not exist!') % {'project_id': project_id})
return redirect(nxt)
else:
acc = acc_project(project, request.user)
if acc.modify:
if not request.POST:
form = ProjectForm(instance=project)
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the form (template)
next=nxt, # the url for redirection
next_anchor='project-%d' % project_id, # the anchor for redirection
item_id=project.id, # project.id for Preview, ... (template)
disable_delete=True, # disable delete button
disable_search=True, # disable search button
title=_('Edit Project #%d') % project_id # the title for the page (template)
)
return render(request, 'patt/raw_single_form.html', context=context)
else:
form = ProjectForm(request.POST, instance=project)
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the form (template)
next=nxt, # the url for redirection
next_anchor='project-%d' % project_id, # the anchor for redirection
item_id=project.id, # project.id for Preview, ... (template)
disable_delete=True, # disable delete button
disable_search=True, # disable search button
title=_('Edit Project #%d') % project_id # the title for the page (template)
)
#
if request.POST.get('save'):
if form.is_valid():
form.save()
messages.success(request, _('Thanks for editing Project #%(project_id)d.') % {'project_id': project.id})
return redirect(nxt + '#project-%d' % project_id)
elif request.POST.get('preview'):
context['template'] = 'patt/project/project.html' # the template for preview (template)
context['project'] = ProjectLike(request, project) # the object to be used in the preview template
return render(request, 'patt/raw_single_form.html', context=context)
else:
permission_denied_msg_project(request, project_id)
return redirect(nxt + '#project-%d' % project_id)
@login_required
def patt_commentnew(request, task_id):
context = Context(request) # needs to be executed first because of time mesurement
nxt = get_next(request)
try:
task = Task.objects.get(id=task_id)
except Task.DoesNotExist:
does_not_exist_msg_task(request, task_id)
return redirect(nxt)
else:
acc = acc_task(task, request.user)
if acc.add_comments:
if not request.POST:
form = CommentForm(instance=Comment())
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the form (template)
next=nxt, # the url for redirection
next_anchor='task-%d' % task_id, # the anchor for redirection
disable_delete=True, # disable delete button
disable_search=True, # disable search button
title=_('Add a Comment (Task #%d)') % task_id # the title for the page (template)
)
return render(request, 'patt/raw_single_form.html', context=context)
else:
comment = Comment(task_id=task_id, user=request.user)
#
form = CommentForm(request.POST, instance=comment)
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the form (template)
next=nxt, # the url for redirection
next_anchor='task-%d' % task_id, # the anchor for redirection
disable_delete=True, # disable delete button
disable_search=True, # disable search button
title=_('Add Comment to Task #%d') % task_id # the title for the page (template)
)
if request.POST.get('save'):
if form.is_valid():
form.save()
messages.success(request, _('Thanks for adding a comment to Task #%(task_id)d.') % {'task_id': task_id})
return redirect(nxt + '#task-%d' % task_id)
elif request.POST.get('preview'):
context['template'] = 'patt/task/comment.html' # the template for preview (template)
context['comment'] = CommentLike(request, comment) # the object to be used in the preview template
return render(request, 'patt/raw_single_form.html', context=context)
else:
permission_denied_msg_comment(request, task_id)
return redirect(nxt + '#task-%d' % task_id)
@login_required
def patt_commentedit(request, task_id, comment_id):
context = Context(request) # needs to be executed first because of time mesurement
nxt = get_next(request)
comment = Comment.objects.get(id=comment_id)
acc = acc_task(comment.task, request.user)
if acc.modify_comment:
if not request.POST:
form = CommentForm(instance=comment)
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the form (template)
next=nxt, # the url for redirection
next_anchor='task-%d' % task_id, # the anchor for redirection
disable_delete=True, # disable delete button
disable_search=True, # disable search button
title=_('Edit Comment (Task #%d)') % task_id # the title for the page (template)
)
return render(request, 'patt/raw_single_form.html', context=context)
else:
form = CommentForm(request.POST, instance=comment)
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the form (template)
next=nxt, # the url for redirection
next_anchor='task-%d' % task_id, # the anchor for redirection
disable_delete=True, # disable delete button
disable_search=True, # disable search button
title=_('Edit Comment (Task #%d)') % task_id # the title for the page (template)
)
#
if request.POST.get('save'):
if form.is_valid():
if form.save().save_needed:
messages.success(request, _('Thanks for editing a comment of Task #%(task_id)d.') % {'task_id': task_id})
else:
no_change_msg(request)
return redirect(nxt + '#task-%d' % task_id)
elif request.POST.get('preview'):
context['template'] = 'patt/task/comment.html' # the template for preview (template)
context['comment'] = CommentLike(request, comment) # the object to be used in the preview template
return render(request, 'patt/raw_single_form.html', context=context)
else:
permission_denied_msg_comment(request, task_id)
return redirect(nxt + '#task-%d' % task_id)
def patt_filteredit(request, search_id=None):
def filter_does_not_exist_error(request, search_id):
messages.error(request, _('Filter #%d does not exist.') % search_id)
context = Context(request) # needs to be executed first because of time mesurement
if not request.POST:
if search_id is not None:
try:
form = SearchForm(instance=Search.objects.get(id=search_id))
except Search.DoesNotExist:
filter_does_not_exist_error(request, search_id)
return redirect('/')
else:
if patt.get_search_query(request) is None:
form = SearchForm(initial={'user': request.user})
else:
form = SearchForm(initial={'search_txt': patt.get_search_query(request), 'user': request.user})
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the form (template)
disable_preview=True, # disable the preview button
disable_search=True, # disable search button
title=_('Edit Filter') # the title for the page (template)
)
return render(request, 'patt/raw_single_form.html', context=context)
else:
if search_id is not None:
try:
s = Search.objects.get(id=search_id)
except Search.DoesNotExist:
filter_does_not_exist_error(request, search_id)
return redirect('/')
else:
s = Search(user=request.user)
if request.user == s.user:
form = SearchForm(request.POST, instance=s)
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the form (template)
disable_preview=True, # disable the preview button
disable_search=True, # disable search button
title=_('Edit Filter') # the title for the page (template)
)
#
if request.POST.get('save'):
if form.is_valid():
s.save()
messages.success(request, _('Thanks for editing Filter #%(search_id)d.') % {'search_id': s.id})
return redirect(patt.url_tasklist(request, user_filter_id=s.id))
elif request.POST.get('delete'):
messages.success(request, _('Filter #%(search_id)d delteted.') % {'search_id': s.id})
s.delete()
return redirect('/')
return render(request, 'patt/raw_single_form.html', context=context)
else:
messages.error(request, _('Access to Filter (%s) denied.') % repr(search_id))
return redirect('/')
def patt_easysearch(request):
context = Context(request) # needs to be executed first because of time mesurement
if not request.POST:
form = EasySearchForm()
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
form=form, # the form object to create the form (template)
disable_preview=True, # disable the preview button
disable_delete=True, # disable the delete button
disable_save=True, # disable save button
title=_('Edit Filter') # the title for the page (template)
)
return render(request, 'patt/raw_single_form.html', context=context)
else:
form = EasySearchForm(request.POST)
form_data = dict(form.data)
if 'states' not in form_data:
form_data['states'] = []
return redirect(patt.url_tasklist(request, search_txt=mk_search_pattern(**form_data)))
def patt_helpview(request, page='main'):
context = Context(request) # needs to be executed first because of time mesurement
help_content = help_pages[page]
context_adaption(
context, # the base context
request, # the request object to be used in context_adaption
current_help_page=page, # the current help_page to identify which taskbar entry has to be highlighted
help_content=help_content, # the help content itself (template)
title=_('Help') # the title for the page (template)
)
return render(request, 'patt/help.html', context=context)