Browse Source

Sorting improved, more general thresholds for warning groups

master
Dirk Alders 3 years ago
parent
commit
63e9af4d65
10 changed files with 196 additions and 47 deletions
  1. 4
    0
      __init__.py
  2. 6
    1
      admin.py
  3. 17
    0
      context.py
  4. 13
    8
      forms.py
  5. 37
    0
      migrations/0013_auto_20210117_1332.py
  6. 60
    36
      models.py
  7. 12
    0
      templates/patt/profile.html
  8. 6
    0
      templates/patt/profile_formdata.html
  9. 1
    0
      urls.py
  10. 40
    2
      views.py

+ 4
- 0
__init__.py View File

21
         return reverse('patt-tasklist')
21
         return reverse('patt-tasklist')
22
 
22
 
23
 
23
 
24
+def url_profile(request):
25
+    return reverse('patt-profile')
26
+
27
+
24
 def url_projectlist(request):
28
 def url_projectlist(request):
25
     return reverse('patt-projectlist')
29
     return reverse('patt-projectlist')
26
 
30
 

+ 6
- 1
admin.py View File

1
 from django.contrib import admin
1
 from django.contrib import admin
2
-from .models import Project, Task, Comment, Search
2
+from .models import Project, Task, Comment, Search, PattUserProfile
3
 from simple_history.admin import SimpleHistoryAdmin
3
 from simple_history.admin import SimpleHistoryAdmin
4
 
4
 
5
 
5
 
59
     )
59
     )
60
 
60
 
61
 
61
 
62
+class PattUserProfileAdmin(admin.ModelAdmin):
63
+    list_display = ('user', 'threshold_very_soon', 'threshold_soon', )
64
+
65
+
62
 admin.site.register(Project, ProjectAdmin)
66
 admin.site.register(Project, ProjectAdmin)
63
 admin.site.register(Task, TaskAdmin)
67
 admin.site.register(Task, TaskAdmin)
64
 admin.site.register(Comment, CommentAdmin)
68
 admin.site.register(Comment, CommentAdmin)
65
 admin.site.register(Search, SearchAdmin)
69
 admin.site.register(Search, SearchAdmin)
70
+admin.site.register(PattUserProfile, PattUserProfileAdmin)

+ 17
- 0
context.py View File

8
 from .search import common_searches
8
 from .search import common_searches
9
 from themes import empty_entry_parameters, color_icon_url, gray_icon_url
9
 from themes import empty_entry_parameters, color_icon_url, gray_icon_url
10
 from users.context import menubar as menubar_users
10
 from users.context import menubar as menubar_users
11
+from users.context import PROFILE_ENTRY_UID
11
 
12
 
12
 ATTACHMENT_UID = 'attachment'
13
 ATTACHMENT_UID = 'attachment'
13
 BACK_UID = 'back'
14
 BACK_UID = 'back'
37
 
38
 
38
 def menubar(context, request, caller_name, **kwargs):
39
 def menubar(context, request, caller_name, **kwargs):
39
     bar = context[context.MENUBAR]
40
     bar = context[context.MENUBAR]
41
+    replace_profile(request, bar)
40
     add_help_menu(request, bar)
42
     add_help_menu(request, bar)
41
     add_tasklist_menu(request, bar)
43
     add_tasklist_menu(request, bar)
42
     add_filter_submenu(request, bar, VIEW_TASKLIST_UID)
44
     add_filter_submenu(request, bar, VIEW_TASKLIST_UID)
76
         bar.append_entry(*empty_entry_parameters(request))
78
         bar.append_entry(*empty_entry_parameters(request))
77
 
79
 
78
 
80
 
81
+def replace_profile(request, bar):
82
+    try:
83
+        bar.replace_entry(
84
+            PROFILE_ENTRY_UID,
85
+            PROFILE_ENTRY_UID,                          # uid
86
+            request.user.username,                      # name
87
+            color_icon_url(request, 'user.png'),        # icon
88
+            patt.url_profile(request),                  # url
89
+            False,                                      # left
90
+            False                                       # active
91
+        )
92
+    except ValueError:
93
+        pass        # Profile entry does not exist, so exchange is not needed (e.g. no user is logged in)
94
+
95
+
79
 def add_help_menu(request, bar):
96
 def add_help_menu(request, bar):
80
     bar.append_entry(
97
     bar.append_entry(
81
         HELP_UID,                                   # uid
98
         HELP_UID,                                   # uid

+ 13
- 8
forms.py View File

2
 from django.contrib.auth.models import User
2
 from django.contrib.auth.models import User
3
 from django.db.models import Q
3
 from django.db.models import Q
4
 from django.utils.translation import gettext as _
4
 from django.utils.translation import gettext as _
5
-from .models import Task, Project, Comment, Search, TASKSTATE_CHOICES, PROJECTSTATE_OPEN, ModelList
5
+from .models import Task, Project, Comment, Search, TASKSTATE_CHOICES, PROJECTSTATE_OPEN, ModelList, PattUserProfile
6
 from .search import INDEX_STATES
6
 from .search import INDEX_STATES
7
 
7
 
8
 
8
 
9
+class PattUserProfileForm(forms.ModelForm):
10
+    class Meta:
11
+        model = PattUserProfile
12
+        fields = ['threshold_very_soon', 'threshold_soon']
13
+        labels = {
14
+            'threshold_very_soon': _('Period to targetdate for warning Group "very soon"'),
15
+            'threshold_soon': _('Period to targetdate for warning Group "soon"'),
16
+        }
17
+
18
+
9
 class TaskForm(forms.ModelForm):
19
 class TaskForm(forms.ModelForm):
10
 
20
 
11
     def __init__(self, *args, **kwargs):
21
     def __init__(self, *args, **kwargs):
56
 
66
 
57
     class Meta:
67
     class Meta:
58
         model = Task
68
         model = Task
59
-        fields = ['project', 'assigned_user', 'name', 'state', 'priority', 'targetdate', 'progress', 'description']
69
+        fields = ['name', 'project', 'assigned_user', 'targetdate', 'priority', 'state', 'progress', 'description']
60
         widgets = {
70
         widgets = {
61
             'assigned_user': forms.Select(attrs={'required': True}),
71
             'assigned_user': forms.Select(attrs={'required': True}),
62
             'project': forms.Select(attrs={'onchange': 'submit()'}),
72
             'project': forms.Select(attrs={'onchange': 'submit()'}),
84
 class ProjectForm(forms.ModelForm):
94
 class ProjectForm(forms.ModelForm):
85
     class Meta:
95
     class Meta:
86
         model = Project
96
         model = Project
87
-        fields = ['name', 'state', 'role_leader', 'role_member', 'role_visitor', 'description', 'days_late', 'days_very_soon', 'days_soon']
97
+        fields = ['name', 'state', 'role_leader', 'role_member', 'role_visitor', 'description']
88
         widgets = {
98
         widgets = {
89
             'description': forms.Textarea(attrs={'rows': 5}),
99
             'description': forms.Textarea(attrs={'rows': 5}),
90
         }
100
         }
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
 
101
 
97
     def __init__(self, *args, **kwargs):
102
     def __init__(self, *args, **kwargs):
98
         super(ProjectForm, self).__init__(*args, **kwargs)
103
         super(ProjectForm, self).__init__(*args, **kwargs)

+ 37
- 0
migrations/0013_auto_20210117_1332.py View File

1
+# Generated by Django 3.1.5 on 2021-01-17 13:32
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', '0012_auto_20200114_1035'),
13
+    ]
14
+
15
+    operations = [
16
+        migrations.RemoveField(
17
+            model_name='project',
18
+            name='days_late',
19
+        ),
20
+        migrations.RemoveField(
21
+            model_name='project',
22
+            name='days_soon',
23
+        ),
24
+        migrations.RemoveField(
25
+            model_name='project',
26
+            name='days_very_soon',
27
+        ),
28
+        migrations.CreateModel(
29
+            name='PattUserProfile',
30
+            fields=[
31
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
32
+                ('threshold_very_soon', models.IntegerField(choices=[(1, '1 day'), (2, '2 days'), (3, '3 days'), (4, '4 days'), (5, '5 days'), (7, '7 days'), (9, '9 days'), (12, '12 days')], default=4)),
33
+                ('threshold_soon', models.IntegerField(choices=[(1, '1 day'), (2, '2 days'), (3, '3 days'), (5, '5 days'), (7, '7 days'), (10, '10 days'), (14, '14 days'), (21, ' days')], default=10)),
34
+                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
35
+            ],
36
+        ),
37
+    ]

+ 60
- 36
models.py View File

1
 import datetime
1
 import datetime
2
 from django.contrib.auth.models import User
2
 from django.contrib.auth.models import User
3
 from django.db import models
3
 from django.db import models
4
-from django.utils.dateformat import format
5
 from django.utils.translation import gettext as _
4
 from django.utils.translation import gettext as _
6
 from simple_history.models import HistoricalRecords
5
 from simple_history.models import HistoricalRecords
7
 
6
 
7
+
8
+THRESHOLD_VERY_SOON_CHOICES = ((1, '1 day'), (2, '2 days'), (3, '3 days'), (4, '4 days'), (5, '5 days'), (7, '7 days'), (9, '9 days'), (12, '12 days'), )    # REQ-38
9
+THRESHOLD_SOON_CHOICES = ((1, '1 day'), (2, '2 days'), (3, '3 days'), (5, '5 days'), (7, '7 days'), (10, '10 days'), (14, '14 days'), (21, ' days'), )       # REQ-39
10
+
11
+
8
 # PROJECTSTATE (REQ-??)
12
 # PROJECTSTATE (REQ-??)
9
 #
13
 #
10
 PROJECTSTATE_OPEN = 0
14
 PROJECTSTATE_OPEN = 0
75
 
79
 
76
 # GENERAL Methods and Classes
80
 # GENERAL Methods and Classes
77
 #
81
 #
82
+def get_pattuserprofile(user):
83
+    if user is None:    # return a default profile if no (assigned_)user exist
84
+        profile = PattUserProfile()
85
+    else:
86
+        try:
87
+            profile = user.pattuserprofile
88
+        except PattUserProfile.DoesNotExist:
89
+            profile = PattUserProfile(user=user)
90
+            profile.save()
91
+    return profile
92
+
93
+
94
+# USERPROFILE Model
95
+#
96
+class PattUserProfile(models.Model):
97
+    user = models.OneToOneField(User, unique=True, on_delete=models.CASCADE)
98
+    threshold_very_soon = models.IntegerField(default=4, choices=THRESHOLD_VERY_SOON_CHOICES)   # REQ-38
99
+    threshold_soon = models.IntegerField(default=10, choices=THRESHOLD_SOON_CHOICES)            # REQ-39
100
+
101
+
78
 class ModelList(list):
102
 class ModelList(list):
79
     def __init__(self, lst, chk=None, user=None):
103
     def __init__(self, lst, chk=None, user=None):
80
         if chk is None:
104
         if chk is None:
100
     role_member = models.ManyToManyField(User, related_name='role_member', blank=True)      # REQ-12
124
     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
125
     role_visitor = models.ManyToManyField(User, related_name='role_visitor', blank=True)    # REQ-33
102
     creation_date = models.DateTimeField(auto_now_add=True)
126
     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
 
127
 
107
     @property
128
     @property
108
     def attachment_target_path(self):
129
     def attachment_target_path(self):
142
 # TASK Model
163
 # TASK Model
143
 #
164
 #
144
 class Task(models.Model):
165
 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
166
+    WARNING_GROUP_OVERDUE = 0           # REQ-40
167
+    WARNING_GROUP_VERY_SOON = 1         # REQ-40
168
+    WARNING_GROUP_SOON = 2              # REQ-40
169
+    WARNING_GROUP_DEFAULT = 3           # REQ-40
170
+    WARNING_GROUP_DONE = 4              # REQ-40
150
     #
171
     #
151
     SAVE_ON_CHANGE_FIELDS = ['state', 'priority', 'targetdate', 'progress', 'name', 'description', 'assigned_user', 'project']
172
     SAVE_ON_CHANGE_FIELDS = ['state', 'priority', 'targetdate', 'progress', 'name', 'description', 'assigned_user', 'project']
152
     #
173
     #
194
         rv.sort(reverse=True)
215
         rv.sort(reverse=True)
195
         return rv
216
         return rv
196
 
217
 
197
-    def datafusion_state(self):
218
+    def days_before_targetdate(self):   # REQ-40, REQ-43
219
+        if self.targetdate is None:
220
+            return float('Inf')
221
+        else:
222
+            if type(self.targetdate) == datetime.date:
223
+                targetdate = self.targetdate
224
+            else:
225
+                targetdate = datetime.datetime.strptime(self.targetdate, '%Y-%m-%d').date()
226
+            rv = targetdate - datetime.datetime.now().date()
227
+            return rv.days
228
+
229
+    def warning_group(self):            # REQ-40
198
         if self.state in [TASKSTATE_CANCELED, TASKSTATE_CLOSED, TASKSTATE_FINISHED]:
230
         if self.state in [TASKSTATE_CANCELED, TASKSTATE_CLOSED, TASKSTATE_FINISHED]:
199
-            return self.FUSION_STATE_FINISHED
231
+            return self.WARNING_GROUP_DONE
200
         else:
232
         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
233
+            dbt = self.days_before_targetdate()
234
+            if dbt <= 0:
235
+                return self.WARNING_GROUP_OVERDUE
236
+            if dbt <= get_pattuserprofile(self.assigned_user).threshold_very_soon:
237
+                return self.WARNING_GROUP_VERY_SOON
238
+            if dbt <= get_pattuserprofile(self.assigned_user).threshold_soon:
239
+                return self.WARNING_GROUP_SOON
240
+            return self.WARNING_GROUP_DEFAULT
213
 
241
 
214
     @property
242
     @property
215
     def class_by_state(self):
243
     def class_by_state(self):
216
         return {
244
         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)
245
+            self.WARNING_GROUP_DONE: 'task-finished',
246
+            self.WARNING_GROUP_DEFAULT: 'task-normal',
247
+            self.WARNING_GROUP_SOON: 'task-soon',
248
+            self.WARNING_GROUP_VERY_SOON: 'task-very-soon',
249
+            self.WARNING_GROUP_OVERDUE: 'task-late',
250
+        }.get(self.warning_group())
251
+
252
+    def sort_string(self):      # REQ-41
253
+        return (self.warning_group(), self.priority, self.days_before_targetdate(), self.progress, self.name)
230
 
254
 
231
     def __str__(self):
255
     def __str__(self):
232
         return 'Task #%d: %s' % (self.id, self.name)
256
         return 'Task #%d: %s' % (self.id, self.name)

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

1
+{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
2
+{% load i18n %}
3
+
4
+{% block content %}
5
+  <form action="" method="post">
6
+    {% csrf_token %}
7
+    {% include 'users/profile_formdata.html' %}
8
+    {% include 'patt/profile_formdata.html' %}
9
+    <input type="submit" value="{% trans "Save" %}" class="button" />
10
+  </form>
11
+
12
+{% endblock content %}

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

1
+{% load i18n %}
2
+
3
+{% get_current_language as LANGUAGE_CODE %}
4
+  <h1>{% trans "Warning Group Thresholds" %}</h1>
5
+  {{ form_pattprofile.as_p }}
6
+  </select>

+ 1
- 0
urls.py View File

6
 
6
 
7
 urlpatterns = [
7
 urlpatterns = [
8
     path('', lambda request: redirect(reverse('patt-commonfilter', kwargs={'common_filter_id': SEARCH_MY_OPEN_TASKS}), permanent=False)),
8
     path('', lambda request: redirect(reverse('patt-commonfilter', kwargs={'common_filter_id': SEARCH_MY_OPEN_TASKS}), permanent=False)),
9
+    path('profile', views.patt_profile, name='patt-profile'),
9
     #
10
     #
10
     # views and urls
11
     # views and urls
11
     #
12
     #

+ 40
- 2
views.py View File

7
 from django.urls.base import reverse
7
 from django.urls.base import reverse
8
 from django.utils import timezone
8
 from django.utils import timezone
9
 from django.utils.translation import gettext as _
9
 from django.utils.translation import gettext as _
10
-from .forms import TaskForm, TaskFormLimited, ProjectForm, CommentForm, TaskCommentForm, SearchForm, EasySearchForm
10
+from .forms import TaskForm, TaskFormLimited, ProjectForm, CommentForm, TaskCommentForm, SearchForm, EasySearchForm, PattUserProfileForm
11
 from .help import help_pages
11
 from .help import help_pages
12
 from .models import Task, ModelList, Comment, TASKSTATE_CHOICES, PRIO_CHOICES, TASKS_IN_WORK
12
 from .models import Task, ModelList, Comment, TASKSTATE_CHOICES, PRIO_CHOICES, TASKS_IN_WORK
13
-from .models import Project, Search
13
+from .models import Project, Search, get_pattuserprofile
14
 import patt
14
 import patt
15
 from .search import load_index, search, mk_search_pattern, get_project_ids_from_search_pattern, common_searches
15
 from .search import load_index, search, mk_search_pattern, get_project_ids_from_search_pattern, common_searches
16
+from users.forms import UserProfileForm
17
+from users.models import get_userprofile
16
 from themes import Context
18
 from themes import Context
17
 
19
 
18
 
20
 
142
         return request.POST.get('next', '/')
144
         return request.POST.get('next', '/')
143
 
145
 
144
 
146
 
147
+def profile_post_actions(request, context):
148
+    if request.POST:
149
+        form = context.get('form_userprofile')
150
+        if form.is_valid():
151
+            form.save()
152
+        form = context.get('form_pattprofile')
153
+        if form.is_valid():
154
+            form.save()
155
+        return redirect(request.GET.get('next') or '/')
156
+
157
+
158
+def profile_pre_actions(request, context, form_to_be_used=UserProfileForm):
159
+    user_profile = get_userprofile(request.user)
160
+    patt_user_profile = get_pattuserprofile(request.user)
161
+    if request.POST:
162
+        userprofile_form = UserProfileForm(request.POST, instance=user_profile)
163
+        pattprofile_form = PattUserProfileForm(request.POST, instance=patt_user_profile)
164
+    else:
165
+        userprofile_form = UserProfileForm(instance=user_profile)
166
+        pattprofile_form = PattUserProfileForm(instance=patt_user_profile)
167
+    context['form_userprofile'] = userprofile_form
168
+    context['form_pattprofile'] = pattprofile_form
169
+
170
+
171
+@login_required
172
+def patt_profile(request):
173
+    context = Context(request)      # needs to be executed first because of time mesurement
174
+    profile_pre_actions(request, context)
175
+    response = profile_post_actions(request, context)
176
+    if response is not None:
177
+        return response
178
+    else:
179
+        context_adaption(context, request, title=_('Profile for %(username)s') % {'username': request.user.username})
180
+        return render(request, 'patt/profile.html', context=context)
181
+
182
+
145
 @login_required
183
 @login_required
146
 def patt_tasklist(request, user_filter_id=None, common_filter_id=None):
184
 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
185
     context = Context(request)      # needs to be executed first because of time mesurement

Loading…
Cancel
Save