Browse Source

Initial patt implementation

master
Dirk Alders 4 years ago
parent
commit
c834976e79
80 changed files with 3150 additions and 0 deletions
  1. 105
    0
      __init__.py
  2. 208
    0
      access.py
  3. 65
    0
      admin.py
  4. 8
    0
      apps.py
  5. 247
    0
      context.py
  6. 45
    0
      creole.py
  7. 137
    0
      forms.py
  8. 153
    0
      help.py
  9. 0
    0
      management/__init__.py
  10. 0
    0
      management/commands/__init__.py
  11. 9
    0
      management/commands/rebuild_index.py
  12. 76
    0
      migrations/0001_initial.py
  13. 29
    0
      migrations/0002_auto_20191003_1614.py
  14. 17
    0
      migrations/0003_remove_tasklistsetting_sorttype.py
  15. 45
    0
      migrations/0004_auto_20191004_1125.py
  16. 20
    0
      migrations/0005_project_creation_date.py
  17. 18
    0
      migrations/0006_auto_20191006_1824.py
  18. 65
    0
      migrations/0007_historicalcomment_historicaltask.py
  19. 17
    0
      migrations/0008_remove_viewsetting_displaytype.py
  20. 41
    0
      migrations/0009_auto_20191102_1925.py
  21. 36
    0
      migrations/0010_auto_20191117_1157.py
  22. 20
    0
      migrations/0011_project_role_visitor.py
  23. 35
    0
      migrations/0012_auto_20200114_1035.py
  24. 0
    0
      migrations/__init__.py
  25. 281
    0
      models.py
  26. 167
    0
      search.py
  27. 31
    0
      signals.py
  28. 9
    0
      static/patt/datepicker.min.css
  29. 10
    0
      static/patt/datepicker.min.js
  30. BIN
      static/patt/draft.png
  31. BIN
      static/patt/icons/collapse.png
  32. BIN
      static/patt/icons/edit.png
  33. BIN
      static/patt/icons/edit_comment.png
  34. BIN
      static/patt/icons/expand.png
  35. BIN
      static/patt/icons/pg_0.png
  36. BIN
      static/patt/icons/pg_10.png
  37. BIN
      static/patt/icons/pg_100.png
  38. BIN
      static/patt/icons/pg_20.png
  39. BIN
      static/patt/icons/pg_30.png
  40. BIN
      static/patt/icons/pg_40.png
  41. BIN
      static/patt/icons/pg_50.png
  42. BIN
      static/patt/icons/pg_60.png
  43. BIN
      static/patt/icons/pg_70.png
  44. BIN
      static/patt/icons/pg_80.png
  45. BIN
      static/patt/icons/pg_90.png
  46. BIN
      static/patt/icons/prio1.png
  47. BIN
      static/patt/icons/prio2.png
  48. BIN
      static/patt/icons/prio3.png
  49. BIN
      static/patt/icons/prio4.png
  50. BIN
      static/patt/icons/prio5.png
  51. BIN
      static/patt/icons/prio6.png
  52. BIN
      static/patt/icons/prio7.png
  53. BIN
      static/patt/icons/spacer.png
  54. BIN
      static/patt/icons/state0.png
  55. BIN
      static/patt/icons/state1.png
  56. BIN
      static/patt/icons/state2.png
  57. BIN
      static/patt/icons/state3.png
  58. 4
    0
      static/patt/jquery.min.js
  59. 12
    0
      templates/patt/help.html
  60. 131
    0
      templates/patt/patt.css
  61. 3
    0
      templates/patt/project/details.html
  62. 28
    0
      templates/patt/project/head.html
  63. 4
    0
      templates/patt/project/project.html
  64. 20
    0
      templates/patt/projectlist.html
  65. 31
    0
      templates/patt/raw_single_form.html
  66. 9
    0
      templates/patt/task/comment.html
  67. 19
    0
      templates/patt/task/details.html
  68. 41
    0
      templates/patt/task/head.html
  69. 6
    0
      templates/patt/task/task.html
  70. 37
    0
      templates/patt/task_form.html
  71. 36
    0
      templates/patt/tasklist.html
  72. 23
    0
      templates/patt/tasklist_print.html
  73. 12
    0
      templates/patt/taskview.html
  74. 16
    0
      templates/patt/taskview_print.html
  75. 0
    0
      templatetags/__init__.py
  76. 64
    0
      templatetags/access.py
  77. 37
    0
      templatetags/patt_urls.py
  78. 3
    0
      tests.py
  79. 30
    0
      urls.py
  80. 690
    0
      views.py

+ 105
- 0
__init__.py View File

@@ -0,0 +1,105 @@
1
+from django.urls.base import reverse
2
+from django.utils.translation import gettext as _
3
+
4
+
5
+def back_url(request, addition):
6
+    return request.path + addition
7
+
8
+
9
+def url_current(request):
10
+    return request.GET.get('next', request.get_full_path())
11
+
12
+
13
+def url_tasklist(request, user_filter_id=None, search_txt=None, common_filter_id=None):
14
+    if user_filter_id is not None:
15
+        return reverse('patt-userfilter', kwargs={'user_filter_id': user_filter_id})
16
+    elif search_txt is not None:
17
+        return reverse('search') + '?q=%s' % search_txt
18
+    elif common_filter_id is not None:
19
+        return reverse('patt-commonfilter', kwargs={'common_filter_id': common_filter_id})
20
+    else:
21
+        return reverse('patt-tasklist')
22
+
23
+
24
+def url_projectlist(request):
25
+    return reverse('patt-projectlist')
26
+
27
+
28
+def url_helpview(request, page):
29
+    return reverse('patt-helpview', kwargs={'page': page})
30
+
31
+
32
+def url_tasknew(request, project_id):
33
+    nxt = url_current(request)
34
+    return reverse('patt-tasknew') + '?next=%s' % nxt + ('&project_id=%s' % project_id if project_id is not None else '')
35
+
36
+
37
+def url_projectnew(request):
38
+    nxt = url_current(request)
39
+    return reverse('patt-projectnew') + '?next=%s' % nxt
40
+
41
+
42
+def url_taskedit(request, task_id):
43
+    nxt = url_current(request)
44
+    return reverse('patt-taskedit', kwargs={'task_id': task_id}) + '?do=edit&next=%s' % nxt
45
+
46
+
47
+def url_projectedit(request, project_id):
48
+    nxt = url_current(request)
49
+    return reverse('patt-projectedit', kwargs={'project_id': project_id}) + '?next=%s' % nxt
50
+
51
+
52
+def url_commentnew(request, task_id):
53
+    nxt = url_current(request)
54
+    return reverse('patt-commentnew', kwargs={'task_id': task_id}) + '?next=%s' % nxt
55
+
56
+
57
+def url_easysearch(request):
58
+    return reverse('patt-easysearch')
59
+
60
+
61
+def url_taskset(request, task_id, **kwargs):
62
+    nxt = url_current(request)
63
+    if kwargs.get('priority') is not None:
64
+        return reverse('patt-taskedit', kwargs={'task_id': task_id}) + '?do=set_priority&priority=%s&next=%s' % (kwargs.get('priority'), nxt)
65
+    elif kwargs.get('state') is not None:
66
+        return reverse('patt-taskedit', kwargs={'task_id': task_id}) + '?do=set_state&state=%s&next=%s' % (kwargs.get('state'), nxt)
67
+    else:
68
+        raise Exception('Required keyword missing. One of "priority", "state" is not in %s.' % repr(kwargs.keys()))
69
+
70
+
71
+def url_filteredit(request, search_id=None):
72
+    if search_id is None:
73
+        if get_search_query(request) is None:
74
+            return reverse('patt-filternew')
75
+        else:
76
+            return reverse('patt-filternew') + '?q=%s' % get_search_query(request)
77
+    else:
78
+        return reverse('patt-filteredit', kwargs={'search_id': search_id})
79
+
80
+
81
+def url_printview(request):
82
+    if not is_printview(request):
83
+        return '?printview'
84
+    else:
85
+        return request.path
86
+
87
+
88
+def get_search_query(request):
89
+    return request.GET.get('q')
90
+
91
+
92
+def is_printview(request):
93
+    return 'printview' in request.GET
94
+
95
+
96
+def is_projectlistview(request):
97
+    return request.META['PATH_INFO'].startswith(reverse('patt-projectlist'))
98
+
99
+
100
+def is_tasklistview(request, search_id=None):
101
+    if request.META['PATH_INFO'].startswith(url_tasklist('patt-tasklist', search_id)):
102
+        return True
103
+    if search_id is None and get_search_query(request) is not None:
104
+        return True
105
+    return False

+ 208
- 0
access.py View File

@@ -0,0 +1,208 @@
1
+import logging
2
+from .models import Task, Project, Comment, TASKSTATE_CHOICES, TASKS_IN_WORK, PROJECTS_IN_WORK, PRIO_CHOICES
3
+
4
+
5
+logger = logging.getLogger('ACC')
6
+
7
+
8
+def read_attachment(request, rel_path):
9
+    item_type, item_id = rel_path.split('/')[1:3]
10
+    try:
11
+        item_id = int(item_id)
12
+    except ValueError:
13
+        return False
14
+    if item_type == 'task':
15
+        acc = acc_task(Task.objects.get(id=item_id), request.user)
16
+        return acc.read
17
+    elif item_type == 'comment':
18
+        acc = acc_task(Comment.objects.get(id=item_id).task, request.user)
19
+        return acc.read_comments
20
+    elif item_type == 'project':
21
+        acc = acc_project(Project.objects.get(id=item_id), request.user)
22
+        return acc.read
23
+    else:
24
+        return False
25
+
26
+
27
+def modify_attachment(request, rel_path):
28
+    item_type, item_id = rel_path.split('/')[1:3]
29
+    try:
30
+        item_id = int(item_id)
31
+    except ValueError:
32
+        return False
33
+    if item_type == 'task':
34
+        acc = acc_task(Task.objects.get(id=item_id), request.user)
35
+        return acc.modify or acc.modify_limited
36
+    elif item_type == 'comment':
37
+        comment = Comment.objects.get(id=item_id)
38
+        acc = acc_task(comment.task, request.user)
39
+        return request.user == comment.user or acc.modify_comment
40
+    elif item_type == 'project':
41
+        acc = acc_project(Project.objects.get(id=item_id), request.user)
42
+        return acc.modify or acc.modify_limited
43
+    else:
44
+        return False
45
+
46
+
47
+class acc_task(object):
48
+    def __init__(self, task, user):
49
+        self.task = task
50
+        self.user = user
51
+        self.__read__ = None
52
+        self.__modify__ = None
53
+        self.__modify_limited__ = None
54
+        self.__add_comment__ = None
55
+        self.__modify_comment__ = None
56
+        self.user_has_leader_rights = user in task.project.role_leader.all() and user.is_staff
57
+        self.user_has_memeber_rights = user in task.project.role_member.all() and user.is_staff
58
+        self.user_has_visitor_rights = user in task.project.role_visitor.all() and user.is_staff
59
+        self.user_has_role_rights = self.user_has_leader_rights or self.user_has_memeber_rights or self.user_has_visitor_rights
60
+        self.user_is_assigned_user = user == task.assigned_user
61
+
62
+    @property
63
+    def read(self):
64
+        if self.__read__ is None:
65
+            if self.user.is_superuser:
66
+                logger.debug('acc_task.read: Access granted (Task #%d). User is Superuser.', self.task.id)
67
+                self.__read__ = True
68
+            elif self.user_is_assigned_user and self.task.state in TASKS_IN_WORK:
69
+                logger.debug('acc_task.read: Access granted (Task #%d). User is Taskowner and taskstate is open or finished.', self.task.id)
70
+                self.__read__ = True
71
+            elif self.user_has_role_rights:
72
+                logger.debug('acc_task.read: Access granted (Task #%d). User has a role and is Staff.', self.task.id)
73
+                self.__read__ = True
74
+            else:
75
+                logger.debug('acc_task.read: Access denied (Task #%d).', self.task.id)
76
+                self.__read__ = False
77
+        return self.__read__
78
+
79
+    @property
80
+    def read_comments(self):
81
+        return self.read
82
+
83
+    @property
84
+    def modify_limited(self):
85
+        if self.__modify_limited__ is None:
86
+            if self.user_is_assigned_user and self.user.is_staff and self.task.state in TASKS_IN_WORK:
87
+                logger.debug('acc_task.modify_limited: Access granted (Task #%d). User is Taskowner and taskstate is open or finished.', self.task.id)
88
+                self.__modify_limited__ = True
89
+            else:
90
+                logger.debug('acc_task.modify_limited: Access denied (Task #%d).', self.task.id)
91
+                self.__modify_limited__ = False
92
+        return self.__modify_limited__
93
+
94
+    @property
95
+    def modify(self):
96
+        if self.__modify__ is None:
97
+            if self.user.is_superuser:
98
+                logger.debug('acc_task.modify: Access granted (Task #%d). User is Superuser.', self.task.id)
99
+                self.__modify__ = True
100
+            elif self.user_has_leader_rights:
101
+                logger.debug('acc_task.modify: Access granted (Task #%d). User is Projectleader and staff.', self.task.id)
102
+                self.__modify__ = True
103
+            else:
104
+                logger.debug('acc_task.modify: Access denied (Task #%d).', self.task.id)
105
+                self.__modify__ = False
106
+        return self.__modify__
107
+
108
+    @property
109
+    def add_comments(self):
110
+        if self.__add_comment__ is None:
111
+            if self.user.is_superuser:
112
+                logger.debug('acc_task.add_comments: Access granted (Task #%d). User is Superuser.', self.task.id)
113
+                self.__add_comment__ = True
114
+            elif (self.user_has_leader_rights or self.user_has_memeber_rights) and self.task.state in TASKS_IN_WORK:
115
+                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)
116
+                self.__add_comment__ = True
117
+            else:
118
+                logger.debug('acc_task.add_comments: Access denied (Task #%d).', self.task.id)
119
+                self.__add_comment__ = False
120
+        return self.__add_comment__
121
+
122
+    @property
123
+    def modify_comment(self):
124
+        if self.__modify_comment__ is None:
125
+            if self.user.is_superuser:
126
+                logger.debug('acc_task.modify_comment: Access granted (Task #%d). User is Superuser.', self.task.id)
127
+                self.__modify_comment__ = True
128
+            elif self.user_has_leader_rights:
129
+                logger.debug('acc_task.modify_comment: Access granted (Task #%d). User is Projectleader.', self.task.id)
130
+                self.__modify_comment__ = True
131
+            else:
132
+                logger.debug('acc_task.modify_comment: Access denied (Task #%d).', self.task.id)
133
+                self.__modify_comment__ = False
134
+        return self.__modify_comment__
135
+
136
+    @property
137
+    def allowed_targetstates(self):
138
+        if self.modify:
139
+            rv = [state[0] for state in TASKSTATE_CHOICES]
140
+        elif self.modify_limited:
141
+            rv = list(TASKS_IN_WORK)
142
+        else:
143
+            return []
144
+        rv.pop(rv.index(self.task.state))
145
+        rv.sort()
146
+        rv.reverse()
147
+        return rv
148
+
149
+    @property
150
+    def allowed_targetpriority(self):
151
+        if self.modify:
152
+            rv = [prio[0] for prio in PRIO_CHOICES]
153
+            rv.pop(rv.index(self.task.priority))
154
+            rv.sort()
155
+            rv.reverse()
156
+            return rv
157
+        return []
158
+
159
+
160
+class acc_project(object):
161
+    def __init__(self, project, user):
162
+        self.project = project
163
+        self.user = user
164
+        self.__modify__ = None
165
+        self.user_has_leader_rights = user in project.role_leader.all() and user.is_staff
166
+        self.user_has_memeber_rights = user in project.role_member.all() and user.is_staff
167
+        self.user_has_visitor_rights = user in project.role_visitor.all() and user.is_staff
168
+        self.user_has_role_rights = self.user_has_leader_rights or self.user_has_memeber_rights or self.user_has_visitor_rights
169
+
170
+    @property
171
+    def read(self):
172
+        if self.user.is_superuser:
173
+            logger.debug('acc_project.read: Access granted (Project #%d). User is Superuser.', self.project.id)
174
+            return True
175
+        elif self.user_has_leader_rights:
176
+            logger.debug('acc_project.read: Access granted (Project #%d). User is projectleader.', self.project.id)
177
+            return True
178
+        elif self.user_has_role_rights and self.project.state in PROJECTS_IN_WORK:
179
+            logger.debug('acc_project.read: Access granted (Project #%d). User has a role and project is in work.', self.project.id)
180
+            return True
181
+        elif len(self.project.task_set.filter(assigned_user=self.user, state__in=TASKS_IN_WORK)) > 0:
182
+            logger.debug('acc_project.read: Access granted (Project #%d). User has open tasks.', self.project.id)
183
+            return True
184
+        else:
185
+            logger.debug('acc_project.read: Access denied (Project #%d). User is not authenticated.', self.project.id)
186
+            return False
187
+
188
+    @property
189
+    def modify(self):
190
+        if self.__modify__ is None:
191
+            if self.user.is_superuser:
192
+                logger.debug('acc_project.modify: Access granted (Project #%d). User is Superuser.', self.project.id)
193
+                self.__modify__ = True
194
+            elif self.user in self.project.role_leader.all() and self.user.is_staff:
195
+                logger.debug('acc_project.modify: Access granted (Project #%d). User is Projectleader.', self.project.id)
196
+                self.__modify__ = True
197
+            else:
198
+                logger.debug('acc_project.modify: Access denied (Project #%d).', self.project.id)
199
+                self.__modify__ = False
200
+        return self.__modify__
201
+
202
+
203
+def create_task_possible(user):
204
+    return len(Project.objects.filter(role_leader__in=[user])) + len(Project.objects.filter(role_member__in=[user])) > 0 and user.is_staff
205
+
206
+
207
+def create_project_possible(user):
208
+    return user.is_superuser

+ 65
- 0
admin.py View File

@@ -0,0 +1,65 @@
1
+from django.contrib import admin
2
+from .models import Project, Task, Comment, Search
3
+from simple_history.admin import SimpleHistoryAdmin
4
+
5
+
6
+class ProjectAdmin(admin.ModelAdmin):
7
+    list_display = ('name', 'description', 'id', )
8
+    search_fields = ('name', 'description', 'id', )
9
+    list_filter = (
10
+        ('state', admin.ChoicesFieldListFilter),
11
+        ('role_leader', admin.RelatedFieldListFilter),
12
+        ('role_member', admin.RelatedFieldListFilter),
13
+    )
14
+
15
+
16
+class TaskAdmin(SimpleHistoryAdmin):
17
+    list_display = ('name', 'description', 'id', )
18
+    history_list_display = ('name', 'description', 'state', )
19
+    search_fields = ('name', 'description', 'id', )
20
+    list_filter = (
21
+        ('state', admin.ChoicesFieldListFilter),
22
+        ('priority', admin.ChoicesFieldListFilter),
23
+        ('assigned_user', admin.RelatedFieldListFilter),
24
+        ('project', admin.RelatedFieldListFilter),
25
+    )
26
+
27
+
28
+class CommentAdmin(SimpleHistoryAdmin):
29
+    list_display = ('task', 'user', 'comment', )
30
+    history_list_display = ('comment', 'type', )
31
+    search_fields = ('comment', )
32
+    list_filter = (
33
+        ('type', admin.ChoicesFieldListFilter),
34
+        ('user', admin.RelatedFieldListFilter),
35
+    )
36
+
37
+
38
+class ViewSettingAdmin(admin.ModelAdmin):
39
+    list_display = ('profile', 'view', )
40
+    search_fields = ('profile', 'view', )
41
+    list_filter = (
42
+        ('profile', admin.RelatedFieldListFilter),
43
+    )
44
+
45
+
46
+class PattProfileAdmin(admin.ModelAdmin):
47
+    list_display = ('user', )
48
+    search_fields = ('user', )
49
+    list_filter = (
50
+        ('user', admin.RelatedFieldListFilter),
51
+    )
52
+
53
+
54
+class SearchAdmin(admin.ModelAdmin):
55
+    list_display = ('user', 'name', )
56
+    search_fields = ('user', 'name', )
57
+    list_filter = (
58
+        ('user', admin.RelatedFieldListFilter),
59
+    )
60
+
61
+
62
+admin.site.register(Project, ProjectAdmin)
63
+admin.site.register(Task, TaskAdmin)
64
+admin.site.register(Comment, CommentAdmin)
65
+admin.site.register(Search, SearchAdmin)

+ 8
- 0
apps.py View File

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

+ 247
- 0
context.py View File

@@ -0,0 +1,247 @@
1
+from .access import create_project_possible, create_task_possible, acc_task
2
+from django.db.models.functions import Lower
3
+from django.utils.translation import gettext as _
4
+from .help import actionbar as actionbar_add_help
5
+import inspect
6
+import mycreole
7
+import patt
8
+from .search import common_searches
9
+from themes import empty_entry_parameters, color_icon_url, gray_icon_url
10
+from users.context import menubar as menubar_users
11
+
12
+ATTACHMENT_UID = 'attachment'
13
+BACK_UID = 'back'
14
+COMMENTNEW_UID = 'commentnew'
15
+CREATE_PROJECT_UID = 'create-project'
16
+CREATE_TASK_UID = 'create-task'
17
+HELP_UID = 'help'
18
+PRINTVIEW_UID = 'printview'
19
+TASKEDIT_UID = 'taskedit'
20
+VIEW_PROJECTLIST_UID = 'view-projectlist'
21
+VIEW_TASKLIST_UID = 'view-tasklist'
22
+
23
+
24
+def context_adaption(context, request, **kwargs):
25
+    caller_name = inspect.currentframe().f_back.f_code.co_name
26
+    try:
27
+        context.set_additional_title(kwargs.pop('title'))
28
+    except KeyError:
29
+        pass    # no title in kwargs
30
+    menubar_users(context[context.MENUBAR], request)
31
+    menubar(context, request, caller_name, **kwargs)
32
+    actionbar(context, request, caller_name, **kwargs)
33
+    navigationbar(context, request)
34
+    for key in kwargs:
35
+        context[key] = kwargs[key]
36
+
37
+
38
+def menubar(context, request, caller_name, **kwargs):
39
+    bar = context[context.MENUBAR]
40
+    add_help_menu(request, bar)
41
+    add_tasklist_menu(request, bar)
42
+    add_filter_submenu(request, bar, VIEW_TASKLIST_UID)
43
+    add_projectlist_menu(request, bar)
44
+    add_printview_menu(request, bar)
45
+    finalise_bar(request, bar)
46
+
47
+
48
+def navigationbar(context, request):
49
+    bar = context[context.NAVIGATIONBAR]
50
+    add_back_menu(request, bar)
51
+    finalise_bar(request, bar)
52
+
53
+
54
+def actionbar(context, request, caller_name, **kwargs):
55
+    bar = context[context.ACTIONBAR]
56
+    if caller_name == 'patt_tasklist':
57
+        if create_task_possible(request.user):
58
+            add_newtask_menu(request, bar, kwargs.get('project_id'))
59
+    elif caller_name == 'patt_projectlist':
60
+        if create_project_possible(request.user):
61
+            add_newproject_menu(request, bar)
62
+    elif caller_name == 'patt_taskview':
63
+        acc = acc_task(kwargs['task'], request.user)
64
+        if acc.modify or acc.modify_limited:
65
+            add_edittask_menu(request, bar, kwargs['task'].id)
66
+        if acc.add_comments:
67
+            add_newcomment_menu(request, bar, kwargs['task'].id)
68
+        add_manageupload_menu(request, bar, kwargs['task'])
69
+    elif caller_name == 'patt_helpview':
70
+        actionbar_add_help(context, request, **kwargs)
71
+    finalise_bar(request, bar)
72
+
73
+
74
+def finalise_bar(request, bar):
75
+    if len(bar) == 0:
76
+        bar.append_entry(*empty_entry_parameters(request))
77
+
78
+
79
+def add_help_menu(request, bar):
80
+    bar.append_entry(
81
+        HELP_UID,                                   # uid
82
+        _('Help'),                                  # name
83
+        color_icon_url(request, 'help.png'),        # icon
84
+        patt.url_helpview(request, 'main'),         # url
85
+        True,                                       # left
86
+        False                                       # active
87
+    )
88
+
89
+
90
+def add_tasklist_menu(request, bar):
91
+    bar.append_entry(
92
+        VIEW_TASKLIST_UID,                          # uid
93
+        _('Tasklist'),                              # name
94
+        color_icon_url(request, 'task.png'),        # icon
95
+        patt.url_tasklist(request),                 # url
96
+        True,                                       # left
97
+        patt.is_tasklistview(request)               # active
98
+    )
99
+
100
+
101
+def add_projectlist_menu(request, bar):
102
+    bar.append_entry(
103
+        VIEW_PROJECTLIST_UID,                       # uid
104
+        _('Projectlist'),                           # name
105
+        color_icon_url(request, 'folder.png'),      # icon
106
+        patt.url_projectlist(request),              # url
107
+        True,                                       # left
108
+        patt.is_projectlistview(request)            # active
109
+    )
110
+
111
+
112
+def add_printview_menu(request, bar):
113
+    bar.append_entry(
114
+        PRINTVIEW_UID,                              # uid
115
+        _('Printview'),                             # name
116
+        color_icon_url(request, 'print.png'),       # icon
117
+        patt.url_printview(request),                # url
118
+        True,                                       # left
119
+        patt.is_printview(request)                  # active
120
+    )
121
+
122
+
123
+def add_newtask_menu(request, bar, project_id):
124
+    bar.append_entry(
125
+        CREATE_TASK_UID,                            # uid
126
+        _('New Task'),                              # name
127
+        color_icon_url(request, 'plus.png'),        # icon
128
+        patt.url_tasknew(request, project_id),      # url
129
+        True,                                       # left
130
+        False                                       # active
131
+    )
132
+
133
+
134
+def add_edittask_menu(request, bar, task_id):
135
+    bar.append_entry(
136
+        TASKEDIT_UID,                               # uid
137
+        _('Edit'),                                  # name
138
+        color_icon_url(request, 'edit.png'),        # icon
139
+        patt.url_taskedit(request, task_id),        # url
140
+        True,                                       # left
141
+        False                                       # active
142
+    )
143
+
144
+
145
+def add_newcomment_menu(request, bar, task_id):
146
+    bar.append_entry(
147
+        COMMENTNEW_UID,                             # uid
148
+        _('Add Comment'),                           # name
149
+        color_icon_url(request, 'edit2.png'),       # icon
150
+        patt.url_commentnew(request, task_id),      # url
151
+        True,                                       # left
152
+        False                                       # active
153
+    )
154
+
155
+
156
+def add_newproject_menu(request, bar):
157
+    bar.append_entry(
158
+        CREATE_PROJECT_UID,                         # uid
159
+        _('New Project'),                           # name
160
+        color_icon_url(request, 'plus.png'),        # icon
161
+        patt.url_projectnew(request),               # url
162
+        True,                                       # left
163
+        False                                       # active
164
+    )
165
+
166
+
167
+def add_manageupload_menu(request, bar, task):
168
+    bar.append_entry(
169
+        ATTACHMENT_UID,                                                       # uid
170
+        _("Attachments"),                                                     # name
171
+        color_icon_url(request, 'upload.png'),                                # icon
172
+        mycreole.url_manage_uploads(request, task.attachment_target_path),    # url
173
+        True,                                                                 # left
174
+        False,                                                                # active
175
+    )
176
+
177
+
178
+def add_back_menu(request, bar):
179
+    bar.append_entry(
180
+        BACK_UID,                                   # uid
181
+        _('Back'),                                  # name
182
+        gray_icon_url(request, 'back.png'),         # icon
183
+        'javascript:history.back()',                # url
184
+        True,                                       # left
185
+        False                                       # active
186
+    )
187
+
188
+
189
+def add_filter_submenu(request, bar, menu_uid):
190
+    bar.append_entry_to_entry(
191
+        menu_uid,
192
+        menu_uid + '-easysearch',             # uid
193
+        _('Easysearch'),                        # name
194
+        gray_icon_url(request, 'search.png'),   # icon
195
+        patt.url_easysearch(request),           # url
196
+        True,                                   # left
197
+        False                                   # active
198
+    )
199
+    if patt.get_search_query(request) is not None:
200
+        bar.append_entry_to_entry(
201
+            menu_uid,
202
+            menu_uid + '-save',                     # uid
203
+            _('Save Search as Filter'),             # name
204
+            gray_icon_url(request, 'save.png'),     # icon
205
+            patt.url_filteredit(request),           # url
206
+            True,                                   # left
207
+            False                                   # active
208
+        )
209
+    bar.append_entry_to_entry(
210
+        menu_uid,
211
+        menu_uid + '-all',                      # uid
212
+        _('All Tasks'),                         # name
213
+        gray_icon_url(request, 'task.png'),     # icon
214
+        patt.url_tasklist(request),             # url
215
+        True,                                   # left
216
+        False                                   # active
217
+    )
218
+    cs = common_searches(request)
219
+    for common_filter_id in cs:
220
+        bar.append_entry_to_entry(
221
+            menu_uid,
222
+            menu_uid + '-common',                                           # uid
223
+            _(cs[common_filter_id][0]),                                     # name
224
+            gray_icon_url(request, 'filter.png'),                           # icon
225
+            patt.url_tasklist(request, common_filter_id=common_filter_id),  # url
226
+            True,                                                           # left
227
+            False                                                           # active
228
+        )
229
+    for s in request.user.search_set.order_by(Lower('name')):
230
+        active = patt.is_tasklistview(request, s.id)
231
+        if active is True:
232
+            url = patt.url_filteredit(request, s.id)
233
+        else:
234
+            url = patt.url_tasklist(request, user_filter_id=s.id)
235
+        if active:
236
+            icon = 'settings.png'
237
+        else:
238
+            icon = 'favourite.png'
239
+        bar.append_entry_to_entry(
240
+            menu_uid,
241
+            menu_uid + '-sub',              # uid
242
+            s.name,                         # name
243
+            gray_icon_url(request, icon),   # icon
244
+            url,                            # url
245
+            True,                           # left
246
+            active                          # active
247
+        )

+ 45
- 0
creole.py View File

@@ -0,0 +1,45 @@
1
+from django.urls.base import reverse
2
+from .models import Project
3
+
4
+
5
+def task_link_filter(text):
6
+    render_txt = ''
7
+    while len(text) > 0:
8
+        try:
9
+            pos = text.index('[[task:')
10
+        except ValueError:
11
+            pos = len(text)
12
+        render_txt += text[:pos]
13
+        text = text[pos + 7:]
14
+        if len(text):
15
+            pos = text.index(']]')
16
+            try:
17
+                task_id = int(text[:pos])
18
+            except ValueError:
19
+                render_txt += "[[task:" + text[:pos + 2]
20
+            else:
21
+                render_txt += '[[%s|#%d]]' % (reverse('patt-taskview', kwargs={'task_id': task_id}), task_id)
22
+            text = text[pos + 2:]
23
+    return render_txt
24
+
25
+
26
+def tasklist_link_filter(text):
27
+    render_txt = ''
28
+    while len(text) > 0:
29
+        try:
30
+            pos = text.index('[[tasklist:')
31
+        except ValueError:
32
+            pos = len(text)
33
+        render_txt += text[:pos]
34
+        text = text[pos + 11:]
35
+        if len(text):
36
+            pos = text.index(']]')
37
+            try:
38
+                project_id = int(text[:pos])
39
+            except ValueError:
40
+                render_txt += "[[tasklist:" + text[:pos + 2]
41
+            else:
42
+                p = Project.objects.get(id=project_id)
43
+                render_txt += '[[%s|%s]]' % (reverse('patt-tasklist-prj', kwargs={'project_id': project_id}), p.name)
44
+            text = text[pos + 2:]
45
+    return render_txt

+ 137
- 0
forms.py View File

@@ -0,0 +1,137 @@
1
+from django import forms
2
+from django.contrib.auth.models import User
3
+from django.db.models import Q
4
+from django.utils.translation import gettext as _
5
+from .models import Task, Project, Comment, Search, TASKSTATE_CHOICES, PROJECTSTATE_OPEN, ModelList
6
+from .search import INDEX_STATES
7
+
8
+
9
+class TaskForm(forms.ModelForm):
10
+
11
+    def __init__(self, *args, **kwargs):
12
+        # get request from kwargs
13
+        try:
14
+            self.request = kwargs.pop('request')
15
+        except KeyError:
16
+            raise TypeError("needed request object is missing in kwargs")
17
+        plist = self.__projectlist_taskedit__(self.request.user)
18
+        if self.request.POST:
19
+            project = Project.objects.get(id=self.request.POST.get('project'))
20
+        else:
21
+            try:
22
+                project = kwargs.get('instance').project
23
+            except Task.project.RelatedObjectDoesNotExist:
24
+                if len(plist) > 0:
25
+                    project = plist[0]
26
+        # init TaskForm
27
+        super(TaskForm, self).__init__(*args, **kwargs)
28
+        # set projectchoice for project
29
+        self.fields['project'].queryset = plist
30
+        self.fields['project'].empty_label = None
31
+        # set userlist (projectdepending)
32
+        self.fields['assigned_user'].empty_label = _('----- choose a user -----')
33
+        if project is not None:
34
+            self.fields['assigned_user'].queryset = self.__userlist_taskedit__(self.request.user, project)
35
+
36
+    def __projectlist_taskedit__(self, user):
37
+        if user.is_superuser:
38
+            rv = Project.objects.all()
39
+        else:
40
+            rv = Project.objects.filter(
41
+                Q(role_leader__id__exact=user.id) | Q(role_member__id__exact=user.id),
42
+                state=PROJECTSTATE_OPEN,
43
+            ).distinct()
44
+        rv = ModelList(rv)
45
+        rv.sort()
46
+        return Project.objects.filter(pk__in=[p.pk for p in rv])
47
+
48
+    def __userlist_taskedit__(self, user, project=None):
49
+        if user.is_superuser:
50
+            return User.objects.all().order_by('username')
51
+        elif user in project.role_leader.all():
52
+            uids = [u.id for u in project.role_leader.all() | project.role_member.all()]
53
+        else:
54
+            uids = [user.id]
55
+        return User.objects.filter(id__in=set(uids)).order_by('username')
56
+
57
+    class Meta:
58
+        model = Task
59
+        fields = ['project', 'assigned_user', 'name', 'state', 'priority', 'targetdate', 'progress', 'description']
60
+        widgets = {
61
+            'assigned_user': forms.Select(attrs={'required': True}),
62
+            'project': forms.Select(attrs={'onchange': 'submit()'}),
63
+            'state': forms.Select(choices=TASKSTATE_CHOICES),
64
+            'targetdate': forms.DateInput(format="%Y-%m-%d"),
65
+            'description': forms.Textarea(attrs={'rows': 5}),
66
+        }
67
+
68
+
69
+class TaskFormLimited(forms.ModelForm):
70
+    def __init__(self, *args, **kwargs):
71
+        # remove request from kwargs
72
+        kwargs.pop('request')
73
+        super(TaskFormLimited, self).__init__(*args, **kwargs)
74
+
75
+    class Meta:
76
+        model = Task
77
+        fields = ['state', 'progress', 'description']
78
+        widgets = {
79
+            'state': forms.Select(choices=TASKSTATE_CHOICES[:2]),
80
+            'description': forms.Textarea(attrs={'rows': 5}),
81
+        }
82
+
83
+
84
+class ProjectForm(forms.ModelForm):
85
+    class Meta:
86
+        model = Project
87
+        fields = ['name', 'state', 'role_leader', 'role_member', 'role_visitor', 'description', 'days_late', 'days_very_soon', 'days_soon']
88
+        widgets = {
89
+            'description': forms.Textarea(attrs={'rows': 5}),
90
+        }
91
+        labels = {
92
+            'days_late': _('Days to deadline (late)'),
93
+            'days_very_soon': _('Days to deadline (very soon)'),
94
+            'days_soon': _('Days to deadline (soon)'),
95
+        }
96
+
97
+    def __init__(self, *args, **kwargs):
98
+        super(ProjectForm, self).__init__(*args, **kwargs)
99
+        self.fields['role_leader'].queryset = User.objects.order_by('username')
100
+        self.fields['role_member'].queryset = User.objects.order_by('username')
101
+
102
+
103
+class CommentForm(forms.ModelForm):
104
+    class Meta:
105
+        model = Comment
106
+        fields = ['comment']
107
+
108
+
109
+class TaskCommentForm(forms.ModelForm):
110
+    class Meta:
111
+        model = Comment
112
+        fields = ['comment']
113
+
114
+    def __init__(self, *args, **kwargs):
115
+        super(forms.ModelForm, self).__init__(*args, **kwargs)
116
+        self.fields['comment'].required = False
117
+
118
+
119
+class SearchForm(forms.ModelForm):
120
+    class Meta:
121
+        model = Search
122
+        fields = ['name', 'search_txt']
123
+        labels = {
124
+            'search_txt': _('Search Text'),
125
+        }
126
+
127
+
128
+class EasySearchForm(forms.Form):
129
+    user_ids = forms.MultipleChoiceField(required=False, label=_('Assigned User(s)'), widget=forms.widgets.SelectMultiple(attrs={'size': 6}))
130
+    states = forms.MultipleChoiceField(required=False, label=_('State(s)'))
131
+    prj_ids = forms.MultipleChoiceField(required=False, label=_('Project(s)'), widget=forms.widgets.SelectMultiple(attrs={'size': 10}))
132
+
133
+    def __init__(self, *args, **kwargs):
134
+        super(EasySearchForm, self).__init__(*args, **kwargs)
135
+        self.fields['user_ids'].choices = [(u.id, u.username) for u in User.objects.order_by('username')]
136
+        self.fields['states'].choices = [(INDEX_STATES.get(task_num), task_name) for task_num, task_name in TASKSTATE_CHOICES]
137
+        self.fields['prj_ids'].choices = [(p.id, p.name) for p in Project.objects.order_by('state', 'name')]

+ 153
- 0
help.py View File

@@ -0,0 +1,153 @@
1
+from django.utils.translation import gettext as _
2
+import mycreole
3
+import patt
4
+from themes import color_icon_url
5
+
6
+# TODO: Search: Describe search fields
7
+# TODO: Search: Describe logic operator order and brackets if possible
8
+# TODO: Search: Extend Examples with useful features.
9
+# TODO: Search for specific content: Describe search possibilities (also in pygal)
10
+
11
+
12
+HELP_UID = 'help'
13
+
14
+MAIN = mycreole.render_simple(_("""
15
+= PaTT
16
+
17
+**PaTT** is a **P**roject **a**nd **T**eamorganisation **T**ool.
18
+
19
+It is designed to store Tasks in relation to Projects and Users.
20
+
21
+== Help
22
+* [[creole|Creole Markup Language]]
23
+* [[access|Access Control for the site content]]
24
+* [[search|Help on Search]]
25
+
26
+== Items
27
+
28
+=== Task properties:
29
+* State
30
+* Targetdate
31
+* Priority
32
+* Progress
33
+* Name
34
+* Description
35
+
36
+=== Project properties:
37
+* Project Leaders
38
+* Project Members
39
+* State
40
+* Name
41
+* Description
42
+
43
+"""))
44
+
45
+CREOLE = mycreole.mycreole_help_pagecontent()
46
+CREOLE += mycreole.render_simple("""
47
+= PaTT Markup
48
+{{{[[task:number]]}}} will result in a Link to the given tasknumber.
49
+
50
+{{{[[tasklist:number]]}}} will result in a Link to the tasklist of the given projectnumber.
51
+""")
52
+
53
+ACCESS = mycreole.render_simple(_("""
54
+= Superuser(s)
55
+* Are able to view, create and edit everything!
56
+* Only a Superuser is able to create a project.
57
+= Non-Staff-Users
58
+* Are able to read their own tasks, which are in the state "Open" or "Finished" and the related project(s) to these tasks.
59
+* They don't get project role permissions (Projectleader, -member, ...), even if they have a role.
60
+* They don't get permission to change any content.
61
+= Projectleader(s)
62
+* Are able to view and edit everything related to the project.
63
+* They are able to create tasks for the project for any user with a projectrole.
64
+= Projectmember(s)
65
+* Are able to view everything related to the project.
66
+* They are have limited modify permission to their own task related to that project.
67
+* They are able to leave taskcomments at every task related to the project.
68
+* They are able to create tasks related to the project for themselves.
69
+= Projectvisitor(s)
70
+* Are able to view everything related to the project.
71
+* They are have limited modify permission to their own task related to that project.
72
+"""))
73
+
74
+SEARCH = mycreole.render_simple(_("""
75
+= Search
76
+The search looks up full words in //Tasknames (name)// and //Taskdescriptions (description)// without giving \
77
+special search commands in the search string. The search will result in a tasklist.
78
+
79
+
80
+=== Task search fields
81
+* task_id (NUMERIC):
82
+* assigned_user (TEXT):
83
+* assigned_user_missing (BOOLEAN):
84
+* name (TEXT):
85
+* description (TEXT):
86
+* state (TEXT):
87
+** The state of a Task. It is one of the following states: Open, Finished, Closed, Cancelled
88
+* targetdate (DATETIME):
89
+
90
+=== Project related fields
91
+* project_id (NUMERIC):
92
+* project_name (TEXT):
93
+* project_description (TEXT):
94
+
95
+=== Comment related field
96
+* comment (TEXT):
97
+
98
+
99
+== Search syntax (Whoosh)
100
+=== Logic operators
101
+* AND
102
+** **Example:** "foo AND bar" - Search will find all items with foo and bar.
103
+* OR
104
+** **Example:** "foo OR bar" - Search will find all items with foo, bar or with foo and bar.
105
+* NOT
106
+** **Example:** "foo NOT bar" - Search will find all items with foo and no bar.
107
+=== Search in specific fields
108
+A search pattern like //foo:bar// does look for //bar// in the field named //foo//.
109
+
110
+This search pattern can also be combined with other search text via logical operators.
111
+=== Search for specific content
112
+* **Wildcards:**
113
+* **Range:**
114
+** From To:
115
+** Above:
116
+** Below:
117
+* **Named constants:**
118
+** //now//: Current date
119
+** //-[num]y//: Current date minus [num] years
120
+** //+[num]mo//: Current date plus [num] months
121
+** //-[num]d//: Current date minus [num] days
122
+** ...
123
+
124
+== Examples
125
+* [[/patt/tasklist/search?q=project_id:1|project_id:1]] gives results with all tasks of project number #1.
126
+* [[/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'.
127
+* [[/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'.
128
+"""))
129
+
130
+help_pages = {
131
+    'main': MAIN,
132
+    'creole': CREOLE,
133
+    'access': ACCESS,
134
+    'search': SEARCH,
135
+}
136
+
137
+
138
+def actionbar(context, request, current_help_page=None, **kwargs):
139
+    actionbar_entries = (
140
+        ('1', 'Main'),
141
+        ('2', 'Creole'),
142
+        ('3', 'Access'),
143
+        ('4', 'Search'),
144
+    )
145
+    for num, name in actionbar_entries:
146
+        context[context.ACTIONBAR].append_entry(
147
+            HELP_UID + '-%s' % name.lower(),                        # uid
148
+            _(name),                                                # name
149
+            color_icon_url(request, num + '.png'),                  # icon
150
+            patt.url_helpview(request, name.lower()),               # url
151
+            True,                                                   # left
152
+            name.lower() == current_help_page,                      # active
153
+        )

+ 0
- 0
management/__init__.py View File


+ 0
- 0
management/commands/__init__.py View File


+ 9
- 0
management/commands/rebuild_index.py View File

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

+ 76
- 0
migrations/0001_initial.py View File

@@ -0,0 +1,76 @@
1
+# Generated by Django 2.2.5 on 2019-09-30 16:58
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+
7
+
8
+class Migration(migrations.Migration):
9
+
10
+    initial = True
11
+
12
+    dependencies = [
13
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14
+    ]
15
+
16
+    operations = [
17
+        migrations.CreateModel(
18
+            name='Project',
19
+            fields=[
20
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
+                ('name', models.CharField(max_length=128)),
22
+                ('description', models.TextField(blank=True, default='')),
23
+                ('state', models.IntegerField(choices=[(0, 'Open'), (1, 'Closed')], default=0)),
24
+                ('role_leader', models.ManyToManyField(related_name='role_leader', to=settings.AUTH_USER_MODEL)),
25
+                ('role_member', models.ManyToManyField(blank=True, related_name='role_member', to=settings.AUTH_USER_MODEL)),
26
+            ],
27
+        ),
28
+        migrations.CreateModel(
29
+            name='TaskListSetting',
30
+            fields=[
31
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
32
+                ('name', models.CharField(default='All', max_length=50)),
33
+                ('projectfilter', models.CharField(default='all', max_length=50)),
34
+                ('userfilter', models.CharField(default='all', max_length=50)),
35
+                ('statefilter', models.CharField(default='0,1,2,3', max_length=50)),
36
+                ('displaytype', models.CharField(default='short', max_length=50)),
37
+                ('sorttype', models.CharField(default='date', max_length=50)),
38
+                ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
39
+            ],
40
+        ),
41
+        migrations.CreateModel(
42
+            name='UserProfile',
43
+            fields=[
44
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
45
+                ('current_tasklistsetting', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='patt.TaskListSetting')),
46
+                ('my_tasklistsettings', models.ManyToManyField(related_name='my_tasklistsettings', to='patt.TaskListSetting')),
47
+                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
48
+            ],
49
+        ),
50
+        migrations.CreateModel(
51
+            name='Task',
52
+            fields=[
53
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
54
+                ('state', models.IntegerField(choices=[(0, 'Open'), (1, 'Finished'), (2, 'Closed'), (3, 'Canceled')], default=0)),
55
+                ('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)),
56
+                ('targetdate', models.DateField(blank=True, null=True)),
57
+                ('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)),
58
+                ('name', models.CharField(default='', max_length=150)),
59
+                ('description', models.TextField(blank=True, default='')),
60
+                ('creation_date', models.DateTimeField(auto_now_add=True)),
61
+                ('assigned_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
62
+                ('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='patt.Project')),
63
+            ],
64
+        ),
65
+        migrations.CreateModel(
66
+            name='Comment',
67
+            fields=[
68
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
69
+                ('type', models.IntegerField(choices=[(1, 'Appraisal'), (0, 'Comment')], default=0)),
70
+                ('creation_date', models.DateTimeField(auto_now_add=True)),
71
+                ('description', models.TextField()),
72
+                ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patt.Task')),
73
+                ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
74
+            ],
75
+        ),
76
+    ]

+ 29
- 0
migrations/0002_auto_20191003_1614.py View File

@@ -0,0 +1,29 @@
1
+# Generated by Django 2.2.5 on 2019-10-03 16:14
2
+
3
+from django.db import migrations, models
4
+import django.db.models.deletion
5
+
6
+
7
+class Migration(migrations.Migration):
8
+
9
+    dependencies = [
10
+        ('patt', '0001_initial'),
11
+    ]
12
+
13
+    operations = [
14
+        migrations.AlterField(
15
+            model_name='project',
16
+            name='state',
17
+            field=models.IntegerField(choices=[(0, 'Open'), (2, 'Closed')], default=0),
18
+        ),
19
+        migrations.AlterField(
20
+            model_name='task',
21
+            name='project',
22
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patt.Project'),
23
+        ),
24
+        migrations.AlterField(
25
+            model_name='task',
26
+            name='state',
27
+            field=models.IntegerField(default=0),
28
+        ),
29
+    ]

+ 17
- 0
migrations/0003_remove_tasklistsetting_sorttype.py View File

@@ -0,0 +1,17 @@
1
+# Generated by Django 2.2.5 on 2019-10-03 18:36
2
+
3
+from django.db import migrations
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('patt', '0002_auto_20191003_1614'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.RemoveField(
14
+            model_name='tasklistsetting',
15
+            name='sorttype',
16
+        ),
17
+    ]

+ 45
- 0
migrations/0004_auto_20191004_1125.py View File

@@ -0,0 +1,45 @@
1
+# Generated by Django 2.2.5 on 2019-10-04 11:25
2
+
3
+from django.db import migrations, models
4
+import django.db.models.deletion
5
+
6
+
7
+class Migration(migrations.Migration):
8
+
9
+    dependencies = [
10
+        ('patt', '0003_remove_tasklistsetting_sorttype'),
11
+    ]
12
+
13
+    operations = [
14
+        migrations.CreateModel(
15
+            name='ViewSetting',
16
+            fields=[
17
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18
+                ('view', models.CharField(max_length=50)),
19
+                ('userfilter', models.CharField(default='all', max_length=50)),
20
+                ('statefilter', models.CharField(default='1,0', max_length=50)),
21
+                ('displaytype', models.CharField(default='short', max_length=50)),
22
+            ],
23
+        ),
24
+        migrations.RemoveField(
25
+            model_name='userprofile',
26
+            name='current_tasklistsetting',
27
+        ),
28
+        migrations.RemoveField(
29
+            model_name='userprofile',
30
+            name='my_tasklistsettings',
31
+        ),
32
+        migrations.AddField(
33
+            model_name='userprofile',
34
+            name='viewsettings',
35
+            field=models.TextField(default='{}'),
36
+        ),
37
+        migrations.DeleteModel(
38
+            name='TaskListSetting',
39
+        ),
40
+        migrations.AddField(
41
+            model_name='viewsetting',
42
+            name='profile',
43
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patt.UserProfile'),
44
+        ),
45
+    ]

+ 20
- 0
migrations/0005_project_creation_date.py View File

@@ -0,0 +1,20 @@
1
+# Generated by Django 2.2.5 on 2019-10-05 11:48
2
+
3
+from django.db import migrations, models
4
+import django.utils.timezone
5
+
6
+
7
+class Migration(migrations.Migration):
8
+
9
+    dependencies = [
10
+        ('patt', '0004_auto_20191004_1125'),
11
+    ]
12
+
13
+    operations = [
14
+        migrations.AddField(
15
+            model_name='project',
16
+            name='creation_date',
17
+            field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
18
+            preserve_default=False,
19
+        ),
20
+    ]

+ 18
- 0
migrations/0006_auto_20191006_1824.py View File

@@ -0,0 +1,18 @@
1
+# Generated by Django 2.2.5 on 2019-10-06 18:24
2
+
3
+from django.db import migrations
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('patt', '0005_project_creation_date'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.RenameField(
14
+            model_name='comment',
15
+            old_name='description',
16
+            new_name='comment',
17
+        ),
18
+    ]

+ 65
- 0
migrations/0007_historicalcomment_historicaltask.py View File

@@ -0,0 +1,65 @@
1
+# Generated by Django 2.2.5 on 2019-10-09 19:38
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+import simple_history.models
7
+
8
+
9
+class Migration(migrations.Migration):
10
+
11
+    dependencies = [
12
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+        ('patt', '0006_auto_20191006_1824'),
14
+    ]
15
+
16
+    operations = [
17
+        migrations.CreateModel(
18
+            name='HistoricalTask',
19
+            fields=[
20
+                ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
21
+                ('state', models.IntegerField(default=0)),
22
+                ('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)),
23
+                ('targetdate', models.DateField(blank=True, null=True)),
24
+                ('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)),
25
+                ('name', models.CharField(default='', max_length=150)),
26
+                ('description', models.TextField(blank=True, default='')),
27
+                ('creation_date', models.DateTimeField(blank=True, editable=False)),
28
+                ('history_id', models.AutoField(primary_key=True, serialize=False)),
29
+                ('history_date', models.DateTimeField()),
30
+                ('history_change_reason', models.CharField(max_length=100, null=True)),
31
+                ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
32
+                ('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)),
33
+                ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
34
+                ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='patt.Project')),
35
+            ],
36
+            options={
37
+                'verbose_name': 'historical task',
38
+                'ordering': ('-history_date', '-history_id'),
39
+                'get_latest_by': 'history_date',
40
+            },
41
+            bases=(simple_history.models.HistoricalChanges, models.Model),
42
+        ),
43
+        migrations.CreateModel(
44
+            name='HistoricalComment',
45
+            fields=[
46
+                ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
47
+                ('type', models.IntegerField(choices=[(1, 'Appraisal'), (0, 'Comment')], default=0)),
48
+                ('creation_date', models.DateTimeField(blank=True, editable=False)),
49
+                ('comment', models.TextField()),
50
+                ('history_id', models.AutoField(primary_key=True, serialize=False)),
51
+                ('history_date', models.DateTimeField()),
52
+                ('history_change_reason', models.CharField(max_length=100, null=True)),
53
+                ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
54
+                ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
55
+                ('task', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='patt.Task')),
56
+                ('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)),
57
+            ],
58
+            options={
59
+                'verbose_name': 'historical comment',
60
+                'ordering': ('-history_date', '-history_id'),
61
+                'get_latest_by': 'history_date',
62
+            },
63
+            bases=(simple_history.models.HistoricalChanges, models.Model),
64
+        ),
65
+    ]

+ 17
- 0
migrations/0008_remove_viewsetting_displaytype.py View File

@@ -0,0 +1,17 @@
1
+# Generated by Django 2.2.5 on 2019-10-10 18:33
2
+
3
+from django.db import migrations
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('patt', '0007_historicalcomment_historicaltask'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.RemoveField(
14
+            model_name='viewsetting',
15
+            name='displaytype',
16
+        ),
17
+    ]

+ 41
- 0
migrations/0009_auto_20191102_1925.py View File

@@ -0,0 +1,41 @@
1
+# Generated by Django 2.2.5 on 2019-11-02 19:25
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+
7
+
8
+class Migration(migrations.Migration):
9
+
10
+    dependencies = [
11
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12
+        ('patt', '0008_remove_viewsetting_displaytype'),
13
+    ]
14
+
15
+    operations = [
16
+        migrations.CreateModel(
17
+            name='PattProfile',
18
+            fields=[
19
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20
+                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
21
+            ],
22
+        ),
23
+        migrations.AddField(
24
+            model_name='project',
25
+            name='days_highlight',
26
+            field=models.IntegerField(choices=[(0, 'Off'), (1, 1), (2, 2), (3, 3), (5, 5), (7, 7), (10, 10), (14, 14)], default=7),
27
+        ),
28
+        migrations.AddField(
29
+            model_name='project',
30
+            name='days_warn',
31
+            field=models.IntegerField(choices=[(0, 'Off'), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (7, 7), (9, 9)], default=3),
32
+        ),
33
+        migrations.AlterField(
34
+            model_name='viewsetting',
35
+            name='profile',
36
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patt.PattProfile'),
37
+        ),
38
+        migrations.DeleteModel(
39
+            name='UserProfile',
40
+        ),
41
+    ]

+ 36
- 0
migrations/0010_auto_20191117_1157.py View File

@@ -0,0 +1,36 @@
1
+# Generated by Django 2.2.5 on 2019-11-17 11:57
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('patt', '0009_auto_20191102_1925'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.RemoveField(
14
+            model_name='project',
15
+            name='days_highlight',
16
+        ),
17
+        migrations.RemoveField(
18
+            model_name='project',
19
+            name='days_warn',
20
+        ),
21
+        migrations.AddField(
22
+            model_name='project',
23
+            name='days_late',
24
+            field=models.IntegerField(choices=[(-1, 'Off'), (0, 0)], default=0),
25
+        ),
26
+        migrations.AddField(
27
+            model_name='project',
28
+            name='days_soon',
29
+            field=models.IntegerField(choices=[(-1, 'Off'), (1, 1), (2, 2), (3, 3), (5, 5), (7, 7), (10, 10), (14, 14)], default=7),
30
+        ),
31
+        migrations.AddField(
32
+            model_name='project',
33
+            name='days_very_soon',
34
+            field=models.IntegerField(choices=[(-1, 'Off'), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (7, 7), (9, 9)], default=3),
35
+        ),
36
+    ]

+ 20
- 0
migrations/0011_project_role_visitor.py View File

@@ -0,0 +1,20 @@
1
+# Generated by Django 2.2.9 on 2020-01-09 10:53
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+
6
+
7
+class Migration(migrations.Migration):
8
+
9
+    dependencies = [
10
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
11
+        ('patt', '0010_auto_20191117_1157'),
12
+    ]
13
+
14
+    operations = [
15
+        migrations.AddField(
16
+            model_name='project',
17
+            name='role_visitor',
18
+            field=models.ManyToManyField(blank=True, related_name='role_visitor', to=settings.AUTH_USER_MODEL),
19
+        ),
20
+    ]

+ 35
- 0
migrations/0012_auto_20200114_1035.py View File

@@ -0,0 +1,35 @@
1
+# Generated by Django 2.2.9 on 2020-01-14 10:35
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+
7
+
8
+class Migration(migrations.Migration):
9
+
10
+    dependencies = [
11
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12
+        ('patt', '0011_project_role_visitor'),
13
+    ]
14
+
15
+    operations = [
16
+        migrations.CreateModel(
17
+            name='Search',
18
+            fields=[
19
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20
+                ('name', models.CharField(max_length=48)),
21
+                ('search_txt', models.TextField(blank=True, default='')),
22
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
23
+            ],
24
+        ),
25
+        migrations.RemoveField(
26
+            model_name='viewsetting',
27
+            name='profile',
28
+        ),
29
+        migrations.DeleteModel(
30
+            name='PattProfile',
31
+        ),
32
+        migrations.DeleteModel(
33
+            name='ViewSetting',
34
+        ),
35
+    ]

+ 0
- 0
migrations/__init__.py View File


+ 281
- 0
models.py View File

@@ -0,0 +1,281 @@
1
+import datetime
2
+from django.contrib.auth.models import User
3
+from django.db import models
4
+from django.utils.dateformat import format
5
+from django.utils.translation import gettext as _
6
+from simple_history.models import HistoricalRecords
7
+
8
+# PROJECTSTATE (REQ-??)
9
+#
10
+PROJECTSTATE_OPEN = 0
11
+PROJECTSTATE_CLOSED = 2
12
+PROJECTS_IN_WORK = (PROJECTSTATE_OPEN, )
13
+#
14
+PROJECTSTATE_CHOICES = (
15
+    (PROJECTSTATE_OPEN, _('Open')),
16
+    (PROJECTSTATE_CLOSED, _('Closed')),
17
+)
18
+
19
+
20
+# TASKSTATE (REQ-14)
21
+#
22
+TASKSTATE_OPEN = 0
23
+TASKSTATE_FINISHED = 1
24
+TASKSTATE_CLOSED = 2
25
+TASKSTATE_CANCELED = 3
26
+TASKS_IN_WORK = (TASKSTATE_OPEN, TASKSTATE_FINISHED, )
27
+#
28
+TASKSTATE_CHOICES = (
29
+    (TASKSTATE_OPEN, _('Open')),
30
+    (TASKSTATE_FINISHED, _('Finished')),
31
+    (TASKSTATE_CLOSED, _('Closed')),
32
+    (TASKSTATE_CANCELED, _('Cancelled')),
33
+)
34
+
35
+
36
+# TASKPRIORITY (REQ-??)
37
+#
38
+PRIO_CHOICES = (
39
+    (1, _('1 - Highest')),
40
+    (2, _('2 - High')),
41
+    (3, _('3 - Above-Average')),
42
+    (4, _('4 - Average')),
43
+    (5, _('5 - Below-Average')),
44
+    (6, _('6 - Low')),
45
+    (7, _('7 - Lowest')),
46
+)
47
+
48
+
49
+# TASKPROGRESS (REQ-??)
50
+#
51
+PRORGRESS_CHOICES = (
52
+    (0, '0 %'),
53
+    (10, '10 %'),
54
+    (20, '20 %'),
55
+    (30, '30 %'),
56
+    (40, '40 %'),
57
+    (50, '50 %'),
58
+    (60, '60 %'),
59
+    (70, '70 %'),
60
+    (80, '80 %'),
61
+    (90, '90 %'),
62
+    (100, '100 %'),
63
+)
64
+
65
+# COMMENTTYPE (REQ-??)
66
+#
67
+COMMENTTYPE_COMMENT = 0
68
+COMMENTTYPE_APPRAISAL = 1
69
+#
70
+COMMENTTYPE_CHOICES = (
71
+    (COMMENTTYPE_APPRAISAL, _('Appraisal')),
72
+    (COMMENTTYPE_COMMENT, _('Comment')),
73
+)
74
+
75
+
76
+# GENERAL Methods and Classes
77
+#
78
+class ModelList(list):
79
+    def __init__(self, lst, chk=None, user=None):
80
+        if chk is None:
81
+            list.__init__(self, lst)
82
+        else:
83
+            list.__init__(self)
84
+            for e in lst:
85
+                acc = chk(e, user)
86
+                if acc.read:
87
+                    self.append(e)
88
+
89
+    def sort(self, **kwargs):
90
+        list.sort(self, key=lambda entry: entry.sort_string(), reverse=kwargs.get('reverse', False))
91
+
92
+
93
+# PROCECT Model
94
+#
95
+class Project(models.Model):
96
+    name = models.CharField(max_length=128)                                                 # REQ-7
97
+    description = models.TextField(default='', blank=True)                                  # REQ-8
98
+    state = models.IntegerField(choices=PROJECTSTATE_CHOICES, default=PROJECTSTATE_OPEN)    # REQ-26
99
+    role_leader = models.ManyToManyField(User, related_name='role_leader')                  # REQ-9
100
+    role_member = models.ManyToManyField(User, related_name='role_member', blank=True)      # REQ-12
101
+    role_visitor = models.ManyToManyField(User, related_name='role_visitor', blank=True)    # REQ-33
102
+    creation_date = models.DateTimeField(auto_now_add=True)
103
+    days_late = models.IntegerField(default=0, choices=((-1, _('Off')), (0, 0), ))
104
+    days_very_soon = models.IntegerField(default=3, choices=((-1, _('Off')), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (7, 7), (9, 9), ))
105
+    days_soon = models.IntegerField(default=7, choices=((-1, _('Off')), (1, 1), (2, 2), (3, 3), (5, 5), (7, 7), (10, 10), (14, 14), ))
106
+
107
+    @property
108
+    def attachment_target_path(self):
109
+        if self.id:
110
+            return 'patt/project/%d' % self.id
111
+        else:
112
+            return 'patt/project/new'
113
+
114
+    @property
115
+    def formatted_leaders(self):
116
+        if self.id:     # in preview (before saving), the ManyToManyFields are not available
117
+            return ', '.join([user.username for user in self.role_leader.all()]) or '-'
118
+        else:
119
+            return '...'
120
+
121
+    @property
122
+    def formatted_members(self):
123
+        if self.id:     # in preview (before saving), the ManyToManyFields are not available
124
+            return ', '.join([user.username for user in self.role_member.all()]) or '-'
125
+        else:
126
+            return '...'
127
+
128
+    @property
129
+    def formatted_visitors(self):
130
+        if self.id:     # in preview (before saving), the ManyToManyFields are not available
131
+            return ', '.join([user.username for user in self.role_visitor.all()]) or '-'
132
+        else:
133
+            return '...'
134
+
135
+    def sort_string(self):
136
+        return (self.state, self.name)
137
+
138
+    def __str__(self):
139
+        return 'Project: %s' % self.name
140
+
141
+
142
+# TASK Model
143
+#
144
+class Task(models.Model):
145
+    FUSION_STATE_FINISHED = 0
146
+    FUSION_STATE_NORMAL = 1
147
+    FUSION_STATE_SOON = 2
148
+    FUSION_STATE_VERY_SOON = 3
149
+    FUSION_STATE_LATE = 4
150
+    #
151
+    SAVE_ON_CHANGE_FIELDS = ['state', 'priority', 'targetdate', 'progress', 'name', 'description', 'assigned_user', 'project']
152
+    #
153
+    state = models.IntegerField(default=TASKSTATE_OPEN)                                         # REQ-14
154
+    priority = models.IntegerField(choices=PRIO_CHOICES, default=4)                             # REQ-15
155
+    targetdate = models.DateField(null=True, blank=True)                                        # REQ-16
156
+    progress = models.IntegerField(default=0, choices=PRORGRESS_CHOICES)                        # REQ-17
157
+    name = models.CharField(max_length=150, default='')                                         # REQ-?? (18)
158
+    description = models.TextField(default='', blank=True)                                      # REQ-??
159
+    assigned_user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)   # REQ-31
160
+    project = models.ForeignKey(Project, on_delete=models.CASCADE)                              # REQ-??
161
+    creation_date = models.DateTimeField(auto_now_add=True)
162
+    #
163
+    history = HistoricalRecords()
164
+
165
+    @property
166
+    def taskname_prefix(self):
167
+        if self.assigned_user:
168
+            return '**#%d //(%s)//:** ' % (self.id, self.assigned_user.username)
169
+        else:
170
+            return '**#%d:** ' % self.id
171
+
172
+    def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
173
+        if self.id and not force_update:
174
+            orig = Task.objects.get(id=self.id)
175
+            for key in self.SAVE_ON_CHANGE_FIELDS:
176
+                if getattr(self, key) != getattr(orig, key):
177
+                    break
178
+            else:
179
+                self.save_needed = False
180
+                return False
181
+        self.save_needed = True
182
+        return models.Model.save(self, force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
183
+
184
+    @property
185
+    def attachment_target_path(self):
186
+        if self.id:
187
+            return 'patt/task/%d' % self.id
188
+        else:
189
+            return 'patt/task/new'
190
+
191
+    @property
192
+    def comments(self):
193
+        rv = ModelList(self.comment_set.all())
194
+        rv.sort(reverse=True)
195
+        return rv
196
+
197
+    def datafusion_state(self):
198
+        if self.state in [TASKSTATE_CANCELED, TASKSTATE_CLOSED, TASKSTATE_FINISHED]:
199
+            return self.FUSION_STATE_FINISHED
200
+        else:
201
+            if self.targetdate is not None:
202
+                if type(self.targetdate) == datetime.date:
203
+                    targetdate = self.targetdate
204
+                else:
205
+                    targetdate = datetime.datetime.strptime(self.targetdate, '%Y-%m-%d').date()
206
+                if targetdate - datetime.date.today() <= datetime.timedelta(self.project.days_late) and self.project.days_late >= 0:
207
+                    return self.FUSION_STATE_LATE
208
+                elif targetdate - datetime.date.today() <= datetime.timedelta(self.project.days_very_soon) and self.project.days_very_soon >= 0:
209
+                    return self.FUSION_STATE_VERY_SOON
210
+                elif targetdate - datetime.date.today() <= datetime.timedelta(self.project.days_soon) and self.project.days_soon >= 0:
211
+                    return self.FUSION_STATE_SOON
212
+        return self.FUSION_STATE_NORMAL
213
+
214
+    @property
215
+    def class_by_state(self):
216
+        return {
217
+            self.FUSION_STATE_FINISHED: 'task-finished',
218
+            self.FUSION_STATE_NORMAL: 'task-normal',
219
+            self.FUSION_STATE_VERY_SOON: 'task-very-soon',
220
+            self.FUSION_STATE_SOON: 'task-soon',
221
+            self.FUSION_STATE_LATE: 'task-late',
222
+        }.get(self.datafusion_state())
223
+
224
+    def sort_string(self):
225
+        if self.targetdate:
226
+            td = int(format(self.targetdate, 'U'))
227
+        else:
228
+            td = 999999999999
229
+        return (100 - self.datafusion_state(), self.state, self.priority, td, self.progress, self.name)
230
+
231
+    def __str__(self):
232
+        if self.project:
233
+            return 'Task: %s - %s' % (self.project.name, self.name)
234
+        else:
235
+            return 'Task: %s' % (self.name)
236
+
237
+
238
+# COMMENT Model
239
+#
240
+class Comment(models.Model):                                    # REQ-19 and REQ-20
241
+    SAVE_ON_CHANGE_FIELDS = ['task', 'user', 'type', 'comment']
242
+    task = models.ForeignKey(Task, on_delete=models.CASCADE)
243
+    user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)
244
+    type = models.IntegerField(choices=COMMENTTYPE_CHOICES, default=COMMENTTYPE_COMMENT)
245
+    creation_date = models.DateTimeField(auto_now_add=True)
246
+    comment = models.TextField()
247
+    #
248
+    history = HistoricalRecords()
249
+
250
+    def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
251
+        if self.id and not force_update:
252
+            orig = Comment.objects.get(id=self.id)
253
+            for key in self.SAVE_ON_CHANGE_FIELDS:
254
+                if getattr(self, key) != getattr(orig, key):
255
+                    break
256
+            else:
257
+                self.save_needed = False
258
+                return False
259
+        self.save_needed = True
260
+        return models.Model.save(self, force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
261
+
262
+    @property
263
+    def attachment_target_path(self):
264
+        if self.id:
265
+            return 'patt/comment/%d' % self.id
266
+        else:
267
+            return 'patt/comment/new'
268
+
269
+    def sort_string(self):
270
+        return (self.creation_date, self.type, self.comment)
271
+
272
+    def __str__(self):
273
+        return 'Comment: %s - %d' % (self.task.name, self.id)
274
+
275
+
276
+# SEARCH Model
277
+#
278
+class Search(models.Model):
279
+    name = models.CharField(max_length=48)
280
+    search_txt = models.TextField(default='', blank=True)
281
+    user = models.ForeignKey(User, on_delete=models.CASCADE)

+ 167
- 0
search.py View File

@@ -0,0 +1,167 @@
1
+import datetime
2
+from django.conf import settings
3
+import fstools
4
+import logging
5
+from .models import Task, TASKSTATE_OPEN, TASKSTATE_FINISHED, TASKSTATE_CLOSED, TASKSTATE_CANCELED
6
+import os
7
+import re
8
+from whoosh.fields import Schema, ID, TEXT, NUMERIC, DATETIME, BOOLEAN
9
+from whoosh.qparser.dateparse import DateParserPlugin
10
+from whoosh import index, qparser
11
+
12
+logger = logging.getLogger("WHOOSH")
13
+
14
+SEARCH_MY_OPEN_TASKS = 1
15
+SEARCH_LOST_SOULS = 2
16
+
17
+
18
+def common_searches(request):
19
+    cs = {}
20
+    if request.user.is_authenticated:
21
+        cs[SEARCH_MY_OPEN_TASKS] = ('My Open Tasks', mk_search_pattern(user_ids=[request.user.id]))
22
+    cs[SEARCH_LOST_SOULS] = ('Lost Souls (no user)', 'assigned_user_missing:1')
23
+    return cs
24
+
25
+
26
+# INDEX_STATES
27
+#
28
+INDEX_STATES = {
29
+    TASKSTATE_OPEN: 'Open',
30
+    TASKSTATE_FINISHED: 'Finished',
31
+    TASKSTATE_CLOSED: 'Closed',
32
+    TASKSTATE_CANCELED: 'Canselled'
33
+}
34
+
35
+
36
+SCHEMA = Schema(
37
+    id=ID(unique=True, stored=True),
38
+    # Task
39
+    task_id=NUMERIC,
40
+    assigned_user=TEXT,
41
+    assigned_user_missing=BOOLEAN,
42
+    name=TEXT,
43
+    description=TEXT,
44
+    state=TEXT,
45
+    targetdate=DATETIME,
46
+    # Related Project
47
+    project_id=NUMERIC,
48
+    project_name=TEXT,
49
+    project_description=TEXT,
50
+    # Related Comments
51
+    comment=TEXT,
52
+)
53
+
54
+
55
+def mk_whooshpath_if_needed():
56
+    if not os.path.exists(settings.WHOOSH_PATH):
57
+        fstools.mkdir(settings.WHOOSH_PATH)
58
+
59
+
60
+def create_index():
61
+    mk_whooshpath_if_needed()
62
+    logger.debug('Search Index created.')
63
+    return index.create_in(settings.WHOOSH_PATH, schema=SCHEMA)
64
+
65
+
66
+def load_index():
67
+        mk_whooshpath_if_needed()
68
+        try:
69
+            ix = index.open_dir(settings.WHOOSH_PATH)
70
+        except index.EmptyIndexError:
71
+            ix = create_index()
72
+        else:
73
+            logger.debug('Search Index opened.')
74
+        return ix
75
+
76
+
77
+def add_item(ix, item):
78
+    # Define Standard data
79
+    #
80
+    data = dict(
81
+        id='%d' % item.id,
82
+        # Task
83
+        task_id=item.id,
84
+        name=item.name,
85
+        description=item.description,
86
+        state=INDEX_STATES.get(item.state),
87
+        # Related Project
88
+        project_id=item.project.id,
89
+        project_name=item.project.name,
90
+        project_description=item.project.description,
91
+        # Related Comments
92
+        comment=' '.join([c.comment for c in item.comment_set.all()]),
93
+    )
94
+    # Add Optional data
95
+    #
96
+    if item.assigned_user is not None:
97
+        data['assigned_user'] = item.assigned_user.username
98
+        data['assigned_user_missing'] = False
99
+    else:
100
+        data['assigned_user_missing'] = True
101
+    if item.targetdate is not None:
102
+        data['targetdate'] = datetime.datetime.combine(item.targetdate, datetime.datetime.min.time())
103
+    # Write data to the index
104
+    #
105
+    with ix.writer() as w:
106
+        logger.info('Adding document with id=%d to the search index.', data.get('task_id'))
107
+        w.add_document(**data)
108
+        for key in data:
109
+            logger.debug('  - Adding %s=%s', key, repr(data[key]))
110
+
111
+
112
+def delete_item(ix, item):
113
+    with ix.writer() as w:
114
+        logger.info('Removing document with id=%d from the search index.', item.id)
115
+        w.delete_by_term("task_id", item.id)
116
+
117
+
118
+def update_item(ix, item):
119
+    delete_item(ix, item)
120
+    add_item(ix, item)
121
+
122
+
123
+def rebuild_index(ix):
124
+    for t in Task.objects.all():
125
+        add_item(ix, t)
126
+    return len(Task.objects.all())
127
+
128
+
129
+def search(ix, search_txt):
130
+    qp = qparser.MultifieldParser(['name', 'description'], ix.schema)
131
+    qp.add_plugin(DateParserPlugin(free=True))
132
+    try:
133
+        q = qp.parse(search_txt)
134
+    except AttributeError:
135
+        return None
136
+    except Exception:
137
+        return None
138
+    with ix.searcher() as s:
139
+        results = s.search(q, limit=None)
140
+        rpl = []
141
+        for hit in results:
142
+            rpl.append(hit['id'])
143
+        return Task.objects.filter(id__in=rpl)
144
+
145
+
146
+def mk_search_pattern(**kwargs):
147
+    prj_ids = kwargs.get('prj_ids', [])
148
+    user_ids = kwargs.get('user_ids', [])
149
+    states = kwargs.get('states', [INDEX_STATES.get(TASKSTATE_OPEN), INDEX_STATES.get(TASKSTATE_FINISHED)])
150
+    rule_parts = []
151
+    if prj_ids is not None and len(prj_ids) > 0:
152
+        rule_parts.append(' OR '.join(['project_id:%s' % pid for pid in prj_ids]))
153
+    if user_ids is not None and len(user_ids) > 0:
154
+        from django.contrib.auth.models import User
155
+        rule_parts.append(' OR '.join(['assigned_user:%s' % User.objects.get(id=uid).username for uid in user_ids]))
156
+    if states is not None and len(states) > 0:
157
+        rule_parts.append(' OR '.join(['state:%s' % state for state in states]))
158
+    return ' AND '.join('(%s)' % rule for rule in rule_parts)
159
+
160
+
161
+def get_project_ids_from_search_pattern(search_txt):
162
+    try:
163
+        return re.findall('project_id:(\d+)', search_txt)
164
+    except AttributeError:
165
+        return None
166
+    except TypeError:
167
+        return None

+ 31
- 0
signals.py View File

@@ -0,0 +1,31 @@
1
+from django.db.models.signals import pre_delete, post_delete, post_save
2
+from django.dispatch import receiver
3
+from .models import Task, Project, Comment
4
+from mycreole import delete_attachment_target_path
5
+from .search import load_index, delete_item, update_item
6
+
7
+
8
+@receiver(pre_delete, sender=Task)
9
+@receiver(pre_delete, sender=Project)
10
+@receiver(pre_delete, sender=Comment)
11
+def item_cache_delete(instance, **kwargs):
12
+    delete_attachment_target_path(instance.attachment_target_path)
13
+
14
+
15
+@receiver(post_delete, sender=Task)
16
+def search_index_item_delete(instance, **kwargs):
17
+    # delete index entry
18
+    ix = load_index()
19
+    delete_item(ix, instance)
20
+
21
+
22
+@receiver(post_save, sender=Task)
23
+@receiver(post_save, sender=Comment)
24
+@receiver(post_delete, sender=Comment)
25
+def search_index_item_update(instance, **kwargs):
26
+    if type(instance) == Comment:
27
+        task = instance.task
28
+    else:
29
+        task = instance
30
+    ix = load_index()
31
+    update_item(ix, task)

+ 9
- 0
static/patt/datepicker.min.css View File

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

+ 10
- 0
static/patt/datepicker.min.js
File diff suppressed because it is too large
View File


BIN
static/patt/draft.png View File


BIN
static/patt/icons/collapse.png View File


BIN
static/patt/icons/edit.png View File


BIN
static/patt/icons/edit_comment.png View File


BIN
static/patt/icons/expand.png View File


BIN
static/patt/icons/pg_0.png View File


BIN
static/patt/icons/pg_10.png View File


BIN
static/patt/icons/pg_100.png View File


BIN
static/patt/icons/pg_20.png View File


BIN
static/patt/icons/pg_30.png View File


BIN
static/patt/icons/pg_40.png View File


BIN
static/patt/icons/pg_50.png View File


BIN
static/patt/icons/pg_60.png View File


BIN
static/patt/icons/pg_70.png View File


BIN
static/patt/icons/pg_80.png View File


BIN
static/patt/icons/pg_90.png View File


BIN
static/patt/icons/prio1.png View File


BIN
static/patt/icons/prio2.png View File


BIN
static/patt/icons/prio3.png View File


BIN
static/patt/icons/prio4.png View File


BIN
static/patt/icons/prio5.png View File


BIN
static/patt/icons/prio6.png View File


BIN
static/patt/icons/prio7.png View File


BIN
static/patt/icons/spacer.png View File


BIN
static/patt/icons/state0.png View File


BIN
static/patt/icons/state1.png View File


BIN
static/patt/icons/state2.png View File


BIN
static/patt/icons/state3.png View File


+ 4
- 0
static/patt/jquery.min.js
File diff suppressed because it is too large
View File


+ 12
- 0
templates/patt/help.html View File

@@ -0,0 +1,12 @@
1
+{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
2
+
3
+{% block head_extensions %}
4
+<style>
5
+{% include 'patt/patt.css' %}
6
+</style>
7
+{% endblock head_extensions %}
8
+
9
+
10
+{% block content %}
11
+{{ help_content|safe }}
12
+{% endblock content %}

+ 131
- 0
templates/patt/patt.css View File

@@ -0,0 +1,131 @@
1
+{% load static %}
2
+
3
+.tasklist, .projectlist {
4
+  padding-top: 75px;
5
+}
6
+
7
+.tasklist-first, .projectlist-first {
8
+  padding-top: 16px;
9
+}
10
+
11
+.taskheadline, .projectheadline {
12
+  margin: 0px;
13
+  display: block;
14
+  align-items: center;
15
+  padding-top: 4px;
16
+  padding-bottom: 4px;
17
+  padding-left: 15px;
18
+  padding-right: 15px;
19
+  font-size: 16px;
20
+  border-radius: 10px;
21
+  font-size: 13px;
22
+}
23
+
24
+.taskname, .projectname, .projectusers, .projectuserlabel {
25
+  font-size: 18px;
26
+}
27
+
28
+.projectname {
29
+	padding-left: 40px;
30
+}
31
+
32
+.projectuserlabel {
33
+  font-weight: bold;
34
+}
35
+
36
+.taskdetails, .projectdetails {
37
+  padding: 16px;
38
+}
39
+
40
+.taskcomment {
41
+  margin: 16px;
42
+  border-left: 6px solid #323232;
43
+}
44
+
45
+.taskcomment-date {
46
+  padding: 16px;
47
+  font-size: 18px;
48
+  padding-bottom: 8px;
49
+  font-weight: bold;
50
+}
51
+
52
+.taskcomment-description {
53
+  padding: 0 50px;
54
+  padding-right: 16px;
55
+}
56
+
57
+.task-finished {
58
+  background-color: #efffef;
59
+}
60
+
61
+.task-normal {
62
+  background-color: #dfefff;
63
+}
64
+
65
+.task-soon {
66
+  background-color: #ffffe4;
67
+}
68
+
69
+.task-very-soon {
70
+  background-color: #ffe6cf;
71
+}
72
+
73
+.task-late {
74
+  background-color: #ffe4e4;
75
+}
76
+
77
+.project-normal {
78
+  background-color: #efefef;
79
+}
80
+
81
+.spacer {
82
+  padding-left: 6px;
83
+  padding-right: 6px;
84
+}
85
+
86
+.taskicon, .projecticon {
87
+  padding-left: 1px;
88
+  padding-right: 1px;
89
+}
90
+
91
+.projecticon {
92
+	position: absolute;
93
+	top: 0;
94
+	left: 0;
95
+}
96
+
97
+.taskicon:hover {
98
+  background: none;
99
+}
100
+
101
+/* When the screen is less than 700px wide, reduce content to be shown */
102
+@media screen and (max-width: 700px) {
103
+    .prio_icons_hide {
104
+        display: none
105
+    }
106
+}
107
+
108
+/* When the screen is less than 500px wide, reduce content to be shown */
109
+@media screen and (max-width: 500px) {
110
+    .target_date_hide {
111
+        display: none
112
+    }
113
+}
114
+
115
+/* When the screen is less than 375px wide, reduce content to be shown */
116
+@media screen and (max-width: 375px) {
117
+    .state_icons_hide {
118
+        display: none
119
+    }
120
+}
121
+
122
+.preview {
123
+	background-image: url("{% static 'patt/draft.png' %}");
124
+	padding: 40px;
125
+	padding-top: 75px;
126
+	min-height: 300px;
127
+}
128
+
129
+.preview-spacer {
130
+	min-height: 35px;
131
+}

+ 3
- 0
templates/patt/project/details.html View File

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

+ 28
- 0
templates/patt/project/head.html View File

@@ -0,0 +1,28 @@
1
+{% load static %}
2
+{% load i18n %}
3
+{% load mycreole %}
4
+{% load access %}
5
+{% load patt_urls %}
6
+
7
+{% may_modify project as user_may_modify %}
8
+
9
+
10
+<div class="projectheadline project-normal" ondblclick="location.href='{% url_tasklist_for_project project %}';" style="cursor:pointer;">
11
+  <div class="container">
12
+    <div class="projectname">{% render_creole "**Project #"|add:project_id|add:":** "|add:project.name project.attachment_target_path next_anchor %}</div>
13
+    <img class="projecticon" src="{% static 'patt/icons/state'|add:state|add:'.png' %}">
14
+    {% if user_may_modify %}<a href="{% url_projectedit project %}"><img class="projecticon" style="left: unset; right: 0;" src="{% static 'patt/icons/edit.png' %}" style="float:right"></a>{% endif %}
15
+  </div>
16
+  <p>
17
+    <span class="projectuserlabel">{% trans 'Projectleader(s)' %}:</span>
18
+    <span class="projectusers">{{ project.formatted_leaders }}</span>
19
+  </p>
20
+  <p>
21
+    <span class="projectuserlabel">{% trans 'Projectmember(s)' %}:</span>
22
+    <span class="projectusers">{{ project.formatted_members }}</span>
23
+  </p>
24
+  <p>
25
+    <span class="projectuserlabel">{% trans 'Projectvisitor(s)' %}:</span>
26
+    <span class="projectusers">{{ project.formatted_visitors }}</span>
27
+  </p>
28
+</div>

+ 4
- 0
templates/patt/project/project.html View File

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

+ 20
- 0
templates/patt/projectlist.html View File

@@ -0,0 +1,20 @@
1
+{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
2
+
3
+{% block head_extensions %}
4
+<style>
5
+{% include 'patt/patt.css' %}
6
+</style>
7
+{% endblock head_extensions %}
8
+
9
+
10
+{% block content %}
11
+{% with link=True %}
12
+{% for project in projectlist %}
13
+<div {% if forloop.counter != 1 %} id="project-{{ project.id }}"{% endif %}>
14
+  <div class="projectlist{% if forloop.counter == 1 %}-first{% endif %}">
15
+{% include 'patt/project/project.html' %}
16
+  </div>
17
+</div>
18
+{% endfor %}
19
+{% endwith %}
20
+{% endblock content %}

+ 31
- 0
templates/patt/raw_single_form.html View File

@@ -0,0 +1,31 @@
1
+{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
2
+{% load static %}
3
+{% load i18n %}
4
+
5
+{% block head_extensions %}
6
+{{ form.media }}
7
+<style>
8
+{% include 'patt/patt.css' %}
9
+</style>
10
+{% endblock head_extensions %}
11
+
12
+
13
+{% block content %}
14
+    <form class="form" method="post">
15
+      {% csrf_token %}
16
+      {{ form.as_p }}
17
+      <input type="hidden" name="next" value="{{ next }}">
18
+      <input type="hidden" name="item_id" value="{{ item_id }}">
19
+      {% if not disable_save %}<input type="submit" name="save" value="{% trans "Save" %}" class="button" />{% endif %}
20
+      {% if not disable_search %}<input type="submit" formaction="" name="search" value="{% trans "Search" %}" class="button" />{% endif %}
21
+      {% if not disable_preview %}<input type="submit" formaction="#preview" name="preview" value="{% trans "Preview" %}" class="button" />{% endif %}
22
+      {% if not disable_delete %}<input type="submit" formaction="" name="delete" value="{% trans "Delete" %}" class="button" />{% endif %}
23
+    </form>
24
+
25
+    {% if template %}
26
+    <div class="preview-spacer" id="preview"></div>
27
+    <div class="preview">
28
+    {% include template %}
29
+    </div>
30
+    {% endif %}
31
+{% endblock content %}

+ 9
- 0
templates/patt/task/comment.html View File

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

+ 19
- 0
templates/patt/task/details.html View File

@@ -0,0 +1,19 @@
1
+{% load i18n %}
2
+{% load mycreole %}
3
+
4
+<div class="taskdetails">
5
+<strong>{% trans 'Created' %}:</strong> {{ task.creation_date }}<br>
6
+<strong>{% trans 'Project' %}:</strong> {{ task.project.name }}
7
+</div>
8
+{% if task.description %}<div class="taskdetails">{% render_creole task.description task.attachment_target_path next_anchor %}</div>{% endif %}
9
+
10
+{% if comment_new %}
11
+{% if comment_new.comment != "" %}
12
+{% with comment=comment_new %}
13
+{% include 'patt/task/comment.html' %}
14
+{% endwith %}
15
+{% endif %}
16
+{% endif %}
17
+{% for comment in task.comments %}
18
+{% include 'patt/task/comment.html' %}
19
+{% endfor %}

+ 41
- 0
templates/patt/task/head.html View File

@@ -0,0 +1,41 @@
1
+{% load static %}
2
+{% load mycreole %}
3
+{% load access %}
4
+{% load patt_urls %}
5
+
6
+{% may_add_comments task as user_may_add_comments %}
7
+{% may_modify task as user_may_modify %}
8
+{% may_modify_limited task as user_may_modify_limited %}
9
+{% targetstates task as possible_targetstates %}
10
+{% targetpriority task as possible_targetpriority %}
11
+
12
+<div class="taskheadline {{ task.class_by_state }}"{% if target_head %} ondblclick="location.href='{% url target_head task_id=task.id %}{% if target_head == 'patt-taskedit' %}{{"?do=edit&next="|add:request.path }}{% endif %}';" style="cursor:pointer;"{% endif %}>
13
+  <div class="taskname">{% render_creole task.taskname_prefix|add:task.name task.attachment_target_path next_anchor %}</div>
14
+  <p>
15
+{% if printview != True and taskview != True %}
16
+    <span class="toggle-button" style="cursor:pointer; padding-right: 10px;">
17
+      <img class="minus" src="{% static 'patt/icons/collapse.png' %}">
18
+      <img class="plus" src="{% static 'patt/icons/expand.png' %}">
19
+    </span>
20
+{% endif %}
21
+    <img class="taskicon" src="{% static 'patt/icons/prio'|add:priority|add:'.png' %}">
22
+    <img class="taskicon" src="{% static 'patt/icons/state'|add:state|add:'.png' %}">
23
+    <img class="taskicon" src="{% static 'patt/icons/pg_'|add:progress|add:'.png' %}">
24
+    {% if task.targetdate %}<span class="target_date_hide">{{ task.targetdate }}</span>{% endif %}
25
+
26
+{% if printview != True %}
27
+    {% if user_may_add_comments %}<a href="{% url_commentnew task %}"><img src="{% static 'patt/icons/edit_comment.png' %}" style="float:right"></a>{% endif %}
28
+    {% if user_may_modify or user_may_modify_limited %}<a href="{% url_taskedit task %}"><img src="{% static 'patt/icons/edit.png' %}" style="float:right"></a>{% endif %}
29
+    {% if user_may_modify or user_may_add_comments or user_may_modify_limited %}<img class="spacer prio_icons_hide" src="{% static 'patt/icons/spacer.png' %}" style="float:right">{% endif %}
30
+    {% for i in possible_targetpriority %}
31
+      <a class="taskicon prio_icons_hide" href="{% url_taskset_priority task i %}"><img src="{% static 'patt/icons/prio'|add:i|add:'.png' %}" style="float:right"></a>
32
+    {% endfor %}
33
+    {% if possible_targetpriority %}
34
+    <img class="spacer state_icons_hide" src="{% static 'patt/icons/spacer.png' %}" style="float:right">
35
+    {% endif %}
36
+    {% for i in possible_targetstates %}
37
+      <a class="taskicon state_icons_hide" href="{% url_taskset_state task i %}"><img src="{% static 'patt/icons/state'|add:i|add:'.png' %}" style="float:right"></a>
38
+    {% endfor %}
39
+  </p>
40
+{% endif %}
41
+</div>

+ 6
- 0
templates/patt/task/task.html View File

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

+ 37
- 0
templates/patt/task_form.html View File

@@ -0,0 +1,37 @@
1
+{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
2
+{% load static %}
3
+{% load i18n %}
4
+
5
+{% block head_extensions %}
6
+ <script src="{% static 'patt/jquery.min.js' %}"></script>
7
+ <link rel="stylesheet" href="{% static 'patt/datepicker.min.css' %}">
8
+ <script src="{% static 'patt/datepicker.min.js' %}"></script>
9
+{{ form.media }}
10
+<style>
11
+{% include 'patt/patt.css' %}
12
+</style>
13
+<script>
14
+  $(function () {
15
+    $("#id_targetdate").datepicker({format:'yyyy-mm-dd', });
16
+  });
17
+</script>
18
+{% endblock head_extensions %}
19
+
20
+
21
+{% block content %}
22
+    <form class="form" method="post">
23
+      {% csrf_token %}
24
+      {{ form.as_p }}
25
+      <input type="hidden" name="next" value="{{ next }}">
26
+      <input type="submit" name="save" value="{% trans "Save" %}" class="button" />
27
+      {% if not disable_preview %}<input type="submit"  formaction="#preview" name="preview" value="{% trans "Preview" %}" class="button" />{% endif %}
28
+      {{ form_comment.as_p }}
29
+    </form>
30
+
31
+    {% if template %}
32
+    <div class="preview-spacer" id="preview"></div>
33
+    <div class="preview">
34
+    {% include template %}
35
+    </div>
36
+    {% endif %}
37
+{% endblock content %}

+ 36
- 0
templates/patt/tasklist.html View File

@@ -0,0 +1,36 @@
1
+{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
2
+{% load static %}
3
+
4
+{% block head_extensions %}
5
+<script src="{% static 'patt/jquery.min.js' %}"></script>
6
+<style>
7
+{% include 'patt/patt.css' %}
8
+</style>
9
+{% endblock head_extensions %}
10
+
11
+
12
+{% block content %}
13
+{% for task in tasklist %}
14
+{% with task_id=task.id|stringformat:"s" %}
15
+{% with next_anchor='task-'|add:task_id %}
16
+<div {% if forloop.counter != 1 %} id="task-{{ task.id }}"{% endif %}>
17
+  <div class="tasklist{% if forloop.counter == 1 %}-first{% endif %}">
18
+{% include 'patt/task/task.html' %}
19
+  </div>
20
+</div>
21
+{% endwith %}
22
+{% endwith %}
23
+{% endfor %}
24
+
25
+<script>
26
+$(function() {
27
+    $('div.toggle').hide();
28
+    $('img.minus').hide();
29
+    $('.toggle-button').click(function(){
30
+    	$(this).children().first().toggle()
31
+    	$(this).children().last().toggle()
32
+        $(this).closest('div').next('div.toggle').toggle();
33
+    });
34
+});
35
+</script>
36
+{% endblock content %}

+ 23
- 0
templates/patt/tasklist_print.html View File

@@ -0,0 +1,23 @@
1
+<!DOCTYPE html>
2
+<html>
3
+  <head>
4
+    <title>{{ title }}</title>
5
+    <meta charset="utf-8" />
6
+    <meta name="viewport" content="width=device-width, initial-scale=1">
7
+
8
+
9
+    <style>
10
+{% include 'patt/patt.css' %}
11
+    </style>
12
+
13
+
14
+{% for task in tasklist %}
15
+    <div {% if forloop.counter != 1 %} id="task-{{ task.id }}"{% endif %}>
16
+      <div class="tasklist{% if forloop.counter == 1 %}-first{% endif %}">
17
+{% include 'patt/task/task.html' %}
18
+      </div>
19
+    </div>
20
+{% endfor %}
21
+
22
+  </body>
23
+</html>

+ 12
- 0
templates/patt/taskview.html View File

@@ -0,0 +1,12 @@
1
+{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
2
+
3
+{% block head_extensions %}
4
+<style>
5
+{% include 'patt/patt.css' %}
6
+</style>
7
+{% endblock head_extensions %}
8
+
9
+
10
+{% block content %}
11
+{% include 'patt/task/task.html' %}
12
+{% endblock content %}

+ 16
- 0
templates/patt/taskview_print.html View File

@@ -0,0 +1,16 @@
1
+<!DOCTYPE html>
2
+<html>
3
+  <head>
4
+    <title>{{ title }}</title>
5
+    <meta charset="utf-8" />
6
+    <meta name="viewport" content="width=device-width, initial-scale=1">
7
+
8
+
9
+    <style>
10
+{% include 'patt/patt.css' %}
11
+    </style>
12
+
13
+{% include 'patt/task/task.html' %}
14
+
15
+  </body>
16
+</html>

+ 0
- 0
templatetags/__init__.py View File


+ 64
- 0
templatetags/access.py View File

@@ -0,0 +1,64 @@
1
+from ..access import acc_task, acc_project
2
+from django import template
3
+from ..models import Task, Project, Comment
4
+from ..views import TaskLike, ProjectLike, CommentLike
5
+
6
+register = template.Library()
7
+
8
+
9
+class xlike_acc(object):
10
+    add_comments = False
11
+    modify = False
12
+    modify_comment = False
13
+    modify_limited = False
14
+    allowed_targetstates = []
15
+    allowed_targetpriority = []
16
+
17
+
18
+def get_acc(obj, user):
19
+    if type(obj) is Comment:
20
+        return acc_task(obj.task, user)
21
+    elif type(obj) is Task:
22
+        return acc_task(obj, user)
23
+    elif type(obj) is Project:
24
+        return acc_project(obj, user)
25
+    elif type(obj) in [TaskLike, ProjectLike, CommentLike]:
26
+        return xlike_acc()
27
+    else:
28
+        raise AttributeError("Unknown obj of type", type(obj))
29
+
30
+
31
+@register.simple_tag(name='may_add_comments', takes_context=True)
32
+def may_add_comments(context, obj):
33
+    acc = get_acc(obj, context['request'].user)
34
+    return acc.add_comments
35
+
36
+
37
+@register.simple_tag(name='may_modify', takes_context=True)
38
+def may_modify(context, obj):
39
+    acc = get_acc(obj, context['request'].user)
40
+    return acc.modify
41
+
42
+
43
+@register.simple_tag(name='may_modify_comment', takes_context=True)
44
+def may_modify_comment(context, obj):
45
+    acc = get_acc(obj, context['request'].user)
46
+    return acc.modify_comment
47
+
48
+
49
+@register.simple_tag(name='may_modify_limited', takes_context=True)
50
+def may_modify_limited(context, obj):
51
+    acc = get_acc(obj, context['request'].user)
52
+    return acc.modify_limited
53
+
54
+
55
+@register.simple_tag(name='targetstates', takes_context=True)
56
+def targetstates(context, obj):
57
+    acc = get_acc(obj, context['request'].user)
58
+    return [str(i) for i in acc.allowed_targetstates]
59
+
60
+
61
+@register.simple_tag(name='targetpriority', takes_context=True)
62
+def targetpriority(context, obj):
63
+    acc = get_acc(obj, context['request'].user)
64
+    return [str(i) for i in acc.allowed_targetpriority]

+ 37
- 0
templatetags/patt_urls.py View File

@@ -0,0 +1,37 @@
1
+from django import template
2
+import patt
3
+from ..search import mk_search_pattern
4
+
5
+
6
+register = template.Library()
7
+
8
+
9
+@register.simple_tag(name='url_taskedit', takes_context=True)
10
+def url_taskedit(context, task):
11
+    return patt.url_taskedit(context['request'], task.id)
12
+
13
+
14
+@register.simple_tag(name='url_taskset_priority', takes_context=True)
15
+def url_taskset_priority(context, task, target_priority):
16
+    return patt.url_taskset(context['request'], task.id, priority=target_priority)
17
+
18
+
19
+@register.simple_tag(name='url_taskset_state', takes_context=True)
20
+def url_taskset_state(context, task, target_state):
21
+    return patt.url_taskset(context['request'], task.id, state=target_state)
22
+
23
+
24
+@register.simple_tag(name='url_commentnew', takes_context=True)
25
+def url_commentnew(context, task):
26
+    return patt.url_commentnew(context['request'], task.id)
27
+
28
+
29
+@register.simple_tag(name='url_projectedit', takes_context=True)
30
+def url_projectedit(context, project):
31
+    return patt.url_projectedit(context['request'], project.id)
32
+
33
+
34
+@register.simple_tag(name='url_tasklist_for_project', takes_context=True)
35
+def url_tasklist_for_project(context, project):
36
+    request = context['request']
37
+    return patt.url_tasklist(request, search_txt=mk_search_pattern(prj_ids=[project.id]))

+ 3
- 0
tests.py View File

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

+ 30
- 0
urls.py View File

@@ -0,0 +1,30 @@
1
+from django.shortcuts import redirect
2
+from django.urls import path
3
+from django.urls.base import reverse
4
+from .search import SEARCH_MY_OPEN_TASKS
5
+from . import views
6
+
7
+urlpatterns = [
8
+    path('', lambda request: redirect(reverse('patt-commonfilter', kwargs={'common_filter_id': SEARCH_MY_OPEN_TASKS}), permanent=False)),
9
+    #
10
+    # views and urls
11
+    #
12
+    path('tasklist/', views.patt_tasklist, name='patt-tasklist'),
13
+    path('projectlist/', views.patt_projectlist, name='patt-projectlist'),
14
+    path('taskview/<int:task_id>/', views.patt_taskview, name='patt-taskview'),
15
+    path('helpview/', views.patt_helpview, name='patt-helpview'),
16
+    path('helpview/<str:page>', views.patt_helpview, name='patt-helpview'),
17
+    path('tasknew/', views.patt_tasknew, name='patt-tasknew'),
18
+    path('projectnew/', views.patt_projectnew, name='patt-projectnew'),
19
+    path('taskedit/<int:task_id>/', views.patt_taskedit, name='patt-taskedit'),
20
+    path('projectedit/<int:project_id>/', views.patt_projectedit, name='patt-projectedit'),
21
+    path('commentnew/<int:task_id>/', views.patt_commentnew, name='patt-commentnew'),
22
+    path('commentedit/<int:task_id>/<int:comment_id>/', views.patt_commentedit, name='patt-commentedit'),
23
+    path('filteredit/', views.patt_filteredit, name='patt-filternew'),
24
+    path('filteredit/<int:search_id>/', views.patt_filteredit, name='patt-filteredit'),
25
+    path('easysearch/', views.patt_easysearch, name='patt-easysearch'),
26
+    path('tasklist/user_filter/<int:user_filter_id>/', views.patt_tasklist, name='patt-userfilter'),
27
+    path('tasklist/common_filter/<int:common_filter_id>', views.patt_tasklist, name='patt-commonfilter'),
28
+    #
29
+    path('tasklist/search/', views.patt_tasklist, name='search'),
30
+]

+ 690
- 0
views.py View File

@@ -0,0 +1,690 @@
1
+from .access import acc_task, acc_project, create_project_possible, create_task_possible
2
+from .context import context_adaption
3
+from django.contrib import messages
4
+from django.contrib.auth.decorators import login_required
5
+from django.contrib.auth.forms import User
6
+from django.shortcuts import render, redirect
7
+from django.urls.base import reverse
8
+from django.utils import timezone
9
+from django.utils.translation import gettext as _
10
+from .forms import TaskForm, TaskFormLimited, ProjectForm, CommentForm, TaskCommentForm, SearchForm, EasySearchForm
11
+from .help import help_pages
12
+from .models import Task, ModelList, Comment, TASKSTATE_CHOICES, PRIO_CHOICES, TASKS_IN_WORK
13
+from .models import Project, Search
14
+import patt
15
+from .search import load_index, search, mk_search_pattern, get_project_ids_from_search_pattern, common_searches
16
+from themes import Context
17
+
18
+
19
+class Like(object):
20
+    DATA = []
21
+
22
+    def __init__(self, request, obj):
23
+        for key in self.DATA:
24
+            if key in request.POST:
25
+                setattr(self, key, request.POST.get(key))
26
+            else:
27
+                try:
28
+                    setattr(self, key, getattr(obj, key))
29
+                except AttributeError:
30
+                    setattr(self, key, None)
31
+        self.__init_additions__(request, obj)
32
+        if self.creation_date is None:
33
+            self.creation_date = timezone.now()
34
+        if self.id is None:
35
+            self.id = 'xxx'
36
+
37
+    def __init_additions__(self, request, obj):
38
+        pass
39
+
40
+
41
+class TaskLike(Like):
42
+    DATA = ['id', 'state', 'priority', 'targetdate', 'progress', 'name', 'description', 'creation_date', 'comments']
43
+
44
+    def __init_additions__(self, request, obj):
45
+        if 'project' in request.POST:
46
+            self.project = Project.objects.get(id=request.POST.get('project'))
47
+        else:
48
+            self.project = getattr(obj, 'project')
49
+        if 'assigned_user' in request.POST:
50
+            try:
51
+                self.assigned_user = User.objects.get(id=request.POST.get('assigned_user'))
52
+            except ValueError:
53
+                self.assigned_user = None
54
+        else:
55
+            self.assigned_user = getattr(obj, 'assigned_user')
56
+        #
57
+        self.class_by_state = 'task-normal' if self.state == '0' else 'task-finished'
58
+        #
59
+        if obj is None:
60
+            self.attachment_target_path = Task().attachment_target_path
61
+        else:
62
+            self.attachment_target_path = obj.attachment_target_path
63
+
64
+        if self.assigned_user and self.id:
65
+            self.taskname_prefix = '**#%d //(%s)//:** ' % (self.id, self.assigned_user.username)
66
+        elif self.id:
67
+            self.taskname_prefix = '**#%d:** ' % self.id
68
+        elif self.assigned_user:
69
+            self.taskname_prefix = '**//(%s)//:** ' % self.assigned_user.username
70
+        else:
71
+            self.taskname_prefix = ''
72
+
73
+
74
+class CommentLike(Like):
75
+    DATA = ['id', 'type', 'creation_date', 'comment']
76
+
77
+    def __init_additions__(self, request, obj):
78
+        if 'user' in request.POST:
79
+            self.user = User.objects.get(id=request.POST.get('user'))
80
+        else:
81
+            try:
82
+                self.user = getattr(obj, 'user')
83
+            except AttributeError:
84
+                self.user = request.user
85
+        #
86
+        if obj is None:
87
+            self.attachment_target_path = Comment().attachment_target_path
88
+        else:
89
+            self.attachment_target_path = obj.attachment_target_path
90
+
91
+
92
+class ProjectLike(Like):
93
+    DATA = ['id', 'name', 'description', 'state', 'creation_date']
94
+
95
+    def __init_additions__(self, request, obj):
96
+        self.role_leader = [User.objects.get(id=user_id) for user_id in request.POST.getlist('role_leader', [])]
97
+        self.role_member = [User.objects.get(id=user_id) for user_id in request.POST.getlist('role_member', [])]
98
+        self.creation_date = '-'
99
+        self.formatted_leaders = ', '.join([user.username for user in self.role_leader])
100
+        self.formatted_members = ', '.join([user.username for user in self.role_member])
101
+        #
102
+        if obj is None:
103
+            self.attachment_target_path = Project().attachment_target_path
104
+        else:
105
+            self.attachment_target_path = obj.attachment_target_path
106
+
107
+
108
+def permission_denied_msg_project(request, project_id):
109
+    if project_id is None:
110
+        messages.error(request, _('Permission denied to create a new Project.'))
111
+    else:
112
+        messages.error(request, _('Permission denied to Project #%(project_id)d') % {'project_id': project_id})
113
+
114
+
115
+def permission_denied_msg_task(request, task_id):
116
+    if task_id is None:
117
+        messages.error(request, _('Permission denied to create a new Task'))
118
+    else:
119
+        messages.error(request, _('Permission denied to Task #%(task_id)d') % {'task_id': task_id})
120
+
121
+
122
+def permission_denied_msg_comment(request, task_id):
123
+    messages.error(request, _('Permission denied to add or edit a comment of Task #%(task_id)d') % {'task_id': task_id})
124
+
125
+
126
+def no_change_msg(request):
127
+    messages.info(request, _("Nothing changed, no storage needed."))
128
+
129
+
130
+def does_not_exist_msg_task(request, task_id):
131
+    messages.error(request, _('Task #%(id)d does not exist!') % {'id': task_id})
132
+
133
+
134
+def does_not_exist_msg_search(request, search_id):
135
+    messages.error(request, _('Search #%(id)d does not exist!') % {'id': search_id})
136
+
137
+
138
+def get_next(request):
139
+    if not request.POST:
140
+        return request.GET.get('next', '/')
141
+    else:
142
+        return request.POST.get('next', '/')
143
+
144
+
145
+@login_required
146
+def patt_tasklist(request, user_filter_id=None, common_filter_id=None):
147
+    context = Context(request)      # needs to be executed first because of time mesurement
148
+    is_printview = patt.is_printview(request)
149
+    search_txt = patt.get_search_query(request)
150
+    if user_filter_id is not None:
151
+        try:
152
+            s = Search.objects.get(id=user_filter_id)
153
+        except Search.DoesNotExist:
154
+            does_not_exist_msg_search(request, user_filter_id)
155
+            return redirect(patt.url_tasklist(request))
156
+        search_txt = s.search_txt
157
+        title = s.name
158
+        ix = load_index()
159
+        sr = search(ix, search_txt)
160
+        if sr is None:
161
+            messages.error(request, _('Invalid search pattern: %s') % repr(search_txt))
162
+            sr = []
163
+        tasklist = ModelList(sr, acc_task, request.user)
164
+    elif search_txt is not None:
165
+        ix = load_index()
166
+        sr = search(ix, search_txt)
167
+        if sr is None:
168
+            messages.error(request, _('Invalid search pattern: %s') % repr(search_txt))
169
+            sr = []
170
+        tasklist = ModelList(sr, acc_task, request.user)
171
+        max_len = 25
172
+        if len(search_txt) > max_len:
173
+            title = _('Searchresults for %s') % repr(search_txt[:max_len - 3] + '...')
174
+        else:
175
+            title = _('Searchresults for %s') % repr(search_txt)
176
+    elif common_filter_id is not None:
177
+        try:
178
+            title, search_txt = common_searches(request)[common_filter_id]
179
+        except KeyError:
180
+            messages.error(request, _('Invalid common search: %s') % repr(common_filter_id))
181
+            sr = []
182
+            title = _('Common Search Error')
183
+        else:
184
+            ix = load_index()
185
+            sr = search(ix, search_txt)
186
+        if sr is None:
187
+            messages.error(request, _('Invalid search pattern: %s') % repr(search_txt))
188
+            sr = []
189
+        tasklist = ModelList(sr, acc_task, request.user)
190
+    else:
191
+        tasklist = ModelList(Task.objects.filter(state__in=TASKS_IN_WORK), acc_task, request.user)
192
+        title = _("All Tasks in work")
193
+    try:
194
+        project_id = get_project_ids_from_search_pattern(search_txt)[0]
195
+    except IndexError:
196
+        project_id = None
197
+    except TypeError:
198
+        project_id = None
199
+    tasklist.sort()
200
+    context_adaption(
201
+        context,                                                    # the base context
202
+        request,                                                    # the request object to be used in context_adaption
203
+        tasklist=tasklist,                                          # the tasklist to be shown (template)
204
+        printview=is_printview,                                     # Show reduced view and functionality in printview
205
+        target_head=None if is_printview else 'patt-taskview',      # target-link for head (template)
206
+        project_id=project_id,                                      # as default Value for New Task entry
207
+        title=title,                                                # the title for the page (template)
208
+    )
209
+    if patt.is_printview(request):
210
+        return render(request, 'patt/tasklist_print.html', context=context)
211
+    else:
212
+        return render(request, 'patt/tasklist.html', context=context)
213
+
214
+
215
+@login_required
216
+def patt_projectlist(request):
217
+    context = Context(request)      # needs to be executed first because of time mesurement
218
+    projectlist = ModelList(Project.objects.all(), acc_project, request.user)
219
+    projectlist.sort()
220
+    context_adaption(
221
+        context,                                    # the base context
222
+        request,                                    # the request object to be used in context_adaption
223
+        projectlist=projectlist,                    # the projectlist to be shown (template)
224
+        target_head='patt-projectedit',             # target-link for head (template)
225
+        title=_("Projectlist"),                     # the title for the page (template)
226
+    )
227
+    return render(request, 'patt/projectlist.html', context=context)
228
+
229
+
230
+@login_required
231
+def patt_taskview(request, task_id):
232
+    context = Context(request)      # needs to be executed first because of time mesurement
233
+    is_printview = patt.is_printview(request)
234
+    try:
235
+        task = Task.objects.get(id=task_id)
236
+    except Task.DoesNotExist:
237
+        does_not_exist_msg_task(request, task_id)
238
+    else:
239
+        acc = acc_task(task, request.user)
240
+        if acc.read:
241
+            context_adaption(
242
+                context,                                                    # the base context
243
+                request,                                                    # the request object to be used in context_adaption
244
+                task=task,                                                  # the task to be shown (template)
245
+                printview=is_printview,                                     # Show reduced view and functionality in printview
246
+                taskview=True,                                              # deactivate collapse and expand buttons
247
+                target_head=None if is_printview else 'patt-taskedit',      # target-link for head (template)
248
+                project_id=task.project.id,                                 # as default Value for New Task entry
249
+                title=_('Task #%d') % task.id                               # the title for the page (template)
250
+            )
251
+            if patt.is_printview(request):
252
+                return render(request, 'patt/taskview_print.html', context=context)
253
+            else:
254
+                return render(request, 'patt/taskview.html', context=context)
255
+        else:
256
+            permission_denied_msg_task(request, task.id)
257
+    return redirect(reverse('patt-tasklist'))
258
+
259
+
260
+@login_required
261
+def patt_tasknew(request):
262
+    context = Context(request)      # needs to be executed first because of time mesurement
263
+    nxt = get_next(request)
264
+    if create_task_possible(request.user):
265
+        if not request.POST:                        # Initial Form...
266
+            task = Task(project_id=request.GET.get('project_id'))
267
+            form = TaskForm(instance=task, request=request)
268
+            form_comment = TaskCommentForm()
269
+            context_adaption(
270
+                context,                            # the base context
271
+                request,                            # the request object to be used in context_adaption
272
+                form=form,                          # the form object to create the task form (template)
273
+                form_comment=form_comment,          # the form object to create the comment form (template)
274
+                next=nxt,                           # the url for redirection
275
+                title=_('New Task')                 # the title for the page (template)
276
+            )
277
+            return render(request, 'patt/task_form.html', context=context)
278
+        else:
279
+            task = Task()
280
+            form = TaskForm(request.POST, instance=task, request=request)
281
+            form_comment = TaskCommentForm(request.POST)
282
+            context_adaption(
283
+                context,                            # the base context
284
+                request,                            # the request object to be used in context_adaption
285
+                form=form,                          # the form object to create the task form (template)
286
+                form_comment=form_comment,          # the form object to create the comment form (template)
287
+                next=nxt,                           # the url for redirection
288
+                title=_('New Task')                 # the title for the page (template)
289
+            )
290
+            if request.POST.get('save'):            # Save form content...
291
+                if form.is_valid() and form_comment.is_valid():
292
+                    form.save()
293
+                    if request.POST.get('comment'):
294
+                        form_comment = TaskCommentForm(request.POST, instance=Comment(task_id=form.instance.id, user=request.user))
295
+                        form_comment.save()
296
+                    messages.success(request, _('Thanks for adding Task #%(task_id)d.') % {'task_id': form.instance.id})
297
+                    return redirect(nxt + '#task-%d' % form.instance.id)
298
+            elif request.POST.get('preview'):       # Create a preview
299
+                    context['comment_new'] = CommentLike(request, None)         # the new comment to be used in the preview
300
+                    context['template'] = 'patt/task/task.html'                 # the template for preview (template)
301
+                    context['task'] = TaskLike(request, task)                   # the object to be used in the preview template
302
+                    context['printview'] = True                                 # Show reduced view and functionality like in printview
303
+            return render(request, 'patt/task_form.html', context=context)
304
+    else:
305
+        permission_denied_msg_task(request, None)
306
+        return redirect(nxt)
307
+
308
+
309
+@login_required
310
+def patt_projectnew(request):
311
+    context = Context(request)      # needs to be executed first because of time mesurement
312
+    nxt = get_next(request)
313
+    if create_project_possible(request.user):
314
+        project = Project()
315
+        if not request.POST:
316
+            form = ProjectForm(instance=project)
317
+            context_adaption(
318
+                context,                        # the base context
319
+                request,                        # the request object to be used in context_adaption
320
+                form=form,                      # the form object to create the form (template)
321
+                next=nxt,                       # the url for redirection
322
+                disable_delete=True,            # disable delete button
323
+                disable_search=True,            # disable search button
324
+                title=_('New Project')          # the title for the page (template)
325
+            )
326
+            return render(request, 'patt/raw_single_form.html', context=context)
327
+        else:
328
+            form = ProjectForm(request.POST, instance=project)
329
+            context_adaption(
330
+                context,                        # the base context
331
+                request,                        # the request object to be used in context_adaption
332
+                form=form,                      # the form object to create the form (template)
333
+                next=nxt,                       # the url for redirection
334
+                disable_delete=True,            # disable delete button
335
+                disable_search=True,            # disable search button
336
+                title=_('New Project')          # the title for the page (template)
337
+            )
338
+            #
339
+            if request.POST.get('save'):
340
+                if form.is_valid():
341
+                    form.save()
342
+                    messages.success(request, _('Thanks for adding Project #%(project_id)d.') % {'project_id': form.instance.id})
343
+                    return redirect(nxt + '#project-%d' % form.instance.id)
344
+            elif request.POST.get('preview'):
345
+                context['template'] = 'patt/project/project.html'   # the template for preview (template)
346
+                context['project'] = ProjectLike(request, None)     # the object to be used in the preview template
347
+            return render(request, 'patt/raw_single_form.html', context=context)
348
+    else:
349
+        permission_denied_msg_project(request, None)
350
+        return redirect(nxt)
351
+
352
+
353
+@login_required
354
+def patt_taskedit(request, task_id, **kwargs):
355
+    context = Context(request)      # needs to be executed first because of time mesurement
356
+    nxt = get_next(request)
357
+    try:
358
+        task = Task.objects.get(id=task_id)
359
+    except Task.DoesNotExist:
360
+        does_not_exist_msg_task(request, task_id)
361
+        return redirect(nxt)
362
+    else:
363
+        acc = acc_task(task, request.user)
364
+        if acc.modify or acc.modify_limited:
365
+            if acc.modify:
366
+                ThisForm = TaskForm
367
+            else:
368
+                ThisForm = TaskFormLimited
369
+            if not request.POST:
370
+                # get request parameters
371
+                do = request.GET.get('do')
372
+                state = request.GET.get('state')
373
+                if state:
374
+                    state = int(state)
375
+                priority = request.GET.get('priority')
376
+                if priority:
377
+                    priority = int(priority)
378
+                #
379
+                if do == 'edit':
380
+                    form = ThisForm(instance=task, request=request)
381
+                    context_adaption(
382
+                        context,                                # the base context
383
+                        request,                                # the request object to be used in context_adaption
384
+                        form=form,                              # the form object to create the task form (template)
385
+                        form_comment=TaskCommentForm(),         # the form object to create the comment form (template)
386
+                        next=nxt,                               # the url for redirection
387
+                        next_anchor='task-%d' % task.id,        # the anchor for redirection
388
+                        title=_('Edit Task #%d') % task.id      # the title for the page (template)
389
+                    )
390
+                    return render(request, 'patt/task_form.html', context=context)
391
+                elif do == 'set_state':
392
+                    if state in acc.allowed_targetstates:
393
+                        task.state = state
394
+                        task.save()
395
+                        messages.success(request, _('State of Task #%(task_id)d set to %(name)s') % {'task_id': task.id, 'name': dict(TASKSTATE_CHOICES)[state]})
396
+                    else:
397
+                        permission_denied_msg_task(request, task_id)
398
+                elif do == 'set_priority':
399
+                    if priority in acc.allowed_targetpriority:
400
+                        task.priority = priority
401
+                        task.save()
402
+                        messages.success(request, _('Priority of Task #%(task_id)d set to %(name)s') % {'task_id': task.id, 'name': dict(PRIO_CHOICES)[priority]})
403
+                    else:
404
+                        permission_denied_msg_task(request, task_id)
405
+                else:
406
+                    messages.error(request, 'Edit with do="%s" not yet implemented!' % do)
407
+                return redirect(nxt + '#task-%d' % task_id)
408
+            else:
409
+                comment = Comment(task_id=task_id, user=request.user)
410
+                form = ThisForm(request.POST, instance=task, request=request)
411
+                form_comment = TaskCommentForm(request.POST, instance=comment)
412
+                context_adaption(
413
+                    context,                                # the base context
414
+                    request,                                # the request object to be used in context_adaption
415
+                    form=form,                              # the form object to create the task form (template)
416
+                    form_comment=form_comment,              # the form object to create the comment form (template)
417
+                    next=nxt,                               # the url for redirection
418
+                    next_anchor='task-%d' % task.id,        # the anchor for redirection
419
+                    title=_('Edit Task #%d') % task.id      # the title for the page (template)
420
+                )
421
+                if request.POST.get('save'):
422
+                    if form.is_valid() and form_comment.is_valid():             # TODO: Validator depending on modify and modify_limited (to ensure content is okay for this user)!
423
+                        save_needed = form.save().save_needed
424
+                        if request.POST.get('comment'):
425
+                            save_needed |= form_comment.save().save_needed
426
+                        if save_needed:
427
+                            messages.success(request, _('Thanks for editing Task #%(task_id)d.') % {'task_id': task.id})
428
+                        else:
429
+                            no_change_msg(request)
430
+                        return redirect(nxt + '#task-%d' % task_id)
431
+                elif request.POST.get('preview'):
432
+                    context['comment_new'] = CommentLike(request, comment)      # the new comment to be used in the preview
433
+                    context['template'] = 'patt/task/task.html'                 # the template for preview (template)
434
+                    context['task'] = TaskLike(request, task)                   # the object to be used in the preview template
435
+                    context['printview'] = True                                 # Show reduced view and functionality like in printview
436
+                return render(request, 'patt/task_form.html', context=context)
437
+        else:
438
+            permission_denied_msg_task(request, task_id)
439
+            return redirect(nxt + '#task-%d' % task_id)
440
+
441
+
442
+@login_required
443
+def patt_projectedit(request, project_id):
444
+    context = Context(request)      # needs to be executed first because of time mesurement
445
+    nxt = get_next(request)
446
+    try:
447
+        project = Project.objects.get(id=project_id)
448
+    except Task.DoesNotExist:
449
+        messages.error(request, _('Project with id %(project_id)d does not exist!') % {'project_id': project_id})
450
+        return redirect(nxt)
451
+    else:
452
+        acc = acc_project(project, request.user)
453
+        if acc.modify:
454
+            if not request.POST:
455
+                form = ProjectForm(instance=project)
456
+                context_adaption(
457
+                    context,                                        # the base context
458
+                    request,                                        # the request object to be used in context_adaption
459
+                    form=form,                                      # the form object to create the form (template)
460
+                    next=nxt,                                       # the url for redirection
461
+                    next_anchor='project-%d' % project_id,          # the anchor for redirection
462
+                    item_id=project.id,                             # project.id for Preview, ... (template)
463
+                    disable_delete=True,                            # disable delete button
464
+                    disable_search=True,                            # disable search button
465
+                    title=_('Edit Project #%d') % project_id        # the title for the page (template)
466
+                )
467
+                return render(request, 'patt/raw_single_form.html', context=context)
468
+            else:
469
+                form = ProjectForm(request.POST, instance=project)
470
+                context_adaption(
471
+                    context,                                    # the base context
472
+                    request,                                    # the request object to be used in context_adaption
473
+                    form=form,                                  # the form object to create the form (template)
474
+                    next=nxt,                                   # the url for redirection
475
+                    next_anchor='project-%d' % project_id,      # the anchor for redirection
476
+                    item_id=project.id,                         # project.id for Preview, ... (template)
477
+                    disable_delete=True,                        # disable delete button
478
+                    disable_search=True,                        # disable search button
479
+                    title=_('Edit Project #%d') % project_id    # the title for the page (template)
480
+                )
481
+                #
482
+                if request.POST.get('save'):
483
+                    if form.is_valid():
484
+                        form.save()
485
+                        messages.success(request, _('Thanks for editing Project #%(project_id)d.') % {'project_id': project.id})
486
+                        return redirect(nxt + '#project-%d' % project_id)
487
+                elif request.POST.get('preview'):
488
+                    context['template'] = 'patt/project/project.html'   # the template for preview (template)
489
+                    context['project'] = ProjectLike(request, project)  # the object to be used in the preview template
490
+                return render(request, 'patt/raw_single_form.html', context=context)
491
+        else:
492
+            permission_denied_msg_project(request, project_id)
493
+            return redirect(nxt + '#project-%d' % project_id)
494
+
495
+
496
+@login_required
497
+def patt_commentnew(request, task_id):
498
+    context = Context(request)      # needs to be executed first because of time mesurement
499
+    nxt = get_next(request)
500
+    try:
501
+        task = Task.objects.get(id=task_id)
502
+    except Task.DoesNotExist:
503
+        does_not_exist_msg_task(request, task_id)
504
+        return redirect(nxt)
505
+    else:
506
+        acc = acc_task(task, request.user)
507
+        if acc.add_comments:
508
+            if not request.POST:
509
+                form = CommentForm(instance=Comment())
510
+                context_adaption(
511
+                    context,                                            # the base context
512
+                    request,                                            # the request object to be used in context_adaption
513
+                    form=form,                                          # the form object to create the form (template)
514
+                    next=nxt,                                           # the url for redirection
515
+                    next_anchor='task-%d' % task_id,                    # the anchor for redirection
516
+                    disable_delete=True,                                # disable delete button
517
+                    disable_search=True,                                # disable search button
518
+                    title=_('Add a Comment (Task #%d)') % task_id       # the title for the page (template)
519
+                )
520
+                return render(request, 'patt/raw_single_form.html', context=context)
521
+            else:
522
+                comment = Comment(task_id=task_id, user=request.user)
523
+                #
524
+                form = CommentForm(request.POST, instance=comment)
525
+                context_adaption(
526
+                    context,                                            # the base context
527
+                    request,                                            # the request object to be used in context_adaption
528
+                    form=form,                                          # the form object to create the form (template)
529
+                    next=nxt,                                           # the url for redirection
530
+                    next_anchor='task-%d' % task_id,                    # the anchor for redirection
531
+                    disable_delete=True,                                # disable delete button
532
+                    disable_search=True,                                # disable search button
533
+                    title=_('Add Comment to Task #%d') % task_id        # the title for the page (template)
534
+                )
535
+                if request.POST.get('save'):
536
+                    if form.is_valid():
537
+                        form.save()
538
+                        messages.success(request, _('Thanks for adding a comment to Task #%(task_id)d.') % {'task_id': task_id})
539
+                        return redirect(nxt + '#task-%d' % task_id)
540
+                elif request.POST.get('preview'):
541
+                    context['template'] = 'patt/task/comment.html'      # the template for preview (template)
542
+                    context['comment'] = CommentLike(request, comment)  # the object to be used in the preview template
543
+                return render(request, 'patt/raw_single_form.html', context=context)
544
+        else:
545
+            permission_denied_msg_comment(request, task_id)
546
+            return redirect(nxt + '#task-%d' % task_id)
547
+
548
+
549
+@login_required
550
+def patt_commentedit(request, task_id, comment_id):
551
+    context = Context(request)      # needs to be executed first because of time mesurement
552
+    nxt = get_next(request)
553
+    comment = Comment.objects.get(id=comment_id)
554
+    acc = acc_task(comment.task, request.user)
555
+    if acc.modify_comment:
556
+        if not request.POST:
557
+            form = CommentForm(instance=comment)
558
+            context_adaption(
559
+                context,                                            # the base context
560
+                request,                                            # the request object to be used in context_adaption
561
+                form=form,                                          # the form object to create the form (template)
562
+                next=nxt,                                           # the url for redirection
563
+                next_anchor='task-%d' % task_id,                    # the anchor for redirection
564
+                disable_delete=True,                                # disable delete button
565
+                disable_search=True,                                # disable search button
566
+                title=_('Edit Comment (Task #%d)') % task_id        # the title for the page (template)
567
+            )
568
+            return render(request, 'patt/raw_single_form.html', context=context)
569
+        else:
570
+            form = CommentForm(request.POST, instance=comment)
571
+            context_adaption(
572
+                context,                                        # the base context
573
+                request,                                        # the request object to be used in context_adaption
574
+                form=form,                                      # the form object to create the form (template)
575
+                next=nxt,                                       # the url for redirection
576
+                next_anchor='task-%d' % task_id,                # the anchor for redirection
577
+                disable_delete=True,                            # disable delete button
578
+                disable_search=True,                            # disable search button
579
+                title=_('Edit Comment (Task #%d)') % task_id    # the title for the page (template)
580
+            )
581
+            #
582
+            if request.POST.get('save'):
583
+                if form.is_valid():
584
+                    if form.save().save_needed:
585
+                        messages.success(request, _('Thanks for editing a comment of Task #%(task_id)d.') % {'task_id': task_id})
586
+                    else:
587
+                        no_change_msg(request)
588
+                    return redirect(nxt + '#task-%d' % task_id)
589
+            elif request.POST.get('preview'):
590
+                context['template'] = 'patt/task/comment.html'      # the template for preview (template)
591
+                context['comment'] = CommentLike(request, comment)  # the object to be used in the preview template
592
+            return render(request, 'patt/raw_single_form.html', context=context)
593
+    else:
594
+        permission_denied_msg_comment(request, task_id)
595
+        return redirect(nxt + '#task-%d' % task_id)
596
+
597
+
598
+def patt_filteredit(request, search_id=None):
599
+    def filter_does_not_exist_error(request, search_id):
600
+        messages.error(request, _('Filter #%d does not exist.') % search_id)
601
+    context = Context(request)      # needs to be executed first because of time mesurement
602
+    if not request.POST:
603
+        if search_id is not None:
604
+            try:
605
+                form = SearchForm(instance=Search.objects.get(id=search_id))
606
+            except Search.DoesNotExist:
607
+                filter_does_not_exist_error(request, search_id)
608
+                return redirect('/')
609
+        else:
610
+            if patt.get_search_query(request) is None:
611
+                form = SearchForm(initial={'user': request.user})
612
+            else:
613
+                form = SearchForm(initial={'search_txt': patt.get_search_query(request), 'user': request.user})
614
+        context_adaption(
615
+            context,                    # the base context
616
+            request,                    # the request object to be used in context_adaption
617
+            form=form,                  # the form object to create the form (template)
618
+            disable_preview=True,       # disable the preview button
619
+            disable_search=True,        # disable search button
620
+            title=_('Edit Filter')      # the title for the page (template)
621
+        )
622
+        return render(request, 'patt/raw_single_form.html', context=context)
623
+    else:
624
+        if search_id is not None:
625
+            try:
626
+                s = Search.objects.get(id=search_id)
627
+            except Search.DoesNotExist:
628
+                filter_does_not_exist_error(request, search_id)
629
+                return redirect('/')
630
+        else:
631
+            s = Search(user=request.user)
632
+        if request.user == s.user:
633
+            form = SearchForm(request.POST, instance=s)
634
+            context_adaption(
635
+                context,                    # the base context
636
+                request,                    # the request object to be used in context_adaption
637
+                form=form,                  # the form object to create the form (template)
638
+                disable_preview=True,       # disable the preview button
639
+                disable_search=True,        # disable search button
640
+                title=_('Edit Filter')      # the title for the page (template)
641
+            )
642
+            #
643
+            if request.POST.get('save'):
644
+                if form.is_valid():
645
+                    s.save()
646
+                    messages.success(request, _('Thanks for editing Filter #%(search_id)d.') % {'search_id': s.id})
647
+                    return redirect(patt.url_tasklist(request, user_filter_id=s.id))
648
+            elif request.POST.get('delete'):
649
+                messages.success(request, _('Filter #%(search_id)d delteted.') % {'search_id': s.id})
650
+                s.delete()
651
+                return redirect('/')
652
+            return render(request, 'patt/raw_single_form.html', context=context)
653
+        else:
654
+            messages.error(request, _('Access to Filter (%s) denied.') % repr(search_id))
655
+            return redirect('/')
656
+
657
+
658
+def patt_easysearch(request):
659
+    context = Context(request)      # needs to be executed first because of time mesurement
660
+    if not request.POST:
661
+        form = EasySearchForm()
662
+        context_adaption(
663
+            context,                    # the base context
664
+            request,                    # the request object to be used in context_adaption
665
+            form=form,                  # the form object to create the form (template)
666
+            disable_preview=True,       # disable the preview button
667
+            disable_delete=True,        # disable the delete button
668
+            disable_save=True,          # disable save button
669
+            title=_('Edit Filter')      # the title for the page (template)
670
+        )
671
+        return render(request, 'patt/raw_single_form.html', context=context)
672
+    else:
673
+        form = EasySearchForm(request.POST)
674
+        form_data = dict(form.data)
675
+        if 'states' not in form_data:
676
+            form_data['states'] = []
677
+        return redirect(patt.url_tasklist(request, search_txt=mk_search_pattern(**form_data)))
678
+
679
+
680
+def patt_helpview(request, page='main'):
681
+    context = Context(request)      # needs to be executed first because of time mesurement
682
+    help_content = help_pages[page]
683
+    context_adaption(
684
+        context,                            # the base context
685
+        request,                            # the request object to be used in context_adaption
686
+        current_help_page=page,             # the current help_page to identify which taskbar entry has to be highlighted
687
+        help_content=help_content,          # the help content itself (template)
688
+        title=_('Help')                     # the title for the page (template)
689
+    )
690
+    return render(request, 'patt/help.html', context=context)

Loading…
Cancel
Save