diff --git a/__init__.py b/__init__.py index 953c3e9..c811f0d 100644 --- a/__init__.py +++ b/__init__.py @@ -21,6 +21,10 @@ def url_tasklist(request, user_filter_id=None, search_txt=None, common_filter_id return reverse('patt-tasklist') +def url_profile(request): + return reverse('patt-profile') + + def url_projectlist(request): return reverse('patt-projectlist') diff --git a/admin.py b/admin.py index fb7b755..d0baa13 100644 --- a/admin.py +++ b/admin.py @@ -1,5 +1,5 @@ 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 @@ -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(Task, TaskAdmin) admin.site.register(Comment, CommentAdmin) admin.site.register(Search, SearchAdmin) +admin.site.register(PattUserProfile, PattUserProfileAdmin) diff --git a/context.py b/context.py index 1565551..d0fef9f 100644 --- a/context.py +++ b/context.py @@ -8,6 +8,7 @@ import patt from .search import common_searches from themes import empty_entry_parameters, color_icon_url, gray_icon_url from users.context import menubar as menubar_users +from users.context import PROFILE_ENTRY_UID ATTACHMENT_UID = 'attachment' BACK_UID = 'back' @@ -37,6 +38,7 @@ def context_adaption(context, request, **kwargs): def menubar(context, request, caller_name, **kwargs): bar = context[context.MENUBAR] + replace_profile(request, bar) add_help_menu(request, bar) add_tasklist_menu(request, bar) add_filter_submenu(request, bar, VIEW_TASKLIST_UID) @@ -76,6 +78,21 @@ def finalise_bar(request, bar): 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): bar.append_entry( HELP_UID, # uid diff --git a/forms.py b/forms.py index 492c140..94a5938 100644 --- a/forms.py +++ b/forms.py @@ -2,10 +2,20 @@ from django import forms from django.contrib.auth.models import User from django.db.models import Q from django.utils.translation import gettext as _ -from .models import Task, Project, Comment, Search, TASKSTATE_CHOICES, PROJECTSTATE_OPEN, ModelList +from .models import Task, Project, Comment, Search, TASKSTATE_CHOICES, PROJECTSTATE_OPEN, ModelList, PattUserProfile 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): def __init__(self, *args, **kwargs): @@ -56,7 +66,7 @@ class TaskForm(forms.ModelForm): class Meta: model = Task - fields = ['project', 'assigned_user', 'name', 'state', 'priority', 'targetdate', 'progress', 'description'] + fields = ['name', 'project', 'assigned_user', 'targetdate', 'priority', 'state', 'progress', 'description'] widgets = { 'assigned_user': forms.Select(attrs={'required': True}), 'project': forms.Select(attrs={'onchange': 'submit()'}), @@ -84,15 +94,10 @@ class TaskFormLimited(forms.ModelForm): class ProjectForm(forms.ModelForm): class Meta: model = Project - fields = ['name', 'state', 'role_leader', 'role_member', 'role_visitor', 'description', 'days_late', 'days_very_soon', 'days_soon'] + fields = ['name', 'state', 'role_leader', 'role_member', 'role_visitor', 'description'] widgets = { 'description': forms.Textarea(attrs={'rows': 5}), } - labels = { - 'days_late': _('Days to deadline (late)'), - 'days_very_soon': _('Days to deadline (very soon)'), - 'days_soon': _('Days to deadline (soon)'), - } def __init__(self, *args, **kwargs): super(ProjectForm, self).__init__(*args, **kwargs) diff --git a/migrations/0013_auto_20210117_1332.py b/migrations/0013_auto_20210117_1332.py new file mode 100644 index 0000000..7a3ef74 --- /dev/null +++ b/migrations/0013_auto_20210117_1332.py @@ -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)), + ], + ), + ] diff --git a/models.py b/models.py index a316650..9fd5809 100644 --- a/models.py +++ b/models.py @@ -1,10 +1,14 @@ import datetime from django.contrib.auth.models import User from django.db import models -from django.utils.dateformat import format from django.utils.translation import gettext as _ from simple_history.models import HistoricalRecords + +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_OPEN = 0 @@ -75,6 +79,26 @@ COMMENTTYPE_CHOICES = ( # 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): def __init__(self, lst, chk=None, user=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_visitor = models.ManyToManyField(User, related_name='role_visitor', blank=True) # REQ-33 creation_date = models.DateTimeField(auto_now_add=True) - days_late = models.IntegerField(default=0, choices=((-1, _('Off')), (0, 0), )) - days_very_soon = models.IntegerField(default=3, choices=((-1, _('Off')), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (7, 7), (9, 9), )) - days_soon = models.IntegerField(default=7, choices=((-1, _('Off')), (1, 1), (2, 2), (3, 3), (5, 5), (7, 7), (10, 10), (14, 14), )) @property def attachment_target_path(self): @@ -142,11 +163,11 @@ class Project(models.Model): # TASK Model # class Task(models.Model): - FUSION_STATE_FINISHED = 0 - FUSION_STATE_NORMAL = 1 - FUSION_STATE_SOON = 2 - FUSION_STATE_VERY_SOON = 3 - FUSION_STATE_LATE = 4 + WARNING_GROUP_OVERDUE = 0 # REQ-40 + WARNING_GROUP_VERY_SOON = 1 # REQ-40 + WARNING_GROUP_SOON = 2 # REQ-40 + WARNING_GROUP_DEFAULT = 3 # REQ-40 + WARNING_GROUP_DONE = 4 # REQ-40 # 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) return rv - def datafusion_state(self): - if self.state in [TASKSTATE_CANCELED, TASKSTATE_CLOSED, TASKSTATE_FINISHED]: - return self.FUSION_STATE_FINISHED + def days_before_targetdate(self): # REQ-40, REQ-43 + if self.targetdate is None: + return float('Inf') else: - if self.targetdate is not None: - if type(self.targetdate) == datetime.date: - targetdate = self.targetdate - else: - targetdate = datetime.datetime.strptime(self.targetdate, '%Y-%m-%d').date() - if targetdate - datetime.date.today() <= datetime.timedelta(self.project.days_late) and self.project.days_late >= 0: - return self.FUSION_STATE_LATE - elif targetdate - datetime.date.today() <= datetime.timedelta(self.project.days_very_soon) and self.project.days_very_soon >= 0: - return self.FUSION_STATE_VERY_SOON - elif targetdate - datetime.date.today() <= datetime.timedelta(self.project.days_soon) and self.project.days_soon >= 0: - return self.FUSION_STATE_SOON - return self.FUSION_STATE_NORMAL + if type(self.targetdate) == datetime.date: + targetdate = self.targetdate + else: + targetdate = datetime.datetime.strptime(self.targetdate, '%Y-%m-%d').date() + rv = targetdate - datetime.datetime.now().date() + return rv.days + + def warning_group(self): # REQ-40 + if self.state in [TASKSTATE_CANCELED, TASKSTATE_CLOSED, TASKSTATE_FINISHED]: + return self.WARNING_GROUP_DONE + 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 def class_by_state(self): return { - self.FUSION_STATE_FINISHED: 'task-finished', - self.FUSION_STATE_NORMAL: 'task-normal', - self.FUSION_STATE_VERY_SOON: 'task-very-soon', - self.FUSION_STATE_SOON: 'task-soon', - self.FUSION_STATE_LATE: 'task-late', - }.get(self.datafusion_state()) + self.WARNING_GROUP_DONE: 'task-finished', + self.WARNING_GROUP_DEFAULT: 'task-normal', + self.WARNING_GROUP_SOON: 'task-soon', + self.WARNING_GROUP_VERY_SOON: 'task-very-soon', + self.WARNING_GROUP_OVERDUE: 'task-late', + }.get(self.warning_group()) - def sort_string(self): - if self.targetdate: - td = int(format(self.targetdate, 'U')) - else: - td = 999999999999 - return (100 - self.datafusion_state(), self.state, self.priority, td, self.progress, self.name) + def sort_string(self): # REQ-41 + return (self.warning_group(), self.priority, self.days_before_targetdate(), self.progress, self.name) def __str__(self): return 'Task #%d: %s' % (self.id, self.name) diff --git a/templates/patt/profile.html b/templates/patt/profile.html new file mode 100644 index 0000000..51963be --- /dev/null +++ b/templates/patt/profile.html @@ -0,0 +1,12 @@ +{% extends "themes/"|add:settings.page_theme|add:"/base.html" %} +{% load i18n %} + +{% block content %} +
+ {% csrf_token %} + {% include 'users/profile_formdata.html' %} + {% include 'patt/profile_formdata.html' %} + +
+ +{% endblock content %} diff --git a/templates/patt/profile_formdata.html b/templates/patt/profile_formdata.html new file mode 100644 index 0000000..cb71147 --- /dev/null +++ b/templates/patt/profile_formdata.html @@ -0,0 +1,6 @@ +{% load i18n %} + +{% get_current_language as LANGUAGE_CODE %} +

{% trans "Warning Group Thresholds" %}

+ {{ form_pattprofile.as_p }} + diff --git a/urls.py b/urls.py index 5d0c9d3..c38ca0c 100644 --- a/urls.py +++ b/urls.py @@ -6,6 +6,7 @@ from . import views urlpatterns = [ 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 # diff --git a/views.py b/views.py index 231f69e..6c0e0df 100644 --- a/views.py +++ b/views.py @@ -7,12 +7,14 @@ from django.shortcuts import render, redirect from django.urls.base import reverse from django.utils import timezone from django.utils.translation import gettext as _ -from .forms import TaskForm, TaskFormLimited, ProjectForm, CommentForm, TaskCommentForm, SearchForm, EasySearchForm +from .forms import TaskForm, TaskFormLimited, ProjectForm, CommentForm, TaskCommentForm, SearchForm, EasySearchForm, PattUserProfileForm from .help import help_pages 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 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 @@ -142,6 +144,42 @@ def get_next(request): 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 def patt_tasklist(request, user_filter_id=None, common_filter_id=None): context = Context(request) # needs to be executed first because of time mesurement