Sorting improved, more general thresholds for warning groups

This commit is contained in:
Dirk Alders 2021-01-17 14:59:41 +01:00
parent 8d5ce7ccae
commit 63e9af4d65
10 changed files with 196 additions and 47 deletions

View File

@ -21,6 +21,10 @@ def url_tasklist(request, user_filter_id=None, search_txt=None, common_filter_id
return reverse('patt-tasklist') return reverse('patt-tasklist')
def url_profile(request):
return reverse('patt-profile')
def url_projectlist(request): def url_projectlist(request):
return reverse('patt-projectlist') return reverse('patt-projectlist')

View File

@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import Project, Task, Comment, Search from .models import Project, Task, Comment, Search, PattUserProfile
from simple_history.admin import SimpleHistoryAdmin from simple_history.admin import SimpleHistoryAdmin
@ -59,7 +59,12 @@ class SearchAdmin(admin.ModelAdmin):
) )
class PattUserProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'threshold_very_soon', 'threshold_soon', )
admin.site.register(Project, ProjectAdmin) admin.site.register(Project, ProjectAdmin)
admin.site.register(Task, TaskAdmin) admin.site.register(Task, TaskAdmin)
admin.site.register(Comment, CommentAdmin) admin.site.register(Comment, CommentAdmin)
admin.site.register(Search, SearchAdmin) admin.site.register(Search, SearchAdmin)
admin.site.register(PattUserProfile, PattUserProfileAdmin)

View File

@ -8,6 +8,7 @@ import patt
from .search import common_searches from .search import common_searches
from themes import empty_entry_parameters, color_icon_url, gray_icon_url from themes import empty_entry_parameters, color_icon_url, gray_icon_url
from users.context import menubar as menubar_users from users.context import menubar as menubar_users
from users.context import PROFILE_ENTRY_UID
ATTACHMENT_UID = 'attachment' ATTACHMENT_UID = 'attachment'
BACK_UID = 'back' BACK_UID = 'back'
@ -37,6 +38,7 @@ def context_adaption(context, request, **kwargs):
def menubar(context, request, caller_name, **kwargs): def menubar(context, request, caller_name, **kwargs):
bar = context[context.MENUBAR] bar = context[context.MENUBAR]
replace_profile(request, bar)
add_help_menu(request, bar) add_help_menu(request, bar)
add_tasklist_menu(request, bar) add_tasklist_menu(request, bar)
add_filter_submenu(request, bar, VIEW_TASKLIST_UID) add_filter_submenu(request, bar, VIEW_TASKLIST_UID)
@ -76,6 +78,21 @@ def finalise_bar(request, bar):
bar.append_entry(*empty_entry_parameters(request)) bar.append_entry(*empty_entry_parameters(request))
def replace_profile(request, bar):
try:
bar.replace_entry(
PROFILE_ENTRY_UID,
PROFILE_ENTRY_UID, # uid
request.user.username, # name
color_icon_url(request, 'user.png'), # icon
patt.url_profile(request), # url
False, # left
False # active
)
except ValueError:
pass # Profile entry does not exist, so exchange is not needed (e.g. no user is logged in)
def add_help_menu(request, bar): def add_help_menu(request, bar):
bar.append_entry( bar.append_entry(
HELP_UID, # uid HELP_UID, # uid

View File

@ -2,10 +2,20 @@ from django import forms
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from .models import Task, Project, Comment, Search, TASKSTATE_CHOICES, PROJECTSTATE_OPEN, ModelList from .models import Task, Project, Comment, Search, TASKSTATE_CHOICES, PROJECTSTATE_OPEN, ModelList, PattUserProfile
from .search import INDEX_STATES from .search import INDEX_STATES
class PattUserProfileForm(forms.ModelForm):
class Meta:
model = PattUserProfile
fields = ['threshold_very_soon', 'threshold_soon']
labels = {
'threshold_very_soon': _('Period to targetdate for warning Group "very soon"'),
'threshold_soon': _('Period to targetdate for warning Group "soon"'),
}
class TaskForm(forms.ModelForm): class TaskForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -56,7 +66,7 @@ class TaskForm(forms.ModelForm):
class Meta: class Meta:
model = Task model = Task
fields = ['project', 'assigned_user', 'name', 'state', 'priority', 'targetdate', 'progress', 'description'] fields = ['name', 'project', 'assigned_user', 'targetdate', 'priority', 'state', 'progress', 'description']
widgets = { widgets = {
'assigned_user': forms.Select(attrs={'required': True}), 'assigned_user': forms.Select(attrs={'required': True}),
'project': forms.Select(attrs={'onchange': 'submit()'}), 'project': forms.Select(attrs={'onchange': 'submit()'}),
@ -84,15 +94,10 @@ class TaskFormLimited(forms.ModelForm):
class ProjectForm(forms.ModelForm): class ProjectForm(forms.ModelForm):
class Meta: class Meta:
model = Project model = Project
fields = ['name', 'state', 'role_leader', 'role_member', 'role_visitor', 'description', 'days_late', 'days_very_soon', 'days_soon'] fields = ['name', 'state', 'role_leader', 'role_member', 'role_visitor', 'description']
widgets = { widgets = {
'description': forms.Textarea(attrs={'rows': 5}), 'description': forms.Textarea(attrs={'rows': 5}),
} }
labels = {
'days_late': _('Days to deadline (late)'),
'days_very_soon': _('Days to deadline (very soon)'),
'days_soon': _('Days to deadline (soon)'),
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(ProjectForm, self).__init__(*args, **kwargs) super(ProjectForm, self).__init__(*args, **kwargs)

View File

@ -0,0 +1,37 @@
# Generated by Django 3.1.5 on 2021-01-17 13:32
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('patt', '0012_auto_20200114_1035'),
]
operations = [
migrations.RemoveField(
model_name='project',
name='days_late',
),
migrations.RemoveField(
model_name='project',
name='days_soon',
),
migrations.RemoveField(
model_name='project',
name='days_very_soon',
),
migrations.CreateModel(
name='PattUserProfile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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)),
('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)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,10 +1,14 @@
import datetime import datetime
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.utils.dateformat import format
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
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
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
# PROJECTSTATE (REQ-??) # PROJECTSTATE (REQ-??)
# #
PROJECTSTATE_OPEN = 0 PROJECTSTATE_OPEN = 0
@ -75,6 +79,26 @@ COMMENTTYPE_CHOICES = (
# GENERAL Methods and Classes # GENERAL Methods and Classes
# #
def get_pattuserprofile(user):
if user is None: # return a default profile if no (assigned_)user exist
profile = PattUserProfile()
else:
try:
profile = user.pattuserprofile
except PattUserProfile.DoesNotExist:
profile = PattUserProfile(user=user)
profile.save()
return profile
# USERPROFILE Model
#
class PattUserProfile(models.Model):
user = models.OneToOneField(User, unique=True, on_delete=models.CASCADE)
threshold_very_soon = models.IntegerField(default=4, choices=THRESHOLD_VERY_SOON_CHOICES) # REQ-38
threshold_soon = models.IntegerField(default=10, choices=THRESHOLD_SOON_CHOICES) # REQ-39
class ModelList(list): class ModelList(list):
def __init__(self, lst, chk=None, user=None): def __init__(self, lst, chk=None, user=None):
if chk is None: if chk is None:
@ -100,9 +124,6 @@ class Project(models.Model):
role_member = models.ManyToManyField(User, related_name='role_member', blank=True) # REQ-12 role_member = models.ManyToManyField(User, related_name='role_member', blank=True) # REQ-12
role_visitor = models.ManyToManyField(User, related_name='role_visitor', blank=True) # REQ-33 role_visitor = models.ManyToManyField(User, related_name='role_visitor', blank=True) # REQ-33
creation_date = models.DateTimeField(auto_now_add=True) creation_date = models.DateTimeField(auto_now_add=True)
days_late = models.IntegerField(default=0, choices=((-1, _('Off')), (0, 0), ))
days_very_soon = models.IntegerField(default=3, choices=((-1, _('Off')), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (7, 7), (9, 9), ))
days_soon = models.IntegerField(default=7, choices=((-1, _('Off')), (1, 1), (2, 2), (3, 3), (5, 5), (7, 7), (10, 10), (14, 14), ))
@property @property
def attachment_target_path(self): def attachment_target_path(self):
@ -142,11 +163,11 @@ class Project(models.Model):
# TASK Model # TASK Model
# #
class Task(models.Model): class Task(models.Model):
FUSION_STATE_FINISHED = 0 WARNING_GROUP_OVERDUE = 0 # REQ-40
FUSION_STATE_NORMAL = 1 WARNING_GROUP_VERY_SOON = 1 # REQ-40
FUSION_STATE_SOON = 2 WARNING_GROUP_SOON = 2 # REQ-40
FUSION_STATE_VERY_SOON = 3 WARNING_GROUP_DEFAULT = 3 # REQ-40
FUSION_STATE_LATE = 4 WARNING_GROUP_DONE = 4 # REQ-40
# #
SAVE_ON_CHANGE_FIELDS = ['state', 'priority', 'targetdate', 'progress', 'name', 'description', 'assigned_user', 'project'] SAVE_ON_CHANGE_FIELDS = ['state', 'priority', 'targetdate', 'progress', 'name', 'description', 'assigned_user', 'project']
# #
@ -194,39 +215,42 @@ class Task(models.Model):
rv.sort(reverse=True) rv.sort(reverse=True)
return rv return rv
def datafusion_state(self): def days_before_targetdate(self): # REQ-40, REQ-43
if self.state in [TASKSTATE_CANCELED, TASKSTATE_CLOSED, TASKSTATE_FINISHED]: if self.targetdate is None:
return self.FUSION_STATE_FINISHED return float('Inf')
else: else:
if self.targetdate is not None:
if type(self.targetdate) == datetime.date: if type(self.targetdate) == datetime.date:
targetdate = self.targetdate targetdate = self.targetdate
else: else:
targetdate = datetime.datetime.strptime(self.targetdate, '%Y-%m-%d').date() targetdate = datetime.datetime.strptime(self.targetdate, '%Y-%m-%d').date()
if targetdate - datetime.date.today() <= datetime.timedelta(self.project.days_late) and self.project.days_late >= 0: rv = targetdate - datetime.datetime.now().date()
return self.FUSION_STATE_LATE return rv.days
elif targetdate - datetime.date.today() <= datetime.timedelta(self.project.days_very_soon) and self.project.days_very_soon >= 0:
return self.FUSION_STATE_VERY_SOON def warning_group(self): # REQ-40
elif targetdate - datetime.date.today() <= datetime.timedelta(self.project.days_soon) and self.project.days_soon >= 0: if self.state in [TASKSTATE_CANCELED, TASKSTATE_CLOSED, TASKSTATE_FINISHED]:
return self.FUSION_STATE_SOON return self.WARNING_GROUP_DONE
return self.FUSION_STATE_NORMAL else:
dbt = self.days_before_targetdate()
if dbt <= 0:
return self.WARNING_GROUP_OVERDUE
if dbt <= get_pattuserprofile(self.assigned_user).threshold_very_soon:
return self.WARNING_GROUP_VERY_SOON
if dbt <= get_pattuserprofile(self.assigned_user).threshold_soon:
return self.WARNING_GROUP_SOON
return self.WARNING_GROUP_DEFAULT
@property @property
def class_by_state(self): def class_by_state(self):
return { return {
self.FUSION_STATE_FINISHED: 'task-finished', self.WARNING_GROUP_DONE: 'task-finished',
self.FUSION_STATE_NORMAL: 'task-normal', self.WARNING_GROUP_DEFAULT: 'task-normal',
self.FUSION_STATE_VERY_SOON: 'task-very-soon', self.WARNING_GROUP_SOON: 'task-soon',
self.FUSION_STATE_SOON: 'task-soon', self.WARNING_GROUP_VERY_SOON: 'task-very-soon',
self.FUSION_STATE_LATE: 'task-late', self.WARNING_GROUP_OVERDUE: 'task-late',
}.get(self.datafusion_state()) }.get(self.warning_group())
def sort_string(self): def sort_string(self): # REQ-41
if self.targetdate: return (self.warning_group(), self.priority, self.days_before_targetdate(), self.progress, self.name)
td = int(format(self.targetdate, 'U'))
else:
td = 999999999999
return (100 - self.datafusion_state(), self.state, self.priority, td, self.progress, self.name)
def __str__(self): def __str__(self):
return 'Task #%d: %s' % (self.id, self.name) return 'Task #%d: %s' % (self.id, self.name)

View File

@ -0,0 +1,12 @@
{% extends "themes/"|add:settings.page_theme|add:"/base.html" %}
{% load i18n %}
{% block content %}
<form action="" method="post">
{% csrf_token %}
{% include 'users/profile_formdata.html' %}
{% include 'patt/profile_formdata.html' %}
<input type="submit" value="{% trans "Save" %}" class="button" />
</form>
{% endblock content %}

View File

@ -0,0 +1,6 @@
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<h1>{% trans "Warning Group Thresholds" %}</h1>
{{ form_pattprofile.as_p }}
</select>

View File

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

View File

@ -7,12 +7,14 @@ from django.shortcuts import render, redirect
from django.urls.base import reverse from django.urls.base import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from .forms import TaskForm, TaskFormLimited, ProjectForm, CommentForm, TaskCommentForm, SearchForm, EasySearchForm from .forms import TaskForm, TaskFormLimited, ProjectForm, CommentForm, TaskCommentForm, SearchForm, EasySearchForm, PattUserProfileForm
from .help import help_pages from .help import help_pages
from .models import Task, ModelList, Comment, TASKSTATE_CHOICES, PRIO_CHOICES, TASKS_IN_WORK from .models import Task, ModelList, Comment, TASKSTATE_CHOICES, PRIO_CHOICES, TASKS_IN_WORK
from .models import Project, Search from .models import Project, Search, get_pattuserprofile
import patt import patt
from .search import load_index, search, mk_search_pattern, get_project_ids_from_search_pattern, common_searches from .search import load_index, search, mk_search_pattern, get_project_ids_from_search_pattern, common_searches
from users.forms import UserProfileForm
from users.models import get_userprofile
from themes import Context from themes import Context
@ -142,6 +144,42 @@ def get_next(request):
return request.POST.get('next', '/') return request.POST.get('next', '/')
def profile_post_actions(request, context):
if request.POST:
form = context.get('form_userprofile')
if form.is_valid():
form.save()
form = context.get('form_pattprofile')
if form.is_valid():
form.save()
return redirect(request.GET.get('next') or '/')
def profile_pre_actions(request, context, form_to_be_used=UserProfileForm):
user_profile = get_userprofile(request.user)
patt_user_profile = get_pattuserprofile(request.user)
if request.POST:
userprofile_form = UserProfileForm(request.POST, instance=user_profile)
pattprofile_form = PattUserProfileForm(request.POST, instance=patt_user_profile)
else:
userprofile_form = UserProfileForm(instance=user_profile)
pattprofile_form = PattUserProfileForm(instance=patt_user_profile)
context['form_userprofile'] = userprofile_form
context['form_pattprofile'] = pattprofile_form
@login_required
def patt_profile(request):
context = Context(request) # needs to be executed first because of time mesurement
profile_pre_actions(request, context)
response = profile_post_actions(request, context)
if response is not None:
return response
else:
context_adaption(context, request, title=_('Profile for %(username)s') % {'username': request.user.username})
return render(request, 'patt/profile.html', context=context)
@login_required @login_required
def patt_tasklist(request, user_filter_id=None, common_filter_id=None): def patt_tasklist(request, user_filter_id=None, common_filter_id=None):
context = Context(request) # needs to be executed first because of time mesurement context = Context(request) # needs to be executed first because of time mesurement