From c834976e79f98f6c59cf6fb33b0f5850b008281b Mon Sep 17 00:00:00 2001 From: Dirk Alders Date: Sun, 26 Jan 2020 21:04:45 +0100 Subject: [PATCH] Initial patt implementation --- __init__.py | 105 +++ access.py | 208 ++++++ admin.py | 65 ++ apps.py | 8 + context.py | 247 +++++++ creole.py | 45 ++ forms.py | 137 ++++ help.py | 153 ++++ management/__init__.py | 0 management/commands/__init__.py | 0 management/commands/rebuild_index.py | 9 + migrations/0001_initial.py | 76 ++ migrations/0002_auto_20191003_1614.py | 29 + .../0003_remove_tasklistsetting_sorttype.py | 17 + migrations/0004_auto_20191004_1125.py | 45 ++ migrations/0005_project_creation_date.py | 20 + migrations/0006_auto_20191006_1824.py | 18 + .../0007_historicalcomment_historicaltask.py | 65 ++ .../0008_remove_viewsetting_displaytype.py | 17 + migrations/0009_auto_20191102_1925.py | 41 ++ migrations/0010_auto_20191117_1157.py | 36 + migrations/0011_project_role_visitor.py | 20 + migrations/0012_auto_20200114_1035.py | 35 + migrations/__init__.py | 0 models.py | 281 +++++++ search.py | 167 +++++ signals.py | 31 + static/patt/datepicker.min.css | 9 + static/patt/datepicker.min.js | 10 + static/patt/draft.png | Bin 0 -> 6106 bytes static/patt/icons/collapse.png | Bin 0 -> 264 bytes static/patt/icons/edit.png | Bin 0 -> 749 bytes static/patt/icons/edit_comment.png | Bin 0 -> 1312 bytes static/patt/icons/expand.png | Bin 0 -> 279 bytes static/patt/icons/pg_0.png | Bin 0 -> 171 bytes static/patt/icons/pg_10.png | Bin 0 -> 179 bytes static/patt/icons/pg_100.png | Bin 0 -> 173 bytes static/patt/icons/pg_20.png | Bin 0 -> 179 bytes static/patt/icons/pg_30.png | Bin 0 -> 179 bytes static/patt/icons/pg_40.png | Bin 0 -> 179 bytes static/patt/icons/pg_50.png | Bin 0 -> 179 bytes static/patt/icons/pg_60.png | Bin 0 -> 179 bytes static/patt/icons/pg_70.png | Bin 0 -> 179 bytes static/patt/icons/pg_80.png | Bin 0 -> 179 bytes static/patt/icons/pg_90.png | Bin 0 -> 178 bytes static/patt/icons/prio1.png | Bin 0 -> 742 bytes static/patt/icons/prio2.png | Bin 0 -> 906 bytes static/patt/icons/prio3.png | Bin 0 -> 892 bytes static/patt/icons/prio4.png | Bin 0 -> 859 bytes static/patt/icons/prio5.png | Bin 0 -> 912 bytes static/patt/icons/prio6.png | Bin 0 -> 942 bytes static/patt/icons/prio7.png | Bin 0 -> 813 bytes static/patt/icons/spacer.png | Bin 0 -> 138 bytes static/patt/icons/state0.png | Bin 0 -> 651 bytes static/patt/icons/state1.png | Bin 0 -> 800 bytes static/patt/icons/state2.png | Bin 0 -> 557 bytes static/patt/icons/state3.png | Bin 0 -> 454 bytes static/patt/jquery.min.js | 4 + templates/patt/help.html | 12 + templates/patt/patt.css | 131 ++++ templates/patt/project/details.html | 3 + templates/patt/project/head.html | 28 + templates/patt/project/project.html | 4 + templates/patt/projectlist.html | 20 + templates/patt/raw_single_form.html | 31 + templates/patt/task/comment.html | 9 + templates/patt/task/details.html | 19 + templates/patt/task/head.html | 41 ++ templates/patt/task/task.html | 6 + templates/patt/task_form.html | 37 + templates/patt/tasklist.html | 36 + templates/patt/tasklist_print.html | 23 + templates/patt/taskview.html | 12 + templates/patt/taskview_print.html | 16 + templatetags/__init__.py | 0 templatetags/access.py | 64 ++ templatetags/patt_urls.py | 37 + tests.py | 3 + urls.py | 30 + views.py | 690 ++++++++++++++++++ 80 files changed, 3150 insertions(+) create mode 100644 __init__.py create mode 100644 access.py create mode 100644 admin.py create mode 100644 apps.py create mode 100644 context.py create mode 100644 creole.py create mode 100644 forms.py create mode 100644 help.py create mode 100644 management/__init__.py create mode 100644 management/commands/__init__.py create mode 100644 management/commands/rebuild_index.py create mode 100644 migrations/0001_initial.py create mode 100644 migrations/0002_auto_20191003_1614.py create mode 100644 migrations/0003_remove_tasklistsetting_sorttype.py create mode 100644 migrations/0004_auto_20191004_1125.py create mode 100644 migrations/0005_project_creation_date.py create mode 100644 migrations/0006_auto_20191006_1824.py create mode 100644 migrations/0007_historicalcomment_historicaltask.py create mode 100644 migrations/0008_remove_viewsetting_displaytype.py create mode 100644 migrations/0009_auto_20191102_1925.py create mode 100644 migrations/0010_auto_20191117_1157.py create mode 100644 migrations/0011_project_role_visitor.py create mode 100644 migrations/0012_auto_20200114_1035.py create mode 100644 migrations/__init__.py create mode 100644 models.py create mode 100644 search.py create mode 100644 signals.py create mode 100644 static/patt/datepicker.min.css create mode 100644 static/patt/datepicker.min.js create mode 100644 static/patt/draft.png create mode 100644 static/patt/icons/collapse.png create mode 100644 static/patt/icons/edit.png create mode 100644 static/patt/icons/edit_comment.png create mode 100644 static/patt/icons/expand.png create mode 100644 static/patt/icons/pg_0.png create mode 100644 static/patt/icons/pg_10.png create mode 100644 static/patt/icons/pg_100.png create mode 100644 static/patt/icons/pg_20.png create mode 100644 static/patt/icons/pg_30.png create mode 100644 static/patt/icons/pg_40.png create mode 100644 static/patt/icons/pg_50.png create mode 100644 static/patt/icons/pg_60.png create mode 100644 static/patt/icons/pg_70.png create mode 100644 static/patt/icons/pg_80.png create mode 100644 static/patt/icons/pg_90.png create mode 100644 static/patt/icons/prio1.png create mode 100644 static/patt/icons/prio2.png create mode 100644 static/patt/icons/prio3.png create mode 100644 static/patt/icons/prio4.png create mode 100644 static/patt/icons/prio5.png create mode 100644 static/patt/icons/prio6.png create mode 100644 static/patt/icons/prio7.png create mode 100644 static/patt/icons/spacer.png create mode 100644 static/patt/icons/state0.png create mode 100644 static/patt/icons/state1.png create mode 100644 static/patt/icons/state2.png create mode 100644 static/patt/icons/state3.png create mode 100644 static/patt/jquery.min.js create mode 100644 templates/patt/help.html create mode 100644 templates/patt/patt.css create mode 100644 templates/patt/project/details.html create mode 100644 templates/patt/project/head.html create mode 100644 templates/patt/project/project.html create mode 100644 templates/patt/projectlist.html create mode 100644 templates/patt/raw_single_form.html create mode 100644 templates/patt/task/comment.html create mode 100644 templates/patt/task/details.html create mode 100644 templates/patt/task/head.html create mode 100644 templates/patt/task/task.html create mode 100644 templates/patt/task_form.html create mode 100644 templates/patt/tasklist.html create mode 100644 templates/patt/tasklist_print.html create mode 100644 templates/patt/taskview.html create mode 100644 templates/patt/taskview_print.html create mode 100644 templatetags/__init__.py create mode 100644 templatetags/access.py create mode 100644 templatetags/patt_urls.py create mode 100644 tests.py create mode 100644 urls.py create mode 100644 views.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0c2930e --- /dev/null +++ b/__init__.py @@ -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 diff --git a/access.py b/access.py new file mode 100644 index 0000000..58be7d6 --- /dev/null +++ b/access.py @@ -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 diff --git a/admin.py b/admin.py new file mode 100644 index 0000000..ce655ac --- /dev/null +++ b/admin.py @@ -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) diff --git a/apps.py b/apps.py new file mode 100644 index 0000000..c1038c8 --- /dev/null +++ b/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class PattConfig(AppConfig): + name = 'patt' + + def ready(self): + import patt.signals diff --git a/context.py b/context.py new file mode 100644 index 0000000..1565551 --- /dev/null +++ b/context.py @@ -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 + ) diff --git a/creole.py b/creole.py new file mode 100644 index 0000000..02f322f --- /dev/null +++ b/creole.py @@ -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 diff --git a/forms.py b/forms.py new file mode 100644 index 0000000..3f1a710 --- /dev/null +++ b/forms.py @@ -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')] diff --git a/help.py b/help.py new file mode 100644 index 0000000..58181b0 --- /dev/null +++ b/help.py @@ -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 + ) diff --git a/management/__init__.py b/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/management/commands/__init__.py b/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/management/commands/rebuild_index.py b/management/commands/rebuild_index.py new file mode 100644 index 0000000..12de314 --- /dev/null +++ b/management/commands/rebuild_index.py @@ -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) diff --git a/migrations/0001_initial.py b/migrations/0001_initial.py new file mode 100644 index 0000000..df074d8 --- /dev/null +++ b/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/migrations/0002_auto_20191003_1614.py b/migrations/0002_auto_20191003_1614.py new file mode 100644 index 0000000..765cb45 --- /dev/null +++ b/migrations/0002_auto_20191003_1614.py @@ -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), + ), + ] diff --git a/migrations/0003_remove_tasklistsetting_sorttype.py b/migrations/0003_remove_tasklistsetting_sorttype.py new file mode 100644 index 0000000..010bc48 --- /dev/null +++ b/migrations/0003_remove_tasklistsetting_sorttype.py @@ -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', + ), + ] diff --git a/migrations/0004_auto_20191004_1125.py b/migrations/0004_auto_20191004_1125.py new file mode 100644 index 0000000..39d56df --- /dev/null +++ b/migrations/0004_auto_20191004_1125.py @@ -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'), + ), + ] diff --git a/migrations/0005_project_creation_date.py b/migrations/0005_project_creation_date.py new file mode 100644 index 0000000..2b38a23 --- /dev/null +++ b/migrations/0005_project_creation_date.py @@ -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, + ), + ] diff --git a/migrations/0006_auto_20191006_1824.py b/migrations/0006_auto_20191006_1824.py new file mode 100644 index 0000000..690389c --- /dev/null +++ b/migrations/0006_auto_20191006_1824.py @@ -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', + ), + ] diff --git a/migrations/0007_historicalcomment_historicaltask.py b/migrations/0007_historicalcomment_historicaltask.py new file mode 100644 index 0000000..33144f1 --- /dev/null +++ b/migrations/0007_historicalcomment_historicaltask.py @@ -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), + ), + ] diff --git a/migrations/0008_remove_viewsetting_displaytype.py b/migrations/0008_remove_viewsetting_displaytype.py new file mode 100644 index 0000000..1701a93 --- /dev/null +++ b/migrations/0008_remove_viewsetting_displaytype.py @@ -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', + ), + ] diff --git a/migrations/0009_auto_20191102_1925.py b/migrations/0009_auto_20191102_1925.py new file mode 100644 index 0000000..d89bc81 --- /dev/null +++ b/migrations/0009_auto_20191102_1925.py @@ -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', + ), + ] diff --git a/migrations/0010_auto_20191117_1157.py b/migrations/0010_auto_20191117_1157.py new file mode 100644 index 0000000..70c394f --- /dev/null +++ b/migrations/0010_auto_20191117_1157.py @@ -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), + ), + ] diff --git a/migrations/0011_project_role_visitor.py b/migrations/0011_project_role_visitor.py new file mode 100644 index 0000000..9fed1f8 --- /dev/null +++ b/migrations/0011_project_role_visitor.py @@ -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), + ), + ] diff --git a/migrations/0012_auto_20200114_1035.py b/migrations/0012_auto_20200114_1035.py new file mode 100644 index 0000000..d52b6f8 --- /dev/null +++ b/migrations/0012_auto_20200114_1035.py @@ -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', + ), + ] diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models.py b/models.py new file mode 100644 index 0000000..7441d7e --- /dev/null +++ b/models.py @@ -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) diff --git a/search.py b/search.py new file mode 100644 index 0000000..b76cc60 --- /dev/null +++ b/search.py @@ -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 diff --git a/signals.py b/signals.py new file mode 100644 index 0000000..9f7d3b6 --- /dev/null +++ b/signals.py @@ -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) diff --git a/static/patt/datepicker.min.css b/static/patt/datepicker.min.css new file mode 100644 index 0000000..1368f4f --- /dev/null +++ b/static/patt/datepicker.min.css @@ -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} \ No newline at end of file diff --git a/static/patt/datepicker.min.js b/static/patt/datepicker.min.js new file mode 100644 index 0000000..1ef094e --- /dev/null +++ b/static/patt/datepicker.min.js @@ -0,0 +1,10 @@ +/*! + * Datepicker v0.6.5 + * https://github.com/fengyuanchen/datepicker + * + * Copyright (c) 2014-2018 Chen Fengyuan + * Released under the MIT license + * + * Date: 2018-03-31T06:17:11.587Z + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("jquery")):"function"==typeof define&&define.amd?define(["jquery"],t):t(e.jQuery)}(this,function(D){"use strict";D=D&&D.hasOwnProperty("default")?D.default:D;var a={autoShow:!1,autoHide:!1,autoPick:!1,inline:!1,container:null,trigger:null,language:"",format:"mm/dd/yyyy",date:null,startDate:null,endDate:null,startView:0,weekStart:0,yearFirst:!1,yearSuffix:"",days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],daysShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],daysMin:["Su","Mo","Tu","We","Th","Fr","Sa"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],monthsShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],itemTag:"li",mutedClass:"muted",pickedClass:"picked",disabledClass:"disabled",highlightedClass:"highlighted",template:'
          ',offset:10,zIndex:1e3,filter:null,show:null,hide:null,pick:null},e="undefined"!=typeof window?window:{},d="datepicker",s="click."+d,n="focus."+d,r="hide."+d,h="keyup."+d,o="pick."+d,t="resize."+d,l="show."+d,u=d+"-hide",c={},p=0,f=1,g=2,i=Object.prototype.toString;function y(e){return"string"==typeof e}var v=Number.isNaN||e.isNaN;function m(e){return"number"==typeof e&&!v(e)}function w(e){return void 0===e}function k(e){return"date"===(t=e,i.call(t).slice(8,-1).toLowerCase());var t}function b(a,s){for(var e=arguments.length,n=Array(2i.getFullYear(),6===c&&(u=f)),!f&&s&&(f=!1===s.call(this.$element,p));var g=r+c===o,y=g?"year picked":"year";l.push(this.createItem({picked:g,disabled:f,text:r+c,view:f?"year disabled":y,highlighted:p.getFullYear()===h}))}this.$yearsPrev.toggleClass(a,d),this.$yearsNext.toggleClass(a,u),this.$yearsCurrent.toggleClass(a,!0).html(r+-5+n+" - "+(r+6)+n),this.$years.html(l.join(""))},renderMonths:function(){var e=this.options,t=this.startDate,i=this.endDate,a=this.viewDate,s=e.disabledClass||"",n=e.monthsShort,r=D.isFunction(e.filter)&&e.filter,h=a.getFullYear(),o=new Date,l=o.getFullYear(),d=o.getMonth(),u=this.date.getFullYear(),c=this.date.getMonth(),p=[],f=!1,g=!1,y=void 0;for(y=0;y<=11;y+=1){var v=new Date(h,y,1),m=!1;t&&(m=(f=v.getFullYear()===t.getFullYear())&&v.getMonth()i.getMonth()),!m&&r&&(m=!1===r.call(this.$element,v));var w=h===u&&y===c,k=w?"month picked":"month";p.push(this.createItem({disabled:m,picked:w,highlighted:h===l&&v.getMonth()===d,index:y,text:n[y],view:m?"month disabled":k}))}this.$yearPrev.toggleClass(s,f),this.$yearNext.toggleClass(s,g),this.$yearCurrent.toggleClass(s,f&&g).html(h+e.yearSuffix||""),this.$months.html(p.join(""))},renderDays:function(){var e=this.$element,t=this.options,i=this.startDate,a=this.endDate,s=this.viewDate,n=this.date,r=t.disabledClass,h=t.filter,o=t.monthsShort,l=t.weekStart,d=t.yearSuffix,u=s.getFullYear(),c=s.getMonth(),p=new Date,f=p.getFullYear(),g=p.getMonth(),y=p.getDate(),v=n.getFullYear(),m=n.getMonth(),w=n.getDate(),k=void 0,D=void 0,b=void 0,C=[],$=u,x=c,F=!1;0===c?($-=1,x=11):x-=1,k=G($,x);var M=new Date(u,c,1);for((b=M.getDay()-parseInt(l,10)%7)<=0&&(b+=7),i&&(F=M.getTime()<=i.getTime()),D=k-(b-1);D<=k;D+=1){var V=new Date($,x,D),Y=!1;i&&(Y=V.getTime()=a.getTime()),D=1;D<=b;D+=1){var j=new Date(S,T,D),q=S===v&&T===m&&D===w,A=!1;a&&(A=j.getTime()>a.getTime()),!A&&h&&(A=!1===h.call(e,j)),I.push(this.createItem({disabled:A,picked:q,highlighted:S===f&&T===g&&j.getDate()===y,muted:!0,text:D,view:"day next"}))}var W=[];for(D=1;D<=k;D+=1){var z=new Date(u,c,D),J=!1;i&&(J=z.getTime()a.getTime()),!J&&h&&(J=!1===h.call(e,z));var O=u===v&&c===m&&D===w,E=O?"day picked":"day";W.push(this.createItem({disabled:J,picked:O,highlighted:u===f&&c===g&&z.getDate()===y,text:D,view:J?"day disabled":E}))}this.$monthPrev.toggleClass(r,F),this.$monthNext.toggleClass(r,N),this.$monthCurrent.toggleClass(r,F&&N).html(t.yearFirst?u+d+" "+o[c]:o[c]+" "+u+d),this.$days.html(C.join("")+W.join("")+I.join(""))}},I=function(){function a(e,t){for(var i=0;ia.getTime()&&(s=new Date(a)),this.endDate=a),this.date=s,this.viewDate=new Date(s),this.initialDate=new Date(this.date),this.bind(),(t.autoShow||this.inline)&&this.show(),t.autoPick&&this.pick()}},{key:"build",value:function(){if(!this.built){this.built=!0;var e=this.$element,t=this.options,i=D(t.template);this.$picker=i,this.$week=i.find(C("week")),this.$yearsPicker=i.find(C("years picker")),this.$yearsPrev=i.find(C("years prev")),this.$yearsNext=i.find(C("years next")),this.$yearsCurrent=i.find(C("years current")),this.$years=i.find(C("years")),this.$monthsPicker=i.find(C("months picker")),this.$yearPrev=i.find(C("year prev")),this.$yearNext=i.find(C("year next")),this.$yearCurrent=i.find(C("year current")),this.$months=i.find(C("months")),this.$daysPicker=i.find(C("days picker")),this.$monthPrev=i.find(C("month prev")),this.$monthNext=i.find(C("month next")),this.$monthCurrent=i.find(C("month current")),this.$days=i.find(C("days")),this.inline?D(t.container||e).append(i.addClass(d+"-inline")):(D(document.body).append(i.addClass(d+"-dropdown")),i.addClass(u)),this.renderWeek()}}},{key:"unbuild",value:function(){this.built&&(this.built=!1,this.$picker.remove())}},{key:"bind",value:function(){var e=this.options,t=this.$element;D.isFunction(e.show)&&t.on(l,e.show),D.isFunction(e.hide)&&t.on(r,e.hide),D.isFunction(e.pick)&&t.on(o,e.pick),this.isInput&&t.on(h,D.proxy(this.keyup,this)),this.inline||(e.trigger?this.$trigger.on(s,D.proxy(this.toggle,this)):this.isInput?t.on(n,D.proxy(this.show,this)):t.on(s,D.proxy(this.show,this)))}},{key:"unbind",value:function(){var e=this.$element,t=this.options;D.isFunction(t.show)&&e.off(l,t.show),D.isFunction(t.hide)&&e.off(r,t.hide),D.isFunction(t.pick)&&e.off(o,t.pick),this.isInput&&e.off(h,this.keyup),this.inline||(t.trigger?this.$trigger.off(s,this.toggle):this.isInput?e.off(n,this.show):e.off(s,this.show))}},{key:"showView",value:function(e){var t=this.$yearsPicker,i=this.$monthsPicker,a=this.$daysPicker,s=this.format;if(s.hasYear||s.hasMonth||s.hasDay)switch(Number(e)){case g:i.addClass(u),a.addClass(u),s.hasYear?(this.renderYears(),t.removeClass(u),this.place()):this.showView(p);break;case f:t.addClass(u),a.addClass(u),s.hasMonth?(this.renderMonths(),i.removeClass(u),this.place()):this.showView(g);break;default:t.addClass(u),i.addClass(u),s.hasDay?(this.renderDays(),a.removeClass(u),this.place()):this.showView(f)}}},{key:"hideView",value:function(){!this.inline&&this.options.autoHide&&this.hide()}},{key:"place",value:function(){if(!this.inline){var e=this.$element,t=this.options,i=this.$picker,a=D(document).outerWidth(),s=D(document).outerHeight(),n=e.outerWidth(),r=e.outerHeight(),h=i.width(),o=i.height(),l=e.offset(),d=l.left,u=l.top,c=parseFloat(t.offset),p=S;v(c)&&(c=10),o'+a.text+""}},{key:"getValue",value:function(){var e=this.$element;return this.isInput?e.val():e.text()}},{key:"setValue",value:function(){var e=0<|iOuz>B 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 literal 0 HcmV?d00001 diff --git a/static/patt/icons/collapse.png b/static/patt/icons/collapse.png new file mode 100644 index 0000000000000000000000000000000000000000..359dea34834d3b2e6af5c9fc2192ef82cc13ffa9 GIT binary patch literal 264 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9F5M?jcysy3fAP;i&0 zi(`n#@wZnuay1(Wusyi1bncO{g+(=IO4)~4miw+XJcVB@zcfyH@;;!2?`Y6$o=f*c ziXPg%v)~Z0ShPt;QfNo(y9fM#7zEz8r$#ExZ~gvt|Gy49CYL3m6U{gjluS?drKl@y z4R_ECognieqHD&J1yi*4FhspRV`?4}#a(*Zo&#@3)@z!hCvkkX9|w7Yv@R KelF{r5}E)2>Syl& literal 0 HcmV?d00001 diff --git a/static/patt/icons/edit.png b/static/patt/icons/edit.png new file mode 100644 index 0000000000000000000000000000000000000000..1827293e402cfafba5f9a0cff7001454289d935a GIT binary patch literal 749 zcmV!Oh%SqktJt!&ulvsX&P<0i%++*# zcdoItJ32h2J|ETx{8qf>tdr}Dmgow>#H5&5U4#B`JJbVR zP$dlgg=cf(5nJc?1B4iRA3Rq*4UOi=80U_In}r9=;m!cSUIL}(5YS)g+M7!=Qb;h) z4ZAg>lC9e!p()<2y2AA)KqLV&4ui$KJgQ9(Y`j+SGn;)Fj*UcsLVnRdcn_cl|KKbJ zOr-!5fP^({T)w`u`N^%hXDbU>3C>``Qck1Aem(!`@;h*WFCUOM^TP1|2Q0PF0&rld z9W2DPMnH}gj4$3`;mlp&`2l(ONmn>9Z$#$c-7DT)>JD*ITCwnX0Ux#WH_tE;tg<|D zUO+uUi-7~PBl_{(dz=VXNd?19AVMWi%oGFv8$da0rheKgbZ~!0|!Xdk+x{@4hs5i;l-6j;JU_=73 fL$$R{i{1SO{p+)>Z`P;{00000NkvXXu0mjfAPP*? literal 0 HcmV?d00001 diff --git a/static/patt/icons/edit_comment.png b/static/patt/icons/edit_comment.png new file mode 100644 index 0000000000000000000000000000000000000000..f56b4168a5767723b16644bf22041a8ec6418bd3 GIT binary patch literal 1312 zcmV+*1>gFKP)sn#kHn@Esi2GN`XE(zB z?f!_`TT99`wS&aRUn#1 z1kCaj3Z3-iPESl9|L)6CFFchGnBkS$6K%Zq$;-4{pP~JwOB6h}5gHc4^B)bNYQQ9N z`N?=2iHK2%(-aCN7y)3Y7H}>nVUT@&(1zR-)t`?>tTGnLLnZa+U%Wl(X;vE`Jo5pB zePG>uSh{I8eJk%K+c^$T04O2`Y6uBlv_2ElN4lEFzfu{4D=4+jXgwX3_no*XI|hKc zZ*cJhb(|)?oul#S<(QvCuBRI3BBU3&h@s*^JrZ~{^bo{IijnT*skRK; zb9ugXp0?xpL1k-B&F?^6L=;!z?LPYT8>B?JTZsKI5Qu!5ye~oYyeVR00(uG+m2y2-ZW^y{DV19vYx@#49HlzU7K!DCiR6wM4UKMoOyd?}gkn*kVDd60D2 ziK)`kdMgEAPHD++R9F9ms3+=l_3xiqI&#$DeUDe*M~;u3T1K{V3TgsA#;alm>O9`Sdlz7G z7*N8no9p`R0fAKD5eK5e&>>&Y)m25`$_(m45Tk#MXTT#Fd_lm#d%>I}G3i}mCROl< z14Ms7kEz?n*iv+`wActxJ3F5@@E=~x8;?BimrdX`R zg#hy@h%YlY{T^1&Y){B{!`fL~TCSIEg;WWwnVpQ6H{F1`$)KZfS_7^E#G?vH%{YhI zURF+PyueZ4469~d7|K;&UG>e6fj)>x0CEJ+SKUV?BZ z;tmjm`zRZDgcUb-6)Ww3ep0-bOMx^H0AUf6ET8t|MB2}Eaj5B+;`*Q{V?d$UdiSFu z_5kMuNCCruDxgns92T=kdOZ%V^gu8m?u8MM2Xa6g@Vi`g66w{y+LwpFtZ&8U0s9-) WV)RdW%CnaM0000bP0l+XkK5=?S+ literal 0 HcmV?d00001 diff --git a/static/patt/icons/pg_0.png b/static/patt/icons/pg_0.png new file mode 100644 index 0000000000000000000000000000000000000000..7199a448be30fa9bb811ca6b66d199adedeee48e GIT binary patch literal 171 zcmeAS@N?(olHy`uVBq!ia0vp^azM<(!2~3K`OHcMQjEnx?oJHr&dIz4aySb-B8wRq zxP?KOkzv*x37{Z*iKnkC`%P{ZQ7-F%I7JhnkYtH#M2T~LZfk%Xscxy@xs2j}Y&N_Fs$uYS L^>bP0l+XkKfCMdS literal 0 HcmV?d00001 diff --git a/static/patt/icons/pg_10.png b/static/patt/icons/pg_10.png new file mode 100644 index 0000000000000000000000000000000000000000..e13d86bfe2290c32749cf840178dfa2bae3771ce GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^azM<(!2~3K`OHcMQjEnx?oJHr&dIz4aySb-B8wRq zxP?KOkzv*x37{Z*iKnkC`%P{ZQC^e&1*?*PLXst}5hc#~xw)x%B@E6*sfi`2DGKG8 zB^e6tp1uL$jeO!jMGBrSjv*GOlT&`2XJGsgTe~DWM4f8*487 literal 0 HcmV?d00001 diff --git a/static/patt/icons/pg_100.png b/static/patt/icons/pg_100.png new file mode 100644 index 0000000000000000000000000000000000000000..e9fe8b3310e7cc1193e0fbc4a3c8be68ccf88ef7 GIT binary patch literal 173 zcmeAS@N?(olHy`uVBq!ia0vp^azM<(!2~3K`OHcMQjEnx?oJHr&dIz4aySb-B8wRq zxP?KOkzv*x37{Z*iKnkC`%P{ZQ6aN6Uyfb?3Q3l@MwB?`=jNv7l`uFLr6!i7rYMwW zmSiZnd-?{1H}Z)C6-jxzIEGl9PEPr8-a+}mkpl)k%Fg-V~EA+ab+ literal 0 HcmV?d00001 diff --git a/static/patt/icons/pg_30.png b/static/patt/icons/pg_30.png new file mode 100644 index 0000000000000000000000000000000000000000..c8c31c51483ad601565a392ac0ed88d76ffed084 GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^azM<(!2~3K`OHcMQjEnx?oJHr&dIz4aySb-B8wRq zxP?KOkzv*x37{Z*iKnkC`%P{ZQGUU1lVp|yg(OQ{BTAg}b8}PkN*J7rQWHy3QxwWG zOEMJPJ$(bh8~Mb6iWEFu978NlC#U>4-@yGRJ!Julf#C(79x280vtYi|u TRkg$xsFuOg)z4*}Q$iB}3&St_ literal 0 HcmV?d00001 diff --git a/static/patt/icons/pg_50.png b/static/patt/icons/pg_50.png new file mode 100644 index 0000000000000000000000000000000000000000..40b36b15532d4b8eb10c9ab9b4f0ce54ef4dbf62 GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^azM<(!2~3K`OHcMQjEnx?oJHr&dIz4aySb-B8wRq zxP?KOkzv*x37{Z*iKnkC`%P{ZQ30-vYj0l#3Q3l@MwB?`=jNv7l`uFLr6!i7rYMwW zmSiZnd-?{1H}Z)C6)AYSIEGl9PEPr8-ofU_d5@+fHMUuefs5UH5(ej@)Wnk16ovB4 zk_-iRPv3y>Mm}+%A_Y$u#}JFt$tge1JFxvY?-9W(%zXC1ii?Zg`lW0mde|76Rx*jd S{AUkx5`(9!pUXO@geCw57B7GR literal 0 HcmV?d00001 diff --git a/static/patt/icons/pg_70.png b/static/patt/icons/pg_70.png new file mode 100644 index 0000000000000000000000000000000000000000..1831bd2301820823065ec4d1b5aa8f874526ab42 GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^azM<(!2~3K`OHcMQjEnx?oJHr&dIz4aySb-B8wRq zxP?KOkzv*x37{Z*iKnkC`%P{ZQ9-`-xAl5~LXst}5hc#~xw)x%B@E6*sfi`2DGKG8 zB^e6tp1uL$jeO!jMGBrSjv*GOlT&`2ci{YSz9YD+voS5<%8S6o?!9t4Zp;joE1AR{ T7wpRhs%7wW^>bP0l+XkKUe_?P literal 0 HcmV?d00001 diff --git a/static/patt/icons/pg_80.png b/static/patt/icons/pg_80.png new file mode 100644 index 0000000000000000000000000000000000000000..179f9a0112c2c6a7c3f81a3832d8e9585b69df17 GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^azM<(!2~3K`OHcMQjEnx?oJHr&dIz4aySb-B8wRq zxP?KOkzv*x37{Z*iKnkC`%P{ZQ9(_vi?g2rg(OQ{BTAg}b8}PkN*J7rQWHy3QxwWG zOEMJPJ$(bh8~Mb6iWEFu978NlC#U>4?;!eNzF=%iXJcBzl^21F-FxM9+?W{(Rx*h{ T|FviaP%VR}tDnm{r-UW|Xb3S) literal 0 HcmV?d00001 diff --git a/static/patt/icons/pg_90.png b/static/patt/icons/pg_90.png new file mode 100644 index 0000000000000000000000000000000000000000..db18fa10e7c93c0356f1a641d274b1e116de3412 GIT binary patch literal 178 zcmeAS@N?(olHy`uVBq!ia0vp^azM<(!2~3K`OHcMQjEnx?oJHr&dIz4aySb-B8wRq zxP?KOkzv*x37{Z*iKnkC`%P{ZQ6aA8**n95LXst}5hc#~xw)x%B@E6*sfi`2DGKG8 zB^e6tp1uL$jeO!jMe?35jv*GOlT&`2cZmHkU+`2y(gH~d)2b^k0vEG)Ut~yM$t0d` S&3_%Jl)=;0&t;ucLK6TN_Amng literal 0 HcmV?d00001 diff --git a/static/patt/icons/prio1.png b/static/patt/icons/prio1.png new file mode 100644 index 0000000000000000000000000000000000000000..6ffa40d9b91287ec26d3901b7f4819632bbc88db GIT binary patch literal 742 zcmVQe*&dl%kI%v_VG~=|p^lrbI->$jSv7pv*+|p!RgSQ;cs5QL9URtM3ZFM4bX8A_N|kn0Ga?erwQ2? z%8tMG1)T0^t<@ak0zfChFgzAu5z!6XaxOOvjxTry&YFqC%zPW*TKJ23Fn5LTJsw*9 zLacsJFb{SytK2`p0H6*;xk4)5`PT{yZSf=s4>r(3H-*>$9SQf3JX`}T96PiXI!}QS zMtLJI4J#JSJo#MOQk^eV00sb&W%2z{;jEe1$*f)i2zYN7fs6pyDtg&nWTl!Txv}>G z02rM13JdIxq2|PCgl=XKY(3!h_ktuu8USz5TZ>%QeG9gYZ8IfP)!R}BU+BP8iFRpPNN?HLj2AD#zzvhp}gGz^X}h?PaA z(mVoXejj`iS|77I=jpbQ5)0?g005kKZ@lt~ish}+X<^2%f^oJ&1@?vqMte%9g+o_B zC{zHJmxk55X5xqMk9<-jQUDgq2>ULTO8uIJYb?h0?4wB#rvVhp09yhh9nFnBbv02R z+v0l_^IZ__@Ckb>1hv;^N1nNwC;+G*7@d*m9u9VgP Y2`z;7g4u}}!T`#d&5^pq3X`WWuI#Y`J@VCh8d~?1x)jFR#`X`;_oD(D4)> z16&g5H(K?k-Ql5{fMbW(#Vx0BR8SuPPQ(B_6lVkF^`zagsmQ?F?yBv^m~TPFsm<;x z)Skrg;a{HI&`&Ppj%4;2BmMsa2C!6+{#Sd=I1I>7-qf_^Jb!F^w%efr*O0+!rNUhWsJ54fmrAX+9H

          scVtCliWztTU`Lm{C+;FQj1v$ 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 literal 0 HcmV?d00001 diff --git a/static/patt/icons/spacer.png b/static/patt/icons/spacer.png new file mode 100644 index 0000000000000000000000000000000000000000..bd7b563ef018680cb15f43f86c62337cf936107c GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^Oh7Ed!3HF~v+~XZDYhhUcNd2LAh=-f^2tCE&H|6f zVg?3|Xb@(6F~9TzP>{XE)7O>#F{3QEf~w%5sw$w6xTlL_2*>s0KmY&#uV(`SH8u?i bOCE-Qmsw1Bw{H6mRKnot>gTe~DWM4fsaPb5 literal 0 HcmV?d00001 diff --git a/static/patt/icons/state0.png b/static/patt/icons/state0.png new file mode 100644 index 0000000000000000000000000000000000000000..3a040a008f35dec4db266f76e1211ac172204a94 GIT binary patch literal 651 zcmV;60(AX}P)9r3m2*Ij z!ec@bIVXU40TPhSQUxUV3TT>#L}qMDmXZI*p4RuNr0E&yu53O<^d%zSEq|U@j>GrN zkT0vjeeiZvs94LkbgT6?uzb1RM|r%^+HV(ue_Ppn7#l+1Isigs5F)2%4&ks@-CQXM zZw%moD3P(T-3ETlB8fKYi}u@Qib|r5`l5*?9D$#EmY~!OPYpo1 zvm0T=|6fV87A%=qM&JD0v-o$~`yT;JQeAlW<}*fog`d+%qP1Yjs22iPUAXl$NZG^Fv^($`=KDfLwK_bD*5yFj8iHgxwxWqx) z45bR-VqjFg#+c+HF1&9g_Ok{wQ!tR=9pUWlk@OW4l$(2?La7;^ip2gjl7V1axGTYEE${3yMc_#mjt@SG=)?n3dR0GdT65uU zdh1W_VzmL>a>{)xv2VcJnFS92j(SG!6T_8PMuo~Oa91*RGpgP;*0PNd3G51Stq43x lxw9KtYab!J20Ey`_zN$zJybii8E^mq002ovPDHLkV1n**A&>w7 literal 0 HcmV?d00001 diff --git a/static/patt/icons/state1.png b/static/patt/icons/state1.png new file mode 100644 index 0000000000000000000000000000000000000000..a2ded124828fd8ea5da9323cb29d74aa0b3124c1 GIT binary patch literal 800 zcmeAS@N?(olHy`uVBq!ia0vp^;vmey3?#3AQJDm!SkfJR9T^xl_H+M9WCijSl0AZa z85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d72)L$@XSD+10;zoc^h%1nucsmM+rrZsm zc_wY*-ROyTq8k1;fCTRVQS>Arc{dWs=zAOr76EdjCf$pfa0{eh!rjOT_kgls!Q0^w z^^P%o|_;OAdUx`3G|fb#g)xK%B>{GFPI^Ani}ik zTTDxzPHL^V#u@SNi@x2DV7+(s%FowJ-Qzp*ibLqi8>R1`_i-Qp?Q~XbZ*KD?;g$10 z6r6ke$5Z-=Zl^(;NX=Bo-{D@MP-9H;c6VXuV3qX%aySb-B8!31a~*^koit`w00r4g zJbhi+A2Z7GS_`~7#aRdx+Ue=y7@~20>c#6~O$Iy+7pwvUkL=yPLr~=Vyr2L5Cy9J; zINkhA_xlpx$wHspS(+5(dEFTn*>D>&bbOv?AnDRK`GjWY(=9ngYNw0z@;AjK?2I@R zeM%>M)d|zahgHqraxN{oDrPNKUVWnW=qWa5iHltySt_kI^CwTVQg^vD!LeK~Hes8| ziLOt-ZlAf4ur5#YeN6GCgKKYSzx$iXC*L?)FK#IZ0z|d0Hz);u7GQ`lx%GA=z&`jIF z(8|CdH=)T2MMG|WN@iLmZVfWFR&78Hk{}y`^V3So6N^$A%FE03GV`*FlM@S4_413- UXTP(N0xDwgboFyt=akR{09G|nZ2$lO literal 0 HcmV?d00001 diff --git a/static/patt/icons/state2.png b/static/patt/icons/state2.png new file mode 100644 index 0000000000000000000000000000000000000000..77ae0f24e19d43474e9ec1f37b583a52ec5b2457 GIT binary patch literal 557 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaAF&8^|hH!9j+>t*I;7bhncr0V4trO$q6BL!4+EWjtk z_5c6>4Gj&UVBqPQ>*f~g;*tg=oxGBrd=ni!6P&yg9X%2py%S#V7I*eabo7dM@&*bf zIC&+!*)95Tvy7u>qBDpae`J9=kOYcvn5+*Zp~y24%m9-hL&O}RQt@-zESv!zhd*IlKa~H4NxOe~Y z<7Y2kzW@C7$IqXC{+j;`0R{F9PZ!4!jo_^(kMkWe5OKNa{(j-iI45q?Eew+#wEq7O z)=A>Lxv7D-+;o=nk<(k$kAGd@Q`9Q@?boBGx}ZOsPZ!%dPLW@z?Zj@Xq~vvey(QzZ zX`!UeTx#8}Vex+IUjJ+EVFB839%_Di)Lc(A3ad?N4Da$rljf2cKsyiB1 vv&es#Dq!WeqNP0k;2!UpGHYf}`N|o0Zn=Fmr?(J4(2We9u6{1-oD!M<-?i9n literal 0 HcmV?d00001 diff --git a/static/patt/icons/state3.png b/static/patt/icons/state3.png new file mode 100644 index 0000000000000000000000000000000000000000..5feac8be71aedd14129acf3592d072eadc0852f3 GIT binary patch literal 454 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaAF&8^|hH!9j+Xxo_+oG`?qJa+Y_Loqn<8~ zAsQ1~FWoL`HsEo+=xp#XOD0MA#{d6|YZ;=ZWG=7TJY8SP^OE4gZf{NRH%;#X&mWT% zdp-ZJS#kHBxZj7_o?J2aoo|$IxU#Ofc2%FN>AHlqDpw^ReBJTR{{Dn)PvbXnVI4sR zGvfT*HZ0&P`dSc_`a(p}Gfv1w_rh!Es)&#U7u!t&_C3_&o$^{!-B#!M^Eja+%CY+H zyACEynaru3^ literal 0 HcmV?d00001 diff --git a/static/patt/jquery.min.js b/static/patt/jquery.min.js new file mode 100644 index 0000000..50d1b22 --- /dev/null +++ b/static/patt/jquery.min.js @@ -0,0 +1,4 @@ +/*! jQuery v1.9.0 | (c) 2005, 2012 jQuery Foundation, Inc. | jquery.org/license */(function(e,t){"use strict";function n(e){var t=e.length,n=st.type(e);return st.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}function r(e){var t=Tt[e]={};return st.each(e.match(lt)||[],function(e,n){t[n]=!0}),t}function i(e,n,r,i){if(st.acceptData(e)){var o,a,s=st.expando,u="string"==typeof n,l=e.nodeType,c=l?st.cache:e,f=l?e[s]:e[s]&&s;if(f&&c[f]&&(i||c[f].data)||!u||r!==t)return f||(l?e[s]=f=K.pop()||st.guid++:f=s),c[f]||(c[f]={},l||(c[f].toJSON=st.noop)),("object"==typeof n||"function"==typeof n)&&(i?c[f]=st.extend(c[f],n):c[f].data=st.extend(c[f].data,n)),o=c[f],i||(o.data||(o.data={}),o=o.data),r!==t&&(o[st.camelCase(n)]=r),u?(a=o[n],null==a&&(a=o[st.camelCase(n)])):a=o,a}}function o(e,t,n){if(st.acceptData(e)){var r,i,o,a=e.nodeType,u=a?st.cache:e,l=a?e[st.expando]:st.expando;if(u[l]){if(t&&(r=n?u[l]:u[l].data)){st.isArray(t)?t=t.concat(st.map(t,st.camelCase)):t in r?t=[t]:(t=st.camelCase(t),t=t in r?[t]:t.split(" "));for(i=0,o=t.length;o>i;i++)delete r[t[i]];if(!(n?s:st.isEmptyObject)(r))return}(n||(delete u[l].data,s(u[l])))&&(a?st.cleanData([e],!0):st.support.deleteExpando||u!=u.window?delete u[l]:u[l]=null)}}}function a(e,n,r){if(r===t&&1===e.nodeType){var i="data-"+n.replace(Nt,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:wt.test(r)?st.parseJSON(r):r}catch(o){}st.data(e,n,r)}else r=t}return r}function s(e){var t;for(t in e)if(("data"!==t||!st.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}function u(){return!0}function l(){return!1}function c(e,t){do e=e[t];while(e&&1!==e.nodeType);return e}function f(e,t,n){if(t=t||0,st.isFunction(t))return st.grep(e,function(e,r){var i=!!t.call(e,r,e);return i===n});if(t.nodeType)return st.grep(e,function(e){return e===t===n});if("string"==typeof t){var r=st.grep(e,function(e){return 1===e.nodeType});if(Wt.test(t))return st.filter(t,r,!n);t=st.filter(t,r)}return st.grep(e,function(e){return st.inArray(e,t)>=0===n})}function p(e){var t=zt.split("|"),n=e.createDocumentFragment();if(n.createElement)for(;t.length;)n.createElement(t.pop());return n}function d(e,t){return e.getElementsByTagName(t)[0]||e.appendChild(e.ownerDocument.createElement(t))}function h(e){var t=e.getAttributeNode("type");return e.type=(t&&t.specified)+"/"+e.type,e}function g(e){var t=nn.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function m(e,t){for(var n,r=0;null!=(n=e[r]);r++)st._data(n,"globalEval",!t||st._data(t[r],"globalEval"))}function y(e,t){if(1===t.nodeType&&st.hasData(e)){var n,r,i,o=st._data(e),a=st._data(t,o),s=o.events;if(s){delete a.handle,a.events={};for(n in s)for(r=0,i=s[n].length;i>r;r++)st.event.add(t,n,s[n][r])}a.data&&(a.data=st.extend({},a.data))}}function v(e,t){var n,r,i;if(1===t.nodeType){if(n=t.nodeName.toLowerCase(),!st.support.noCloneEvent&&t[st.expando]){r=st._data(t);for(i in r.events)st.removeEvent(t,i,r.handle);t.removeAttribute(st.expando)}"script"===n&&t.text!==e.text?(h(t).text=e.text,g(t)):"object"===n?(t.parentNode&&(t.outerHTML=e.outerHTML),st.support.html5Clone&&e.innerHTML&&!st.trim(t.innerHTML)&&(t.innerHTML=e.innerHTML)):"input"===n&&Zt.test(e.type)?(t.defaultChecked=t.checked=e.checked,t.value!==e.value&&(t.value=e.value)):"option"===n?t.defaultSelected=t.selected=e.defaultSelected:("input"===n||"textarea"===n)&&(t.defaultValue=e.defaultValue)}}function b(e,n){var r,i,o=0,a=e.getElementsByTagName!==t?e.getElementsByTagName(n||"*"):e.querySelectorAll!==t?e.querySelectorAll(n||"*"):t;if(!a)for(a=[],r=e.childNodes||e;null!=(i=r[o]);o++)!n||st.nodeName(i,n)?a.push(i):st.merge(a,b(i,n));return n===t||n&&st.nodeName(e,n)?st.merge([e],a):a}function x(e){Zt.test(e.type)&&(e.defaultChecked=e.checked)}function T(e,t){if(t in e)return t;for(var n=t.charAt(0).toUpperCase()+t.slice(1),r=t,i=Nn.length;i--;)if(t=Nn[i]+n,t in e)return t;return r}function w(e,t){return e=t||e,"none"===st.css(e,"display")||!st.contains(e.ownerDocument,e)}function N(e,t){for(var n,r=[],i=0,o=e.length;o>i;i++)n=e[i],n.style&&(r[i]=st._data(n,"olddisplay"),t?(r[i]||"none"!==n.style.display||(n.style.display=""),""===n.style.display&&w(n)&&(r[i]=st._data(n,"olddisplay",S(n.nodeName)))):r[i]||w(n)||st._data(n,"olddisplay",st.css(n,"display")));for(i=0;o>i;i++)n=e[i],n.style&&(t&&"none"!==n.style.display&&""!==n.style.display||(n.style.display=t?r[i]||"":"none"));return e}function C(e,t,n){var r=mn.exec(t);return r?Math.max(0,r[1]-(n||0))+(r[2]||"px"):t}function k(e,t,n,r,i){for(var o=n===(r?"border":"content")?4:"width"===t?1:0,a=0;4>o;o+=2)"margin"===n&&(a+=st.css(e,n+wn[o],!0,i)),r?("content"===n&&(a-=st.css(e,"padding"+wn[o],!0,i)),"margin"!==n&&(a-=st.css(e,"border"+wn[o]+"Width",!0,i))):(a+=st.css(e,"padding"+wn[o],!0,i),"padding"!==n&&(a+=st.css(e,"border"+wn[o]+"Width",!0,i)));return a}function E(e,t,n){var r=!0,i="width"===t?e.offsetWidth:e.offsetHeight,o=ln(e),a=st.support.boxSizing&&"border-box"===st.css(e,"boxSizing",!1,o);if(0>=i||null==i){if(i=un(e,t,o),(0>i||null==i)&&(i=e.style[t]),yn.test(i))return i;r=a&&(st.support.boxSizingReliable||i===e.style[t]),i=parseFloat(i)||0}return i+k(e,t,n||(a?"border":"content"),r,o)+"px"}function S(e){var t=V,n=bn[e];return n||(n=A(e,t),"none"!==n&&n||(cn=(cn||st("