diff --git a/taiga/projects/due_dates/__init__.py b/taiga/projects/due_dates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/projects/due_dates/models.py b/taiga/projects/due_dates/models.py new file mode 100644 index 00000000..6bb8e3e5 --- /dev/null +++ b/taiga/projects/due_dates/models.py @@ -0,0 +1,28 @@ +# Copyright (C) 2018 Miguel González +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class DueDateMixin(models.Model): + due_date = models.DateField( + blank=True, null=True, default=None, verbose_name=_('due date'), + ) + due_date_reason = models.TextField( + null=False, blank=True, default='', verbose_name=_('reason for the due date'), + ) + + class Meta: + abstract = True diff --git a/taiga/projects/due_dates/serializers.py b/taiga/projects/due_dates/serializers.py new file mode 100644 index 00000000..9f690b38 --- /dev/null +++ b/taiga/projects/due_dates/serializers.py @@ -0,0 +1,39 @@ +# Copyright (C) 2018 Miguel González +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import datetime as dt + +from django.utils import timezone + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField + + +class DueDateSerializerMixin(serializers.LightSerializer): + due_date = Field() + due_date_reason = Field() + due_date_status = MethodField() + + THRESHOLD = 3 + + def get_due_date_status(self, obj): + if obj.due_date is None: + return 'not_set' + elif obj.status.is_closed: + return 'no_longer_applicable' + elif timezone.now().date() > obj.due_date: + return 'past_due' + elif (timezone.now().date() + dt.timedelta(days=self.THRESHOLD)) >= obj.due_date: + return 'due_soon' + else: + return 'set' diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py index 1b5738b2..e489a481 100644 --- a/taiga/projects/history/freeze_impl.py +++ b/taiga/projects/history/freeze_impl.py @@ -357,6 +357,8 @@ def userstory_freezer(us) -> dict: "blocked_note_html": mdrender(us.project, us.blocked_note), "custom_attributes": extract_user_story_custom_attributes(us), "tribe_gig": us.tribe_gig, + "due_date": str(us.due_date), + "due_date_reason": str(us.due_date_reason), } return snapshot @@ -381,6 +383,8 @@ def issue_freezer(issue) -> dict: "blocked_note": issue.blocked_note, "blocked_note_html": mdrender(issue.project, issue.blocked_note), "custom_attributes": extract_issue_custom_attributes(issue), + "due_date": str(issue.due_date), + "due_date_reason": str(issue.due_date_reason), } return snapshot @@ -406,6 +410,8 @@ def task_freezer(task) -> dict: "blocked_note": task.blocked_note, "blocked_note_html": mdrender(task.project, task.blocked_note), "custom_attributes": extract_task_custom_attributes(task), + "due_date": str(task.due_date), + "due_date_reason": str(task.due_date_reason), } return snapshot diff --git a/taiga/projects/issues/migrations/0008_add_due_date.py b/taiga/projects/issues/migrations/0008_add_due_date.py new file mode 100644 index 00000000..96a95c41 --- /dev/null +++ b/taiga/projects/issues/migrations/0008_add_due_date.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.2 on 2018-04-09 09:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '0007_auto_20160614_1201'), + ] + + operations = [ + migrations.AddField( + model_name='issue', + name='due_date', + field=models.DateField(blank=True, default=None, null=True, verbose_name='due date'), + ), + migrations.AddField( + model_name='issue', + name='due_date_reason', + field=models.TextField(blank=True, default='', verbose_name='reason for the due date'), + ), + ] diff --git a/taiga/projects/issues/models.py b/taiga/projects/issues/models.py index c2f3696b..2b1b7f49 100644 --- a/taiga/projects/issues/models.py +++ b/taiga/projects/issues/models.py @@ -24,13 +24,14 @@ from django.utils import timezone from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ +from taiga.projects.due_dates.models import DueDateMixin from taiga.projects.occ import OCCModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.mixins.blocked import BlockedMixin from taiga.projects.tagging.models import TaggedMixin -class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.Model): +class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, DueDateMixin, models.Model): ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, verbose_name=_("ref")) owner = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, default=None, diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py index 80057dcc..f27fb973 100644 --- a/taiga/projects/issues/serializers.py +++ b/taiga/projects/issues/serializers.py @@ -21,6 +21,7 @@ from taiga.base.fields import Field, MethodField from taiga.base.neighbors import NeighborsSerializerMixin from taiga.mdrender.service import render as mdrender +from taiga.projects.due_dates.serializers import DueDateSerializerMixin from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin from taiga.projects.mixins.serializers import ProjectExtraInfoSerializerMixin from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin @@ -33,7 +34,8 @@ from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin class IssueListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, StatusExtraInfoSerializerMixin, ProjectExtraInfoSerializerMixin, - TaggedInProjectResourceSerializer, serializers.LightSerializer): + DueDateSerializerMixin, TaggedInProjectResourceSerializer, + serializers.LightSerializer): id = Field() ref = Field() severity = Field(attr="severity_id") diff --git a/taiga/projects/issues/services.py b/taiga/projects/issues/services.py index dbcb0d4e..dfbdd2d8 100644 --- a/taiga/projects/issues/services.py +++ b/taiga/projects/issues/services.py @@ -82,7 +82,7 @@ def issues_to_csv(project, queryset): "sprint_estimated_finish", "owner", "owner_full_name", "assigned_to", "assigned_to_full_name", "status", "severity", "priority", "type", "is_closed", "attachments", "external_reference", "tags", "watchers", - "voters", "created_date", "modified_date", "finished_date"] + "voters", "created_date", "modified_date", "finished_date", "due_date"] custom_attrs = project.issuecustomattributes.all() for custom_attr in custom_attrs: @@ -125,6 +125,7 @@ def issues_to_csv(project, queryset): "created_date": issue.created_date, "modified_date": issue.modified_date, "finished_date": issue.finished_date, + "due_date": issue.due_date, } for custom_attr in custom_attrs: diff --git a/taiga/projects/tasks/migrations/0012_add_due_date.py b/taiga/projects/tasks/migrations/0012_add_due_date.py new file mode 100644 index 00000000..01efa51a --- /dev/null +++ b/taiga/projects/tasks/migrations/0012_add_due_date.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.2 on 2018-04-09 09:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0011_auto_20160928_0755'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='due_date', + field=models.DateField(blank=True, default=None, null=True, verbose_name='due date'), + ), + migrations.AddField( + model_name='task', + name='due_date_reason', + field=models.TextField(blank=True, default='', verbose_name='reason for the due date'), + ), + ] diff --git a/taiga/projects/tasks/models.py b/taiga/projects/tasks/models.py index 5b7b0045..9f823d0c 100644 --- a/taiga/projects/tasks/models.py +++ b/taiga/projects/tasks/models.py @@ -24,13 +24,14 @@ from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from taiga.base.utils.time import timestamp_ms +from taiga.projects.due_dates.models import DueDateMixin from taiga.projects.occ import OCCModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.mixins.blocked import BlockedMixin from taiga.projects.tagging.models import TaggedMixin -class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.Model): +class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, DueDateMixin, models.Model): user_story = models.ForeignKey("userstories.UserStory", null=True, blank=True, related_name="tasks", verbose_name=_("user story")) ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py index 04cab33e..51b0ecbe 100644 --- a/taiga/projects/tasks/serializers.py +++ b/taiga/projects/tasks/serializers.py @@ -22,6 +22,7 @@ from taiga.base.neighbors import NeighborsSerializerMixin from taiga.mdrender.service import render as mdrender from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin +from taiga.projects.due_dates.serializers import DueDateSerializerMixin from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin from taiga.projects.mixins.serializers import ProjectExtraInfoSerializerMixin from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin @@ -36,7 +37,8 @@ class TaskListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, StatusExtraInfoSerializerMixin, ProjectExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin, TaggedInProjectResourceSerializer, - TotalCommentsSerializerMixin, serializers.LightSerializer): + TotalCommentsSerializerMixin, DueDateSerializerMixin, + serializers.LightSerializer): id = Field() user_story = Field(attr="user_story_id") diff --git a/taiga/projects/tasks/services.py b/taiga/projects/tasks/services.py index c0a6272e..0866ddd7 100644 --- a/taiga/projects/tasks/services.py +++ b/taiga/projects/tasks/services.py @@ -122,7 +122,7 @@ def tasks_to_csv(project, queryset): "sprint_estimated_finish", "owner", "owner_full_name", "assigned_to", "assigned_to_full_name", "status", "is_iocaine", "is_closed", "us_order", "taskboard_order", "attachments", "external_reference", "tags", "watchers", "voters", - "created_date", "modified_date", "finished_date"] + "created_date", "modified_date", "finished_date", "due_date"] custom_attrs = project.taskcustomattributes.all() for custom_attr in custom_attrs: @@ -167,6 +167,7 @@ def tasks_to_csv(project, queryset): "created_date": task.created_date, "modified_date": task.modified_date, "finished_date": task.finished_date, + "due_date": task.due_date, } for custom_attr in custom_attrs: value = task.custom_attributes_values.attributes_values.get(str(custom_attr.id), None) diff --git a/taiga/projects/userstories/migrations/0015_add_due_date.py b/taiga/projects/userstories/migrations/0015_add_due_date.py new file mode 100644 index 00000000..c4025ff4 --- /dev/null +++ b/taiga/projects/userstories/migrations/0015_add_due_date.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.2 on 2018-04-09 09:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0014_auto_20160928_0540'), + ] + + operations = [ + migrations.AddField( + model_name='userstory', + name='due_date', + field=models.DateField(blank=True, default=None, null=True, verbose_name='due date'), + ), + migrations.AddField( + model_name='userstory', + name='due_date_reason', + field=models.TextField(blank=True, default='', verbose_name='reason for the due date'), + ), + ] diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py index a6f3a414..bb802bf0 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -26,6 +26,7 @@ from django.utils import timezone from picklefield.fields import PickledObjectField from taiga.base.utils.time import timestamp_ms +from taiga.projects.due_dates.models import DueDateMixin from taiga.projects.tagging.models import TaggedMixin from taiga.projects.occ import OCCModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin @@ -57,7 +58,7 @@ class RolePoints(models.Model): return self.user_story.project -class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.Model): +class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, DueDateMixin, models.Model): ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, verbose_name=_("ref")) milestone = models.ForeignKey("milestones.Milestone", null=True, blank=True, diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index 3dcd2196..02173c5a 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -22,6 +22,7 @@ from taiga.base.neighbors import NeighborsSerializerMixin from taiga.mdrender.service import render as mdrender from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin +from taiga.projects.due_dates.serializers import DueDateSerializerMixin from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin from taiga.projects.mixins.serializers import ProjectExtraInfoSerializerMixin @@ -49,7 +50,7 @@ class UserStoryListSerializer(ProjectExtraInfoSerializerMixin, OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, StatusExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin, TaggedInProjectResourceSerializer, TotalCommentsSerializerMixin, - serializers.LightSerializer): + DueDateSerializerMixin, serializers.LightSerializer): id = Field() ref = Field() diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py index 7f9dff20..83a0d0a8 100644 --- a/taiga/projects/userstories/services.py +++ b/taiga/projects/userstories/services.py @@ -197,7 +197,7 @@ def userstories_to_csv(project, queryset): "created_date", "modified_date", "finish_date", "client_requirement", "team_requirement", "attachments", "generated_from_issue", "external_reference", "tasks", - "tags", "watchers", "voters"] + "tags", "watchers", "voters", "due_date"] custom_attrs = project.userstorycustomattributes.all() for custom_attr in custom_attrs: @@ -249,7 +249,8 @@ def userstories_to_csv(project, queryset): "tasks": ",".join([str(task.ref) for task in us.tasks.all()]), "tags": ",".join(us.tags or []), "watchers": us.watchers, - "voters": us.total_voters + "voters": us.total_voters, + "due_date": us.due_date, } us_role_points_by_role_id = {us_rp.role.id: us_rp.points.value for us_rp in us.role_points.all()} diff --git a/tests/integration/test_issues.py b/tests/integration/test_issues.py index 8148729f..4096c907 100644 --- a/tests/integration/test_issues.py +++ b/tests/integration/test_issues.py @@ -591,9 +591,9 @@ def test_custom_fields_csv_generation(): data.seek(0) reader = csv.reader(data) row = next(reader) - assert row[23] == attr.name + assert row[24] == attr.name row = next(reader) - assert row[23] == "val1" + assert row[24] == "val1" def test_api_validator_assigned_to_when_update_issues(client): diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py index 12252bf7..10cab2ee 100644 --- a/tests/integration/test_tasks.py +++ b/tests/integration/test_tasks.py @@ -574,9 +574,9 @@ def test_custom_fields_csv_generation(): data.seek(0) reader = csv.reader(data) row = next(reader) - assert row[24] == attr.name + assert row[25] == attr.name row = next(reader) - assert row[24] == "val1" + assert row[25] == "val1" def test_get_tasks_including_attachments(client): diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index 35c7acdf..b186413d 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -899,9 +899,9 @@ def test_custom_fields_csv_generation(): data.seek(0) reader = csv.reader(data) row = next(reader) - assert row[28] == attr.name + assert row[29] == attr.name row = next(reader) - assert row[28] == "val1" + assert row[29] == "val1" def test_update_userstory_respecting_watchers(client): diff --git a/tests/unit/test_due_date_serializers.py b/tests/unit/test_due_date_serializers.py new file mode 100644 index 00000000..075d0b21 --- /dev/null +++ b/tests/unit/test_due_date_serializers.py @@ -0,0 +1,22 @@ +import datetime as dt +from unittest import mock + +import pytest + +from django.utils import timezone + +from taiga.projects.due_dates.serializers import DueDateSerializerMixin + +@pytest.mark.parametrize('due_date, is_closed, expected', [ + (None, False, 'not_set'), + (dt.date(2100, 1, 1), True, 'no_longer_applicable'), + (dt.date(2100, 12, 31), False, 'set'), + (dt.date(2000, 1, 1), False, 'past_due'), + (timezone.now().date(), False, 'due_soon'), +]) +def test_due_date_status(due_date, is_closed, expected): + serializer = DueDateSerializerMixin() + obj_status = mock.MagicMock(is_closed=is_closed) + obj = mock.MagicMock(due_date=due_date, status=obj_status) + status = serializer.get_due_date_status(obj) + assert status == expected