From 75a975113658e6f599055846e420d662637c6475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 24 Jun 2016 13:12:06 +0200 Subject: [PATCH 01/83] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50dae19b..af8bcd13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 3.0.0 ??? (unreleased) ### Features +- Add Epics. - Include created, modified and finished dates for tasks in CSV reports. - Add gravatar url to Users API endpoint. - ProjectTemplates now are sorted by the attribute 'order'. From f70923c064d301118a79b9aa3d3091df6d0ac9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 29 Jun 2016 17:25:01 +0200 Subject: [PATCH 02/83] Initial epic model + epic status + epcic attachments + project template + project defaults --- settings/common.py | 1 + taiga/base/filters.py | 4 + taiga/permissions/choices.py | 8 + taiga/projects/api.py | 56 ++-- taiga/projects/attachments/api.py | 6 + taiga/projects/attachments/permissions.py | 13 +- taiga/projects/epics/__init__.py | 20 ++ taiga/projects/epics/apps.py | 51 ++++ .../projects/epics/migrations/0001_initial.py | 73 ++++++ taiga/projects/epics/migrations/__init__.py | 0 taiga/projects/epics/models.py | 91 +++++++ .../fixtures/initial_project_templates.json | 32 ++- .../management/commands/sample_data.py | 61 +++++ .../migrations/0030_auto_20151128_0757.py | 1 + .../0046_triggers_to_update_tags_colors.py | 3 + .../migrations/0049_auto_20160629_1443.py | 105 ++++++++ taiga/projects/models.py | 105 +++++--- taiga/projects/permissions.py | 12 + taiga/projects/serializers.py | 14 +- taiga/projects/services/__init__.py | 5 +- taiga/projects/services/bulk_update_order.py | 17 ++ taiga/projects/utils.py | 21 ++ taiga/projects/validators.py | 9 +- .../migrations/0022_auto_20160629_1443.py | 21 ++ tests/factories.py | 46 +++- .../test_projects_choices_resources.py | 240 +++++++++++++++++- 26 files changed, 931 insertions(+), 84 deletions(-) create mode 100644 taiga/projects/epics/__init__.py create mode 100644 taiga/projects/epics/apps.py create mode 100644 taiga/projects/epics/migrations/0001_initial.py create mode 100644 taiga/projects/epics/migrations/__init__.py create mode 100644 taiga/projects/epics/models.py create mode 100644 taiga/projects/migrations/0049_auto_20160629_1443.py create mode 100644 taiga/users/migrations/0022_auto_20160629_1443.py diff --git a/settings/common.py b/settings/common.py index 7f3f8cbd..7af70452 100644 --- a/settings/common.py +++ b/settings/common.py @@ -300,6 +300,7 @@ INSTALLED_APPS = [ "taiga.projects.likes", "taiga.projects.votes", "taiga.projects.milestones", + "taiga.projects.epics", "taiga.projects.userstories", "taiga.projects.tasks", "taiga.projects.issues", diff --git a/taiga/base/filters.py b/taiga/base/filters.py index d8010e31..bddec10e 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -198,6 +198,10 @@ class PermissionBasedAttachmentFilterBackend(PermissionBasedFilterBackend): return qs.filter(content_type=ct) +class CanViewEpicAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend): + permission = "view_epic" + + class CanViewUserStoryAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend): permission = "view_us" diff --git a/taiga/permissions/choices.py b/taiga/permissions/choices.py index 48b92ace..5cda50a5 100644 --- a/taiga/permissions/choices.py +++ b/taiga/permissions/choices.py @@ -22,12 +22,14 @@ from django.utils.translation import ugettext_lazy as _ ANON_PERMISSIONS = [ ('view_project', _('View project')), ('view_milestones', _('View milestones')), + ('view_epic', _('View epic')), ('view_us', _('View user stories')), ('view_tasks', _('View tasks')), ('view_issues', _('View issues')), ('view_wiki_pages', _('View wiki pages')), ('view_wiki_links', _('View wiki links')), ] + MEMBERS_PERMISSIONS = [ ('view_project', _('View project')), # Milestone permissions @@ -36,6 +38,12 @@ MEMBERS_PERMISSIONS = [ ('modify_milestone', _('Modify milestone')), ('delete_milestone', _('Delete milestone')), # US permissions + ('view_epic', _('View epic')), + ('add_epic', _('Add epic')), + ('modify_epic', _('Modify epic')), + ('comment_epic', _('Comment epic')), + ('delete_epic', _('Delete epic')), + # US permissions ('view_us', _('View user story')), ('add_us', _('Add user story')), ('modify_us', _('Modify user story')), diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 89fdffe2..34920df4 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -40,6 +40,8 @@ from taiga.base.decorators import detail_route from taiga.base.utils.slug import slugify_uniquely from taiga.permissions import services as permissions_services + +from taiga.projects.epics.models import Epic from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.issues.models import Issue from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin @@ -49,7 +51,6 @@ from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin from taiga.projects.mixins.ordering import BulkUpdateOrderMixin from taiga.projects.tasks.models import Task from taiga.projects.tagging.api import TagsColorsResourceMixin - from taiga.projects.userstories.models import UserStory, RolePoints from . import filters as project_filters @@ -110,17 +111,17 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, now = timezone.now() order_by_field_name = self._get_order_by_field_name() if order_by_field_name == "total_fans_last_week": - qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(weeks=1)) + qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(weeks=1)) elif order_by_field_name == "total_fans_last_month": - qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(months=1)) + qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(months=1)) elif order_by_field_name == "total_fans_last_year": - qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(years=1)) + qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(years=1)) elif order_by_field_name == "total_activity_last_week": - qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(weeks=1)) + qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(weeks=1)) elif order_by_field_name == "total_activity_last_month": - qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(months=1)) + qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(months=1)) elif order_by_field_name == "total_activity_last_year": - qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(years=1)) + qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(years=1)) return qs @@ -449,21 +450,21 @@ class ProjectWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): ## Custom values for selectors ###################################################### -class PointsViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, - ModelCrudViewSet, BulkUpdateOrderMixin): +class EpicStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, + ModelCrudViewSet, BulkUpdateOrderMixin): - model = models.Points - serializer_class = serializers.PointsSerializer - validator_class = validators.PointsValidator - permission_classes = (permissions.PointsPermission,) + model = models.EpicStatus + serializer_class = serializers.EpicStatusSerializer + validator_class = validators.EpicStatusValidator + permission_classes = (permissions.EpicStatusPermission,) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ('project',) - bulk_update_param = "bulk_points" - bulk_update_perm = "change_points" - bulk_update_order_action = services.bulk_update_points_order - move_on_destroy_related_class = RolePoints - move_on_destroy_related_field = "points" - move_on_destroy_project_default_field = "default_points" + bulk_update_param = "bulk_epic_statuses" + bulk_update_perm = "change_epicstatus" + bulk_update_order_action = services.bulk_update_epic_status_order + move_on_destroy_related_class = Epic + move_on_destroy_related_field = "status" + move_on_destroy_project_default_field = "default_epic_status" class UserStoryStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, @@ -483,6 +484,23 @@ class UserStoryStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, move_on_destroy_project_default_field = "default_us_status" +class PointsViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, + ModelCrudViewSet, BulkUpdateOrderMixin): + + model = models.Points + serializer_class = serializers.PointsSerializer + validator_class = validators.PointsValidator + permission_classes = (permissions.PointsPermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ('project',) + bulk_update_param = "bulk_points" + bulk_update_perm = "change_points" + bulk_update_order_action = services.bulk_update_points_order + move_on_destroy_related_class = RolePoints + move_on_destroy_related_field = "points" + move_on_destroy_project_default_field = "default_points" + + class TaskStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, ModelCrudViewSet, BulkUpdateOrderMixin): diff --git a/taiga/projects/attachments/api.py b/taiga/projects/attachments/api.py index f2a023c5..3bcbf6cf 100644 --- a/taiga/projects/attachments/api.py +++ b/taiga/projects/attachments/api.py @@ -83,6 +83,12 @@ class BaseAttachmentViewSet(HistoryResourceMixin, WatchedResourceMixin, return obj.content_object +class EpicAttachmentViewSet(BaseAttachmentViewSet): + permission_classes = (permissions.EpicAttachmentPermission,) + filter_backends = (filters.CanViewEpicAttachmentFilterBackend,) + content_type = "epics.epic" + + class UserStoryAttachmentViewSet(BaseAttachmentViewSet): permission_classes = (permissions.UserStoryAttachmentPermission,) filter_backends = (filters.CanViewUserStoryAttachmentFilterBackend,) diff --git a/taiga/projects/attachments/permissions.py b/taiga/projects/attachments/permissions.py index 4e0f5d3e..ee768014 100644 --- a/taiga/projects/attachments/permissions.py +++ b/taiga/projects/attachments/permissions.py @@ -28,6 +28,15 @@ class IsAttachmentOwnerPerm(PermissionComponent): return False +class EpicAttachmentPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_epic') | IsAttachmentOwnerPerm() + create_perms = HasProjectPerm('modify_epic') + update_perms = HasProjectPerm('modify_epic') | IsAttachmentOwnerPerm() + partial_update_perms = HasProjectPerm('modify_epic') | IsAttachmentOwnerPerm() + destroy_perms = HasProjectPerm('modify_epic') | IsAttachmentOwnerPerm() + list_perms = AllowAny() + + class UserStoryAttachmentPermission(TaigaResourcePermission): retrieve_perms = HasProjectPerm('view_us') | IsAttachmentOwnerPerm() create_perms = HasProjectPerm('modify_us') @@ -67,7 +76,9 @@ class WikiAttachmentPermission(TaigaResourcePermission): class RawAttachmentPerm(PermissionComponent): def check_permissions(self, request, view, obj=None): is_owner = IsAttachmentOwnerPerm().check_permissions(request, view, obj) - if obj.content_type.app_label == "userstories" and obj.content_type.model == "userstory": + if obj.content_type.app_label == "epics" and obj.content_type.model == "epic": + return EpicAttachmentPermission(request, view).check_permissions('retrieve', obj) or is_owner + elif obj.content_type.app_label == "userstories" and obj.content_type.model == "userstory": return UserStoryAttachmentPermission(request, view).check_permissions('retrieve', obj) or is_owner elif obj.content_type.app_label == "tasks" and obj.content_type.model == "task": return TaskAttachmentPermission(request, view).check_permissions('retrieve', obj) or is_owner diff --git a/taiga/projects/epics/__init__.py b/taiga/projects/epics/__init__.py new file mode 100644 index 00000000..cc0dd3b9 --- /dev/null +++ b/taiga/projects/epics/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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 . + +default_app_config = "taiga.projects.epics.apps.EpicsAppConfig" + diff --git a/taiga/projects/epics/apps.py b/taiga/projects/epics/apps.py new file mode 100644 index 00000000..cff4d699 --- /dev/null +++ b/taiga/projects/epics/apps.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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.apps import AppConfig +from django.apps import apps +from django.db.models import signals + + +def connect_epics_signals(): + from taiga.projects.tagging import signals as tagging_handlers + + # Tags + signals.pre_save.connect(tagging_handlers.tags_normalization, + sender=apps.get_model("epics", "Epic"), + dispatch_uid="tags_normalization_epic") + + +def connect_all_epics_signals(): + connect_epics_signals() + + +def disconnect_epics_signals(): + signals.pre_save.disconnect(sender=apps.get_model("epics", "Task"), + dispatch_uid="tags_normalization") + + +def disconnect_all_epics_signals(): + disconnect_epics_signals() + + +class EpicsAppConfig(AppConfig): + name = "taiga.projects.epics" + verbose_name = "Epics" + + def ready(self): + connect_all_epics_signals() diff --git a/taiga/projects/epics/migrations/0001_initial.py b/taiga/projects/epics/migrations/0001_initial.py new file mode 100644 index 00000000..dc670d7e --- /dev/null +++ b/taiga/projects/epics/migrations/0001_initial.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-06-29 14:43 +from __future__ import unicode_literals + +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import taiga.projects.notifications.mixins + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('projects', '0049_auto_20160629_1443'), + ('userstories', '0012_auto_20160614_1201'), + ] + + operations = [ + migrations.CreateModel( + name='Epic', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=[], null=True, size=None, verbose_name='tags')), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('is_blocked', models.BooleanField(default=False, verbose_name='is blocked')), + ('blocked_note', models.TextField(blank=True, default='', verbose_name='blocked note')), + ('ref', models.BigIntegerField(blank=True, db_index=True, default=None, null=True, verbose_name='ref')), + ('is_closed', models.BooleanField(default=False)), + ('epics_order', models.IntegerField(default=10000, verbose_name='epics order')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created date')), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('finish_date', models.DateTimeField(blank=True, null=True, verbose_name='finish date')), + ('subject', models.TextField(verbose_name='subject')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('client_requirement', models.BooleanField(default=False, verbose_name='is client requirement')), + ('team_requirement', models.BooleanField(default=False, verbose_name='is team requirement')), + ('assigned_to', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='epics_assigned_to_me', to=settings.AUTH_USER_MODEL, verbose_name='assigned to')), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_epics', to=settings.AUTH_USER_MODEL, verbose_name='owner')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='epics', to='projects.Project', verbose_name='project')), + ('status', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='epics', to='projects.EpicStatus', verbose_name='status')), + ('user_stories', models.ManyToManyField(related_name='epics', to='userstories.UserStory', verbose_name='user stories')), + ], + options={ + 'verbose_name_plural': 'epics', + 'ordering': ['project', 'ref'], + 'verbose_name': 'epic', + }, + bases=(taiga.projects.notifications.mixins.WatchedModelMixin, models.Model), + ), + # Execute trigger after epic update + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_epic_update ON epics_epic; + CREATE TRIGGER update_project_tags_colors_on_epic_update + AFTER UPDATE ON epics_epic + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Execute trigger after epic insert + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_epic_insert ON epics_epic; + CREATE TRIGGER update_project_tags_colors_on_epic_insert + AFTER INSERT ON epics_epic + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + ] diff --git a/taiga/projects/epics/migrations/__init__.py b/taiga/projects/epics/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/projects/epics/models.py b/taiga/projects/epics/models.py new file mode 100644 index 00000000..b4a9118a --- /dev/null +++ b/taiga/projects/epics/models.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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.contrib.contenttypes.fields import GenericRelation +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone + +from taiga.projects.tagging.models import TaggedMixin +from taiga.projects.occ import OCCModelMixin +from taiga.projects.notifications.mixins import WatchedModelMixin +from taiga.projects.mixins.blocked import BlockedMixin + + +class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.Model): + ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, + verbose_name=_("ref")) + project = models.ForeignKey("projects.Project", null=False, blank=False, + related_name="epics", verbose_name=_("project")) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, + related_name="owned_epics", verbose_name=_("owner"), + on_delete=models.SET_NULL) + status = models.ForeignKey("projects.EpicStatus", null=True, blank=True, + related_name="epics", verbose_name=_("status"), + on_delete=models.SET_NULL) + is_closed = models.BooleanField(default=False) + + epics_order = models.IntegerField(null=False, blank=False, default=10000, + verbose_name=_("epics order")) + + created_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("created date"), + default=timezone.now) + modified_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("modified date")) + finish_date = models.DateTimeField(null=True, blank=True, + verbose_name=_("finish date")) + + subject = models.TextField(null=False, blank=False, + verbose_name=_("subject")) + description = models.TextField(null=False, blank=True, verbose_name=_("description")) + assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, + default=None, related_name="epics_assigned_to_me", + verbose_name=_("assigned to")) + client_requirement = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is client requirement")) + team_requirement = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is team requirement")) + + user_stories = models.ManyToManyField("userstories.UserStory", related_name="epics", + verbose_name=_("user stories")) + + attachments = GenericRelation("attachments.Attachment") + + _importing = None + + class Meta: + verbose_name = "epic" + verbose_name_plural = "epics" + ordering = ["project", "ref"] + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + + if not self.status: + self.status = self.project.default_epic_status + + super().save(*args, **kwargs) + + def __str__(self): + return "({1}) {0}".format(self.ref, self.subject) + + def __repr__(self): + return "" % (self.id) diff --git a/taiga/projects/fixtures/initial_project_templates.json b/taiga/projects/fixtures/initial_project_templates.json index ea638da4..83d64b80 100644 --- a/taiga/projects/fixtures/initial_project_templates.json +++ b/taiga/projects/fixtures/initial_project_templates.json @@ -5,26 +5,28 @@ "fields": { "name": "Scrum", "slug": "scrum", - "order": 1, "description": "The agile product backlog in Scrum is a prioritized features list, containing short descriptions of all functionality desired in the product. When applying Scrum, it's not necessary to start a project with a lengthy, upfront effort to document all requirements. The Scrum product backlog is then allowed to grow and change as more is learned about the product and its customers", + "order": 1, "created_date": "2014-04-22T14:48:43.596Z", - "modified_date": "2014-07-25T10:02:46.479Z", + "modified_date": "2016-06-29T14:52:11.273Z", "default_owner_role": "product-owner", + "is_epics_activated": true, "is_backlog_activated": true, "is_kanban_activated": false, "is_wiki_activated": true, "is_issues_activated": true, "videoconferences": null, "videoconferences_extra_data": "", - "default_options": "{\"severity\": \"Normal\", \"priority\": \"Normal\", \"task_status\": \"New\", \"points\": \"?\", \"us_status\": \"New\", \"issue_type\": \"Bug\", \"issue_status\": \"New\"}", - "us_statuses": "[{\"is_archived\": false, \"slug\": \"new\", \"is_closed\": false, \"wip_limit\": null, \"order\": 1, \"name\": \"New\", \"color\": \"#999999\"}, {\"is_archived\": false, \"slug\": \"ready\", \"is_closed\": false, \"wip_limit\": null, \"order\": 2, \"name\": \"Ready\", \"color\": \"#ff8a84\"}, {\"is_archived\": false, \"slug\": \"in-progress\", \"is_closed\": false, \"wip_limit\": null, \"order\": 3, \"name\": \"In progress\", \"color\": \"#ff9900\"}, {\"is_archived\": false, \"slug\": \"ready-for-test\", \"is_closed\": false, \"wip_limit\": null, \"order\": 4, \"name\": \"Ready for test\", \"color\": \"#fcc000\"}, {\"is_archived\": false, \"slug\": \"done\", \"is_closed\": true, \"wip_limit\": null, \"order\": 5, \"name\": \"Done\", \"color\": \"#669900\"}, {\"is_archived\": true, \"slug\": \"archived\", \"is_closed\": true, \"wip_limit\": null, \"order\": 6, \"name\": \"Archived\", \"color\": \"#5c3566\"}]", + "default_options": "{\"epic_status\": \"New\", \"severity\": \"Normal\", \"issue_type\": \"Bug\", \"us_status\": \"New\", \"points\": \"?\", \"priority\": \"Normal\", \"task_status\": \"New\", \"issue_status\": \"New\"}", + "epic_statuses": "[{\"order\": 1, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"order\": 2, \"name\": \"Ready\", \"color\": \"#ff8a84\", \"slug\": \"ready\", \"is_closed\": false}, {\"order\": 3, \"name\": \"In progress\", \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"order\": 4, \"name\": \"Ready for test\", \"color\": \"#fcc000\", \"slug\": \"ready-for-test\", \"is_closed\": false}, {\"order\": 5, \"name\": \"Done\", \"color\": \"#669900\", \"slug\": \"done\", \"is_closed\": true}]", + "us_statuses": "[{\"name\": \"New\", \"is_archived\": false, \"wip_limit\": null, \"order\": 1, \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"name\": \"Ready\", \"is_archived\": false, \"wip_limit\": null, \"order\": 2, \"color\": \"#ff8a84\", \"slug\": \"ready\", \"is_closed\": false}, {\"name\": \"In progress\", \"is_archived\": false, \"wip_limit\": null, \"order\": 3, \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"name\": \"Ready for test\", \"is_archived\": false, \"wip_limit\": null, \"order\": 4, \"color\": \"#fcc000\", \"slug\": \"ready-for-test\", \"is_closed\": false}, {\"name\": \"Done\", \"is_archived\": false, \"wip_limit\": null, \"order\": 5, \"color\": \"#669900\", \"slug\": \"done\", \"is_closed\": true}, {\"name\": \"Archived\", \"is_archived\": true, \"wip_limit\": null, \"order\": 6, \"color\": \"#5c3566\", \"slug\": \"archived\", \"is_closed\": true}]", "points": "[{\"order\": 1, \"name\": \"?\", \"value\": null}, {\"order\": 2, \"name\": \"0\", \"value\": 0.0}, {\"order\": 3, \"name\": \"1/2\", \"value\": 0.5}, {\"order\": 4, \"name\": \"1\", \"value\": 1.0}, {\"order\": 5, \"name\": \"2\", \"value\": 2.0}, {\"order\": 6, \"name\": \"3\", \"value\": 3.0}, {\"order\": 7, \"name\": \"5\", \"value\": 5.0}, {\"order\": 8, \"name\": \"8\", \"value\": 8.0}, {\"order\": 9, \"name\": \"10\", \"value\": 10.0}, {\"order\": 10, \"name\": \"13\", \"value\": 13.0}, {\"order\": 11, \"name\": \"20\", \"value\": 20.0}, {\"order\": 12, \"name\": \"40\", \"value\": 40.0}]", - "task_statuses": "[{\"order\": 1, \"name\": \"New\", \"slug\": \"new\", \"color\": \"#999999\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"slug\": \"in-progress\", \"color\": \"#ff9900\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"color\": \"#ffcc00\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"slug\": \"closed\", \"color\": \"#669900\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"slug\": \"needs-info\", \"color\": \"#999999\", \"is_closed\": false}]", - "issue_statuses": "[{\"order\": 1, \"name\": \"New\", \"slug\": \"new\", \"color\": \"#8C2318\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"slug\": \"in-progress\", \"color\": \"#5E8C6A\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"color\": \"#88A65E\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"slug\": \"closed\", \"color\": \"#BFB35A\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"slug\": \"needs-info\", \"color\": \"#89BAB4\", \"is_closed\": false}, {\"order\": 6, \"name\": \"Rejected\", \"slug\": \"rejected\", \"color\": \"#CC0000\", \"is_closed\": true}, {\"order\": 7, \"name\": \"Postponed\", \"slug\": \"posponed\", \"color\": \"#666666\", \"is_closed\": false}]", + "task_statuses": "[{\"order\": 1, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"color\": \"#ffcc00\", \"slug\": \"ready-for-test\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"color\": \"#669900\", \"slug\": \"closed\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"color\": \"#999999\", \"slug\": \"needs-info\", \"is_closed\": false}]", + "issue_statuses": "[{\"order\": 1, \"name\": \"New\", \"color\": \"#8C2318\", \"slug\": \"new\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"color\": \"#5E8C6A\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"color\": \"#88A65E\", \"slug\": \"ready-for-test\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"color\": \"#BFB35A\", \"slug\": \"closed\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"color\": \"#89BAB4\", \"slug\": \"needs-info\", \"is_closed\": false}, {\"order\": 6, \"name\": \"Rejected\", \"color\": \"#CC0000\", \"slug\": \"rejected\", \"is_closed\": true}, {\"order\": 7, \"name\": \"Postponed\", \"color\": \"#666666\", \"slug\": \"posponed\", \"is_closed\": false}]", "issue_types": "[{\"order\": 1, \"name\": \"Bug\", \"color\": \"#89BAB4\"}, {\"order\": 2, \"name\": \"Question\", \"color\": \"#ba89a8\"}, {\"order\": 3, \"name\": \"Enhancement\", \"color\": \"#89a8ba\"}]", "priorities": "[{\"order\": 1, \"name\": \"Low\", \"color\": \"#666666\"}, {\"order\": 3, \"name\": \"Normal\", \"color\": \"#669933\"}, {\"order\": 5, \"name\": \"High\", \"color\": \"#CC0000\"}]", "severities": "[{\"order\": 1, \"name\": \"Wishlist\", \"color\": \"#666666\"}, {\"order\": 2, \"name\": \"Minor\", \"color\": \"#669933\"}, {\"order\": 3, \"name\": \"Normal\", \"color\": \"#0000FF\"}, {\"order\": 4, \"name\": \"Important\", \"color\": \"#FFA500\"}, {\"order\": 5, \"name\": \"Critical\", \"color\": \"#CC0000\"}]", - "roles": "[{\"order\": 10, \"name\": \"UX\", \"slug\": \"ux\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 20, \"name\": \"Design\", \"slug\": \"design\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 30, \"name\": \"Front\", \"slug\": \"front\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 40, \"name\": \"Back\", \"slug\": \"back\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 50, \"name\": \"Product Owner\", \"slug\": \"product-owner\", \"computable\": false, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 60, \"name\": \"Stakeholder\", \"slug\": \"stakeholder\", \"computable\": false, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}]" + "roles": "[{\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 10, \"name\": \"UX\", \"slug\": \"ux\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 20, \"name\": \"Design\", \"slug\": \"design\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 30, \"name\": \"Front\", \"slug\": \"front\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 40, \"name\": \"Back\", \"slug\": \"back\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 50, \"name\": \"Product Owner\", \"slug\": \"product-owner\", \"computable\": false}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 60, \"name\": \"Stakeholder\", \"slug\": \"stakeholder\", \"computable\": false}]" } }, { @@ -33,26 +35,28 @@ "fields": { "name": "Kanban", "slug": "kanban", - "order": 2, "description": "Kanban is a method for managing knowledge work with an emphasis on just-in-time delivery while not overloading the team members. In this approach, the process, from definition of a task to its delivery to the customer, is displayed for participants to see and team members pull work from a queue.", + "order": 2, "created_date": "2014-04-22T14:50:19.738Z", - "modified_date": "2014-07-25T13:11:42.754Z", + "modified_date": "2016-06-29T14:52:15.232Z", "default_owner_role": "product-owner", + "is_epics_activated": true, "is_backlog_activated": false, "is_kanban_activated": true, "is_wiki_activated": false, "is_issues_activated": false, "videoconferences": null, "videoconferences_extra_data": "", - "default_options": "{\"severity\": \"Normal\", \"priority\": \"Normal\", \"task_status\": \"New\", \"points\": \"?\", \"us_status\": \"New\", \"issue_type\": \"Bug\", \"issue_status\": \"New\"}", - "us_statuses": "[{\"is_archived\": false, \"slug\": \"new\", \"is_closed\": false, \"wip_limit\": null, \"order\": 1, \"name\": \"New\", \"color\": \"#999999\"}, {\"is_archived\": false, \"slug\": \"ready\", \"is_closed\": false, \"wip_limit\": null, \"order\": 2, \"name\": \"Ready\", \"color\": \"#f57900\"}, {\"is_archived\": false, \"slug\": \"in-progress\", \"is_closed\": false, \"wip_limit\": null, \"order\": 3, \"name\": \"In progress\", \"color\": \"#729fcf\"}, {\"is_archived\": false, \"slug\": \"ready-for-test\", \"is_closed\": false, \"wip_limit\": null, \"order\": 4, \"name\": \"Ready for test\", \"color\": \"#4e9a06\"}, {\"is_archived\": false, \"slug\": \"done\", \"is_closed\": true, \"wip_limit\": null, \"order\": 5, \"name\": \"Done\", \"color\": \"#cc0000\"}, {\"is_archived\": true, \"slug\": \"archived\", \"is_closed\": true, \"wip_limit\": null, \"order\": 6, \"name\": \"Archived\", \"color\": \"#5c3566\"}]", + "default_options": "{\"epic_status\": \"New\", \"severity\": \"Normal\", \"issue_type\": \"Bug\", \"us_status\": \"New\", \"points\": \"?\", \"priority\": \"Normal\", \"task_status\": \"New\", \"issue_status\": \"New\"}", + "epic_statuses": "[{\"order\": 1, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"order\": 2, \"name\": \"Ready\", \"color\": \"#ff8a84\", \"slug\": \"ready\", \"is_closed\": false}, {\"order\": 3, \"name\": \"In progress\", \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"order\": 4, \"name\": \"Ready for test\", \"color\": \"#fcc000\", \"slug\": \"ready-for-test\", \"is_closed\": false}, {\"order\": 5, \"name\": \"Done\", \"color\": \"#669900\", \"slug\": \"done\", \"is_closed\": true}]", + "us_statuses": "[{\"name\": \"New\", \"is_archived\": false, \"wip_limit\": null, \"order\": 1, \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"name\": \"Ready\", \"is_archived\": false, \"wip_limit\": null, \"order\": 2, \"color\": \"#f57900\", \"slug\": \"ready\", \"is_closed\": false}, {\"name\": \"In progress\", \"is_archived\": false, \"wip_limit\": null, \"order\": 3, \"color\": \"#729fcf\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"name\": \"Ready for test\", \"is_archived\": false, \"wip_limit\": null, \"order\": 4, \"color\": \"#4e9a06\", \"slug\": \"ready-for-test\", \"is_closed\": false}, {\"name\": \"Done\", \"is_archived\": false, \"wip_limit\": null, \"order\": 5, \"color\": \"#cc0000\", \"slug\": \"done\", \"is_closed\": true}, {\"name\": \"Archived\", \"is_archived\": true, \"wip_limit\": null, \"order\": 6, \"color\": \"#5c3566\", \"slug\": \"archived\", \"is_closed\": true}]", "points": "[{\"order\": 1, \"name\": \"?\", \"value\": null}, {\"order\": 2, \"name\": \"0\", \"value\": 0.0}, {\"order\": 3, \"name\": \"1/2\", \"value\": 0.5}, {\"order\": 4, \"name\": \"1\", \"value\": 1.0}, {\"order\": 5, \"name\": \"2\", \"value\": 2.0}, {\"order\": 6, \"name\": \"3\", \"value\": 3.0}, {\"order\": 7, \"name\": \"5\", \"value\": 5.0}, {\"order\": 8, \"name\": \"8\", \"value\": 8.0}, {\"order\": 9, \"name\": \"10\", \"value\": 10.0}, {\"order\": 10, \"name\": \"13\", \"value\": 13.0}, {\"order\": 11, \"name\": \"20\", \"value\": 20.0}, {\"order\": 12, \"name\": \"40\", \"value\": 40.0}]", - "task_statuses": "[{\"order\": 1, \"name\": \"New\", \"slug\": \"new\", \"color\": \"#999999\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"slug\": \"in-progress\", \"color\": \"#729fcf\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"color\": \"#f57900\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"slug\": \"closed\", \"color\": \"#4e9a06\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"slug\": \"needs-info\", \"color\": \"#cc0000\", \"is_closed\": false}]", - "issue_statuses": "[{\"order\": 1, \"name\": \"New\", \"slug\": \"new\", \"color\": \"#999999\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"slug\": \"in-progress\", \"color\": \"#729fcf\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"color\": \"#f57900\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"slug\": \"closed\", \"color\": \"#4e9a06\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"slug\": \"needs-info\", \"color\": \"#cc0000\", \"is_closed\": false}, {\"order\": 6, \"name\": \"Rejected\", \"slug\": \"rejected\", \"color\": \"#d3d7cf\", \"is_closed\": true}, {\"order\": 7, \"name\": \"Postponed\", \"slug\": \"posponed\", \"color\": \"#75507b\", \"is_closed\": false}]", + "task_statuses": "[{\"order\": 1, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"color\": \"#729fcf\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"color\": \"#f57900\", \"slug\": \"ready-for-test\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"color\": \"#4e9a06\", \"slug\": \"closed\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"color\": \"#cc0000\", \"slug\": \"needs-info\", \"is_closed\": false}]", + "issue_statuses": "[{\"order\": 1, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"color\": \"#729fcf\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"color\": \"#f57900\", \"slug\": \"ready-for-test\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"color\": \"#4e9a06\", \"slug\": \"closed\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"color\": \"#cc0000\", \"slug\": \"needs-info\", \"is_closed\": false}, {\"order\": 6, \"name\": \"Rejected\", \"color\": \"#d3d7cf\", \"slug\": \"rejected\", \"is_closed\": true}, {\"order\": 7, \"name\": \"Postponed\", \"color\": \"#75507b\", \"slug\": \"posponed\", \"is_closed\": false}]", "issue_types": "[{\"order\": 1, \"name\": \"Bug\", \"color\": \"#cc0000\"}, {\"order\": 2, \"name\": \"Question\", \"color\": \"#729fcf\"}, {\"order\": 3, \"name\": \"Enhancement\", \"color\": \"#4e9a06\"}]", "priorities": "[{\"order\": 1, \"name\": \"Low\", \"color\": \"#999999\"}, {\"order\": 3, \"name\": \"Normal\", \"color\": \"#4e9a06\"}, {\"order\": 5, \"name\": \"High\", \"color\": \"#CC0000\"}]", "severities": "[{\"order\": 1, \"name\": \"Wishlist\", \"color\": \"#999999\"}, {\"order\": 2, \"name\": \"Minor\", \"color\": \"#729fcf\"}, {\"order\": 3, \"name\": \"Normal\", \"color\": \"#4e9a06\"}, {\"order\": 4, \"name\": \"Important\", \"color\": \"#f57900\"}, {\"order\": 5, \"name\": \"Critical\", \"color\": \"#CC0000\"}]", - "roles": "[{\"order\": 10, \"name\": \"UX\", \"slug\": \"ux\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 20, \"name\": \"Design\", \"slug\": \"design\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 30, \"name\": \"Front\", \"slug\": \"front\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 40, \"name\": \"Back\", \"slug\": \"back\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 50, \"name\": \"Product Owner\", \"slug\": \"product-owner\", \"computable\": false, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 60, \"name\": \"Stakeholder\", \"slug\": \"stakeholder\", \"computable\": false, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}]" + "roles": "[{\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 10, \"name\": \"UX\", \"slug\": \"ux\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 20, \"name\": \"Design\", \"slug\": \"design\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 30, \"name\": \"Front\", \"slug\": \"front\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 40, \"name\": \"Back\", \"slug\": \"back\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 50, \"name\": \"Product Owner\", \"slug\": \"product-owner\", \"computable\": false}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 60, \"name\": \"Stakeholder\", \"slug\": \"stakeholder\", \"computable\": false}]" } } ] diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index 7ced0599..b1726496 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -34,6 +34,7 @@ from taiga.permissions.choices import ANON_PERMISSIONS from taiga.projects.choices import BLOCKED_BY_STAFF from taiga.external_apps.models import Application, ApplicationToken from taiga.projects.models import * +from taiga.projects.epics.models import * from taiga.projects.milestones.models import * from taiga.projects.notifications.choices import NotifyLevel from taiga.projects.services.stats import get_stats_for_project @@ -109,6 +110,8 @@ NUM_PROJECTS =getattr(settings, "SAMPLE_DATA_NUM_PROJECTS", 4) NUM_EMPTY_PROJECTS = getattr(settings, "SAMPLE_DATA_NUM_EMPTY_PROJECTS", 2) NUM_BLOCKED_PROJECTS = getattr(settings, "SAMPLE_DATA_NUM_BLOCKED_PROJECTS", 1) NUM_MILESTONES = getattr(settings, "SAMPLE_DATA_NUM_MILESTONES", (1, 5)) +NUM_EPICS = getattr(settings, "SAMPLE_DATA_NUM_EPICS", (4, 8)) +NUM_USS_EPICS = getattr(settings, "SAMPLE_DATA_NUM_USS_EPICS", (2, 6)) NUM_USS = getattr(settings, "SAMPLE_DATA_NUM_USS", (3, 7)) NUM_TASKS_FINISHED = getattr(settings, "SAMPLE_DATA_NUM_TASKS_FINISHED", (1, 5)) NUM_TASKS = getattr(settings, "SAMPLE_DATA_NUM_TASKS", (0, 4)) @@ -255,6 +258,11 @@ class Command(BaseCommand): if self.sd.boolean(): self.create_wiki_page(project, wiki_link.href) + # create epics + for y in range(self.sd.int(*NUM_EPICS)): + epic = self.create_epic(project) + + project.refresh_from_db() @@ -494,6 +502,59 @@ class Command(BaseCommand): return milestone + def create_epic(self, project): + epic = Epic.objects.create(subject=self.sd.choice(SUBJECT_CHOICES), + project=project, + owner=self.sd.db_object_from_queryset( + project.memberships.filter(user__isnull=False)).user, + description=self.sd.paragraph(), + status=self.sd.db_object_from_queryset(project.epic_statuses.filter( + is_closed=False)), + tags=self.sd.words(1, 3).split(" ")) + + # TODO: Epic custom attributes + #custom_attributes_values = {str(ca.id): self.get_custom_attributes_value(ca.type) for ca + # in project.epiccustomattributes.all() if self.sd.boolean()} + #if custom_attributes_values: + # epic.custom_attributes_values.attributes_values = custom_attributes_values + # epic.custom_attributes_values.save() + + + for i in range(self.sd.int(*NUM_ATTACHMENTS)): + attachment = self.create_attachment(epic, i+1) + + if self.sd.choice([True, True, False, True, True]): + epic.assigned_to = self.sd.db_object_from_queryset(project.memberships.filter( + user__isnull=False)).user + epic.save() + + # TODO: Epic history + #take_snapshot(epic, + # comment=self.sd.paragraph(), + # user=epic.owner) + # + # Add history entry + #epic.status=self.sd.db_object_from_queryset(project.epic_statuses.filter(is_closed=False)) + #epic.save() + #take_snapshot(epic, + # comment=self.sd.paragraph(), + # user=epic.owner) + + # TODO: Epic voters + #self.create_votes(epic) + # TODO: Epic watchers + #self.create_watchers(epic) + + if self.sd.choice([True, True, False, True, True]): + filters = {} + if self.sd.choice([True, True, False, True, True]): + filters = {"project": epic.project} + n = self.sd.choice(list(range(self.sd.int(*NUM_USS_EPICS)))) + + epic.user_stories.add(*UserStory.objects.filter(**filters).order_by("?")[:n]) + + return epic + def create_project(self, counter, is_private=None, blocked_code=None): if is_private is None: is_private=self.sd.boolean() diff --git a/taiga/projects/migrations/0030_auto_20151128_0757.py b/taiga/projects/migrations/0030_auto_20151128_0757.py index 2a8631df..425598e7 100644 --- a/taiga/projects/migrations/0030_auto_20151128_0757.py +++ b/taiga/projects/migrations/0030_auto_20151128_0757.py @@ -110,6 +110,7 @@ class Migration(migrations.Migration): dependencies = [ ('projects', '0029_project_is_looking_for_people'), + ('likes', '0001_initial'), ('timeline', '0004_auto_20150603_1312'), ('likes', '0001_initial'), ] diff --git a/taiga/projects/migrations/0046_triggers_to_update_tags_colors.py b/taiga/projects/migrations/0046_triggers_to_update_tags_colors.py index abb4806d..af7fbf8d 100644 --- a/taiga/projects/migrations/0046_triggers_to_update_tags_colors.py +++ b/taiga/projects/migrations/0046_triggers_to_update_tags_colors.py @@ -9,6 +9,9 @@ class Migration(migrations.Migration): dependencies = [ ('projects', '0045_merge'), + ('userstories', '0011_userstory_tribe_gig'), + ('tasks', '0009_auto_20151104_1131'), + ('issues', '0006_remove_issue_watchers'), ] operations = [ diff --git a/taiga/projects/migrations/0049_auto_20160629_1443.py b/taiga/projects/migrations/0049_auto_20160629_1443.py new file mode 100644 index 00000000..cdfd420b --- /dev/null +++ b/taiga/projects/migrations/0049_auto_20160629_1443.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-06-29 14:43 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django_pgjson.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0048_auto_20160615_1508'), + ] + + operations = [ + migrations.CreateModel( + name='EpicStatus', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('slug', models.SlugField(blank=True, max_length=255, verbose_name='slug')), + ('order', models.IntegerField(default=10, verbose_name='order')), + ('is_closed', models.BooleanField(default=False, verbose_name='is closed')), + ('color', models.CharField(default='#999999', max_length=20, verbose_name='color')), + ], + options={ + 'verbose_name_plural': 'epic statuses', + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'epic status', + }, + ), + migrations.AlterModelOptions( + name='issuestatus', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'issue status', 'verbose_name_plural': 'issue statuses'}, + ), + migrations.AlterModelOptions( + name='issuetype', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'issue type', 'verbose_name_plural': 'issue types'}, + ), + migrations.AlterModelOptions( + name='membership', + options={'ordering': ['project', 'user__full_name', 'user__username', 'user__email', 'email'], 'verbose_name': 'membership', 'verbose_name_plural': 'memberships'}, + ), + migrations.AlterModelOptions( + name='points', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'points', 'verbose_name_plural': 'points'}, + ), + migrations.AlterModelOptions( + name='priority', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'priority', 'verbose_name_plural': 'priorities'}, + ), + migrations.AlterModelOptions( + name='severity', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'severity', 'verbose_name_plural': 'severities'}, + ), + migrations.AlterModelOptions( + name='taskstatus', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'task status', 'verbose_name_plural': 'task statuses'}, + ), + migrations.AlterModelOptions( + name='userstorystatus', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'user story status', 'verbose_name_plural': 'user story statuses'}, + ), + migrations.AddField( + model_name='project', + name='is_epics_activated', + field=models.BooleanField(default=True, verbose_name='active epics panel'), + ), + migrations.AddField( + model_name='projecttemplate', + name='epic_statuses', + field=django_pgjson.fields.JsonField(blank=True, null=True, verbose_name='epic statuses'), + ), + migrations.AddField( + model_name='projecttemplate', + name='is_epics_activated', + field=models.BooleanField(default=True, verbose_name='active epics panel'), + ), + migrations.AlterField( + model_name='project', + name='anon_permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('view_epic', 'View epic'), ('view_us', 'View user stories'), ('view_tasks', 'View tasks'), ('view_issues', 'View issues'), ('view_wiki_pages', 'View wiki pages'), ('view_wiki_links', 'View wiki links')]), blank=True, default=[], null=True, size=None, verbose_name='anonymous permissions'), + ), + migrations.AlterField( + model_name='project', + name='public_permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_epic', 'View epic'), ('add_epic', 'Add epic'), ('modify_epic', 'Modify epic'), ('comment_epic', 'Comment epic'), ('delete_epic', 'Delete epic'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=[], null=True, size=None, verbose_name='user permissions'), + ), + migrations.AddField( + model_name='epicstatus', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='epic_statuses', to='projects.Project', verbose_name='project'), + ), + migrations.AddField( + model_name='project', + name='default_epic_status', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='projects.EpicStatus', verbose_name='default epic status'), + ), + migrations.AlterUniqueTogether( + name='epicstatus', + unique_together=set([('project', 'slug'), ('project', 'name')]), + ), + ] diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 6f9b386c..02d24519 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -102,19 +102,20 @@ class Membership(models.Model): verbose_name_plural = "memberships" unique_together = ("user", "project",) ordering = ["project", "user__full_name", "user__username", "user__email", "email"] - permissions = ( - ("view_membership", "Can view membership"), - ) class ProjectDefaults(models.Model): - default_points = models.OneToOneField("projects.Points", on_delete=models.SET_NULL, - related_name="+", null=True, blank=True, - verbose_name=_("default points")) + default_epic_status = models.OneToOneField("projects.EpicStatus", + on_delete=models.SET_NULL, related_name="+", + null=True, blank=True, + verbose_name=_("default epic status")) default_us_status = models.OneToOneField("projects.UserStoryStatus", on_delete=models.SET_NULL, related_name="+", null=True, blank=True, verbose_name=_("default US status")) + default_points = models.OneToOneField("projects.Points", on_delete=models.SET_NULL, + related_name="+", null=True, blank=True, + verbose_name=_("default points")) default_task_status = models.OneToOneField("projects.TaskStatus", on_delete=models.SET_NULL, related_name="+", null=True, blank=True, @@ -164,6 +165,8 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): verbose_name=_("total of milestones")) total_story_points = models.FloatField(null=True, blank=True, verbose_name=_("total story points")) + is_epics_activated = models.BooleanField(default=True, null=False, blank=True, + verbose_name=_("active epics panel")) is_backlog_activated = models.BooleanField(default=True, null=False, blank=True, verbose_name=_("active backlog panel")) is_kanban_activated = models.BooleanField(default=False, null=False, blank=True, @@ -504,6 +507,39 @@ class ProjectModulesConfig(models.Model): ordering = ["project"] +# Epic common Models +class EpicStatus(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + slug = models.SlugField(max_length=255, null=False, blank=True, + verbose_name=_("slug")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + is_closed = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is closed")) + color = models.CharField(max_length=20, null=False, blank=False, default="#999999", + verbose_name=_("color")) + project = models.ForeignKey("Project", null=False, blank=False, + related_name="epic_statuses", verbose_name=_("project")) + + class Meta: + verbose_name = "epic status" + verbose_name_plural = "epic statuses" + ordering = ["project", "order", "name"] + unique_together = (("project", "name"), ("project", "slug")) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + qs = self.project.epic_statuses + if self.id: + qs = qs.exclude(id=self.id) + + self.slug = slugify_uniquely_for_queryset(self.name, qs) + return super().save(*args, **kwargs) + + # User Stories common Models class UserStoryStatus(models.Model): name = models.CharField(max_length=255, null=False, blank=False, @@ -528,9 +564,6 @@ class UserStoryStatus(models.Model): verbose_name_plural = "user story statuses" ordering = ["project", "order", "name"] unique_together = (("project", "name"), ("project", "slug")) - permissions = ( - ("view_userstorystatus", "Can view user story status"), - ) def __str__(self): return self.name @@ -559,9 +592,6 @@ class Points(models.Model): verbose_name_plural = "points" ordering = ["project", "order", "name"] unique_together = ("project", "name") - permissions = ( - ("view_points", "Can view points"), - ) def __str__(self): return self.name @@ -588,9 +618,6 @@ class TaskStatus(models.Model): verbose_name_plural = "task statuses" ordering = ["project", "order", "name"] unique_together = (("project", "name"), ("project", "slug")) - permissions = ( - ("view_taskstatus", "Can view task status"), - ) def __str__(self): return self.name @@ -621,9 +648,6 @@ class Priority(models.Model): verbose_name_plural = "priorities" ordering = ["project", "order", "name"] unique_together = ("project", "name") - permissions = ( - ("view_priority", "Can view priority"), - ) def __str__(self): return self.name @@ -644,9 +668,6 @@ class Severity(models.Model): verbose_name_plural = "severities" ordering = ["project", "order", "name"] unique_together = ("project", "name") - permissions = ( - ("view_severity", "Can view severity"), - ) def __str__(self): return self.name @@ -671,9 +692,6 @@ class IssueStatus(models.Model): verbose_name_plural = "issue statuses" ordering = ["project", "order", "name"] unique_together = (("project", "name"), ("project", "slug")) - permissions = ( - ("view_issuestatus", "Can view issue status"), - ) def __str__(self): return self.name @@ -702,9 +720,6 @@ class IssueType(models.Model): verbose_name_plural = "issue types" ordering = ["project", "order", "name"] unique_together = ("project", "name") - permissions = ( - ("view_issuetype", "Can view issue type"), - ) def __str__(self): return self.name @@ -728,6 +743,8 @@ class ProjectTemplate(models.Model): blank=False, verbose_name=_("default owner's role")) + is_epics_activated = models.BooleanField(default=True, null=False, blank=True, + verbose_name=_("active epics panel")) is_backlog_activated = models.BooleanField(default=True, null=False, blank=True, verbose_name=_("active backlog panel")) is_kanban_activated = models.BooleanField(default=False, null=False, blank=True, @@ -743,6 +760,7 @@ class ProjectTemplate(models.Model): verbose_name=_("videoconference extra data")) default_options = JsonField(null=True, blank=True, verbose_name=_("default options")) + epic_statuses = JsonField(null=True, blank=True, verbose_name=_("epic statuses")) us_statuses = JsonField(null=True, blank=True, verbose_name=_("us statuses")) points = JsonField(null=True, blank=True, verbose_name=_("points")) task_statuses = JsonField(null=True, blank=True, verbose_name=_("task statuses")) @@ -770,6 +788,7 @@ class ProjectTemplate(models.Model): super().save(*args, **kwargs) def load_data_from_project(self, project): + self.is_epics_activated = project.is_epics_activated self.is_backlog_activated = project.is_backlog_activated self.is_kanban_activated = project.is_kanban_activated self.is_wiki_activated = project.is_wiki_activated @@ -779,6 +798,7 @@ class ProjectTemplate(models.Model): self.default_options = { "points": getattr(project.default_points, "name", None), + "epic_status": getattr(project.default_epic_status, "name", None), "us_status": getattr(project.default_us_status, "name", None), "task_status": getattr(project.default_task_status, "name", None), "issue_status": getattr(project.default_issue_status, "name", None), @@ -787,6 +807,16 @@ class ProjectTemplate(models.Model): "severity": getattr(project.default_severity, "name", None) } + self.epic_statuses = [] + for epic_status in project.epic_statuses.all(): + self.epic_statuses.append({ + "name": epic_status.name, + "slug": epic_status.slug, + "is_closed": epic_status.is_closed, + "color": epic_status.color, + "order": epic_status.order, + }) + self.us_statuses = [] for us_status in project.us_statuses.all(): self.us_statuses.append({ @@ -874,6 +904,7 @@ class ProjectTemplate(models.Model): raise Exception("Project need an id (must be a saved project)") project.creation_template = self + project.is_epics_activated = self.is_epics_activated project.is_backlog_activated = self.is_backlog_activated project.is_kanban_activated = self.is_kanban_activated project.is_wiki_activated = self.is_wiki_activated @@ -881,6 +912,16 @@ class ProjectTemplate(models.Model): project.videoconferences = self.videoconferences project.videoconferences_extra_data = self.videoconferences_extra_data + for epic_status in self.epic_statuses: + EpicStatus.objects.create( + name=epic_status["name"], + slug=epic_status["slug"], + is_closed=epic_status["is_closed"], + color=epic_status["color"], + order=epic_status["order"], + project=project + ) + for us_status in self.us_statuses: UserStoryStatus.objects.create( name=us_status["name"], @@ -955,12 +996,16 @@ class ProjectTemplate(models.Model): permissions=role['permissions'] ) - if self.points: - project.default_points = Points.objects.get(name=self.default_options["points"], - project=project) + if self.epic_statuses: + project.default_epic_status = EpicStatus.objects.get(name=self.default_options["epic_status"], + project=project) + if self.us_statuses: project.default_us_status = UserStoryStatus.objects.get(name=self.default_options["us_status"], project=project) + if self.points: + project.default_points = Points.objects.get(name=self.default_options["points"], + project=project) if self.task_statuses: project.default_task_status = TaskStatus.objects.get(name=self.default_options["task_status"], diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py index 0cc95427..5e3b44db 100644 --- a/taiga/projects/permissions.py +++ b/taiga/projects/permissions.py @@ -109,6 +109,18 @@ class MembershipPermission(TaigaResourcePermission): resend_invitation_perms = IsProjectAdmin() +# Epics + +class EpicStatusPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + # User Stories class PointsPermission(TaigaResourcePermission): diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index f50c0daf..43369621 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -37,11 +37,13 @@ from .notifications.choices import NotifyLevel # Custom values for selectors ###################################################### -class PointsSerializer(serializers.LightSerializer): +class EpicStatusSerializer(serializers.LightSerializer): id = Field() name = I18NField() + slug = Field() order = Field() - value = Field() + is_closed = Field() + color = Field() project = Field(attr="project_id") @@ -57,6 +59,14 @@ class UserStoryStatusSerializer(serializers.LightSerializer): project = Field(attr="project_id") +class PointsSerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + order = Field() + value = Field() + project = Field(attr="project_id") + + class TaskStatusSerializer(serializers.LightSerializer): id = Field() name = I18NField() diff --git a/taiga/projects/services/__init__.py b/taiga/projects/services/__init__.py index e8a93623..3be0a9d8 100644 --- a/taiga/projects/services/__init__.py +++ b/taiga/projects/services/__init__.py @@ -19,7 +19,7 @@ # This makes all code that import services works and # is not the baddest practice ;) -from .bulk_update_order import update_projects_order_in_bulk +from .bulk_update_order import apply_order_updates from .bulk_update_order import bulk_update_severity_order from .bulk_update_order import bulk_update_priority_order from .bulk_update_order import bulk_update_issue_type_order @@ -27,7 +27,8 @@ from .bulk_update_order import bulk_update_issue_status_order from .bulk_update_order import bulk_update_task_status_order from .bulk_update_order import bulk_update_points_order from .bulk_update_order import bulk_update_userstory_status_order -from .bulk_update_order import apply_order_updates +from .bulk_update_order import bulk_update_epic_status_order +from .bulk_update_order import update_projects_order_in_bulk from .filters import get_all_tags diff --git a/taiga/projects/services/bulk_update_order.py b/taiga/projects/services/bulk_update_order.py index 4abf0e24..614fd507 100644 --- a/taiga/projects/services/bulk_update_order.py +++ b/taiga/projects/services/bulk_update_order.py @@ -86,6 +86,23 @@ def update_projects_order_in_bulk(bulk_data: list, field: str, user): db.update_attr_in_bulk_for_ids(memberships_orders, field, model=models.Membership) +@transaction.atomic +def bulk_update_epic_status_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update projects_epicstatus set "order" = $1 + where projects_epicstatus.id = $2 and + projects_epicstatus.project_id = $3; + """ + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + @transaction.atomic def bulk_update_userstory_status_order(project, user, data): cursor = connection.cursor() diff --git a/taiga/projects/utils.py b/taiga/projects/utils.py index 271a511c..55dc77fa 100644 --- a/taiga/projects/utils.py +++ b/taiga/projects/utils.py @@ -117,6 +117,26 @@ def attach_notify_policies(queryset, as_field="notify_policies_attr"): return queryset +def attach_epic_statuses(queryset, as_field="epic_statuses_attr"): + """Attach a json epic statuses representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the epic statuses as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(projects_epicstatus)) + FROM projects_epicstatus + WHERE + projects_epicstatus.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + def attach_userstory_statuses(queryset, as_field="userstory_statuses_attr"): """Attach a json userstory statuses representation to each object of the queryset. @@ -443,6 +463,7 @@ def attach_extra_info(queryset, user=None): queryset = attach_members(queryset) queryset = attach_closed_milestones(queryset) queryset = attach_notify_policies(queryset) + queryset = attach_epic_statuses(queryset) queryset = attach_userstory_statuses(queryset) queryset = attach_points(queryset) queryset = attach_task_statuses(queryset) diff --git a/taiga/projects/validators.py b/taiga/projects/validators.py index de06c05c..fdf917ea 100644 --- a/taiga/projects/validators.py +++ b/taiga/projects/validators.py @@ -86,9 +86,9 @@ class TaskStatusExistsValidator: # Custom values for selectors ###################################################### -class PointsValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): +class EpicStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): class Meta: - model = models.Points + model = models.EpicStatus class UserStoryStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): @@ -96,6 +96,11 @@ class UserStoryStatusValidator(DuplicatedNameInProjectValidator, validators.Mode model = models.UserStoryStatus +class PointsValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.Points + + class TaskStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): class Meta: model = models.TaskStatus diff --git a/taiga/users/migrations/0022_auto_20160629_1443.py b/taiga/users/migrations/0022_auto_20160629_1443.py new file mode 100644 index 00000000..2acaf944 --- /dev/null +++ b/taiga/users/migrations/0022_auto_20160629_1443.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-06-29 14:43 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0021_auto_20160614_1201'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_epic', 'View epic'), ('add_epic', 'Add epic'), ('modify_epic', 'Modify epic'), ('comment_epic', 'Comment epic'), ('delete_epic', 'Delete epic'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=[], null=True, size=None, verbose_name='permissions'), + ), + ] diff --git a/tests/factories.py b/tests/factories.py index 379556ca..1f9d2848 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -229,22 +229,17 @@ class StorageEntryFactory(Factory): value = factory.Sequence(lambda n: {"value": "value-{}".format(n)}) -class UserStoryStatusFactory(Factory): +class EpicFactory(Factory): class Meta: - model = "projects.UserStoryStatus" + model = "epics.Epic" strategy = factory.CREATE_STRATEGY - name = factory.Sequence(lambda n: "User Story status {}".format(n)) - project = factory.SubFactory("tests.factories.ProjectFactory") - - -class TaskStatusFactory(Factory): - class Meta: - model = "projects.TaskStatus" - strategy = factory.CREATE_STRATEGY - - name = factory.Sequence(lambda n: "Task status {}".format(n)) + ref = factory.Sequence(lambda n: n) project = factory.SubFactory("tests.factories.ProjectFactory") + owner = factory.SubFactory("tests.factories.UserFactory") + subject = factory.Sequence(lambda n: "User Story {}".format(n)) + description = factory.Sequence(lambda n: "User Story {} description".format(n)) + status = factory.SubFactory("tests.factories.EpicStatusFactory") class MilestoneFactory(Factory): @@ -330,6 +325,33 @@ class WikiLinkFactory(Factory): order = factory.Sequence(lambda n: n) +class EpicStatusFactory(Factory): + class Meta: + model = "projects.EpicStatus" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Epic status {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class UserStoryStatusFactory(Factory): + class Meta: + model = "projects.UserStoryStatus" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "User Story status {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class TaskStatusFactory(Factory): + class Meta: + model = "projects.TaskStatus" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Task status {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + class IssueStatusFactory(Factory): class Meta: model = "projects.IssueStatus" diff --git a/tests/integration/resources_permissions/test_projects_choices_resources.py b/tests/integration/resources_permissions/test_projects_choices_resources.py index 77a0b754..75c0f39d 100644 --- a/tests/integration/resources_permissions/test_projects_choices_resources.py +++ b/tests/integration/resources_permissions/test_projects_choices_resources.py @@ -115,6 +115,11 @@ def data(): user=m.project_owner, is_admin=True) + m.public_epic_status = f.EpicStatusFactory(project=m.public_project) + m.private_epic_status1 = f.EpicStatusFactory(project=m.private_project1) + m.private_epic_status2 = f.EpicStatusFactory(project=m.private_project2) + m.blocked_epic_status = f.EpicStatusFactory(project=m.blocked_project) + m.public_points = f.PointsFactory(project=m.public_project) m.private_points1 = f.PointsFactory(project=m.private_project1) m.private_points2 = f.PointsFactory(project=m.private_project2) @@ -155,6 +160,10 @@ def data(): return m +##################################################### +# Roles +##################################################### + def test_roles_retrieve(client, data): public_url = reverse('roles-detail', kwargs={"pk": data.public_project.roles.all()[0].pk}) private1_url = reverse('roles-detail', kwargs={"pk": data.private_project1.roles.all()[0].pk}) @@ -299,6 +308,198 @@ def test_roles_patch(client, data): assert results == [401, 403, 403, 403, 451] +##################################################### +# Epic Status +##################################################### + +def test_epic_status_retrieve(client, data): + public_url = reverse('epic-statuses-detail', kwargs={"pk": data.public_epic_status.pk}) + private1_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status1.pk}) + private2_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status2.pk}) + blocked_url = reverse('epic-statuses-detail', kwargs={"pk": data.blocked_epic_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_status_update(client, data): + public_url = reverse('epic-statuses-detail', kwargs={"pk": data.public_epic_status.pk}) + private1_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status1.pk}) + private2_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status2.pk}) + blocked_url = reverse('epic-statuses-detail', kwargs={"pk": data.blocked_epic_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + epic_status_data = serializers.EpicStatusSerializer(data.public_epic_status).data + epic_status_data["name"] = "test" + epic_status_data = json.dumps(epic_status_data) + results = helper_test_http_method(client, 'put', public_url, epic_status_data, users) + assert results == [401, 403, 403, 403, 200] + + epic_status_data = serializers.EpicStatusSerializer(data.private_epic_status1).data + epic_status_data["name"] = "test" + epic_status_data = json.dumps(epic_status_data) + results = helper_test_http_method(client, 'put', private1_url, epic_status_data, users) + assert results == [401, 403, 403, 403, 200] + + epic_status_data = serializers.EpicStatusSerializer(data.private_epic_status2).data + epic_status_data["name"] = "test" + epic_status_data = json.dumps(epic_status_data) + results = helper_test_http_method(client, 'put', private2_url, epic_status_data, users) + assert results == [401, 403, 403, 403, 200] + + epic_status_data = serializers.EpicStatusSerializer(data.blocked_epic_status).data + epic_status_data["name"] = "test" + epic_status_data = json.dumps(epic_status_data) + results = helper_test_http_method(client, 'put', blocked_url, epic_status_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_epic_status_delete(client, data): + public_url = reverse('epic-statuses-detail', kwargs={"pk": data.public_epic_status.pk}) + private1_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status1.pk}) + private2_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status2.pk}) + blocked_url = reverse('epic-statuses-detail', kwargs={"pk": data.blocked_epic_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_epic_status_list(client, data): + url = reverse('epic-statuses-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + +def test_epic_status_patch(client, data): + public_url = reverse('epic-statuses-detail', kwargs={"pk": data.public_epic_status.pk}) + private1_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status1.pk}) + private2_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status2.pk}) + blocked_url = reverse('epic-statuses-detail', kwargs={"pk": data.blocked_epic_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_epic_status_action_bulk_update_order(client, data): + url = reverse('epic-statuses-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_epic_statuses": [(1, 2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_epic_statuses": [(1, 2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_epic_statuses": [(1, 2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_epic_statuses": [(1, 2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + + +##################################################### +# Points +##################################################### + def test_points_retrieve(client, data): public_url = reverse('points-detail', kwargs={"pk": data.public_points.pk}) private1_url = reverse('points-detail', kwargs={"pk": data.private_points1.pk}) @@ -483,6 +684,10 @@ def test_points_action_bulk_update_order(client, data): assert results == [401, 403, 403, 403, 451] +##################################################### +# User Story Status +##################################################### + def test_user_story_status_retrieve(client, data): public_url = reverse('userstory-statuses-detail', kwargs={"pk": data.public_user_story_status.pk}) private1_url = reverse('userstory-statuses-detail', kwargs={"pk": data.private_user_story_status1.pk}) @@ -570,7 +775,6 @@ def test_user_story_status_delete(client, data): assert results == [401, 403, 403, 403, 451] - def test_user_story_status_list(client, data): url = reverse('userstory-statuses-list') @@ -668,6 +872,10 @@ def test_user_story_status_action_bulk_update_order(client, data): assert results == [401, 403, 403, 403, 451] +##################################################### +# Task Status +##################################################### + def test_task_status_retrieve(client, data): public_url = reverse('task-statuses-detail', kwargs={"pk": data.public_task_status.pk}) private1_url = reverse('task-statuses-detail', kwargs={"pk": data.private_task_status1.pk}) @@ -755,7 +963,6 @@ def test_task_status_delete(client, data): assert results == [401, 403, 403, 403, 451] - def test_task_status_list(client, data): url = reverse('task-statuses-list') @@ -853,6 +1060,10 @@ def test_task_status_action_bulk_update_order(client, data): assert results == [401, 403, 403, 403, 451] +##################################################### +# Issue Status +##################################################### + def test_issue_status_retrieve(client, data): public_url = reverse('issue-statuses-detail', kwargs={"pk": data.public_issue_status.pk}) private1_url = reverse('issue-statuses-detail', kwargs={"pk": data.private_issue_status1.pk}) @@ -1037,6 +1248,10 @@ def test_issue_status_action_bulk_update_order(client, data): assert results == [401, 403, 403, 403, 451] +##################################################### +# Issue Type +##################################################### + def test_issue_type_retrieve(client, data): public_url = reverse('issue-types-detail', kwargs={"pk": data.public_issue_type.pk}) private1_url = reverse('issue-types-detail', kwargs={"pk": data.private_issue_type1.pk}) @@ -1221,6 +1436,10 @@ def test_issue_type_action_bulk_update_order(client, data): assert results == [401, 403, 403, 403, 451] +##################################################### +# Priority +##################################################### + def test_priority_retrieve(client, data): public_url = reverse('priorities-detail', kwargs={"pk": data.public_priority.pk}) private1_url = reverse('priorities-detail', kwargs={"pk": data.private_priority1.pk}) @@ -1283,6 +1502,7 @@ def test_priority_update(client, data): results = helper_test_http_method(client, 'put', blocked_url, priority_data, users) assert results == [401, 403, 403, 403, 451] + def test_priority_delete(client, data): public_url = reverse('priorities-detail', kwargs={"pk": data.public_priority.pk}) private1_url = reverse('priorities-detail', kwargs={"pk": data.private_priority1.pk}) @@ -1404,6 +1624,10 @@ def test_priority_action_bulk_update_order(client, data): assert results == [401, 403, 403, 403, 451] +##################################################### +# Severity +##################################################### + def test_severity_retrieve(client, data): public_url = reverse('severities-detail', kwargs={"pk": data.public_severity.pk}) private1_url = reverse('severities-detail', kwargs={"pk": data.private_severity1.pk}) @@ -1588,6 +1812,10 @@ def test_severity_action_bulk_update_order(client, data): assert results == [401, 403, 403, 403, 451] +##################################################### +# Memberships +##################################################### + def test_membership_retrieve(client, data): public_url = reverse('memberships-detail', kwargs={"pk": data.public_membership.pk}) private1_url = reverse('memberships-detail', kwargs={"pk": data.private_membership1.pk}) @@ -1859,6 +2087,10 @@ def test_membership_action_resend_invitation(client, data): assert results == [404, 404, 404, 403, 451] +##################################################### +# Project Templates +##################################################### + def test_project_template_retrieve(client, data): url = reverse('project-templates-detail', kwargs={"pk": data.project_template.pk}) @@ -1935,6 +2167,10 @@ def test_project_template_patch(client, data): assert results == [401, 403, 200] +##################################################### +# Tags +##################################################### + def test_create_tag(client, data): users = [ None, From 389a18026bc766a61dc4f61353db625de649f887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 30 Jun 2016 13:27:48 +0200 Subject: [PATCH 03/83] Epic custom attributes values --- taiga/projects/custom_attributes/admin.py | 5 + taiga/projects/custom_attributes/api.py | 26 +++ .../migrations/0008_auto_20160630_0849.py | 84 ++++++++ taiga/projects/custom_attributes/models.py | 28 ++- .../projects/custom_attributes/permissions.py | 20 ++ .../projects/custom_attributes/serializers.py | 8 + taiga/projects/custom_attributes/services.py | 17 ++ taiga/projects/custom_attributes/signals.py | 6 + .../projects/custom_attributes/validators.py | 14 ++ taiga/projects/epics/apps.py | 14 ++ .../management/commands/sample_data.py | 22 +- taiga/projects/serializers.py | 31 ++- taiga/projects/utils.py | 21 ++ taiga/routers.py | 71 +++++-- tests/factories.py | 1 + .../test_custom_attributes_epics.py | 201 ++++++++++++++++++ 16 files changed, 532 insertions(+), 37 deletions(-) create mode 100644 taiga/projects/custom_attributes/migrations/0008_auto_20160630_0849.py create mode 100644 tests/integration/test_custom_attributes_epics.py diff --git a/taiga/projects/custom_attributes/admin.py b/taiga/projects/custom_attributes/admin.py index fca94b96..ffa676d5 100644 --- a/taiga/projects/custom_attributes/admin.py +++ b/taiga/projects/custom_attributes/admin.py @@ -38,6 +38,11 @@ class BaseCustomAttributeAdmin: raw_id_fields = ["project"] +@admin.register(models.EpicCustomAttribute) +class EpicCustomAttributeAdmin(BaseCustomAttributeAdmin, admin.ModelAdmin): + pass + + @admin.register(models.UserStoryCustomAttribute) class UserStoryCustomAttributeAdmin(BaseCustomAttributeAdmin, admin.ModelAdmin): pass diff --git a/taiga/projects/custom_attributes/api.py b/taiga/projects/custom_attributes/api.py index 2d05d186..f8e74b00 100644 --- a/taiga/projects/custom_attributes/api.py +++ b/taiga/projects/custom_attributes/api.py @@ -41,6 +41,18 @@ from . import services # Custom Attribute ViewSets ####################################################### +class EpicCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet): + model = models.EpicCustomAttribute + serializer_class = serializers.EpicCustomAttributeSerializer + validator_class = validators.EpicCustomAttributeValidator + permission_classes = (permissions.EpicCustomAttributePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_epic_custom_attributes" + bulk_update_perm = "change_epic_custom_attributes" + bulk_update_order_action = services.bulk_update_epic_custom_attribute_order + + class UserStoryCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet): model = models.UserStoryCustomAttribute serializer_class = serializers.UserStoryCustomAttributeSerializer @@ -87,6 +99,20 @@ class BaseCustomAttributesValuesViewSet(OCCResourceMixin, HistoryResourceMixin, return getattr(obj, self.content_object) +class EpicCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): + model = models.EpicCustomAttributesValues + serializer_class = serializers.EpicCustomAttributesValuesSerializer + validator_class = validators.EpicCustomAttributesValuesValidator + permission_classes = (permissions.EpicCustomAttributesValuesPermission,) + lookup_field = "epic_id" + content_object = "epic" + + def get_queryset(self): + qs = self.model.objects.all() + qs = qs.select_related("epic", "epic__project") + return qs + + class UserStoryCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): model = models.UserStoryCustomAttributesValues serializer_class = serializers.UserStoryCustomAttributesValuesSerializer diff --git a/taiga/projects/custom_attributes/migrations/0008_auto_20160630_0849.py b/taiga/projects/custom_attributes/migrations/0008_auto_20160630_0849.py new file mode 100644 index 00000000..bcb7668c --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0008_auto_20160630_0849.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-06-30 08:49 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import django_pgjson.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('epics', '0001_initial'), + ('projects', '0049_auto_20160629_1443'), + ('custom_attributes', '0007_auto_20160208_1751'), + ] + + operations = [ + migrations.AlterModelOptions( + name='issuecustomattributesvalues', + options={'ordering': ['id'], 'verbose_name': 'issue custom attributes values', 'verbose_name_plural': 'issue custom attributes values'}, + ), + migrations.AlterModelOptions( + name='taskcustomattributesvalues', + options={'ordering': ['id'], 'verbose_name': 'task custom attributes values', 'verbose_name_plural': 'task custom attributes values'}, + ), + migrations.AlterModelOptions( + name='userstorycustomattributesvalues', + options={'ordering': ['id'], 'verbose_name': 'user story custom attributes values', 'verbose_name_plural': 'user story custom attributes values'}, + ), + + migrations.CreateModel( + name='EpicCustomAttribute', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=64, verbose_name='name')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('type', models.CharField(choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('date', 'Date'), ('url', 'Url')], default='text', max_length=16, verbose_name='type')), + ('order', models.IntegerField(default=10000, verbose_name='order')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created date')), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='epiccustomattributes', to='projects.Project', verbose_name='project')), + ], + options={ + 'verbose_name_plural': 'epic custom attributes', + 'verbose_name': 'epic custom attribute', + 'abstract': False, + 'ordering': ['project', 'order', 'name'], + }, + ), + migrations.CreateModel( + name='EpicCustomAttributesValues', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('attributes_values', django_pgjson.fields.JsonField(default={}, verbose_name='values')), + ('epic', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='custom_attributes_values', to='epics.Epic', verbose_name='epic')), + ], + options={ + 'verbose_name_plural': 'epic custom attributes values', + 'verbose_name': 'epic custom attributes values', + 'abstract': False, + 'ordering': ['id'], + }, + ), + migrations.AlterUniqueTogether( + name='epiccustomattribute', + unique_together=set([('project', 'name')]), + ), + + # Trigger: Clean epiccustomattributes values before remove a epiccustomattribute + migrations.RunSQL( + """ + CREATE TRIGGER "update_epiccustomvalues_after_remove_epiccustomattribute" + AFTER DELETE ON custom_attributes_epiccustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('custom_attributes_epiccustomattributesvalues'); + """, + reverse_sql="""DROP TRIGGER IF EXISTS "update_epiccustomvalues_after_remove_epiccustomattribute" + ON custom_attributes_epiccustomattribute + CASCADE;""" + ), + ] diff --git a/taiga/projects/custom_attributes/models.py b/taiga/projects/custom_attributes/models.py index 5fe3c6a0..8b5747f0 100644 --- a/taiga/projects/custom_attributes/models.py +++ b/taiga/projects/custom_attributes/models.py @@ -31,7 +31,6 @@ from . import choices # Custom Attribute Models ####################################################### - class AbstractCustomAttribute(models.Model): name = models.CharField(null=False, blank=False, max_length=64, verbose_name=_("name")) description = models.TextField(null=False, blank=True, verbose_name=_("description")) @@ -63,6 +62,12 @@ class AbstractCustomAttribute(models.Model): return super().save(*args, **kwargs) +class EpicCustomAttribute(AbstractCustomAttribute): + class Meta(AbstractCustomAttribute.Meta): + verbose_name = "epic custom attribute" + verbose_name_plural = "epic custom attributes" + + class UserStoryCustomAttribute(AbstractCustomAttribute): class Meta(AbstractCustomAttribute.Meta): verbose_name = "user story custom attribute" @@ -93,13 +98,28 @@ class AbstractCustomAttributesValues(OCCModelMixin, models.Model): ordering = ["id"] +class EpicCustomAttributesValues(AbstractCustomAttributesValues): + epic = models.OneToOneField("epics.Epic", + null=False, blank=False, related_name="custom_attributes_values", + verbose_name=_("epic")) + + class Meta(AbstractCustomAttributesValues.Meta): + verbose_name = "epic custom attributes values" + verbose_name_plural = "epic custom attributes values" + + @property + def project(self): + # NOTE: This property simplifies checking permissions + return self.epic.project + + class UserStoryCustomAttributesValues(AbstractCustomAttributesValues): user_story = models.OneToOneField("userstories.UserStory", null=False, blank=False, related_name="custom_attributes_values", verbose_name=_("user story")) class Meta(AbstractCustomAttributesValues.Meta): - verbose_name = "user story ustom attributes values" + verbose_name = "user story custom attributes values" verbose_name_plural = "user story custom attributes values" index_together = [("user_story",)] @@ -115,7 +135,7 @@ class TaskCustomAttributesValues(AbstractCustomAttributesValues): verbose_name=_("task")) class Meta(AbstractCustomAttributesValues.Meta): - verbose_name = "task ustom attributes values" + verbose_name = "task custom attributes values" verbose_name_plural = "task custom attributes values" index_together = [("task",)] @@ -131,7 +151,7 @@ class IssueCustomAttributesValues(AbstractCustomAttributesValues): verbose_name=_("issue")) class Meta(AbstractCustomAttributesValues.Meta): - verbose_name = "issue ustom attributes values" + verbose_name = "issue custom attributes values" verbose_name_plural = "issue custom attributes values" index_together = [("issue",)] diff --git a/taiga/projects/custom_attributes/permissions.py b/taiga/projects/custom_attributes/permissions.py index 5771cce4..ffc6a04c 100644 --- a/taiga/projects/custom_attributes/permissions.py +++ b/taiga/projects/custom_attributes/permissions.py @@ -27,6 +27,18 @@ from taiga.base.api.permissions import IsSuperUser # Custom Attribute Permissions ####################################################### +class EpicCustomAttributePermission(TaigaResourcePermission): + enought_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + class UserStoryCustomAttributePermission(TaigaResourcePermission): enought_perms = IsProjectAdmin() | IsSuperUser() global_perms = None @@ -67,6 +79,14 @@ class IssueCustomAttributePermission(TaigaResourcePermission): # Custom Attributes Values Permissions ####################################################### +class EpicCustomAttributesValuesPermission(TaigaResourcePermission): + enought_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_us') + update_perms = HasProjectPerm('modify_us') + partial_update_perms = HasProjectPerm('modify_us') + + class UserStoryCustomAttributesValuesPermission(TaigaResourcePermission): enought_perms = IsProjectAdmin() | IsSuperUser() global_perms = None diff --git a/taiga/projects/custom_attributes/serializers.py b/taiga/projects/custom_attributes/serializers.py index afc5ff72..10e9c756 100644 --- a/taiga/projects/custom_attributes/serializers.py +++ b/taiga/projects/custom_attributes/serializers.py @@ -36,6 +36,10 @@ class BaseCustomAttributeSerializer(serializers.LightSerializer): modified_date = Field() +class EpicCustomAttributeSerializer(BaseCustomAttributeSerializer): + pass + + class UserStoryCustomAttributeSerializer(BaseCustomAttributeSerializer): pass @@ -56,6 +60,10 @@ class BaseCustomAttributesValuesSerializer(serializers.LightSerializer): version = Field() +class EpicCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer): + epic = Field(attr="epic.id") + + class UserStoryCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer): user_story = Field(attr="user_story.id") diff --git a/taiga/projects/custom_attributes/services.py b/taiga/projects/custom_attributes/services.py index c957c5dc..4a30305e 100644 --- a/taiga/projects/custom_attributes/services.py +++ b/taiga/projects/custom_attributes/services.py @@ -20,6 +20,23 @@ from django.db import transaction from django.db import connection +@transaction.atomic +def bulk_update_epic_custom_attribute_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update custom_attributes_epiccustomattribute set "order" = $1 + where custom_attributes_epiccustomattribute.id = $2 and + custom_attributes_epiccustomattribute.project_id = $3; + """ + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + @transaction.atomic def bulk_update_userstory_custom_attribute_order(project, user, data): cursor = connection.cursor() diff --git a/taiga/projects/custom_attributes/signals.py b/taiga/projects/custom_attributes/signals.py index 72b715a7..96e74e9e 100644 --- a/taiga/projects/custom_attributes/signals.py +++ b/taiga/projects/custom_attributes/signals.py @@ -19,6 +19,12 @@ from . import models +def create_custom_attribute_value_when_create_epic(sender, instance, created, **kwargs): + if created: + models.EpicCustomAttributesValues.objects.get_or_create(epic=instance, + defaults={"attributes_values":{}}) + + def create_custom_attribute_value_when_create_user_story(sender, instance, created, **kwargs): if created: models.UserStoryCustomAttributesValues.objects.get_or_create(user_story=instance, diff --git a/taiga/projects/custom_attributes/validators.py b/taiga/projects/custom_attributes/validators.py index 6663de5d..4169eee6 100644 --- a/taiga/projects/custom_attributes/validators.py +++ b/taiga/projects/custom_attributes/validators.py @@ -66,6 +66,11 @@ class BaseCustomAttributeValidator(ModelValidator): return self._validate_integrity_between_project_and_name(attrs, source) +class EpicCustomAttributeValidator(BaseCustomAttributeValidator): + class Meta(BaseCustomAttributeValidator.Meta): + model = models.EpicCustomAttribute + + class UserStoryCustomAttributeValidator(BaseCustomAttributeValidator): class Meta(BaseCustomAttributeValidator.Meta): model = models.UserStoryCustomAttribute @@ -121,6 +126,15 @@ class BaseCustomAttributesValuesValidator(ModelValidator): return attrs +class EpicCustomAttributesValuesValidator(BaseCustomAttributesValuesValidator): + _custom_attribute_model = models.EpicCustomAttribute + _container_model = "epics.Epic" + _container_field = "epic" + + class Meta(BaseCustomAttributesValuesValidator.Meta): + model = models.EpicCustomAttributesValues + + class UserStoryCustomAttributesValuesValidator(BaseCustomAttributesValuesValidator): _custom_attribute_model = models.UserStoryCustomAttribute _container_model = "userstories.UserStory" diff --git a/taiga/projects/epics/apps.py b/taiga/projects/epics/apps.py index cff4d699..5389b98a 100644 --- a/taiga/projects/epics/apps.py +++ b/taiga/projects/epics/apps.py @@ -30,8 +30,16 @@ def connect_epics_signals(): dispatch_uid="tags_normalization_epic") +def connect_epics_custom_attributes_signals(): + from taiga.projects.custom_attributes import signals as custom_attributes_handlers + signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_epic, + sender=apps.get_model("epics", "Epic"), + dispatch_uid="create_custom_attribute_value_when_create_epic") + + def connect_all_epics_signals(): connect_epics_signals() + connect_epics_custom_attributes_signals() def disconnect_epics_signals(): @@ -39,8 +47,14 @@ def disconnect_epics_signals(): dispatch_uid="tags_normalization") +def disconnect_epics_custom_attributes_signals(): + signals.post_save.disconnect(sender=apps.get_model("epics", "Epic"), + dispatch_uid="create_custom_attribute_value_when_create_epic") + + def disconnect_all_epics_signals(): disconnect_epics_signals() + disconnect_epics_custom_attributes_signals() class EpicsAppConfig(AppConfig): diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index b1726496..70ec16a1 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -131,7 +131,7 @@ LOOKING_FOR_PEOPLE_PROJECTS_POSITIONS = [0, 1, 2] class Command(BaseCommand): sd = SampleDataHelper(seed=12345678901) - @transaction.atomic + #@transaction.atomic def handle(self, *args, **options): # Prevent events emission when sample data is running disconnect_events_signals() @@ -193,6 +193,13 @@ class Command(BaseCommand): # added custom attributes names = set([self.sd.words(1, 3) for i in range(1, 6)]) + for name in names: + EpicCustomAttribute.objects.create(name=name, + description=self.sd.words(3, 12), + type=self.sd.choice(TYPES_CHOICES)[0], + project=project, + order=i) + names = set([self.sd.words(1, 3) for i in range(1, 6)]) for name in names: UserStoryCustomAttribute.objects.create(name=name, description=self.sd.words(3, 12), @@ -511,14 +518,13 @@ class Command(BaseCommand): status=self.sd.db_object_from_queryset(project.epic_statuses.filter( is_closed=False)), tags=self.sd.words(1, 3).split(" ")) + epic.save() - # TODO: Epic custom attributes - #custom_attributes_values = {str(ca.id): self.get_custom_attributes_value(ca.type) for ca - # in project.epiccustomattributes.all() if self.sd.boolean()} - #if custom_attributes_values: - # epic.custom_attributes_values.attributes_values = custom_attributes_values - # epic.custom_attributes_values.save() - + custom_attributes_values = {str(ca.id): self.get_custom_attributes_value(ca.type) for ca + in project.epiccustomattributes.all() if self.sd.boolean()} + if custom_attributes_values: + epic.custom_attributes_values.attributes_values = custom_attributes_values + epic.custom_attributes_values.save() for i in range(self.sd.int(*NUM_ATTACHMENTS)): attachment = self.create_attachment(epic, i+1) diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 43369621..9a9c639d 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -30,6 +30,15 @@ from taiga.permissions.services import calculate_permissions from taiga.permissions.services import is_project_admin, is_project_owner from . import services +<<<<<<< HEAD +======= +from .custom_attributes.serializers import EpicCustomAttributeSerializer +from .custom_attributes.serializers import UserStoryCustomAttributeSerializer +from .custom_attributes.serializers import TaskCustomAttributeSerializer +from .custom_attributes.serializers import IssueCustomAttributeSerializer +from .likes.mixins.serializers import FanResourceSerializerMixin +from .mixins.serializers import ValidateDuplicatedNameInProjectMixin +>>>>>>> 5f3559d... Epic custom attributes values from .notifications.choices import NotifyLevel @@ -352,6 +361,7 @@ class ProjectSerializer(serializers.LightSerializer): class ProjectDetailSerializer(ProjectSerializer): + epic_statuses = Field(attr="epic_statuses_attr") us_statuses = Field(attr="userstory_statuses_attr") points = Field(attr="points_attr") task_statuses = Field(attr="task_statuses_attr") @@ -359,6 +369,7 @@ class ProjectDetailSerializer(ProjectSerializer): issue_types = Field(attr="issue_types_attr") priorities = Field(attr="priorities_attr") severities = Field(attr="severities_attr") + epic_custom_attributes = Field(attr="epic_custom_attributes_attr") userstory_custom_attributes = Field(attr="userstory_custom_attributes_attr") task_custom_attributes = Field(attr="task_custom_attributes_attr") issue_custom_attributes = Field(attr="issue_custom_attributes_attr") @@ -385,9 +396,9 @@ class ProjectDetailSerializer(ProjectSerializer): def to_value(self, instance): # Name attributes must be translated - for attr in ["userstory_statuses_attr", "points_attr", "task_statuses_attr", - "issue_statuses_attr", "issue_types_attr", "priorities_attr", - "severities_attr", "userstory_custom_attributes_attr", + for attr in ["epic_statuses_attr", "userstory_statuses_attr", "points_attr", "task_statuses_attr", + "issue_statuses_attr", "issue_types_attr", "priorities_attr", "severities_attr", + "epic_custom_attributes_attr", "userstory_custom_attributes_attr", "task_custom_attributes_attr", "issue_custom_attributes_attr", "roles_attr"]: assert hasattr(instance, attr), "instance must have a {} attribute".format(attr) @@ -430,8 +441,10 @@ class ProjectDetailSerializer(ProjectSerializer): return len(obj.members_attr) def get_is_out_of_owner_limits(self, obj): - assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute" - assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute" + assert (hasattr(obj, "private_projects_same_owner_attr"), + "instance must have a private_projects_same_owner_attr attribute" + assert (hasattr(obj, "public_projects_same_owner_attr"), + "instance must have a public_projects_same_owner_attr attribute" return services.check_if_project_is_out_of_owner_limits( obj, current_memberships=self.get_total_memberships(obj), @@ -440,8 +453,10 @@ class ProjectDetailSerializer(ProjectSerializer): ) def get_is_private_extra_info(self, obj): - assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute" - assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute" + assert (hasattr(obj, "private_projects_same_owner_attr"), + "instance must have a private_projects_same_owner_attr attribute" + assert (hasattr(obj, "public_projects_same_owner_attr"), + "instance must have a public_projects_same_owner_attr attribute" return services.check_if_project_privacity_can_be_changed( obj, current_memberships=self.get_total_memberships(obj), @@ -466,6 +481,7 @@ class ProjectTemplateSerializer(serializers.LightSerializer): created_date = Field() modified_date = Field() default_owner_role = Field() + is_epics_activated = Field() is_backlog_activated = Field() is_kanban_activated = Field() is_wiki_activated = Field() @@ -473,6 +489,7 @@ class ProjectTemplateSerializer(serializers.LightSerializer): videoconferences = Field() videoconferences_extra_data = Field() default_options = Field() + epic_statuses = Field() us_statuses = Field() points = Field() task_statuses = Field() diff --git a/taiga/projects/utils.py b/taiga/projects/utils.py index 55dc77fa..d56a96c9 100644 --- a/taiga/projects/utils.py +++ b/taiga/projects/utils.py @@ -277,6 +277,26 @@ def attach_severities(queryset, as_field="severities_attr"): return queryset +def attach_epic_custom_attributes(queryset, as_field="epic_custom_attributes_attr"): + """Attach a json epic custom attributes representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the epic custom attributes as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(custom_attributes_epiccustomattribute)) + FROM custom_attributes_epiccustomattribute + WHERE + custom_attributes_epiccustomattribute.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + def attach_userstory_custom_attributes(queryset, as_field="userstory_custom_attributes_attr"): """Attach a json userstory custom attributes representation to each object of the queryset. @@ -471,6 +491,7 @@ def attach_extra_info(queryset, user=None): queryset = attach_issue_types(queryset) queryset = attach_priorities(queryset) queryset = attach_severities(queryset) + queryset = attach_epic_custom_attributes(queryset) queryset = attach_userstory_custom_attributes(queryset) queryset = attach_task_custom_attributes(queryset) queryset = attach_issue_custom_attributes(queryset) diff --git a/taiga/routers.py b/taiga/routers.py index 24974b74..d8bc8b00 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -54,6 +54,7 @@ from taiga.projects.api import ProjectFansViewSet from taiga.projects.api import ProjectWatchersViewSet from taiga.projects.api import MembershipViewSet from taiga.projects.api import InvitationViewSet +from taiga.projects.api import EpicStatusViewSet from taiga.projects.api import UserStoryStatusViewSet from taiga.projects.api import PointsViewSet from taiga.projects.api import TaskStatusViewSet @@ -69,6 +70,7 @@ router.register(r"projects/(?P\d+)/watchers", ProjectWatchersViewSe router.register(r"project-templates", ProjectTemplateViewSet, base_name="project-templates") router.register(r"memberships", MembershipViewSet, base_name="memberships") router.register(r"invitations", InvitationViewSet, base_name="invitations") +router.register(r"epic-statuses", EpicStatusViewSet, base_name="epic-statuses") router.register(r"userstory-statuses", UserStoryStatusViewSet, base_name="userstory-statuses") router.register(r"points", PointsViewSet, base_name="points") router.register(r"task-statuses", TaskStatusViewSet, base_name="task-statuses") @@ -79,13 +81,18 @@ router.register(r"severities",SeverityViewSet , base_name="severities") # Custom Attributes +from taiga.projects.custom_attributes.api import EpicCustomAttributeViewSet from taiga.projects.custom_attributes.api import UserStoryCustomAttributeViewSet from taiga.projects.custom_attributes.api import TaskCustomAttributeViewSet from taiga.projects.custom_attributes.api import IssueCustomAttributeViewSet + +from taiga.projects.custom_attributes.api import EpicCustomAttributesValuesViewSet from taiga.projects.custom_attributes.api import UserStoryCustomAttributesValuesViewSet from taiga.projects.custom_attributes.api import TaskCustomAttributesValuesViewSet from taiga.projects.custom_attributes.api import IssueCustomAttributesValuesViewSet +router.register(r"epic-custom-attributes", EpicCustomAttributeViewSet, + base_name="epic-custom-attributes") router.register(r"userstory-custom-attributes", UserStoryCustomAttributeViewSet, base_name="userstory-custom-attributes") router.register(r"task-custom-attributes", TaskCustomAttributeViewSet, @@ -93,6 +100,8 @@ router.register(r"task-custom-attributes", TaskCustomAttributeViewSet, router.register(r"issue-custom-attributes", IssueCustomAttributeViewSet, base_name="issue-custom-attributes") +router.register(r"epics/custom-attributes-values", EpicCustomAttributesValuesViewSet, + base_name="epic-custom-attributes-values") router.register(r"userstories/custom-attributes-values", UserStoryCustomAttributesValuesViewSet, base_name="userstory-custom-attributes-values") router.register(r"tasks/custom-attributes-values", TaskCustomAttributesValuesViewSet, @@ -114,50 +123,76 @@ router.register(r"resolver", ResolverViewSet, base_name="resolver") # Attachments +from taiga.projects.attachments.api import EpicAttachmentViewSet from taiga.projects.attachments.api import UserStoryAttachmentViewSet from taiga.projects.attachments.api import IssueAttachmentViewSet from taiga.projects.attachments.api import TaskAttachmentViewSet from taiga.projects.attachments.api import WikiAttachmentViewSet +router.register(r"epics/attachments", EpicAttachmentViewSet, + base_name="epic-attachments") router.register(r"userstories/attachments", UserStoryAttachmentViewSet, base_name="userstory-attachments") -router.register(r"tasks/attachments", TaskAttachmentViewSet, base_name="task-attachments") -router.register(r"issues/attachments", IssueAttachmentViewSet, base_name="issue-attachments") -router.register(r"wiki/attachments", WikiAttachmentViewSet, base_name="wiki-attachments") +router.register(r"tasks/attachments", TaskAttachmentViewSet, + base_name="task-attachments") +router.register(r"issues/attachments", IssueAttachmentViewSet, + base_name="issue-attachments") +router.register(r"wiki/attachments", WikiAttachmentViewSet, + base_name="wiki-attachments") # Project components from taiga.projects.milestones.api import MilestoneViewSet from taiga.projects.milestones.api import MilestoneWatchersViewSet + from taiga.projects.userstories.api import UserStoryViewSet from taiga.projects.userstories.api import UserStoryVotersViewSet from taiga.projects.userstories.api import UserStoryWatchersViewSet + from taiga.projects.tasks.api import TaskViewSet from taiga.projects.tasks.api import TaskVotersViewSet from taiga.projects.tasks.api import TaskWatchersViewSet + from taiga.projects.issues.api import IssueViewSet from taiga.projects.issues.api import IssueVotersViewSet from taiga.projects.issues.api import IssueWatchersViewSet + from taiga.projects.wiki.api import WikiViewSet from taiga.projects.wiki.api import WikiLinkViewSet from taiga.projects.wiki.api import WikiWatchersViewSet -router.register(r"milestones", MilestoneViewSet, base_name="milestones") -router.register(r"milestones/(?P\d+)/watchers", MilestoneWatchersViewSet, base_name="milestone-watchers") -router.register(r"userstories", UserStoryViewSet, base_name="userstories") -router.register(r"userstories/(?P\d+)/voters", UserStoryVotersViewSet, base_name="userstory-voters") -router.register(r"userstories/(?P\d+)/watchers", UserStoryWatchersViewSet, base_name="userstory-watchers") -router.register(r"tasks", TaskViewSet, base_name="tasks") -router.register(r"tasks/(?P\d+)/voters", TaskVotersViewSet, base_name="task-voters") -router.register(r"tasks/(?P\d+)/watchers", TaskWatchersViewSet, base_name="task-watchers") -router.register(r"issues", IssueViewSet, base_name="issues") -router.register(r"issues/(?P\d+)/voters", IssueVotersViewSet, base_name="issue-voters") -router.register(r"issues/(?P\d+)/watchers", IssueWatchersViewSet, base_name="issue-watchers") -router.register(r"wiki", WikiViewSet, base_name="wiki") -router.register(r"wiki/(?P\d+)/watchers", WikiWatchersViewSet, base_name="wiki-watchers") -router.register(r"wiki-links", WikiLinkViewSet, base_name="wiki-links") +router.register(r"milestones", MilestoneViewSet, + base_name="milestones") +router.register(r"milestones/(?P\d+)/watchers", MilestoneWatchersViewSet, + base_name="milestone-watchers") +router.register(r"userstories", UserStoryViewSet, + base_name="userstories") +router.register(r"userstories/(?P\d+)/voters", UserStoryVotersViewSet, + base_name="userstory-voters") +router.register(r"userstories/(?P\d+)/watchers", UserStoryWatchersViewSet, + base_name="userstory-watchers") +router.register(r"tasks", TaskViewSet, + base_name="tasks") +router.register(r"tasks/(?P\d+)/voters", TaskVotersViewSet, + base_name="task-voters") +router.register(r"tasks/(?P\d+)/watchers", TaskWatchersViewSet, + base_name="task-watchers") + +router.register(r"issues", IssueViewSet, + base_name="issues") +router.register(r"issues/(?P\d+)/voters", IssueVotersViewSet, + base_name="issue-voters") +router.register(r"issues/(?P\d+)/watchers", IssueWatchersViewSet, + base_name="issue-watchers") + +router.register(r"wiki", WikiViewSet, + base_name="wiki") +router.register(r"wiki/(?P\d+)/watchers", WikiWatchersViewSet, + base_name="wiki-watchers") +router.register(r"wiki-links", WikiLinkViewSet, + base_name="wiki-links") # History & Components @@ -223,11 +258,11 @@ router.register(r"exporter", ProjectExporterViewSet, base_name="exporter") # External apps from taiga.external_apps.api import Application, ApplicationToken + router.register(r"applications", Application, base_name="applications") router.register(r"application-tokens", ApplicationToken, base_name="application-tokens") - # Stats # - see taiga.stats.routers and taiga.stats.apps diff --git a/tests/factories.py b/tests/factories.py index 1f9d2848..b5fdd880 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -57,6 +57,7 @@ class ProjectTemplateFactory(Factory): slug = settings.DEFAULT_PROJECT_TEMPLATE description = factory.Sequence(lambda n: "Description {}".format(n)) + epic_statuses = [] us_statuses = [] points = [] task_statuses = [] diff --git a/tests/integration/test_custom_attributes_epics.py b/tests/integration/test_custom_attributes_epics.py new file mode 100644 index 00000000..e24f1d8f --- /dev/null +++ b/tests/integration/test_custom_attributes_epics.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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.transaction import atomic +from django.core.urlresolvers import reverse +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +######################################################### +# Epic Custom Attributes +######################################################### + +def test_epic_custom_attribute_duplicate_name_error_on_create(client): + custom_attr_1 = f.EpicCustomAttributeFactory() + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + + + url = reverse("epic-custom-attributes-list") + data = {"name": custom_attr_1.name, + "project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_epic_custom_attribute_duplicate_name_error_on_update(client): + custom_attr_1 = f.EpicCustomAttributeFactory() + custom_attr_2 = f.EpicCustomAttributeFactory(project=custom_attr_1.project) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + + + url = reverse("epic-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"name": custom_attr_1.name} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_epic_custom_attribute_duplicate_name_error_on_move_between_projects(client): + custom_attr_1 = f.EpicCustomAttributeFactory() + custom_attr_2 = f.EpicCustomAttributeFactory(name=custom_attr_1.name) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_2.project, + is_admin=True) + + url = reverse("epic-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +######################################################### +# Epic Custom Attributes Values +######################################################### + +def test_epic_custom_attributes_values_when_create_us(client): + epic = f.EpicFactory() + assert epic.custom_attributes_values.attributes_values == {} + + +def test_epic_custom_attributes_values_update(client): + epic = f.EpicFactory() + member = f.MembershipFactory(user=epic.project.owner, + project=epic.project, + is_admin=True) + + custom_attr_1 = f.EpicCustomAttributeFactory(project=epic.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.EpicCustomAttributeFactory(project=epic.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = epic.custom_attributes_values + + url = reverse("epic-custom-attributes-values-detail", args=[epic.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + }, + "version": custom_attrs_val.version + } + + + assert epic.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["attributes_values"] == data["attributes_values"] + epic = epic.__class__.objects.get(id=epic.id) + assert epic.custom_attributes_values.attributes_values == data["attributes_values"] + + +def test_epic_custom_attributes_values_update_with_error_invalid_key(client): + epic = f.EpicFactory() + member = f.MembershipFactory(user=epic.project.owner, + project=epic.project, + is_admin=True) + + custom_attr_1 = f.EpicCustomAttributeFactory(project=epic.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.EpicCustomAttributeFactory(project=epic.project) + + custom_attrs_val = epic.custom_attributes_values + + url = reverse("epic-custom-attributes-values-detail", args=[epic.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + "123456": "test_2_updated" + }, + "version": custom_attrs_val.version + } + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + +def test_epic_custom_attributes_values_delete_epic(client): + epic = f.EpicFactory() + member = f.MembershipFactory(user=epic.project.owner, + project=epic.project, + is_admin=True) + + custom_attr_1 = f.EpicCustomAttributeFactory(project=epic.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.EpicCustomAttributeFactory(project=epic.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = epic.custom_attributes_values + + url = reverse("epics-detail", args=[epic.id]) + + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + assert not epic.__class__.objects.filter(id=epic.id).exists() + assert not custom_attrs_val.__class__.objects.filter(id=custom_attrs_val.id).exists() + + +######################################################### +# Test tristres triggers :-P +######################################################### + +def test_trigger_update_epiccustomvalues_afeter_remove_epiccustomattribute(client): + epic = f.EpicFactory() + member = f.MembershipFactory(user=epic.project.owner, + project=epic.project, + is_admin=True) + custom_attr_1 = f.EpicCustomAttributeFactory(project=epic.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.EpicCustomAttributeFactory(project=epic.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = epic.custom_attributes_values + custom_attrs_val.attributes_values = {ct1_id: "test_1", ct2_id: "test_2"} + custom_attrs_val.save() + + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id in custom_attrs_val.attributes_values.keys() + + url = reverse("epic-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + + custom_attrs_val = custom_attrs_val.__class__.objects.get(id=custom_attrs_val.id) + assert not custom_attr_2.__class__.objects.filter(pk=custom_attr_2.pk).exists() + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id not in custom_attrs_val.attributes_values.keys() From a7c262ffdc39b3463f2d4f527d05e37129d36e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 1 Jul 2016 12:37:19 +0200 Subject: [PATCH 04/83] Working in history and timeline for epics (initial version) --- taiga/projects/history/api.py | 5 ++ taiga/projects/history/freeze_impl.py | 49 +++++++++++++++++++ taiga/projects/history/permissions.py | 8 +++ taiga/projects/history/services.py | 10 ++-- taiga/projects/serializers.py | 38 +++++--------- taiga/routers.py | 2 + taiga/timeline/api.py | 2 + taiga/timeline/apps.py | 6 +-- .../_rebuild_timeline_for_user_creation.py | 8 +-- .../_update_timeline_for_updated_tasks.py | 4 +- .../management/commands/rebuild_timeline.py | 12 +++-- ...rebuild_timeline_iterating_per_projects.py | 6 +-- taiga/timeline/service.py | 24 +++++++-- taiga/timeline/signals.py | 18 ++++++- taiga/timeline/timeline_implementations.py | 12 +++++ tests/factories.py | 19 +++++++ 16 files changed, 173 insertions(+), 50 deletions(-) diff --git a/taiga/projects/history/api.py b/taiga/projects/history/api.py index db1d6bbc..17c2fa83 100644 --- a/taiga/projects/history/api.py +++ b/taiga/projects/history/api.py @@ -168,6 +168,11 @@ class HistoryViewSet(ReadOnlyListViewSet): return self.response_for_queryset(qs) +class EpicHistory(HistoryViewSet): + content_type = "epics.epic" + permission_classes = (permissions.EpicHistoryPermission,) + + class UserStoryHistory(HistoryViewSet): content_type = "userstories.userstory" permission_classes = (permissions.UserStoryHistoryPermission,) diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py index 9b2dcadc..7d5b3543 100644 --- a/taiga/projects/history/freeze_impl.py +++ b/taiga/projects/history/freeze_impl.py @@ -106,6 +106,17 @@ def milestone_values(diff): return values +def epic_values(diff): + values = _common_users_values(diff) + + if "status" in diff: + values["status"] = _get_us_status_values(diff["status"]) + + # TODO EPICS: What happen with usr stories? + + return values + + def userstory_values(diff): values = _common_users_values(diff) @@ -190,6 +201,18 @@ def extract_attachments(obj) -> list: "order": attach.order} +@as_tuple +def extract_epic_custom_attributes(obj) -> list: + with suppress(ObjectDoesNotExist): + custom_attributes_values = obj.custom_attributes_values.attributes_values + for attr in obj.project.epiccustomattributes.all(): + with suppress(KeyError): + value = custom_attributes_values[str(attr.id)] + yield {"id": attr.id, + "name": attr.name, + "value": value} + + @as_tuple def extract_user_story_custom_attributes(obj) -> list: with suppress(ObjectDoesNotExist): @@ -235,6 +258,7 @@ def project_freezer(project) -> dict: "total_milestones", "total_story_points", "tags", + "is_epics_activated", "is_backlog_activated", "is_kanban_activated", "is_wiki_activated", @@ -256,6 +280,31 @@ def milestone_freezer(milestone) -> dict: return snapshot +def epic_freezer(epic) -> dict: + snapshot = { + "ref": epic.ref, + "owner": epic.owner_id, + "status": epic.status.id if epic.status else None, + "is_closed": epic.is_closed, + "finish_date": str(epic.finish_date), + "epics_order": epic.epics_order, + "subject": epic.subject, + "description": epic.description, + "description_html": mdrender(epic.project, epic.description), + "assigned_to": epic.assigned_to_id, + "client_requirement": epic.client_requirement, + "team_requirement": epic.team_requirement, + "attachments": extract_attachments(epic), + "tags": epic.tags, + "is_blocked": epic.is_blocked, + "blocked_note": epic.blocked_note, + "blocked_note_html": mdrender(epic.project, epic.blocked_note), + "custom_attributes": extract_epic_custom_attributes(epic), + } + + return snapshot + + def userstory_freezer(us) -> dict: rp_cls = apps.get_model("userstories", "RolePoints") rpqsd = rp_cls.objects.filter(user_story=us) diff --git a/taiga/projects/history/permissions.py b/taiga/projects/history/permissions.py index e55ac3c4..40e4f98b 100644 --- a/taiga/projects/history/permissions.py +++ b/taiga/projects/history/permissions.py @@ -42,6 +42,14 @@ class IsCommentProjectAdmin(PermissionComponent): return is_project_admin(request.user, project) +class EpicHistoryPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + edit_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + delete_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + undelete_comment_perms = IsCommentProjectAdmin() | IsCommentDeleter() + comment_versions_perms = IsCommentProjectAdmin() | IsCommentOwner() + + class UserStoryHistoryPermission(TaigaResourcePermission): retrieve_perms = HasProjectPerm('view_project') edit_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() diff --git a/taiga/projects/history/services.py b/taiga/projects/history/services.py index b9526b08..54dd78fc 100644 --- a/taiga/projects/history/services.py +++ b/taiga/projects/history/services.py @@ -50,6 +50,7 @@ from .models import HistoryType # Freeze implementatitions from .freeze_impl import project_freezer from .freeze_impl import milestone_freezer +from .freeze_impl import epic_freezer from .freeze_impl import userstory_freezer from .freeze_impl import issue_freezer from .freeze_impl import task_freezer @@ -58,6 +59,7 @@ from .freeze_impl import wikipage_freezer from .freeze_impl import project_values from .freeze_impl import milestone_values +from .freeze_impl import epic_values from .freeze_impl import userstory_values from .freeze_impl import issue_values from .freeze_impl import task_values @@ -337,10 +339,7 @@ def take_snapshot(obj: object, *, comment: str="", user=None, delete: bool=False # If diff and comment are empty, do # not create empty history entry - if (not fdiff.diff and not comment - and old_fobj is not None - and entry_type != HistoryType.delete): - + if (not fdiff.diff and not comment and old_fobj is not None and entry_type != HistoryType.delete): return None fvals = make_diff_values(typename, fdiff) @@ -394,8 +393,10 @@ def prefetch_owners_in_history_queryset(qs): return qs +# Freeze & value register register_freeze_implementation("projects.project", project_freezer) register_freeze_implementation("milestones.milestone", milestone_freezer,) +register_freeze_implementation("epics.epic", epic_freezer) register_freeze_implementation("userstories.userstory", userstory_freezer) register_freeze_implementation("issues.issue", issue_freezer) register_freeze_implementation("tasks.task", task_freezer) @@ -403,6 +404,7 @@ register_freeze_implementation("wiki.wikipage", wikipage_freezer) register_values_implementation("projects.project", project_values) register_values_implementation("milestones.milestone", milestone_values) +register_values_implementation("epics.epic", epic_values) register_values_implementation("userstories.userstory", userstory_values) register_values_implementation("issues.issue", issue_values) register_values_implementation("tasks.task", task_values) diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 9a9c639d..58999578 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -30,15 +30,6 @@ from taiga.permissions.services import calculate_permissions from taiga.permissions.services import is_project_admin, is_project_owner from . import services -<<<<<<< HEAD -======= -from .custom_attributes.serializers import EpicCustomAttributeSerializer -from .custom_attributes.serializers import UserStoryCustomAttributeSerializer -from .custom_attributes.serializers import TaskCustomAttributeSerializer -from .custom_attributes.serializers import IssueCustomAttributeSerializer -from .likes.mixins.serializers import FanResourceSerializerMixin -from .mixins.serializers import ValidateDuplicatedNameInProjectMixin ->>>>>>> 5f3559d... Epic custom attributes values from .notifications.choices import NotifyLevel @@ -222,6 +213,7 @@ class ProjectSerializer(serializers.LightSerializer): members = MethodField() total_milestones = Field() total_story_points = Field() + is_epics_activated = Field() is_backlog_activated = Field() is_kanban_activated = Field() is_wiki_activated = Field() @@ -249,6 +241,7 @@ class ProjectSerializer(serializers.LightSerializer): tags = Field() tags_colors = MethodField() + default_epic_status = Field(attr="default_epic_status_id") default_points = Field(attr="default_points_id") default_us_status = Field(attr="default_us_status_id") default_task_status = Field(attr="default_task_status_id") @@ -300,14 +293,13 @@ class ProjectSerializer(serializers.LightSerializer): def get_my_permissions(self, obj): if "request" in self.context: user = self.context["request"].user - return calculate_permissions( - is_authenticated=user.is_authenticated(), - is_superuser=user.is_superuser, - is_member=self.get_i_am_member(obj), - is_admin=self.get_i_am_admin(obj), - role_permissions=obj.my_role_permissions_attr, - anon_permissions=obj.anon_permissions, - public_permissions=obj.public_permissions) + return calculate_permissions(is_authenticated=user.is_authenticated(), + is_superuser=user.is_superuser, + is_member=self.get_i_am_member(obj), + is_admin=self.get_i_am_admin(obj), + role_permissions=obj.my_role_permissions_attr, + anon_permissions=obj.anon_permissions, + public_permissions=obj.public_permissions) return [] def get_owner(self, obj): @@ -441,10 +433,8 @@ class ProjectDetailSerializer(ProjectSerializer): return len(obj.members_attr) def get_is_out_of_owner_limits(self, obj): - assert (hasattr(obj, "private_projects_same_owner_attr"), - "instance must have a private_projects_same_owner_attr attribute" - assert (hasattr(obj, "public_projects_same_owner_attr"), - "instance must have a public_projects_same_owner_attr attribute" + assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute" + assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute" return services.check_if_project_is_out_of_owner_limits( obj, current_memberships=self.get_total_memberships(obj), @@ -453,10 +443,8 @@ class ProjectDetailSerializer(ProjectSerializer): ) def get_is_private_extra_info(self, obj): - assert (hasattr(obj, "private_projects_same_owner_attr"), - "instance must have a private_projects_same_owner_attr attribute" - assert (hasattr(obj, "public_projects_same_owner_attr"), - "instance must have a public_projects_same_owner_attr attribute" + assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute" + assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute" return services.check_if_project_privacity_can_be_changed( obj, current_memberships=self.get_total_memberships(obj), diff --git a/taiga/routers.py b/taiga/routers.py index d8bc8b00..49db63b2 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -196,11 +196,13 @@ router.register(r"wiki-links", WikiLinkViewSet, # History & Components +from taiga.projects.history.api import EpicHistory from taiga.projects.history.api import UserStoryHistory from taiga.projects.history.api import TaskHistory from taiga.projects.history.api import IssueHistory from taiga.projects.history.api import WikiHistory +router.register(r"history/epic", EpicHistory, base_name="epic-history") router.register(r"history/userstory", UserStoryHistory, base_name="userstory-history") router.register(r"history/task", TaskHistory, base_name="task-history") router.register(r"history/issue", IssueHistory, base_name="issue-history") diff --git a/taiga/timeline/api.py b/taiga/timeline/api.py index 3e3bd6f4..2ebe5fa2 100644 --- a/taiga/timeline/api.py +++ b/taiga/timeline/api.py @@ -85,6 +85,7 @@ class TimelineViewSet(ReadOnlyListViewSet): event_type::text = ANY('{issues.issue.change, tasks.task.change, userstories.userstory.change, + epics.epic.change, wiki.wikipage.change}'::text[]) ) """]) @@ -92,6 +93,7 @@ class TimelineViewSet(ReadOnlyListViewSet): qs = qs.exclude(event_type__in=["issues.issue.delete", "tasks.task.delete", "userstories.userstory.delete", + "epics.epic.delete", "wiki.wikipage.delete", "projects.project.change"]) diff --git a/taiga/timeline/apps.py b/taiga/timeline/apps.py index 7b193552..fa951716 100644 --- a/taiga/timeline/apps.py +++ b/taiga/timeline/apps.py @@ -33,8 +33,8 @@ class TimelineAppConfig(AppConfig): sender=apps.get_model("history", "HistoryEntry"), dispatch_uid="timeline") signals.post_save.connect(handlers.create_membership_push_to_timeline, - sender=apps.get_model("projects", "Membership")) + sender=apps.get_model("projects", "Membership")) signals.pre_delete.connect(handlers.delete_membership_push_to_timeline, - sender=apps.get_model("projects", "Membership")) + sender=apps.get_model("projects", "Membership")) signals.post_save.connect(handlers.create_user_push_to_timeline, - sender=get_user_model()) + sender=get_user_model()) diff --git a/taiga/timeline/management/commands/_rebuild_timeline_for_user_creation.py b/taiga/timeline/management/commands/_rebuild_timeline_for_user_creation.py index 07290281..f3a5fa57 100644 --- a/taiga/timeline/management/commands/_rebuild_timeline_for_user_creation.py +++ b/taiga/timeline/management/commands/_rebuild_timeline_for_user_creation.py @@ -25,8 +25,9 @@ from django.core.management.base import BaseCommand from django.db.models import Model from django.test.utils import override_settings -from taiga.timeline.service import (_get_impl_key_from_model, - _timeline_impl_map, extract_user_info) +from taiga.timeline.service import _get_impl_key_from_model, +from taiga.timeline.service import _timeline_impl_map, +from taiga.timeline.service import extract_user_info) from taiga.timeline.models import Timeline from taiga.timeline.signals import _push_to_timelines @@ -54,7 +55,8 @@ class BulkCreator(object): bulk_creator = BulkCreator() -def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, namespace:str="default", extra_data:dict={}): +def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, + namespace:str="default", extra_data:dict={}): assert isinstance(obj, Model), "obj must be a instance of Model" assert isinstance(instance, Model), "instance must be a instance of Model" event_type_key = _get_impl_key_from_model(instance.__class__, event_type) diff --git a/taiga/timeline/management/commands/_update_timeline_for_updated_tasks.py b/taiga/timeline/management/commands/_update_timeline_for_updated_tasks.py index 090cf5f6..6c37b17c 100644 --- a/taiga/timeline/management/commands/_update_timeline_for_updated_tasks.py +++ b/taiga/timeline/management/commands/_update_timeline_for_updated_tasks.py @@ -40,7 +40,9 @@ def update_timeline(initial_date, final_date): print("Generating tasks indexed by id dict") task_ids = timelines.values_list("object_id", flat=True) - tasks_per_id = {task.id: task for task in Task.objects.filter(id__in=task_ids).select_related("user_story").iterator()} + + tasks_iterator = Task.objects.filter(id__in=task_ids).select_related("user_story").iterator() + tasks_per_id = {task.id: task for task in tasks_iterator} del task_ids counter = 1 diff --git a/taiga/timeline/management/commands/rebuild_timeline.py b/taiga/timeline/management/commands/rebuild_timeline.py index 947a7418..d3772a4f 100644 --- a/taiga/timeline/management/commands/rebuild_timeline.py +++ b/taiga/timeline/management/commands/rebuild_timeline.py @@ -58,7 +58,8 @@ class BulkCreator(object): bulk_creator = BulkCreator() -def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, namespace:str="default", extra_data:dict={}): +def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, + namespace:str="default", extra_data:dict={}): assert isinstance(obj, Model), "obj must be a instance of Model" assert isinstance(instance, Model), "instance must be a instance of Model" event_type_key = _get_impl_key_from_model(instance.__class__, event_type) @@ -102,11 +103,13 @@ def generate_timeline(initial_date, final_date, project_id): if project_id: project = Project.objects.get(id=project_id) - us_keys = ['userstories.userstory:%s'%(id) for id in project.user_stories.values_list("id", flat=True)] + epic_keys = ['epics.epic:%s'%(id) for id in project.epics.values_list("id", flat=True)] + us_keys = ['userstories.userstory:%s'%(id) for id in project.user_stories.values_list("id", + flat=True)] tasks_keys = ['tasks.task:%s'%(id) for id in project.tasks.values_list("id", flat=True)] issue_keys = ['issues.issue:%s'%(id) for id in project.issues.values_list("id", flat=True)] wiki_keys = ['wiki.wikipage:%s'%(id) for id in project.wiki_pages.values_list("id", flat=True)] - keys = us_keys + tasks_keys + issue_keys + wiki_keys + keys = epic_keys + us_keys + tasks_keys + issue_keys + wiki_keys projects = projects.filter(id=project_id) history_entries = history_entries.filter(key__in=keys) @@ -121,7 +124,8 @@ def generate_timeline(initial_date, final_date, project_id): "values_diff": {}, "user": extract_user_info(project.owner), } - _push_to_timelines(project, project.owner, project, "create", project.created_date, extra_data=extra_data) + _push_to_timelines(project, project.owner, project, "create", project.created_date, + extra_data=extra_data) del extra_data for historyEntry in history_entries.iterator(): diff --git a/taiga/timeline/management/commands/rebuild_timeline_iterating_per_projects.py b/taiga/timeline/management/commands/rebuild_timeline_iterating_per_projects.py index 2f2ae1b5..f4c1a0a4 100644 --- a/taiga/timeline/management/commands/rebuild_timeline_iterating_per_projects.py +++ b/taiga/timeline/management/commands/rebuild_timeline_iterating_per_projects.py @@ -31,7 +31,7 @@ class Command(BaseCommand): total = Project.objects.count() for count,project in enumerate(Project.objects.order_by("id")): - print("""*********************************** - %s/%s %s -***********************************"""%(count+1, total, project.name)) + print("***********************************\n", + " {}/{} {}\n".format(count+1, total, project.name), + "***********************************") call_command("rebuild_timeline", project=project.id) diff --git a/taiga/timeline/service.py b/taiga/timeline/service.py index d3e81976..06964eb3 100644 --- a/taiga/timeline/service.py +++ b/taiga/timeline/service.py @@ -52,7 +52,8 @@ def build_project_namespace(project: object): return "{0}:{1}".format("project", project.id) -def _add_to_object_timeline(obj: object, instance: object, event_type: str, created_datetime: object, namespace: str="default", extra_data: dict={}): +def _add_to_object_timeline(obj: object, instance: object, event_type: str, created_datetime: object, + namespace: str="default", extra_data: dict={}): assert isinstance(obj, Model), "obj must be a instance of Model" assert isinstance(instance, Model), "instance must be a instance of Model" from .models import Timeline @@ -74,12 +75,14 @@ def _add_to_object_timeline(obj: object, instance: object, event_type: str, crea ) -def _add_to_objects_timeline(objects, instance: object, event_type: str, created_datetime: object, namespace: str="default", extra_data: dict={}): +def _add_to_objects_timeline(objects, instance: object, event_type: str, created_datetime: object, + namespace: str="default", extra_data: dict={}): for obj in objects: _add_to_object_timeline(obj, instance, event_type, created_datetime, namespace, extra_data) -def _push_to_timeline(objects, instance: object, event_type: str, created_datetime: object, namespace: str="default", extra_data: dict={}): +def _push_to_timeline(objects, instance: object, event_type: str, created_datetime: object, + namespace: str="default", extra_data: dict={}): if isinstance(objects, Model): _add_to_object_timeline(objects, instance, event_type, created_datetime, namespace, extra_data) elif isinstance(objects, QuerySet) or isinstance(objects, list): @@ -89,7 +92,8 @@ def _push_to_timeline(objects, instance: object, event_type: str, created_dateti @app.task -def push_to_timelines(project_id, user_id, obj_app_label, obj_model_name, obj_id, event_type, created_datetime, extra_data={}): +def push_to_timelines(project_id, user_id, obj_app_label, obj_model_name, obj_id, event_type, + created_datetime, extra_data={}): ObjModel = apps.get_model(obj_app_label, obj_model_name) try: obj = ObjModel.objects.get(id=obj_id) @@ -156,6 +160,7 @@ def filter_timeline_for_user(timeline, user): content_types = { "view_project": ContentType.objects.get_by_natural_key("projects", "project"), "view_milestones": ContentType.objects.get_by_natural_key("milestones", "milestone"), + "view_epic": ContentType.objects.get_by_natural_key("epics", "epic"), "view_us": ContentType.objects.get_by_natural_key("userstories", "userstory"), "view_tasks": ContentType.objects.get_by_natural_key("tasks", "task"), "view_issues": ContentType.objects.get_by_natural_key("issues", "issue"), @@ -181,7 +186,8 @@ def filter_timeline_for_user(timeline, user): if membership.is_admin: tl_filter |= Q(project=membership.project) else: - data_content_types = list(filter(None, [content_types.get(a, None) for a in membership.role.permissions])) + data_content_types = list(filter(None, [content_types.get(a, None) for a in + membership.role.permissions])) data_content_types.append(membership_content_type) tl_filter |= Q(project=membership.project, data_content_type__in=data_content_types) @@ -252,6 +258,14 @@ def extract_milestone_info(instance): } +def extract_epic_info(instance): + return { + "id": instance.pk, + "ref": instance.ref, + "subject": instance.subject, + } + + def extract_userstory_info(instance): return { "id": instance.pk, diff --git a/taiga/timeline/signals.py b/taiga/timeline/signals.py index 1bee6c27..7f754b63 100644 --- a/taiga/timeline/signals.py +++ b/taiga/timeline/signals.py @@ -36,9 +36,23 @@ def _push_to_timelines(project, user, obj, event_type, created_datetime, extra_d ct = ContentType.objects.get_for_model(obj) if settings.CELERY_ENABLED: - connection.on_commit(lambda: push_to_timelines.delay(project_id, user.id, ct.app_label, ct.model, obj.id, event_type, created_datetime, extra_data=extra_data)) + connection.on_commit(lambda: push_to_timelines.delay(project_id, + user.id, + ct.app_label, + ct.model, + obj.id, + event_type, + created_datetime, + extra_data=extra_data)) else: - push_to_timelines(project_id, user.id, ct.app_label, ct.model, obj.id, event_type, created_datetime, extra_data=extra_data) + push_to_timelines(project_id, + user.id, + ct.app_label, + ct.model, + obj.id, + event_type, + created_datetime, + extra_data=extra_data) def _clean_description_fields(values_diff): diff --git a/taiga/timeline/timeline_implementations.py b/taiga/timeline/timeline_implementations.py index e6065971..9b97fa06 100644 --- a/taiga/timeline/timeline_implementations.py +++ b/taiga/timeline/timeline_implementations.py @@ -43,6 +43,18 @@ def milestone_timeline(instance, extra_data={}): return result +@register_timeline_implementation("epics.epic", "create") +@register_timeline_implementation("epics.epic", "change") +@register_timeline_implementation("epics.epic", "delete") +def epic_timeline(instance, extra_data={}): + result ={ + "epic": service.extract_epic_info(instance), + "project": service.extract_project_info(instance.project), + } + result.update(extra_data) + return result + + @register_timeline_implementation("userstories.userstory", "create") @register_timeline_implementation("userstories.userstory", "change") @register_timeline_implementation("userstories.userstory", "delete") diff --git a/tests/factories.py b/tests/factories.py index b5fdd880..8558907d 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -389,6 +389,16 @@ class IssueTypeFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") +class EpicCustomAttributeFactory(Factory): + class Meta: + model = "custom_attributes.EpicCustomAttribute" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Epic Custom Attribute {}".format(n)) + description = factory.Sequence(lambda n: "Description for Epic Custom Attribute {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + class UserStoryCustomAttributeFactory(Factory): class Meta: model = "custom_attributes.UserStoryCustomAttribute" @@ -419,6 +429,15 @@ class IssueCustomAttributeFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") +class EpicCustomAttributesValuesFactory(Factory): + class Meta: + model = "custom_attributes.EpicCustomAttributesValues" + strategy = factory.CREATE_STRATEGY + + attributes_values = {} + epic = factory.SubFactory("tests.factories.EpicFactory") + + class UserStoryCustomAttributesValuesFactory(Factory): class Meta: model = "custom_attributes.UserStoryCustomAttributesValues" From 46006706c5a95121586199e1bd375259b2b335dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 5 Jul 2016 13:15:51 +0200 Subject: [PATCH 05/83] Add order attr for user stories relationships --- .../projects/epics/migrations/0001_initial.py | 30 ++++++++++++++----- taiga/projects/epics/models.py | 26 ++++++++++++---- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/taiga/projects/epics/migrations/0001_initial.py b/taiga/projects/epics/migrations/0001_initial.py index dc670d7e..78d2dc8f 100644 --- a/taiga/projects/epics/migrations/0001_initial.py +++ b/taiga/projects/epics/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-06-29 14:43 +# Generated by Django 1.9.2 on 2016-07-05 11:12 from __future__ import unicode_literals from django.conf import settings @@ -15,9 +15,9 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('projects', '0049_auto_20160629_1443'), ('userstories', '0012_auto_20160614_1201'), + ('projects', '0049_auto_20160629_1443'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -30,11 +30,9 @@ class Migration(migrations.Migration): ('is_blocked', models.BooleanField(default=False, verbose_name='is blocked')), ('blocked_note', models.TextField(blank=True, default='', verbose_name='blocked note')), ('ref', models.BigIntegerField(blank=True, db_index=True, default=None, null=True, verbose_name='ref')), - ('is_closed', models.BooleanField(default=False)), ('epics_order', models.IntegerField(default=10000, verbose_name='epics order')), ('created_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created date')), ('modified_date', models.DateTimeField(verbose_name='modified date')), - ('finish_date', models.DateTimeField(blank=True, null=True, verbose_name='finish date')), ('subject', models.TextField(verbose_name='subject')), ('description', models.TextField(blank=True, verbose_name='description')), ('client_requirement', models.BooleanField(default=False, verbose_name='is client requirement')), @@ -43,15 +41,33 @@ class Migration(migrations.Migration): ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_epics', to=settings.AUTH_USER_MODEL, verbose_name='owner')), ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='epics', to='projects.Project', verbose_name='project')), ('status', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='epics', to='projects.EpicStatus', verbose_name='status')), - ('user_stories', models.ManyToManyField(related_name='epics', to='userstories.UserStory', verbose_name='user stories')), ], options={ + 'ordering': ['project', 'epics_order', 'ref'], 'verbose_name_plural': 'epics', - 'ordering': ['project', 'ref'], 'verbose_name': 'epic', }, bases=(taiga.projects.notifications.mixins.WatchedModelMixin, models.Model), ), + migrations.CreateModel( + name='RelatedUserStory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.IntegerField(default=10000, verbose_name='order')), + ('epic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epics.Epic')), + ('user_story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='userstories.UserStory')), + ], + options={ + 'ordering': ['user_story', 'order', 'id'], + 'verbose_name_plural': 'related user stories', + 'verbose_name': 'related user story', + }, + ), + migrations.AddField( + model_name='epic', + name='user_stories', + field=models.ManyToManyField(related_name='epics', through='epics.RelatedUserStory', to='userstories.UserStory', verbose_name='user stories'), + ), # Execute trigger after epic update migrations.RunSQL( """ diff --git a/taiga/projects/epics/models.py b/taiga/projects/epics/models.py index b4a9118a..d55c1dff 100644 --- a/taiga/projects/epics/models.py +++ b/taiga/projects/epics/models.py @@ -39,8 +39,6 @@ class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M status = models.ForeignKey("projects.EpicStatus", null=True, blank=True, related_name="epics", verbose_name=_("status"), on_delete=models.SET_NULL) - is_closed = models.BooleanField(default=False) - epics_order = models.IntegerField(null=False, blank=False, default=10000, verbose_name=_("epics order")) @@ -49,8 +47,6 @@ class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M default=timezone.now) modified_date = models.DateTimeField(null=False, blank=False, verbose_name=_("modified date")) - finish_date = models.DateTimeField(null=True, blank=True, - verbose_name=_("finish date")) subject = models.TextField(null=False, blank=False, verbose_name=_("subject")) @@ -64,6 +60,7 @@ class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M verbose_name=_("is team requirement")) user_stories = models.ManyToManyField("userstories.UserStory", related_name="epics", + through='RelatedUserStory', verbose_name=_("user stories")) attachments = GenericRelation("attachments.Attachment") @@ -73,7 +70,7 @@ class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M class Meta: verbose_name = "epic" verbose_name_plural = "epics" - ordering = ["project", "ref"] + ordering = ["project", "epics_order", "ref"] def save(self, *args, **kwargs): if not self._importing or not self.modified_date: @@ -85,7 +82,24 @@ class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M super().save(*args, **kwargs) def __str__(self): - return "({1}) {0}".format(self.ref, self.subject) + return "#{0} {1}".format(self.ref, self.subject) def __repr__(self): return "" % (self.id) + + + +class RelatedUserStory(models.Model): + user_story = models.ForeignKey("userstories.UserStory", on_delete=models.CASCADE) + epic = models.ForeignKey("epics.Epic", on_delete=models.CASCADE) + + order = models.IntegerField(null=False, blank=False, default=10000, + verbose_name=_("order")) + + class Meta: + verbose_name = "related user story" + verbose_name_plural = "related user stories" + ordering = ["user_story", "order", "id"] + + def __str__(self): + return "{0} - {1}".format(self.epic, self.user_story) From 6bdfe255038884004fd9919aea7cabaa8227b09c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 5 Jul 2016 13:16:28 +0200 Subject: [PATCH 06/83] Save related stories in history items for epics --- taiga/projects/history/freeze_impl.py | 15 +++++++++++++-- taiga/projects/history/models.py | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py index 7d5b3543..1c2fae44 100644 --- a/taiga/projects/history/freeze_impl.py +++ b/taiga/projects/history/freeze_impl.py @@ -187,6 +187,18 @@ def _generic_extract(obj:object, fields:list, default=None) -> dict: return result +@as_tuple +def extract_user_stories(obj) -> list: + for user_story in obj.user_stories.all(): + + yield {"id": user_story.id, + "ref": user_story.ref, + "subject": user_story.subject, + "project": { + "id": user_story.project.id, + "name": user_story.project.name, + "slug": user_story.project.slug}} + @as_tuple def extract_attachments(obj) -> list: for attach in obj.attachments.all(): @@ -285,8 +297,6 @@ def epic_freezer(epic) -> dict: "ref": epic.ref, "owner": epic.owner_id, "status": epic.status.id if epic.status else None, - "is_closed": epic.is_closed, - "finish_date": str(epic.finish_date), "epics_order": epic.epics_order, "subject": epic.subject, "description": epic.description, @@ -300,6 +310,7 @@ def epic_freezer(epic) -> dict: "blocked_note": epic.blocked_note, "blocked_note_html": mdrender(epic.project, epic.blocked_note), "custom_attributes": extract_epic_custom_attributes(epic), + "user_stories": extract_user_stories(epic), } return snapshot diff --git a/taiga/projects/history/models.py b/taiga/projects/history/models.py index d5c023a9..aafaa915 100644 --- a/taiga/projects/history/models.py +++ b/taiga/projects/history/models.py @@ -258,6 +258,24 @@ class HistoryEntry(models.Model): if custom_attributes["new"] or custom_attributes["changed"] or custom_attributes["deleted"]: value = custom_attributes + elif key == "user_stories": + user_stories = { + "new": [], + "deleted": [], + } + + olduss = {x["id"]:x for x in self.diff["user_stories"][0]} + newuss = {x["id"]:x for x in self.diff["user_stories"][1]} + + for usid in set(tuple(olduss.keys()) + tuple(newuss.keys())): + if usid in olduss and usid not in newuss: + user_stories["deleted"].append(olduss[usid]) + elif usid not in olduss and usid in newuss: + user_stories["new"].append(newuss[usid]) + + if user_stories["new"] or user_stories["changed"] or user_stories["deleted"]: + value = user_stories + elif key in self.values: value = [resolve_value(key, x) for x in self.diff[key]] else: From 329a3e5ef33a4f25362038f86648f6d7a0ef9ca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 7 Jul 2016 11:14:03 +0200 Subject: [PATCH 07/83] Add default permissions --- taiga/projects/epics/permissions.py | 55 +++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 taiga/projects/epics/permissions.py diff --git a/taiga/projects/epics/permissions.py b/taiga/projects/epics/permissions.py new file mode 100644 index 00000000..86f2626e --- /dev/null +++ b/taiga/projects/epics/permissions.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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 taiga.base.api.permissions import TaigaResourcePermission, AllowAny, IsAuthenticated, IsSuperUser +from taiga.permissions.permissions import HasProjectPerm, IsProjectAdmin + +from taiga.permissions.permissions import CommentAndOrUpdatePerm + + +class EpicPermission(TaigaResourcePermission): + enought_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_epics') + create_perms = HasProjectPerm('add_epic') + update_perms = CommentAndOrUpdatePerm('modify_epic', 'comment_epic') + partial_update_perms = CommentAndOrUpdatePerm('modify_epic', 'comment_epic') + destroy_perms = HasProjectPerm('delete_epic') + list_perms = AllowAny() + filters_data_perms = AllowAny() + csv_perms = AllowAny() + bulk_create_perms = HasProjectPerm('add_epic') + bulk_update_order_perms = HasProjectPerm('modify_epic') + upvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') + downvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') + watch_perms = IsAuthenticated() & HasProjectPerm('view_epics') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_epics') + + +class EpicVotersPermission(TaigaResourcePermission): + enought_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_epics') + list_perms = HasProjectPerm('view_epics') + + +class EpicWatchersPermission(TaigaResourcePermission): + enought_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_epics') + list_perms = HasProjectPerm('view_epics') From d5b2bc95ab9e097da0ef71200bf6e867dab966fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 7 Jul 2016 12:27:42 +0200 Subject: [PATCH 08/83] Add initial Epic viewset (+ Voters and watchers) --- taiga/base/filters.py | 4 + taiga/projects/epics/api.py | 223 +++++++++++++++++ taiga/projects/epics/serializers.py | 74 ++++++ taiga/projects/epics/services.py | 376 ++++++++++++++++++++++++++++ taiga/projects/epics/utils.py | 39 +++ taiga/projects/epics/validators.py | 67 +++++ taiga/routers.py | 11 + 7 files changed, 794 insertions(+) create mode 100644 taiga/projects/epics/api.py create mode 100644 taiga/projects/epics/serializers.py create mode 100644 taiga/projects/epics/services.py create mode 100644 taiga/projects/epics/utils.py create mode 100644 taiga/projects/epics/validators.py diff --git a/taiga/base/filters.py b/taiga/base/filters.py index bddec10e..187033c1 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -160,6 +160,10 @@ class CanViewProjectFilterBackend(PermissionBasedFilterBackend): permission = "view_project" +class CanViewEpicsFilterBackend(PermissionBasedFilterBackend): + permission = "view_epics" + + class CanViewUsFilterBackend(PermissionBasedFilterBackend): permission = "view_us" diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py new file mode 100644 index 00000000..7b703ba5 --- /dev/null +++ b/taiga/projects/epics/api.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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.http import HttpResponse +from django.utils.translation import ugettext as _ + +from taiga.base.api.utils import get_object_or_404 +from taiga.base import filters, response +from taiga.base import exceptions as exc +from taiga.base.decorators import list_route +from taiga.base.api import ModelCrudViewSet, ModelListViewSet +from taiga.base.api.mixins import BlockedByProjectMixin + +from taiga.projects.history.mixins import HistoryResourceMixin +from taiga.projects.models import Project, EpicStatus +from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin +from taiga.projects.occ import OCCResourceMixin +from taiga.projects.tagging.api import TaggedResourceMixin +from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin + +from . import models +from . import permissions +from . import serializers +from . import services +from . import validators +from . import utils as epics_utils + + +class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, + WatchedResourceMixin, TaggedResourceMixin, BlockedByProjectMixin, + ModelCrudViewSet): + validator_class = validators.EpicValidator + queryset = models.Epic.objects.all() + permission_classes = (permissions.EpicPermission,) + filter_backends = (filters.CanViewEpicsFilterBackend, + filters.OwnersFilter, + filters.AssignedToFilter, + filters.StatusesFilter, + filters.TagsFilter, + filters.WatchersFilter, + filters.QFilter) + retrieve_exclude_filters = (filters.OwnersFilter, + filters.AssignedToFilter, + filters.StatusesFilter, + filters.TagsFilter, + filters.WatchersFilter) + filter_fields = ["project", + "project__slug", + "assigned_to", + "status__is_closed"] + + def get_serializer_class(self, *args, **kwargs): + if self.action in ["retrieve", "by_ref"]: + return serializers.EpicNeighborsSerializer + + if self.action == "list": + return serializers.EpicListSerializer + + return serializers.EpicSerializer + + def get_queryset(self): + qs = super().get_queryset() + qs = qs.select_related("project", + "status", + "owner", + "assigned_to") + + include_attachments = "include_attachments" in self.request.QUERY_PARAMS + qs = epics_utils.attach_extra_info(qs, user=self.request.user, + include_attachments=include_attachments) + + return qs + + def pre_conditions_on_save(self, obj): + super().pre_conditions_on_save(obj) + + if obj.status and obj.status.project != obj.project: + raise exc.WrongArguments(_("You don't have permissions to set this status to this epic.")) + + def pre_save(self, obj): + if not obj.id: + obj.owner = self.request.user + super().pre_save(obj) + + def update(self, request, *args, **kwargs): + self.object = self.get_object_or_none() + project_id = request.DATA.get('project', None) + if project_id and self.object and self.object.project.id != project_id: + try: + new_project = Project.objects.get(pk=project_id) + self.check_permissions(request, "destroy", self.object) + self.check_permissions(request, "create", new_project) + + status_id = request.DATA.get('status', None) + if status_id is not None: + try: + old_status = self.object.project.epic_statuses.get(pk=status_id) + new_status = new_project.epic_statuses.get(slug=old_status.slug) + request.DATA['status'] = new_status.id + except EpicStatus.DoesNotExist: + request.DATA['status'] = new_project.default_epic_status.id + + except Project.DoesNotExist: + return response.BadRequest(_("The project doesn't exist")) + + return super().update(request, *args, **kwargs) + + @list_route(methods=["GET"]) + def filters_data(self, request, *args, **kwargs): + project_id = request.QUERY_PARAMS.get("project", None) + project = get_object_or_404(Project, id=project_id) + + filter_backends = self.get_filter_backends() + statuses_filter_backends = (f for f in filter_backends if f != filters.StatusesFilter) + assigned_to_filter_backends = (f for f in filter_backends if f != filters.AssignedToFilter) + owners_filter_backends = (f for f in filter_backends if f != filters.OwnersFilter) + + queryset = self.get_queryset() + querysets = { + "statuses": self.filter_queryset(queryset, filter_backends=statuses_filter_backends), + "assigned_to": self.filter_queryset(queryset, filter_backends=assigned_to_filter_backends), + "owners": self.filter_queryset(queryset, filter_backends=owners_filter_backends), + "tags": self.filter_queryset(queryset) + } + return response.Ok(services.get_epics_filters_data(project, querysets)) + + @list_route(methods=["GET"]) + def by_ref(self, request): + retrieve_kwargs = { + "ref": request.QUERY_PARAMS.get("ref", None) + } + project_id = request.QUERY_PARAMS.get("project", None) + if project_id is not None: + retrieve_kwargs["project_id"] = project_id + + project_slug = request.QUERY_PARAMS.get("project__slug", None) + if project_slug is not None: + retrieve_kwargs["project__slug"] = project_slug + + return self.retrieve(request, **retrieve_kwargs) + + #@list_route(methods=["GET"]) + #def csv(self, request): + # uuid = request.QUERY_PARAMS.get("uuid", None) + # if uuid is None: + # return response.NotFound() + + # project = get_object_or_404(Project, epics_csv_uuid=uuid) + # queryset = project.epics.all().order_by('ref') + # data = services.epics_to_csv(project, queryset) + # csv_response = HttpResponse(data.getvalue(), content_type='application/csv; charset=utf-8') + # csv_response['Content-Disposition'] = 'attachment; filename="epics.csv"' + # return csv_response + + @list_route(methods=["POST"]) + def bulk_create(self, request, **kwargs): + validator = validators.EpicsBulkValidator(data=request.DATA) + if validator.is_valid(): + data = validator.data + project = Project.objects.get(id=data["project_id"]) + self.check_permissions(request, 'bulk_create', project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + epics = services.create_epics_in_bulk( + data["bulk_epics"], milestone_id=data["sprint_id"], user_story_id=data["us_id"], + status_id=data.get("status_id") or project.default_epic_status_id, + project=project, owner=request.user, callback=self.post_save, precall=self.pre_save) + + epics = self.get_queryset().filter(id__in=[i.id for i in epics]) + epics_serialized = self.get_serializer_class()(epics, many=True) + + return response.Ok(epics_serialized.data) + + return response.BadRequest(validator.errors) + + def _bulk_update_order(self, order_field, request, **kwargs): + validator = validators.UpdateEpicsOrderBulkValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + project = get_object_or_404(Project, pk=data["project_id"]) + + self.check_permissions(request, "bulk_update_order", project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + services.update_epics_order_in_bulk(data["bulk_epics"], + project=project, + field=order_field) + services.snapshot_epics_in_bulk(data["bulk_epics"], request.user) + + return response.NoContent() + + @list_route(methods=["POST"]) + def bulk_update_epic_order(self, request, **kwargs): + return self._bulk_update_order("epic_order", request, **kwargs) + + +class EpicVotersViewSet(VotersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.EpicVotersPermission,) + resource_model = models.Epic + + +class EpicWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.EpicWatchersPermission,) + resource_model = models.Epic diff --git a/taiga/projects/epics/serializers.py b/taiga/projects/epics/serializers.py new file mode 100644 index 00000000..d9d45b2b --- /dev/null +++ b/taiga/projects/epics/serializers.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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 taiga.base.api import serializers +from taiga.base.fields import Field, MethodField +from taiga.base.neighbors import NeighborsSerializerMixin + +from taiga.mdrender.service import render as mdrender +from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin +from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin +from taiga.projects.notifications.mixins import WatchedResourceSerializer +from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin + + +class EpicListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, + OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, + StatusExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin, + serializers.LightSerializer): + + id = Field() + ref = Field() + project = Field(attr="project_id") + created_date = Field() + modified_date = Field() + subject = Field() + epic_order = Field() + client_requirement = Field() + team_requirement = Field() + version = Field() + watchers = Field() + is_blocked = Field() + blocked_note = Field() + tags = Field() + is_closed = MethodField() + + def get_is_closed(self, obj): + return obj.status is not None and obj.status.is_closed + + +class EpicSerializer(EpicListSerializer): + comment = MethodField() + blocked_note_html = MethodField() + description = Field() + description_html = MethodField() + + def get_comment(self, obj): + return "" + + def get_blocked_note_html(self, obj): + return mdrender(obj.project, obj.blocked_note) + + def get_description_html(self, obj): + return mdrender(obj.project, obj.description) + + +class EpicNeighborsSerializer(NeighborsSerializerMixin, EpicSerializer): + pass diff --git a/taiga/projects/epics/services.py b/taiga/projects/epics/services.py new file mode 100644 index 00000000..01a0f0fa --- /dev/null +++ b/taiga/projects/epics/services.py @@ -0,0 +1,376 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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 csv +import io +from collections import OrderedDict +from operator import itemgetter +from contextlib import closing + +from django.db import connection +from django.utils.translation import ugettext as _ + +from taiga.base.utils import db, text +from taiga.projects.history.services import take_snapshot +from taiga.projects.epics.apps import connect_epics_signals +from taiga.projects.epics.apps import disconnect_epics_signals +from taiga.events import events +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.notifications.utils import attach_watchers_to_queryset + +from . import models + + +##################################################### +# Bulk actions +##################################################### + +def get_epics_from_bulk(bulk_data, **additional_fields): + """Convert `bulk_data` into a list of epics. + + :param bulk_data: List of epics in bulk format. + :param additional_fields: Additional fields when instantiating each epic. + + :return: List of `Epic` instances. + """ + return [models.Epic(subject=line, **additional_fields) + for line in text.split_in_lines(bulk_data)] + + +def create_epics_in_bulk(bulk_data, callback=None, precall=None, **additional_fields): + """Create epics from `bulk_data`. + + :param bulk_data: List of epics in bulk format. + :param callback: Callback to execute after each epic save. + :param additional_fields: Additional fields when instantiating each epic. + + :return: List of created `Epic` instances. + """ + epics = get_epics_from_bulk(bulk_data, **additional_fields) + + disconnect_epics_signals() + + try: + db.save_in_bulk(epics, callback, precall) + finally: + connect_epics_signals() + + return epics + + +def update_epics_order_in_bulk(bulk_data: list, field: str, project: object): + """ + Update the order of some epics. + `bulk_data` should be a list of tuples with the following format: + + [(, {: , ...}), ...] + """ + epic_ids = [] + new_order_values = [] + for epic_data in bulk_data: + epic_ids.append(epic_data["epic_id"]) + new_order_values.append({field: epic_data["order"]}) + + events.emit_event_for_ids(ids=epic_ids, + content_type="epics.epic", + projectid=project.pk) + + db.update_in_bulk_with_ids(epic_ids, new_order_values, model=models.Epic) + + +def snapshot_epics_in_bulk(bulk_data, user): + for epic_data in bulk_data: + try: + epic = models.Epic.objects.get(pk=epic_data['epic_id']) + take_snapshot(epic, user=user) + except models.Epic.DoesNotExist: + pass + + +##################################################### +# CSV +##################################################### +# +#def epics_to_csv(project, queryset): +# csv_data = io.StringIO() +# fieldnames = ["ref", "subject", "description", "user_story", "sprint", "sprint_estimated_start", +# "sprint_estimated_finish", "owner", "owner_full_name", "assigned_to", +# "assigned_to_full_name", "status", "is_iocaine", "is_closed", "us_order", +# "epicboard_order", "attachments", "external_reference", "tags", "watchers", "voters", +# "created_date", "modified_date", "finished_date"] +# +# custom_attrs = project.epiccustomattributes.all() +# for custom_attr in custom_attrs: +# fieldnames.append(custom_attr.name) +# +# queryset = queryset.prefetch_related("attachments", +# "custom_attributes_values") +# queryset = queryset.select_related("milestone", +# "owner", +# "assigned_to", +# "status", +# "project") +# +# queryset = attach_total_voters_to_queryset(queryset) +# queryset = attach_watchers_to_queryset(queryset) +# +# writer = csv.DictWriter(csv_data, fieldnames=fieldnames) +# writer.writeheader() +# for epic in queryset: +# epic_data = { +# "ref": epic.ref, +# "subject": epic.subject, +# "description": epic.description, +# "user_story": epic.user_story.ref if epic.user_story else None, +# "sprint": epic.milestone.name if epic.milestone else None, +# "sprint_estimated_start": epic.milestone.estimated_start if epic.milestone else None, +# "sprint_estimated_finish": epic.milestone.estimated_finish if epic.milestone else None, +# "owner": epic.owner.username if epic.owner else None, +# "owner_full_name": epic.owner.get_full_name() if epic.owner else None, +# "assigned_to": epic.assigned_to.username if epic.assigned_to else None, +# "assigned_to_full_name": epic.assigned_to.get_full_name() if epic.assigned_to else None, +# "status": epic.status.name if epic.status else None, +# "is_iocaine": epic.is_iocaine, +# "is_closed": epic.status is not None and epic.status.is_closed, +# "us_order": epic.us_order, +# "epicboard_order": epic.epicboard_order, +# "attachments": epic.attachments.count(), +# "external_reference": epic.external_reference, +# "tags": ",".join(epic.tags or []), +# "watchers": epic.watchers, +# "voters": epic.total_voters, +# "created_date": epic.created_date, +# "modified_date": epic.modified_date, +# "finished_date": epic.finished_date, +# } +# for custom_attr in custom_attrs: +# value = epic.custom_attributes_values.attributes_values.get(str(custom_attr.id), None) +# epic_data[custom_attr.name] = value +# +# writer.writerow(epic_data) +# +# return csv_data + + +##################################################### +# Api filter data +##################################################### + +def _get_epics_statuses(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + SELECT "projects_epicstatus"."id", + "projects_epicstatus"."name", + "projects_epicstatus"."color", + "projects_epicstatus"."order", + (SELECT count(*) + FROM "epics_epic" + INNER JOIN "projects_project" ON + ("epics_epic"."project_id" = "projects_project"."id") + WHERE {where} AND "epics_epic"."status_id" = "projects_epicstatus"."id") + FROM "projects_epicstatus" + WHERE "projects_epicstatus"."project_id" = %s + ORDER BY "projects_epicstatus"."order"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, name, color, order, count in rows: + result.append({ + "id": id, + "name": _(name), + "color": color, + "order": order, + "count": count, + }) + return sorted(result, key=itemgetter("order")) + + +def _get_epics_assigned_to(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT assigned_to_id, count(assigned_to_id) count + FROM "epics_epic" + INNER JOIN "projects_project" ON ("epics_epic"."project_id" = "projects_project"."id") + WHERE {where} AND "epics_epic"."assigned_to_id" IS NOT NULL + GROUP BY assigned_to_id + ) + + SELECT "projects_membership"."user_id" user_id, + "users_user"."full_name", + "users_user"."username", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."assigned_to_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- unassigned epics + UNION + + SELECT NULL user_id, NULL, NULL, count(coalesce(assigned_to_id, -1)) count + FROM "epics_epic" + INNER JOIN "projects_project" ON ("epics_epic"."project_id" = "projects_project"."id") + WHERE {where} AND "epics_epic"."assigned_to_id" IS NULL + GROUP BY assigned_to_id + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id] + where_params) + rows = cursor.fetchall() + + result = [] + none_valued_added = False + for id, full_name, username, count in rows: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + }) + + if id is None: + none_valued_added = True + + # If there was no epic with null assigned_to we manually add it + if not none_valued_added: + result.append({ + "id": None, + "full_name": "", + "count": 0, + }) + + return sorted(result, key=itemgetter("full_name")) + + +def _get_epics_owners(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT "epics_epic"."owner_id" owner_id, + count(coalesce("epics_epic"."owner_id", -1)) count + FROM "epics_epic" + INNER JOIN "projects_project" ON ("epics_epic"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY "epics_epic"."owner_id" + ) + + SELECT "projects_membership"."user_id" id, + "users_user"."full_name", + "users_user"."username", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- System users + UNION + + SELECT "users_user"."id" user_id, + "users_user"."full_name" full_name, + "users_user"."username" username, + COALESCE("counters".count, 0) count + FROM users_user + LEFT OUTER JOIN counters ON ("users_user"."id" = "counters"."owner_id") + WHERE ("users_user"."is_system" IS TRUE) + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, full_name, username, count in rows: + if count > 0: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + }) + return sorted(result, key=itemgetter("full_name")) + + +def _get_epics_tags(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH epics_tags AS ( + SELECT tag, + COUNT(tag) counter FROM ( + SELECT UNNEST(epics_epic.tags) tag + FROM epics_epic + INNER JOIN projects_project + ON (epics_epic.project_id = projects_project.id) + WHERE {where}) tags + GROUP BY tag), + project_tags AS ( + SELECT reduce_dim(tags_colors) tag_color + FROM projects_project + WHERE id=%s) + + SELECT tag_color[1] tag, COALESCE(epics_tags.counter, 0) counter + FROM project_tags + LEFT JOIN epics_tags ON project_tags.tag_color[1] = epics_tags.tag + ORDER BY tag + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for name, count in rows: + result.append({ + "name": name, + "count": count, + }) + return sorted(result, key=itemgetter("name")) + + +def get_epics_filters_data(project, querysets): + """ + Given a project and an epics queryset, return a simple data structure + of all possible filters for the epics in the queryset. + """ + data = OrderedDict([ + ("statuses", _get_epics_statuses(project, querysets["statuses"])), + ("assigned_to", _get_epics_assigned_to(project, querysets["assigned_to"])), + ("owners", _get_epics_owners(project, querysets["owners"])), + ("tags", _get_epics_tags(project, querysets["tags"])), + ]) + + return data diff --git a/taiga/projects/epics/utils.py b/taiga/projects/epics/utils.py new file mode 100644 index 00000000..d10dddab --- /dev/null +++ b/taiga/projects/epics/utils.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# Copyright (C) 2014-2016 Anler Hernández +# 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 taiga.projects.attachments.utils import attach_basic_attachments +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.notifications.utils import attach_total_watchers_to_queryset +from taiga.projects.notifications.utils import attach_is_watcher_to_queryset +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.votes.utils import attach_is_voter_to_queryset + + +def attach_extra_info(queryset, user=None, include_attachments=False): + + if include_attachments: + queryset = attach_basic_attachments(queryset) + queryset = queryset.extra(select={"include_attachments": "True"}) + + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + queryset = attach_total_watchers_to_queryset(queryset) + queryset = attach_is_voter_to_queryset(queryset, user) + queryset = attach_is_watcher_to_queryset(queryset, user) + return queryset diff --git a/taiga/projects/epics/validators.py b/taiga/projects/epics/validators.py new file mode 100644 index 00000000..9d7a617f --- /dev/null +++ b/taiga/projects/epics/validators.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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.utils.translation import ugettext as _ + +from taiga.base.api import serializers +from taiga.base.api import validators +from taiga.base.exceptions import ValidationError +from taiga.base.fields import PgArrayField +from taiga.projects.milestones.validators import MilestoneExistsValidator +from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer +from taiga.projects.notifications.validators import WatchersValidator +from taiga.projects.tagging.fields import TagsAndTagsColorsField +from taiga.projects.validators import ProjectExistsValidator +from . import models + + +class EpicExistsValidator: + def validate_epic_id(self, attrs, source): + value = attrs[source] + if not models.Epic.objects.filter(pk=value).exists(): + msg = _("There's no epic with that id") + raise ValidationError(msg) + return attrs + + +class EpicValidator(WatchersValidator, EditableWatchedResourceSerializer, validators.ModelValidator): + tags = TagsAndTagsColorsField(default=[], required=False) + external_reference = PgArrayField(required=False) + + class Meta: + model = models.Epic + read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner') + + +class EpicsBulkValidator(ProjectExistsValidator, EpicExistsValidator, + validators.Validator): + project_id = serializers.IntegerField() + status_id = serializers.IntegerField(required=False) + bulk_epics = serializers.CharField() + + +# Order bulk validators + +class _EpicOrderBulkValidator(EpicExistsValidator, validators.Validator): + epic_id = serializers.IntegerField() + order = serializers.IntegerField() + + +class UpdateEpicsOrderBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + bulk_epics = _EpicOrderBulkValidator(many=True) diff --git a/taiga/routers.py b/taiga/routers.py index 49db63b2..e0d0baa1 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -145,6 +145,10 @@ router.register(r"wiki/attachments", WikiAttachmentViewSet, from taiga.projects.milestones.api import MilestoneViewSet from taiga.projects.milestones.api import MilestoneWatchersViewSet +from taiga.projects.epics.api import EpicViewSet +from taiga.projects.epics.api import EpicVotersViewSet +from taiga.projects.epics.api import EpicWatchersViewSet + from taiga.projects.userstories.api import UserStoryViewSet from taiga.projects.userstories.api import UserStoryVotersViewSet from taiga.projects.userstories.api import UserStoryWatchersViewSet @@ -166,6 +170,13 @@ router.register(r"milestones", MilestoneViewSet, router.register(r"milestones/(?P\d+)/watchers", MilestoneWatchersViewSet, base_name="milestone-watchers") +router.register(r"epics", EpicViewSet, + base_name="epics") +router.register(r"epics/(?P\d+)/voters", EpicVotersViewSet, + base_name="epic-voters") +router.register(r"epics/(?P\d+)/watchers", EpicWatchersViewSet, + base_name="epic-watchers") + router.register(r"userstories", UserStoryViewSet, base_name="userstories") router.register(r"userstories/(?P\d+)/voters", UserStoryVotersViewSet, From 82bff6f5bfb9d500c6d3b656fac2812a3cbad25a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 7 Jul 2016 13:23:48 +0200 Subject: [PATCH 09/83] Fixup. Custom atributes --- .../test_epics_custom_attributes_resource.py | 458 ++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 tests/integration/resources_permissions/test_epics_custom_attributes_resource.py diff --git a/tests/integration/resources_permissions/test_epics_custom_attributes_resource.py b/tests/integration/resources_permissions/test_epics_custom_attributes_resource.py new file mode 100644 index 00000000..c35b93bd --- /dev/null +++ b/tests/integration/resources_permissions/test_epics_custom_attributes_resource.py @@ -0,0 +1,458 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects.custom_attributes import serializers +from taiga.permissions.choices import (MEMBERS_PERMISSIONS, + ANON_PERMISSIONS) + +from tests import factories as f +from tests.utils import helper_test_http_method + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + blocked_code=project_choices.BLOCKED_BY_STAFF) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project1, + role__permissions=[]) + + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project2, + role__permissions=[]) + + m.blocked_membership = f.MembershipFactory(project=m.blocked_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_epic_ca = f.EpicCustomAttributeFactory(project=m.public_project) + m.private_epic_ca1 = f.EpicCustomAttributeFactory(project=m.private_project1) + m.private_epic_ca2 = f.EpicCustomAttributeFactory(project=m.private_project2) + m.blocked_epic_ca = f.EpicCustomAttributeFactory(project=m.blocked_project) + + m.public_epic = f.EpicFactory(project=m.public_project, + status__project=m.public_project) + m.private_epic1 = f.EpicFactory(project=m.private_project1, + status__project=m.private_project1) + m.private_epic2 = f.EpicFactory(project=m.private_project2, + status__project=m.private_project2) + m.blocked_epic = f.EpicFactory(project=m.blocked_project, + status__project=m.blocked_project) + + m.public_epic_cav = m.public_epic.custom_attributes_values + m.private_epic_cav1 = m.private_epic1.custom_attributes_values + m.private_epic_cav2 = m.private_epic2.custom_attributes_values + m.blocked_epic_cav = m.blocked_epic.custom_attributes_values + + return m + + +######################################################### +# Epic Custom Attribute +######################################################### + +def test_epic_custom_attribute_retrieve(client, data): + public_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.public_epic_ca.pk}) + private1_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca1.pk}) + private2_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca2.pk}) + blocked_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.blocked_epic_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_custom_attribute_create(client, data): + public_url = reverse('epic-custom-attributes-list') + private1_url = reverse('epic-custom-attributes-list') + private2_url = reverse('epic-custom-attributes-list') + blocked_url = reverse('epic-custom-attributes-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + epic_ca_data = {"name": "test-new", "project": data.public_project.id} + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'post', public_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + epic_ca_data = {"name": "test-new", "project": data.private_project1.id} + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'post', private1_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + epic_ca_data = {"name": "test-new", "project": data.private_project2.id} + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'post', private2_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + epic_ca_data = {"name": "test-new", "project": data.blocked_project.id} + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'post', blocked_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_epic_custom_attribute_update(client, data): + public_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.public_epic_ca.pk}) + private1_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca1.pk}) + private2_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca2.pk}) + blocked_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.blocked_epic_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + epic_ca_data = serializers.EpicCustomAttributeSerializer(data.public_epic_ca).data + epic_ca_data["name"] = "test" + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'put', public_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + epic_ca_data = serializers.EpicCustomAttributeSerializer(data.private_epic_ca1).data + epic_ca_data["name"] = "test" + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'put', private1_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + epic_ca_data = serializers.EpicCustomAttributeSerializer(data.private_epic_ca2).data + epic_ca_data["name"] = "test" + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'put', private2_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + epic_ca_data = serializers.EpicCustomAttributeSerializer(data.blocked_epic_ca).data + epic_ca_data["name"] = "test" + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'put', private2_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_epic_custom_attribute_delete(client, data): + public_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.public_epic_ca.pk}) + private1_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca1.pk}) + private2_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca2.pk}) + blocked_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.blocked_epic_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + + +def test_epic_custom_attribute_list(client, data): + url = reverse('epic-custom-attributes-list') + + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.json.get(url) + assert len(response.data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.json.get(url) + assert len(response.data) == 4 + assert response.status_code == 200 + + +def test_epic_custom_attribute_patch(client, data): + public_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.public_epic_ca.pk}) + private1_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca1.pk}) + private2_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca2.pk}) + blocked_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.blocked_epic_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_epic_custom_attribute_action_bulk_update_order(client, data): + url = reverse('epic-custom-attributes-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_epic_custom_attributes": [(1,2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_epic_custom_attributes": [(1,2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_epic_custom_attributes": [(1,2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_epic_custom_attributes": [(1,2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + +######################################################### +# Epic Custom Attribute +######################################################### + + +def test_epic_custom_attributes_values_retrieve(client, data): + public_url = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.public_epic.pk}) + private_url1 = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.private_epic1.pk}) + private_url2 = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.private_epic2.pk}) + blocked_url = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_custom_attributes_values_update(client, data): + public_url = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.public_epic.pk}) + private_url1 = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.private_epic1.pk}) + private_url2 = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.private_epic2.pk}) + blocked_url = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + epic_data = serializers.EpicCustomAttributesValuesSerializer(data.public_epic_cav).data + epic_data["attributes_values"] = {str(data.public_epic_ca.pk): "test"} + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', public_url, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = serializers.EpicCustomAttributesValuesSerializer(data.private_epic_cav1).data + epic_data["attributes_values"] = {str(data.private_epic_ca1.pk): "test"} + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url1, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = serializers.EpicCustomAttributesValuesSerializer(data.private_epic_cav2).data + epic_data["attributes_values"] = {str(data.private_epic_ca2.pk): "test"} + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url2, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = serializers.EpicCustomAttributesValuesSerializer(data.blocked_epic_cav).data + epic_data["attributes_values"] = {str(data.blocked_epic_ca.pk): "test"} + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', blocked_url, epic_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_custom_attributes_values_patch(client, data): + public_url = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.public_epic.pk}) + private_url1 = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.private_epic1.pk}) + private_url2 = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.private_epic2.pk}) + blocked_url = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"attributes_values": {str(data.public_epic_ca.pk): "test"}, + "version": data.public_epic.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_epic_ca1.pk): "test"}, + "version": data.private_epic1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_epic_ca2.pk): "test"}, + "version": data.private_epic2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.blocked_epic_ca.pk): "test"}, + "version": data.blocked_epic.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] From 7a412fb5367879b748098094d44d4768cf00e740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 7 Jul 2016 15:22:34 +0200 Subject: [PATCH 10/83] Fix some tests --- taiga/base/filters.py | 2 +- taiga/permissions/choices.py | 4 +- taiga/projects/attachments/permissions.py | 2 +- taiga/projects/epics/api.py | 10 +- taiga/projects/epics/apps.py | 2 +- taiga/projects/epics/serializers.py | 2 +- .../fixtures/initial_project_templates.json | 44 +- .../migrations/0049_auto_20160629_1443.py | 4 +- taiga/timeline/service.py | 2 +- .../migrations/0022_auto_20160629_1443.py | 2 +- .../test_epics_resources.py | 901 ++++++++++++++++++ 11 files changed, 939 insertions(+), 36 deletions(-) create mode 100644 tests/integration/resources_permissions/test_epics_resources.py diff --git a/taiga/base/filters.py b/taiga/base/filters.py index 187033c1..a30e2dcf 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -203,7 +203,7 @@ class PermissionBasedAttachmentFilterBackend(PermissionBasedFilterBackend): class CanViewEpicAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend): - permission = "view_epic" + permission = "view_epics" class CanViewUserStoryAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend): diff --git a/taiga/permissions/choices.py b/taiga/permissions/choices.py index 5cda50a5..3564acc8 100644 --- a/taiga/permissions/choices.py +++ b/taiga/permissions/choices.py @@ -22,7 +22,7 @@ from django.utils.translation import ugettext_lazy as _ ANON_PERMISSIONS = [ ('view_project', _('View project')), ('view_milestones', _('View milestones')), - ('view_epic', _('View epic')), + ('view_epics', _('View epic')), ('view_us', _('View user stories')), ('view_tasks', _('View tasks')), ('view_issues', _('View issues')), @@ -38,7 +38,7 @@ MEMBERS_PERMISSIONS = [ ('modify_milestone', _('Modify milestone')), ('delete_milestone', _('Delete milestone')), # US permissions - ('view_epic', _('View epic')), + ('view_epics', _('View epic')), ('add_epic', _('Add epic')), ('modify_epic', _('Modify epic')), ('comment_epic', _('Comment epic')), diff --git a/taiga/projects/attachments/permissions.py b/taiga/projects/attachments/permissions.py index ee768014..4c7a7915 100644 --- a/taiga/projects/attachments/permissions.py +++ b/taiga/projects/attachments/permissions.py @@ -29,7 +29,7 @@ class IsAttachmentOwnerPerm(PermissionComponent): class EpicAttachmentPermission(TaigaResourcePermission): - retrieve_perms = HasProjectPerm('view_epic') | IsAttachmentOwnerPerm() + retrieve_perms = HasProjectPerm('view_epics') | IsAttachmentOwnerPerm() create_perms = HasProjectPerm('modify_epic') update_perms = HasProjectPerm('modify_epic') | IsAttachmentOwnerPerm() partial_update_perms = HasProjectPerm('modify_epic') | IsAttachmentOwnerPerm() diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index 7b703ba5..e71106d1 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -178,9 +178,11 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, raise exc.Blocked(_("Blocked element")) epics = services.create_epics_in_bulk( - data["bulk_epics"], milestone_id=data["sprint_id"], user_story_id=data["us_id"], + data["bulk_epics"], status_id=data.get("status_id") or project.default_epic_status_id, - project=project, owner=request.user, callback=self.post_save, precall=self.pre_save) + project=project, + owner=request.user, + callback=self.post_save, precall=self.pre_save) epics = self.get_queryset().filter(id__in=[i.id for i in epics]) epics_serialized = self.get_serializer_class()(epics, many=True) @@ -209,8 +211,8 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, return response.NoContent() @list_route(methods=["POST"]) - def bulk_update_epic_order(self, request, **kwargs): - return self._bulk_update_order("epic_order", request, **kwargs) + def bulk_update_epics_order(self, request, **kwargs): + return self._bulk_update_order("epics_order", request, **kwargs) class EpicVotersViewSet(VotersViewSetMixin, ModelListViewSet): diff --git a/taiga/projects/epics/apps.py b/taiga/projects/epics/apps.py index 5389b98a..bf489ea0 100644 --- a/taiga/projects/epics/apps.py +++ b/taiga/projects/epics/apps.py @@ -43,7 +43,7 @@ def connect_all_epics_signals(): def disconnect_epics_signals(): - signals.pre_save.disconnect(sender=apps.get_model("epics", "Task"), + signals.pre_save.disconnect(sender=apps.get_model("epics", "Epic"), dispatch_uid="tags_normalization") diff --git a/taiga/projects/epics/serializers.py b/taiga/projects/epics/serializers.py index d9d45b2b..4118553e 100644 --- a/taiga/projects/epics/serializers.py +++ b/taiga/projects/epics/serializers.py @@ -40,7 +40,7 @@ class EpicListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, created_date = Field() modified_date = Field() subject = Field() - epic_order = Field() + epics_order = Field() client_requirement = Field() team_requirement = Field() version = Field() diff --git a/taiga/projects/fixtures/initial_project_templates.json b/taiga/projects/fixtures/initial_project_templates.json index 83d64b80..5c711c00 100644 --- a/taiga/projects/fixtures/initial_project_templates.json +++ b/taiga/projects/fixtures/initial_project_templates.json @@ -8,7 +8,7 @@ "description": "The agile product backlog in Scrum is a prioritized features list, containing short descriptions of all functionality desired in the product. When applying Scrum, it's not necessary to start a project with a lengthy, upfront effort to document all requirements. The Scrum product backlog is then allowed to grow and change as more is learned about the product and its customers", "order": 1, "created_date": "2014-04-22T14:48:43.596Z", - "modified_date": "2016-06-29T14:52:11.273Z", + "modified_date": "2016-07-07T13:18:25.350Z", "default_owner_role": "product-owner", "is_epics_activated": true, "is_backlog_activated": true, @@ -17,16 +17,16 @@ "is_issues_activated": true, "videoconferences": null, "videoconferences_extra_data": "", - "default_options": "{\"epic_status\": \"New\", \"severity\": \"Normal\", \"issue_type\": \"Bug\", \"us_status\": \"New\", \"points\": \"?\", \"priority\": \"Normal\", \"task_status\": \"New\", \"issue_status\": \"New\"}", - "epic_statuses": "[{\"order\": 1, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"order\": 2, \"name\": \"Ready\", \"color\": \"#ff8a84\", \"slug\": \"ready\", \"is_closed\": false}, {\"order\": 3, \"name\": \"In progress\", \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"order\": 4, \"name\": \"Ready for test\", \"color\": \"#fcc000\", \"slug\": \"ready-for-test\", \"is_closed\": false}, {\"order\": 5, \"name\": \"Done\", \"color\": \"#669900\", \"slug\": \"done\", \"is_closed\": true}]", - "us_statuses": "[{\"name\": \"New\", \"is_archived\": false, \"wip_limit\": null, \"order\": 1, \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"name\": \"Ready\", \"is_archived\": false, \"wip_limit\": null, \"order\": 2, \"color\": \"#ff8a84\", \"slug\": \"ready\", \"is_closed\": false}, {\"name\": \"In progress\", \"is_archived\": false, \"wip_limit\": null, \"order\": 3, \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"name\": \"Ready for test\", \"is_archived\": false, \"wip_limit\": null, \"order\": 4, \"color\": \"#fcc000\", \"slug\": \"ready-for-test\", \"is_closed\": false}, {\"name\": \"Done\", \"is_archived\": false, \"wip_limit\": null, \"order\": 5, \"color\": \"#669900\", \"slug\": \"done\", \"is_closed\": true}, {\"name\": \"Archived\", \"is_archived\": true, \"wip_limit\": null, \"order\": 6, \"color\": \"#5c3566\", \"slug\": \"archived\", \"is_closed\": true}]", - "points": "[{\"order\": 1, \"name\": \"?\", \"value\": null}, {\"order\": 2, \"name\": \"0\", \"value\": 0.0}, {\"order\": 3, \"name\": \"1/2\", \"value\": 0.5}, {\"order\": 4, \"name\": \"1\", \"value\": 1.0}, {\"order\": 5, \"name\": \"2\", \"value\": 2.0}, {\"order\": 6, \"name\": \"3\", \"value\": 3.0}, {\"order\": 7, \"name\": \"5\", \"value\": 5.0}, {\"order\": 8, \"name\": \"8\", \"value\": 8.0}, {\"order\": 9, \"name\": \"10\", \"value\": 10.0}, {\"order\": 10, \"name\": \"13\", \"value\": 13.0}, {\"order\": 11, \"name\": \"20\", \"value\": 20.0}, {\"order\": 12, \"name\": \"40\", \"value\": 40.0}]", - "task_statuses": "[{\"order\": 1, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"color\": \"#ffcc00\", \"slug\": \"ready-for-test\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"color\": \"#669900\", \"slug\": \"closed\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"color\": \"#999999\", \"slug\": \"needs-info\", \"is_closed\": false}]", - "issue_statuses": "[{\"order\": 1, \"name\": \"New\", \"color\": \"#8C2318\", \"slug\": \"new\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"color\": \"#5E8C6A\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"color\": \"#88A65E\", \"slug\": \"ready-for-test\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"color\": \"#BFB35A\", \"slug\": \"closed\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"color\": \"#89BAB4\", \"slug\": \"needs-info\", \"is_closed\": false}, {\"order\": 6, \"name\": \"Rejected\", \"color\": \"#CC0000\", \"slug\": \"rejected\", \"is_closed\": true}, {\"order\": 7, \"name\": \"Postponed\", \"color\": \"#666666\", \"slug\": \"posponed\", \"is_closed\": false}]", - "issue_types": "[{\"order\": 1, \"name\": \"Bug\", \"color\": \"#89BAB4\"}, {\"order\": 2, \"name\": \"Question\", \"color\": \"#ba89a8\"}, {\"order\": 3, \"name\": \"Enhancement\", \"color\": \"#89a8ba\"}]", - "priorities": "[{\"order\": 1, \"name\": \"Low\", \"color\": \"#666666\"}, {\"order\": 3, \"name\": \"Normal\", \"color\": \"#669933\"}, {\"order\": 5, \"name\": \"High\", \"color\": \"#CC0000\"}]", - "severities": "[{\"order\": 1, \"name\": \"Wishlist\", \"color\": \"#666666\"}, {\"order\": 2, \"name\": \"Minor\", \"color\": \"#669933\"}, {\"order\": 3, \"name\": \"Normal\", \"color\": \"#0000FF\"}, {\"order\": 4, \"name\": \"Important\", \"color\": \"#FFA500\"}, {\"order\": 5, \"name\": \"Critical\", \"color\": \"#CC0000\"}]", - "roles": "[{\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 10, \"name\": \"UX\", \"slug\": \"ux\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 20, \"name\": \"Design\", \"slug\": \"design\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 30, \"name\": \"Front\", \"slug\": \"front\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 40, \"name\": \"Back\", \"slug\": \"back\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 50, \"name\": \"Product Owner\", \"slug\": \"product-owner\", \"computable\": false}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 60, \"name\": \"Stakeholder\", \"slug\": \"stakeholder\", \"computable\": false}]" + "default_options": "{\"issue_status\": \"New\", \"task_status\": \"New\", \"severity\": \"Normal\", \"issue_type\": \"Bug\", \"points\": \"?\", \"priority\": \"Normal\", \"us_status\": \"New\", \"epic_status\": \"New\"}", + "epic_statuses": "[{\"name\": \"New\", \"is_closed\": false, \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"Ready\", \"is_closed\": false, \"slug\": \"ready\", \"order\": 2, \"color\": \"#ff8a84\"}, {\"name\": \"In progress\", \"is_closed\": false, \"slug\": \"in-progress\", \"order\": 3, \"color\": \"#ff9900\"}, {\"name\": \"Ready for test\", \"is_closed\": false, \"slug\": \"ready-for-test\", \"order\": 4, \"color\": \"#fcc000\"}, {\"name\": \"Done\", \"is_closed\": true, \"slug\": \"done\", \"order\": 5, \"color\": \"#669900\"}]", + "us_statuses": "[{\"wip_limit\": null, \"name\": \"New\", \"slug\": \"new\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#999999\", \"order\": 1}, {\"wip_limit\": null, \"name\": \"Ready\", \"slug\": \"ready\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#ff8a84\", \"order\": 2}, {\"wip_limit\": null, \"name\": \"In progress\", \"slug\": \"in-progress\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#ff9900\", \"order\": 3}, {\"wip_limit\": null, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#fcc000\", \"order\": 4}, {\"wip_limit\": null, \"name\": \"Done\", \"slug\": \"done\", \"is_closed\": true, \"is_archived\": false, \"color\": \"#669900\", \"order\": 5}, {\"wip_limit\": null, \"name\": \"Archived\", \"slug\": \"archived\", \"is_closed\": true, \"is_archived\": true, \"color\": \"#5c3566\", \"order\": 6}]", + "points": "[{\"name\": \"?\", \"value\": null, \"order\": 1}, {\"name\": \"0\", \"value\": 0.0, \"order\": 2}, {\"name\": \"1/2\", \"value\": 0.5, \"order\": 3}, {\"name\": \"1\", \"value\": 1.0, \"order\": 4}, {\"name\": \"2\", \"value\": 2.0, \"order\": 5}, {\"name\": \"3\", \"value\": 3.0, \"order\": 6}, {\"name\": \"5\", \"value\": 5.0, \"order\": 7}, {\"name\": \"8\", \"value\": 8.0, \"order\": 8}, {\"name\": \"10\", \"value\": 10.0, \"order\": 9}, {\"name\": \"13\", \"value\": 13.0, \"order\": 10}, {\"name\": \"20\", \"value\": 20.0, \"order\": 11}, {\"name\": \"40\", \"value\": 40.0, \"order\": 12}]", + "task_statuses": "[{\"name\": \"New\", \"is_closed\": false, \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"In progress\", \"is_closed\": false, \"slug\": \"in-progress\", \"order\": 2, \"color\": \"#ff9900\"}, {\"name\": \"Ready for test\", \"is_closed\": true, \"slug\": \"ready-for-test\", \"order\": 3, \"color\": \"#ffcc00\"}, {\"name\": \"Closed\", \"is_closed\": true, \"slug\": \"closed\", \"order\": 4, \"color\": \"#669900\"}, {\"name\": \"Needs Info\", \"is_closed\": false, \"slug\": \"needs-info\", \"order\": 5, \"color\": \"#999999\"}]", + "issue_statuses": "[{\"name\": \"New\", \"is_closed\": false, \"slug\": \"new\", \"order\": 1, \"color\": \"#8C2318\"}, {\"name\": \"In progress\", \"is_closed\": false, \"slug\": \"in-progress\", \"order\": 2, \"color\": \"#5E8C6A\"}, {\"name\": \"Ready for test\", \"is_closed\": true, \"slug\": \"ready-for-test\", \"order\": 3, \"color\": \"#88A65E\"}, {\"name\": \"Closed\", \"is_closed\": true, \"slug\": \"closed\", \"order\": 4, \"color\": \"#BFB35A\"}, {\"name\": \"Needs Info\", \"is_closed\": false, \"slug\": \"needs-info\", \"order\": 5, \"color\": \"#89BAB4\"}, {\"name\": \"Rejected\", \"is_closed\": true, \"slug\": \"rejected\", \"order\": 6, \"color\": \"#CC0000\"}, {\"name\": \"Postponed\", \"is_closed\": false, \"slug\": \"posponed\", \"order\": 7, \"color\": \"#666666\"}]", + "issue_types": "[{\"name\": \"Bug\", \"order\": 1, \"color\": \"#89BAB4\"}, {\"name\": \"Question\", \"order\": 2, \"color\": \"#ba89a8\"}, {\"name\": \"Enhancement\", \"order\": 3, \"color\": \"#89a8ba\"}]", + "priorities": "[{\"name\": \"Low\", \"order\": 1, \"color\": \"#666666\"}, {\"name\": \"Normal\", \"order\": 3, \"color\": \"#669933\"}, {\"name\": \"High\", \"order\": 5, \"color\": \"#CC0000\"}]", + "severities": "[{\"name\": \"Wishlist\", \"order\": 1, \"color\": \"#666666\"}, {\"name\": \"Minor\", \"order\": 2, \"color\": \"#669933\"}, {\"name\": \"Normal\", \"order\": 3, \"color\": \"#0000FF\"}, {\"name\": \"Important\", \"order\": 4, \"color\": \"#FFA500\"}, {\"name\": \"Critical\", \"order\": 5, \"color\": \"#CC0000\"}]", + "roles": "[{\"name\": \"UX\", \"computable\": true, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 10}, {\"name\": \"Design\", \"computable\": true, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 20}, {\"name\": \"Front\", \"computable\": true, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 30}, {\"name\": \"Back\", \"computable\": true, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 40}, {\"name\": \"Product Owner\", \"computable\": false, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 50}, {\"name\": \"Stakeholder\", \"computable\": false, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\"], \"order\": 60}]" } }, { @@ -38,7 +38,7 @@ "description": "Kanban is a method for managing knowledge work with an emphasis on just-in-time delivery while not overloading the team members. In this approach, the process, from definition of a task to its delivery to the customer, is displayed for participants to see and team members pull work from a queue.", "order": 2, "created_date": "2014-04-22T14:50:19.738Z", - "modified_date": "2016-06-29T14:52:15.232Z", + "modified_date": "2016-07-07T13:18:28.186Z", "default_owner_role": "product-owner", "is_epics_activated": true, "is_backlog_activated": false, @@ -47,16 +47,16 @@ "is_issues_activated": false, "videoconferences": null, "videoconferences_extra_data": "", - "default_options": "{\"epic_status\": \"New\", \"severity\": \"Normal\", \"issue_type\": \"Bug\", \"us_status\": \"New\", \"points\": \"?\", \"priority\": \"Normal\", \"task_status\": \"New\", \"issue_status\": \"New\"}", - "epic_statuses": "[{\"order\": 1, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"order\": 2, \"name\": \"Ready\", \"color\": \"#ff8a84\", \"slug\": \"ready\", \"is_closed\": false}, {\"order\": 3, \"name\": \"In progress\", \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"order\": 4, \"name\": \"Ready for test\", \"color\": \"#fcc000\", \"slug\": \"ready-for-test\", \"is_closed\": false}, {\"order\": 5, \"name\": \"Done\", \"color\": \"#669900\", \"slug\": \"done\", \"is_closed\": true}]", - "us_statuses": "[{\"name\": \"New\", \"is_archived\": false, \"wip_limit\": null, \"order\": 1, \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"name\": \"Ready\", \"is_archived\": false, \"wip_limit\": null, \"order\": 2, \"color\": \"#f57900\", \"slug\": \"ready\", \"is_closed\": false}, {\"name\": \"In progress\", \"is_archived\": false, \"wip_limit\": null, \"order\": 3, \"color\": \"#729fcf\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"name\": \"Ready for test\", \"is_archived\": false, \"wip_limit\": null, \"order\": 4, \"color\": \"#4e9a06\", \"slug\": \"ready-for-test\", \"is_closed\": false}, {\"name\": \"Done\", \"is_archived\": false, \"wip_limit\": null, \"order\": 5, \"color\": \"#cc0000\", \"slug\": \"done\", \"is_closed\": true}, {\"name\": \"Archived\", \"is_archived\": true, \"wip_limit\": null, \"order\": 6, \"color\": \"#5c3566\", \"slug\": \"archived\", \"is_closed\": true}]", - "points": "[{\"order\": 1, \"name\": \"?\", \"value\": null}, {\"order\": 2, \"name\": \"0\", \"value\": 0.0}, {\"order\": 3, \"name\": \"1/2\", \"value\": 0.5}, {\"order\": 4, \"name\": \"1\", \"value\": 1.0}, {\"order\": 5, \"name\": \"2\", \"value\": 2.0}, {\"order\": 6, \"name\": \"3\", \"value\": 3.0}, {\"order\": 7, \"name\": \"5\", \"value\": 5.0}, {\"order\": 8, \"name\": \"8\", \"value\": 8.0}, {\"order\": 9, \"name\": \"10\", \"value\": 10.0}, {\"order\": 10, \"name\": \"13\", \"value\": 13.0}, {\"order\": 11, \"name\": \"20\", \"value\": 20.0}, {\"order\": 12, \"name\": \"40\", \"value\": 40.0}]", - "task_statuses": "[{\"order\": 1, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"color\": \"#729fcf\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"color\": \"#f57900\", \"slug\": \"ready-for-test\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"color\": \"#4e9a06\", \"slug\": \"closed\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"color\": \"#cc0000\", \"slug\": \"needs-info\", \"is_closed\": false}]", - "issue_statuses": "[{\"order\": 1, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"color\": \"#729fcf\", \"slug\": \"in-progress\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"color\": \"#f57900\", \"slug\": \"ready-for-test\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"color\": \"#4e9a06\", \"slug\": \"closed\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"color\": \"#cc0000\", \"slug\": \"needs-info\", \"is_closed\": false}, {\"order\": 6, \"name\": \"Rejected\", \"color\": \"#d3d7cf\", \"slug\": \"rejected\", \"is_closed\": true}, {\"order\": 7, \"name\": \"Postponed\", \"color\": \"#75507b\", \"slug\": \"posponed\", \"is_closed\": false}]", - "issue_types": "[{\"order\": 1, \"name\": \"Bug\", \"color\": \"#cc0000\"}, {\"order\": 2, \"name\": \"Question\", \"color\": \"#729fcf\"}, {\"order\": 3, \"name\": \"Enhancement\", \"color\": \"#4e9a06\"}]", - "priorities": "[{\"order\": 1, \"name\": \"Low\", \"color\": \"#999999\"}, {\"order\": 3, \"name\": \"Normal\", \"color\": \"#4e9a06\"}, {\"order\": 5, \"name\": \"High\", \"color\": \"#CC0000\"}]", - "severities": "[{\"order\": 1, \"name\": \"Wishlist\", \"color\": \"#999999\"}, {\"order\": 2, \"name\": \"Minor\", \"color\": \"#729fcf\"}, {\"order\": 3, \"name\": \"Normal\", \"color\": \"#4e9a06\"}, {\"order\": 4, \"name\": \"Important\", \"color\": \"#f57900\"}, {\"order\": 5, \"name\": \"Critical\", \"color\": \"#CC0000\"}]", - "roles": "[{\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 10, \"name\": \"UX\", \"slug\": \"ux\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 20, \"name\": \"Design\", \"slug\": \"design\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 30, \"name\": \"Front\", \"slug\": \"front\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 40, \"name\": \"Back\", \"slug\": \"back\", \"computable\": true}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 50, \"name\": \"Product Owner\", \"slug\": \"product-owner\", \"computable\": false}, {\"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"order\": 60, \"name\": \"Stakeholder\", \"slug\": \"stakeholder\", \"computable\": false}]" + "default_options": "{\"issue_status\": \"New\", \"task_status\": \"New\", \"severity\": \"Normal\", \"issue_type\": \"Bug\", \"points\": \"?\", \"priority\": \"Normal\", \"us_status\": \"New\", \"epic_status\": \"New\"}", + "epic_statuses": "[{\"name\": \"New\", \"is_closed\": false, \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"Ready\", \"is_closed\": false, \"slug\": \"ready\", \"order\": 2, \"color\": \"#ff8a84\"}, {\"name\": \"In progress\", \"is_closed\": false, \"slug\": \"in-progress\", \"order\": 3, \"color\": \"#ff9900\"}, {\"name\": \"Ready for test\", \"is_closed\": false, \"slug\": \"ready-for-test\", \"order\": 4, \"color\": \"#fcc000\"}, {\"name\": \"Done\", \"is_closed\": true, \"slug\": \"done\", \"order\": 5, \"color\": \"#669900\"}]", + "us_statuses": "[{\"wip_limit\": null, \"name\": \"New\", \"slug\": \"new\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#999999\", \"order\": 1}, {\"wip_limit\": null, \"name\": \"Ready\", \"slug\": \"ready\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#f57900\", \"order\": 2}, {\"wip_limit\": null, \"name\": \"In progress\", \"slug\": \"in-progress\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#729fcf\", \"order\": 3}, {\"wip_limit\": null, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#4e9a06\", \"order\": 4}, {\"wip_limit\": null, \"name\": \"Done\", \"slug\": \"done\", \"is_closed\": true, \"is_archived\": false, \"color\": \"#cc0000\", \"order\": 5}, {\"wip_limit\": null, \"name\": \"Archived\", \"slug\": \"archived\", \"is_closed\": true, \"is_archived\": true, \"color\": \"#5c3566\", \"order\": 6}]", + "points": "[{\"name\": \"?\", \"value\": null, \"order\": 1}, {\"name\": \"0\", \"value\": 0.0, \"order\": 2}, {\"name\": \"1/2\", \"value\": 0.5, \"order\": 3}, {\"name\": \"1\", \"value\": 1.0, \"order\": 4}, {\"name\": \"2\", \"value\": 2.0, \"order\": 5}, {\"name\": \"3\", \"value\": 3.0, \"order\": 6}, {\"name\": \"5\", \"value\": 5.0, \"order\": 7}, {\"name\": \"8\", \"value\": 8.0, \"order\": 8}, {\"name\": \"10\", \"value\": 10.0, \"order\": 9}, {\"name\": \"13\", \"value\": 13.0, \"order\": 10}, {\"name\": \"20\", \"value\": 20.0, \"order\": 11}, {\"name\": \"40\", \"value\": 40.0, \"order\": 12}]", + "task_statuses": "[{\"name\": \"New\", \"is_closed\": false, \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"In progress\", \"is_closed\": false, \"slug\": \"in-progress\", \"order\": 2, \"color\": \"#729fcf\"}, {\"name\": \"Ready for test\", \"is_closed\": true, \"slug\": \"ready-for-test\", \"order\": 3, \"color\": \"#f57900\"}, {\"name\": \"Closed\", \"is_closed\": true, \"slug\": \"closed\", \"order\": 4, \"color\": \"#4e9a06\"}, {\"name\": \"Needs Info\", \"is_closed\": false, \"slug\": \"needs-info\", \"order\": 5, \"color\": \"#cc0000\"}]", + "issue_statuses": "[{\"name\": \"New\", \"is_closed\": false, \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"In progress\", \"is_closed\": false, \"slug\": \"in-progress\", \"order\": 2, \"color\": \"#729fcf\"}, {\"name\": \"Ready for test\", \"is_closed\": true, \"slug\": \"ready-for-test\", \"order\": 3, \"color\": \"#f57900\"}, {\"name\": \"Closed\", \"is_closed\": true, \"slug\": \"closed\", \"order\": 4, \"color\": \"#4e9a06\"}, {\"name\": \"Needs Info\", \"is_closed\": false, \"slug\": \"needs-info\", \"order\": 5, \"color\": \"#cc0000\"}, {\"name\": \"Rejected\", \"is_closed\": true, \"slug\": \"rejected\", \"order\": 6, \"color\": \"#d3d7cf\"}, {\"name\": \"Postponed\", \"is_closed\": false, \"slug\": \"posponed\", \"order\": 7, \"color\": \"#75507b\"}]", + "issue_types": "[{\"name\": \"Bug\", \"order\": 1, \"color\": \"#cc0000\"}, {\"name\": \"Question\", \"order\": 2, \"color\": \"#729fcf\"}, {\"name\": \"Enhancement\", \"order\": 3, \"color\": \"#4e9a06\"}]", + "priorities": "[{\"name\": \"Low\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"Normal\", \"order\": 3, \"color\": \"#4e9a06\"}, {\"name\": \"High\", \"order\": 5, \"color\": \"#CC0000\"}]", + "severities": "[{\"name\": \"Wishlist\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"Minor\", \"order\": 2, \"color\": \"#729fcf\"}, {\"name\": \"Normal\", \"order\": 3, \"color\": \"#4e9a06\"}, {\"name\": \"Important\", \"order\": 4, \"color\": \"#f57900\"}, {\"name\": \"Critical\", \"order\": 5, \"color\": \"#CC0000\"}]", + "roles": "[{\"name\": \"UX\", \"computable\": true, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 10}, {\"name\": \"Design\", \"computable\": true, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 20}, {\"name\": \"Front\", \"computable\": true, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 30}, {\"name\": \"Back\", \"computable\": true, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 40}, {\"name\": \"Product Owner\", \"computable\": false, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 50}, {\"name\": \"Stakeholder\", \"computable\": false, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\"], \"order\": 60}]" } } ] diff --git a/taiga/projects/migrations/0049_auto_20160629_1443.py b/taiga/projects/migrations/0049_auto_20160629_1443.py index cdfd420b..417875e5 100644 --- a/taiga/projects/migrations/0049_auto_20160629_1443.py +++ b/taiga/projects/migrations/0049_auto_20160629_1443.py @@ -81,12 +81,12 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='project', name='anon_permissions', - field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('view_epic', 'View epic'), ('view_us', 'View user stories'), ('view_tasks', 'View tasks'), ('view_issues', 'View issues'), ('view_wiki_pages', 'View wiki pages'), ('view_wiki_links', 'View wiki links')]), blank=True, default=[], null=True, size=None, verbose_name='anonymous permissions'), + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('view_epics', 'View epic'), ('view_us', 'View user stories'), ('view_tasks', 'View tasks'), ('view_issues', 'View issues'), ('view_wiki_pages', 'View wiki pages'), ('view_wiki_links', 'View wiki links')]), blank=True, default=[], null=True, size=None, verbose_name='anonymous permissions'), ), migrations.AlterField( model_name='project', name='public_permissions', - field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_epic', 'View epic'), ('add_epic', 'Add epic'), ('modify_epic', 'Modify epic'), ('comment_epic', 'Comment epic'), ('delete_epic', 'Delete epic'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=[], null=True, size=None, verbose_name='user permissions'), + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_epics', 'View epic'), ('add_epic', 'Add epic'), ('modify_epic', 'Modify epic'), ('comment_epic', 'Comment epic'), ('delete_epic', 'Delete epic'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=[], null=True, size=None, verbose_name='user permissions'), ), migrations.AddField( model_name='epicstatus', diff --git a/taiga/timeline/service.py b/taiga/timeline/service.py index 06964eb3..94d37c80 100644 --- a/taiga/timeline/service.py +++ b/taiga/timeline/service.py @@ -160,7 +160,7 @@ def filter_timeline_for_user(timeline, user): content_types = { "view_project": ContentType.objects.get_by_natural_key("projects", "project"), "view_milestones": ContentType.objects.get_by_natural_key("milestones", "milestone"), - "view_epic": ContentType.objects.get_by_natural_key("epics", "epic"), + "view_epics": ContentType.objects.get_by_natural_key("epics", "epic"), "view_us": ContentType.objects.get_by_natural_key("userstories", "userstory"), "view_tasks": ContentType.objects.get_by_natural_key("tasks", "task"), "view_issues": ContentType.objects.get_by_natural_key("issues", "issue"), diff --git a/taiga/users/migrations/0022_auto_20160629_1443.py b/taiga/users/migrations/0022_auto_20160629_1443.py index 2acaf944..68a65443 100644 --- a/taiga/users/migrations/0022_auto_20160629_1443.py +++ b/taiga/users/migrations/0022_auto_20160629_1443.py @@ -16,6 +16,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='role', name='permissions', - field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_epic', 'View epic'), ('add_epic', 'Add epic'), ('modify_epic', 'Modify epic'), ('comment_epic', 'Comment epic'), ('delete_epic', 'Delete epic'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=[], null=True, size=None, verbose_name='permissions'), + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_epics', 'View epic'), ('add_epic', 'Add epic'), ('modify_epic', 'Modify epic'), ('comment_epic', 'Comment epic'), ('delete_epic', 'Delete epic'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=[], null=True, size=None, verbose_name='permissions'), ), ] diff --git a/tests/integration/resources_permissions/test_epics_resources.py b/tests/integration/resources_permissions/test_epics_resources.py new file mode 100644 index 00000000..ceffe206 --- /dev/null +++ b/tests/integration/resources_permissions/test_epics_resources.py @@ -0,0 +1,901 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# Copyright (C) 2014-2016 Anler Hernández +# 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 uuid + +from django.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects.models import Project +from taiga.projects.epics.serializers import EpicSerializer +from taiga.projects.epics.models import Epic +from taiga.projects.epics.utils import attach_extra_info as attach_epic_extra_info +from taiga.projects.utils import attach_extra_info as attach_project_extra_info +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.occ import OCCResourceMixin + +from tests import factories as f +from tests.utils import helper_test_http_method, reconnect_signals +from taiga.projects.votes.services import add_vote +from taiga.projects.notifications.services import add_watcher + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_function(function): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + #epics_csv_uuid=uuid.uuid4().hex) + m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) + + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + #epics_csv_uuid=uuid.uuid4().hex) + m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id) + + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + #epics_csv_uuid=uuid.uuid4().hex) + m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) + + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + #epics_csv_uuid=uuid.uuid4().hex, + blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) + + m.public_membership = f.MembershipFactory( + project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + m.private_membership1 = f.MembershipFactory( + project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory( + project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory( + project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory( + project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory( + project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_epic = f.EpicFactory(project=m.public_project, + status__project=m.public_project) + m.public_epic = attach_epic_extra_info(Epic.objects.all()).get(id=m.public_epic.id) + + m.private_epic1 = f.EpicFactory(project=m.private_project1, + status__project=m.private_project1) + m.private_epic1 = attach_epic_extra_info(Epic.objects.all()).get(id=m.private_epic1.id) + + m.private_epic2 = f.EpicFactory(project=m.private_project2, + status__project=m.private_project2) + m.private_epic2 = attach_epic_extra_info(Epic.objects.all()).get(id=m.private_epic2.id) + + m.blocked_epic = f.EpicFactory(project=m.blocked_project, + status__project=m.blocked_project) + m.blocked_epic = attach_epic_extra_info(Epic.objects.all()).get(id=m.blocked_epic.id) + + m.public_project.default_epic_status = m.public_epic.status + m.public_project.save() + m.private_project1.default_epic_status = m.private_epic1.status + m.private_project1.save() + m.private_project2.default_epic_status = m.private_epic2.status + m.private_project2.save() + m.blocked_project.default_epic_status = m.blocked_epic.status + m.blocked_project.save() + + return m + + +def test_epic_list(client, data): + url = reverse('epics-list') + + response = client.get(url) + epics_data = json.loads(response.content.decode('utf-8')) + assert len(epics_data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + epics_data = json.loads(response.content.decode('utf-8')) + assert len(epics_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + + response = client.get(url) + epics_data = json.loads(response.content.decode('utf-8')) + assert len(epics_data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + epics_data = json.loads(response.content.decode('utf-8')) + assert len(epics_data) == 4 + assert response.status_code == 200 + + +def test_epic_retrieve(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_create(client, data): + url = reverse('epics-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + create_data = json.dumps({ + "subject": "test", + "ref": 1, + "project": data.public_project.pk, + "status": data.public_project.epic_statuses.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "subject": "test", + "ref": 2, + "project": data.private_project1.pk, + "status": data.private_project1.epic_statuses.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "subject": "test", + "ref": 3, + "project": data.private_project2.pk, + "status": data.private_project2.epic_statuses.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "subject": "test", + "ref": 3, + "project": data.blocked_project.pk, + "status": data.blocked_project.epic_statuses.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_put_update(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + epic_data = EpicSerializer(data.public_epic).data + epic_data["subject"] = "test" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', public_url, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.private_epic1).data + epic_data["subject"] = "test" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url1, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.private_epic2).data + epic_data["subject"] = "test" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url2, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.blocked_epic).data + epic_data["subject"] = "test" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', blocked_url, epic_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_put_comment(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + epic_data = EpicSerializer(data.public_epic).data + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', public_url, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.private_epic1).data + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url1, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.private_epic2).data + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url2, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.blocked_epic).data + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', blocked_url, epic_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_put_update_and_comment(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + epic_data = EpicSerializer(data.public_epic).data + epic_data["subject"] = "test" + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', public_url, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.private_epic1).data + epic_data["subject"] = "test" + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url1, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.private_epic2).data + epic_data["subject"] = "test" + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url2, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.blocked_epic).data + epic_data["subject"] = "test" + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', blocked_url, epic_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_put_update_with_project_change(client): + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + user3 = f.UserFactory.create() + user4 = f.UserFactory.create() + project1 = f.ProjectFactory() + project2 = f.ProjectFactory() + + epic_status1 = f.EpicStatusFactory.create(project=project1) + epic_status2 = f.EpicStatusFactory.create(project=project2) + + project1.default_epic_status = epic_status1 + project2.default_epic_status = epic_status2 + + project1.save() + project2.save() + + project1 = attach_project_extra_info(Project.objects.all()).get(id=project1.id) + project2 = attach_project_extra_info(Project.objects.all()).get(id=project2.id) + + f.MembershipFactory(project=project1, + user=user1, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user1, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project1, + user=user2, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user3, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + epic = f.EpicFactory.create(project=project1) + epic = attach_epic_extra_info(Epic.objects.all()).get(id=epic.id) + + url = reverse('epics-detail', kwargs={"pk": epic.pk}) + + # Test user with permissions in both projects + client.login(user1) + + epic_data = EpicSerializer(epic).data + epic_data["project"] = project2.id + epic_data = json.dumps(epic_data) + + response = client.put(url, data=epic_data, content_type="application/json") + + assert response.status_code == 200 + + epic.project = project1 + epic.save() + + # Test user with permissions in only origin project + client.login(user2) + + epic_data = EpicSerializer(epic).data + epic_data["project"] = project2.id + epic_data = json.dumps(epic_data) + + response = client.put(url, data=epic_data, content_type="application/json") + + assert response.status_code == 403 + + epic.project = project1 + epic.save() + + # Test user with permissions in only destionation project + client.login(user3) + + epic_data = EpicSerializer(epic).data + epic_data["project"] = project2.id + epic_data = json.dumps(epic_data) + + response = client.put(url, data=epic_data, content_type="application/json") + + assert response.status_code == 403 + + epic.project = project1 + epic.save() + + # Test user without permissions in the projects + client.login(user4) + + epic_data = EpicSerializer(epic).data + epic_data["project"] = project2.id + epic_data = json.dumps(epic_data) + + response = client.put(url, data=epic_data, content_type="application/json") + + assert response.status_code == 403 + + epic.project = project1 + epic.save() + + +def test_epic_patch_update(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"subject": "test", "version": data.public_epic.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.private_epic1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.private_epic2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.blocked_epic.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_patch_comment(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"comment": "test comment", "version": data.public_epic.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.private_epic1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.private_epic2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.blocked_epic.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_patch_update_and_comment(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.public_epic.version + }) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.private_epic1.version + }) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.private_epic2.version + }) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.blocked_epic.version + }) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_delete(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_epic_action_bulk_create(client, data): + url = reverse('epics-bulk-create') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + bulk_data = json.dumps({ + "bulk_epics": "test1\ntest2", + "project_id": data.public_epic.project.pk, + }) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_epics": "test1\ntest2", + "project_id": data.private_epic1.project.pk, + }) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_epics": "test1\ntest2", + "project_id": data.private_epic2.project.pk, + }) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_epics": "test1\ntest2", + "project_id": data.blocked_epic.project.pk, + }) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_action_upvote(client, data): + public_url = reverse('epics-upvote', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-upvote', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-upvote', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-upvote', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [404, 404, 404, 451, 451] + + +def test_epic_action_downvote(client, data): + public_url = reverse('epics-downvote', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-downvote', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-downvote', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-downvote', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [404, 404, 404, 451, 451] + + +def test_epic_voters_list(client, data): + public_url = reverse('epic-voters-list', kwargs={"resource_id": data.public_epic.pk}) + private_url1 = reverse('epic-voters-list', kwargs={"resource_id": data.private_epic1.pk}) + private_url2 = reverse('epic-voters-list', kwargs={"resource_id": data.private_epic2.pk}) + blocked_url = reverse('epic-voters-list', kwargs={"resource_id": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_voters_retrieve(client, data): + add_vote(data.public_epic, data.project_owner) + public_url = reverse('epic-voters-detail', kwargs={"resource_id": data.public_epic.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_epic1, data.project_owner) + private_url1 = reverse('epic-voters-detail', kwargs={"resource_id": data.private_epic1.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_epic2, data.project_owner) + private_url2 = reverse('epic-voters-detail', kwargs={"resource_id": data.private_epic2.pk, + "pk": data.project_owner.pk}) + + add_vote(data.blocked_epic, data.project_owner) + blocked_url = reverse('epic-voters-detail', kwargs={"resource_id": data.blocked_epic.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_action_watch(client, data): + public_url = reverse('epics-watch', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-watch', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-watch', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-watch', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [404, 404, 404, 451, 451] + + +def test_epic_action_unwatch(client, data): + public_url = reverse('epics-unwatch', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-unwatch', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-unwatch', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-unwatch', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [404, 404, 404, 451, 451] + + +def test_epic_watchers_list(client, data): + public_url = reverse('epic-watchers-list', kwargs={"resource_id": data.public_epic.pk}) + private_url1 = reverse('epic-watchers-list', kwargs={"resource_id": data.private_epic1.pk}) + private_url2 = reverse('epic-watchers-list', kwargs={"resource_id": data.private_epic2.pk}) + blocked_url = reverse('epic-watchers-list', kwargs={"resource_id": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_watchers_retrieve(client, data): + add_watcher(data.public_epic, data.project_owner) + public_url = reverse('epic-watchers-detail', kwargs={"resource_id": data.public_epic.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_epic1, data.project_owner) + private_url1 = reverse('epic-watchers-detail', kwargs={"resource_id": data.private_epic1.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_epic2, data.project_owner) + private_url2 = reverse('epic-watchers-detail', kwargs={"resource_id": data.private_epic2.pk, + "pk": data.project_owner.pk}) + + add_watcher(data.blocked_epic, data.project_owner) + blocked_url = reverse('epic-watchers-detail', kwargs={"resource_id": data.blocked_epic.pk, + "pk": data.project_owner.pk}) + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +#def test_epics_csv(client, data): +# url = reverse('epics-csv') +# csv_public_uuid = data.public_project.epics_csv_uuid +# csv_private1_uuid = data.private_project1.epics_csv_uuid +# csv_private2_uuid = data.private_project1.epics_csv_uuid +# csv_blocked_uuid = data.blocked_project.epics_csv_uuid +# +# users = [ +# None, +# data.registered_user, +# data.project_member_without_perms, +# data.project_member_with_perms, +# data.project_owner +# ] +# +# results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users) +# assert results == [200, 200, 200, 200, 200] +# +# results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users) +# assert results == [200, 200, 200, 200, 200] +# +# results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) +# assert results == [200, 200, 200, 200, 200] +# +# results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_blocked_uuid), None, users) +# assert results == [200, 200, 200, 200, 200] From 1ae4e49246f15494bed9f70e8ace4e57f951296d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 19 Jul 2016 13:54:30 +0200 Subject: [PATCH 11/83] Add Epics to taiga.searches --- taiga/searches/api.py | 9 +++++++ taiga/searches/serializers.py | 26 ++++++++++++------- taiga/searches/services.py | 15 ++++++++--- tests/integration/test_searches.py | 41 +++++++++++++++++++++++++++--- 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/taiga/searches/api.py b/taiga/searches/api.py index 37f9c7f3..0c252b5a 100644 --- a/taiga/searches/api.py +++ b/taiga/searches/api.py @@ -40,6 +40,10 @@ class SearchViewSet(viewsets.ViewSet): result = {} with futures.ThreadPoolExecutor(max_workers=4) as executor: futures_list = [] + if user_has_perm(request.user, "view_epics", project): + epics_future = executor.submit(self._search_epics, project, text) + epics_future.result_key = "epics" + futures_list.append(epics_future) if user_has_perm(request.user, "view_us", project): uss_future = executor.submit(self._search_user_stories, project, text) uss_future.result_key = "userstories" @@ -73,6 +77,11 @@ class SearchViewSet(viewsets.ViewSet): project_model = apps.get_model("projects", "Project") return get_object_or_404(project_model, pk=project_id) + def _search_epics(self, project, text): + queryset = services.search_epics(project, text) + serializer = serializers.EpicSearchResultsSerializer(queryset, many=True) + return serializer.data + def _search_user_stories(self, project, text): queryset = services.search_user_stories(project, text) serializer = serializers.UserStorySearchResultsSerializer(queryset, many=True) diff --git a/taiga/searches/serializers.py b/taiga/searches/serializers.py index e96e1131..7adc34b2 100644 --- a/taiga/searches/serializers.py +++ b/taiga/searches/serializers.py @@ -20,15 +20,7 @@ from taiga.base.api import serializers from taiga.base.fields import Field, MethodField -class IssueSearchResultsSerializer(serializers.LightSerializer): - id = Field() - ref = Field() - subject = Field() - status = Field(attr="status_id") - assigned_to = Field(attr="assigned_to_id") - - -class TaskSearchResultsSerializer(serializers.LightSerializer): +class EpicSearchResultsSerializer(serializers.LightSerializer): id = Field() ref = Field() subject = Field() @@ -58,6 +50,22 @@ class UserStorySearchResultsSerializer(serializers.LightSerializer): return obj.total_points_attr +class TaskSearchResultsSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + status = Field(attr="status_id") + assigned_to = Field(attr="assigned_to_id") + + +class IssueSearchResultsSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + status = Field(attr="status_id") + assigned_to = Field(attr="assigned_to_id") + + class WikiPageSearchResultsSerializer(serializers.LightSerializer): id = Field() slug = Field() diff --git a/taiga/searches/services.py b/taiga/searches/services.py index afc6a7e5..adda60bb 100644 --- a/taiga/searches/services.py +++ b/taiga/searches/services.py @@ -24,6 +24,13 @@ from taiga.projects.userstories.utils import attach_total_points MAX_RESULTS = getattr(settings, "SEARCHES_MAX_RESULTS", 150) +def search_epics(project, text): + model = apps.get_model("epics", "Epic") + queryset = model.objects.filter(project_id=project.pk) + table = "epics_epic" + return _search_items(queryset, table, text) + + def search_user_stories(project, text): model = apps.get_model("userstories", "UserStory") queryset = model.objects.filter(project_id=project.pk) @@ -32,16 +39,16 @@ def search_user_stories(project, text): def search_tasks(project, text): - model = apps.get_model("userstories", "UserStory") + model = apps.get_model("tasks", "Task") queryset = model.objects.filter(project_id=project.pk) - table = "userstories_userstory" + table = "tasks_task" return _search_items(queryset, table, text) def search_issues(project, text): - model = apps.get_model("userstories", "UserStory") + model = apps.get_model("issues", "Issue") queryset = model.objects.filter(project_id=project.pk) - table = "userstories_userstory" + table = "issues_issue" return _search_items(queryset, table, text) diff --git a/tests/integration/test_searches.py b/tests/integration/test_searches.py index 1ccd5233..bb5e681d 100644 --- a/tests/integration/test_searches.py +++ b/tests/integration/test_searches.py @@ -53,6 +53,12 @@ def searches_initial_data(): role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.epic11 = f.EpicFactory(project=m.project1, subject="Back to the future") + m.epic12 = f.EpicFactory(project=m.project1, tags=["Back", "future"]) + m.epic13 = f.EpicFactory(project=m.project1) + m.epic14 = f.EpicFactory(project=m.project1, description="Backend to the future") + m.epic21 = f.EpicFactory(project=m.project2, subject="Back to the future") + m.us11 = f.UserStoryFactory(project=m.project1, subject="Back to the future") m.us12 = f.UserStoryFactory(project=m.project1, description="Back to the future") m.us13 = f.UserStoryFactory(project=m.project1, tags=["Backend", "future"]) @@ -87,7 +93,8 @@ def test_search_all_objects_in_my_project(client, searches_initial_data): response = client.get(reverse("search-list"), {"project": data.project1.id}) assert response.status_code == 200 - assert response.data["count"] == 16 + assert response.data["count"] == 20 + assert len(response.data["epics"]) == 4 assert len(response.data["userstories"]) == 4 assert len(response.data["tasks"]) == 4 assert len(response.data["issues"]) == 4 @@ -111,20 +118,48 @@ def test_search_text_query_in_my_project(client, searches_initial_data): response = client.get(reverse("search-list"), {"project": data.project1.id, "text": "future"}) assert response.status_code == 200 - assert response.data["count"] == 9 + assert response.data["count"] == 12 + assert len(response.data["epics"]) == 3 + assert response.data["epics"][0]["id"] == searches_initial_data.epic11.id + assert response.data["epics"][1]["id"] == searches_initial_data.epic12.id + assert response.data["epics"][2]["id"] == searches_initial_data.epic14.id assert len(response.data["userstories"]) == 3 + assert response.data["userstories"][0]["id"] == searches_initial_data.us11.id + assert response.data["userstories"][1]["id"] == searches_initial_data.us13.id + assert response.data["userstories"][2]["id"] == searches_initial_data.us12.id assert len(response.data["tasks"]) == 3 + assert response.data["tasks"][0]["id"] == searches_initial_data.task11.id + assert response.data["tasks"][1]["id"] == searches_initial_data.task12.id + assert response.data["tasks"][2]["id"] == searches_initial_data.task14.id assert len(response.data["issues"]) == 3 + assert response.data["issues"][0]["id"] == searches_initial_data.issue14.id + assert response.data["issues"][1]["id"] == searches_initial_data.issue12.id + assert response.data["issues"][2]["id"] == searches_initial_data.issue11.id assert len(response.data["wikipages"]) == 0 response = client.get(reverse("search-list"), {"project": data.project1.id, "text": "back"}) assert response.status_code == 200 - assert response.data["count"] == 11 + assert response.data["count"] == 14 + assert len(response.data["epics"]) == 3 + assert response.data["epics"][0]["id"] == searches_initial_data.epic11.id + assert response.data["epics"][1]["id"] == searches_initial_data.epic12.id + assert response.data["epics"][2]["id"] == searches_initial_data.epic14.id assert len(response.data["userstories"]) == 3 + assert response.data["userstories"][0]["id"] == searches_initial_data.us11.id + assert response.data["userstories"][1]["id"] == searches_initial_data.us13.id + assert response.data["userstories"][2]["id"] == searches_initial_data.us12.id assert len(response.data["tasks"]) == 3 + assert response.data["tasks"][0]["id"] == searches_initial_data.task11.id + assert response.data["tasks"][1]["id"] == searches_initial_data.task12.id + assert response.data["tasks"][2]["id"] == searches_initial_data.task14.id assert len(response.data["issues"]) == 3 + assert response.data["issues"][0]["id"] == searches_initial_data.issue14.id + assert response.data["issues"][1]["id"] == searches_initial_data.issue12.id + assert response.data["issues"][2]["id"] == searches_initial_data.issue11.id # Back is a backend substring assert len(response.data["wikipages"]) == 2 + assert response.data["wikipages"][0]["id"] == searches_initial_data.wikipage14.id + assert response.data["wikipages"][1]["id"] == searches_initial_data.wikipage13.id def test_search_text_query_with_an_invalid_project_id(client, searches_initial_data): From a752c6b84fb01b74b9235b476c5120cc7dc75116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 19 Jul 2016 20:12:05 +0200 Subject: [PATCH 12/83] Add tests over Epic attachments --- tests/factories.py | 11 + .../test_attachment_resources.py | 221 +++++++++++++++++- 2 files changed, 228 insertions(+), 4 deletions(-) diff --git a/tests/factories.py b/tests/factories.py index 8558907d..2e831c13 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -121,6 +121,17 @@ class RolePointsFactory(Factory): points = factory.SubFactory("tests.factories.PointsFactory") +class EpicAttachmentFactory(Factory): + project = factory.SubFactory("tests.factories.ProjectFactory") + owner = factory.SubFactory("tests.factories.UserFactory") + content_object = factory.SubFactory("tests.factories.EpicFactory") + attached_file = factory.django.FileField(data=b"File contents") + + class Meta: + model = "attachments.Attachment" + strategy = factory.CREATE_STRATEGY + + class UserStoryAttachmentFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") owner = factory.SubFactory("tests.factories.UserFactory") diff --git a/tests/integration/resources_permissions/test_attachment_resources.py b/tests/integration/resources_permissions/test_attachment_resources.py index c30fb854..456c96f2 100644 --- a/tests/integration/resources_permissions/test_attachment_resources.py +++ b/tests/integration/resources_permissions/test_attachment_resources.py @@ -120,6 +120,20 @@ def data(): return m +@pytest.fixture +def data_epic(data): + m = type("Models", (object,), {}) + m.public_epic = f.EpicFactory(project=data.public_project, ref=20) + m.public_epic_attachment = f.EpicAttachmentFactory(project=data.public_project, content_object=m.public_epic) + m.private_epic1 = f.EpicFactory(project=data.private_project1, ref=21) + m.private_epic1_attachment = f.EpicAttachmentFactory(project=data.private_project1, content_object=m.private_epic1) + m.private_epic2 = f.EpicFactory(project=data.private_project2, ref=22) + m.private_epic2_attachment = f.EpicAttachmentFactory(project=data.private_project2, content_object=m.private_epic2) + m.blocked_epic = f.EpicFactory(project=data.blocked_project, ref=23) + m.blocked_epic_attachment = f.EpicAttachmentFactory(project=data.blocked_project, content_object=m.blocked_epic) + return m + + @pytest.fixture def data_us(data): m = type("Models", (object,), {}) @@ -180,6 +194,30 @@ def data_wiki(data): return m +def test_epic_attachment_retrieve(client, data, data_epic): + public_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.public_epic_attachment.pk}) + private_url1 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic1_attachment.pk}) + private_url2 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic2_attachment.pk}) + blocked_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.blocked_epic_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + def test_user_story_attachment_retrieve(client, data, data_us): public_url = reverse('userstory-attachments-detail', kwargs={"pk": data_us.public_user_story_attachment.pk}) private_url1 = reverse('userstory-attachments-detail', kwargs={"pk": data_us.private_user_story1_attachment.pk}) @@ -276,6 +314,41 @@ def test_wiki_attachment_retrieve(client, data, data_wiki): assert results == [401, 403, 403, 200, 200] +def test_epic_attachment_update(client, data, data_epic): + public_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.public_epic_attachment.pk}) + private_url1 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic1_attachment.pk}) + private_url2 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic2_attachment.pk}) + blocked_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.blocked_epic_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = AttachmentSerializer(data_epic.public_epic_attachment).data + attachment_data["description"] = "test" + attachment_data = json.dumps(attachment_data) + + results = helper_test_http_method(client, 'put', public_url, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', private_url1, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', private_url2, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', blocked_url, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + def test_user_story_attachment_update(client, data, data_us): public_url = reverse("userstory-attachments-detail", args=[data_us.public_user_story_attachment.pk]) @@ -299,20 +372,20 @@ def test_user_story_attachment_update(client, data, data_us): attachment_data = json.dumps(attachment_data) results = helper_test_http_method(client, "put", public_url, attachment_data, users) - # assert results == [401, 403, 403, 400, 400] assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 400, 400] results = helper_test_http_method(client, "put", private_url1, attachment_data, users) - # assert results == [401, 403, 403, 400, 400] assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 400, 400] results = helper_test_http_method(client, "put", private_url2, attachment_data, users) - # assert results == [401, 403, 403, 400, 400] assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 400, 400] results = helper_test_http_method(client, "put", blocked_url, attachment_data, users) - # assert results == [401, 403, 403, 400, 400] assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 400, 400] def test_task_attachment_update(client, data, data_task): @@ -336,12 +409,15 @@ def test_task_attachment_update(client, data, data_task): results = helper_test_http_method(client, 'put', public_url, attachment_data, users) assert results == [405, 405, 405, 405, 405] # assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'put', private_url1, attachment_data, users) assert results == [405, 405, 405, 405, 405] # assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'put', private_url2, attachment_data, users) assert results == [405, 405, 405, 405, 405] # assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'put', blocked_url, attachment_data, users) assert results == [405, 405, 405, 405, 405] # assert results == [401, 403, 403, 200, 200] @@ -368,12 +444,15 @@ def test_issue_attachment_update(client, data, data_issue): results = helper_test_http_method(client, 'put', public_url, attachment_data, users) assert results == [405, 405, 405, 405, 405] # assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'put', private_url1, attachment_data, users) assert results == [405, 405, 405, 405, 405] # assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'put', private_url2, attachment_data, users) assert results == [405, 405, 405, 405, 405] # assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'put', blocked_url, attachment_data, users) assert results == [405, 405, 405, 405, 405] # assert results == [401, 403, 403, 200, 200] @@ -400,17 +479,50 @@ def test_wiki_attachment_update(client, data, data_wiki): results = helper_test_http_method(client, 'put', public_url, attachment_data, users) assert results == [405, 405, 405, 405, 405] # assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'put', private_url1, attachment_data, users) assert results == [405, 405, 405, 405, 405] # assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'put', private_url2, attachment_data, users) assert results == [405, 405, 405, 405, 405] # assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'put', blocked_url, attachment_data, users) assert results == [405, 405, 405, 405, 405] # assert results == [401, 403, 403, 200, 200] +def test_epic_attachment_patch(client, data, data_epic): + public_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.public_epic_attachment.pk}) + private_url1 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic1_attachment.pk}) + private_url2 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic2_attachment.pk}) + blocked_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.blocked_epic_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = {"description": "test"} + attachment_data = json.dumps(attachment_data) + + results = helper_test_http_method(client, 'patch', public_url, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url1, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url2, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', blocked_url, attachment_data, users) + assert results == [401, 403, 403, 451, 451] + + def test_user_story_attachment_patch(client, data, data_us): public_url = reverse('userstory-attachments-detail', kwargs={"pk": data_us.public_user_story_attachment.pk}) private_url1 = reverse('userstory-attachments-detail', kwargs={"pk": data_us.private_user_story1_attachment.pk}) @@ -430,10 +542,13 @@ def test_user_story_attachment_patch(client, data, data_us): results = helper_test_http_method(client, 'patch', public_url, attachment_data, users) assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'patch', private_url1, attachment_data, users) assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'patch', private_url2, attachment_data, users) assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'patch', blocked_url, attachment_data, users) assert results == [401, 403, 403, 451, 451] @@ -457,10 +572,13 @@ def test_task_attachment_patch(client, data, data_task): results = helper_test_http_method(client, 'patch', public_url, attachment_data, users) assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'patch', private_url1, attachment_data, users) assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'patch', private_url2, attachment_data, users) assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'patch', blocked_url, attachment_data, users) assert results == [401, 403, 403, 451, 451] @@ -484,10 +602,13 @@ def test_issue_attachment_patch(client, data, data_issue): results = helper_test_http_method(client, 'patch', public_url, attachment_data, users) assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'patch', private_url1, attachment_data, users) assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'patch', private_url2, attachment_data, users) assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'patch', blocked_url, attachment_data, users) assert results == [401, 403, 403, 451, 451] @@ -511,14 +632,43 @@ def test_wiki_attachment_patch(client, data, data_wiki): results = helper_test_http_method(client, 'patch', public_url, attachment_data, users) assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'patch', private_url1, attachment_data, users) assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'patch', private_url2, attachment_data, users) assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'patch', blocked_url, attachment_data, users) assert results == [401, 403, 403, 451, 451] +def test_epic_attachment_delete(client, data, data_epic): + public_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.public_epic_attachment.pk}) + private_url1 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic1_attachment.pk}) + private_url2 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic2_attachment.pk}) + blocked_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.blocked_epic_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + def test_user_story_attachment_delete(client, data, data_us): public_url = reverse('userstory-attachments-detail', kwargs={"pk": data_us.public_user_story_attachment.pk}) private_url1 = reverse('userstory-attachments-detail', kwargs={"pk": data_us.private_user_story1_attachment.pk}) @@ -534,10 +684,13 @@ def test_user_story_attachment_delete(client, data, data_us): results = helper_test_http_method(client, 'delete', public_url, None, users) assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) assert results == [401, 403, 403, 451] @@ -557,10 +710,13 @@ def test_task_attachment_delete(client, data, data_task): results = helper_test_http_method(client, 'delete', public_url, None, users) assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) assert results == [401, 403, 403, 451] @@ -580,10 +736,13 @@ def test_issue_attachment_delete(client, data, data_issue): results = helper_test_http_method(client, 'delete', public_url, None, users) assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) assert results == [401, 403, 403, 451] @@ -603,14 +762,53 @@ def test_wiki_attachment_delete(client, data, data_wiki): results = helper_test_http_method(client, 'delete', public_url, None, [None, data.registered_user]) assert results == [401, 403] + results = helper_test_http_method(client, 'delete', private_url1, None, [None, data.registered_user]) assert results == [401, 403] + results = helper_test_http_method(client, 'delete', private_url2, None, users) assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) assert results == [401, 403, 403, 451] +def test_epic_attachment_create(client, data, data_epic): + url = reverse('epic-attachments-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = {"description": "test", + "object_id": data_epic.public_epic_attachment.object_id, + "project": data_epic.public_epic_attachment.project_id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + + _after_each_request_hook = lambda: attachment_data["attached_file"].seek(0) + + results = helper_test_http_method(client, 'post', url, attachment_data, users, + content_type=MULTIPART_CONTENT, + after_each_request=_after_each_request_hook) + assert results == [401, 403, 403, 201, 201] + + attachment_data = {"description": "test", + "object_id": data_epic.blocked_epic_attachment.object_id, + "project": data_epic.blocked_epic_attachment.project_id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + + _after_each_request_hook = lambda: attachment_data["attached_file"].seek(0) + + results = helper_test_http_method(client, 'post', url, attachment_data, users, + content_type=MULTIPART_CONTENT, + after_each_request=_after_each_request_hook) + assert results == [401, 403, 403, 451, 451] + + def test_user_story_attachment_create(client, data, data_us): url = reverse('userstory-attachments-list') @@ -756,6 +954,21 @@ def test_wiki_attachment_create(client, data, data_wiki): assert results == [401, 403, 403, 451, 451] +def test_epic_attachment_list(client, data, data_epic): + url = reverse('epic-attachments-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method_and_count(client, 'get', url, None, users) + assert results == [(200, 2), (200, 2), (200, 2), (200, 4), (200, 4)] + + def test_user_story_attachment_list(client, data, data_us): url = reverse('userstory-attachments-list') From 0e2ba632db46425e4388767a7800a3545cdd43cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 19 Jul 2016 21:03:31 +0200 Subject: [PATCH 13/83] Add and fix some tests --- .../test_modules_resources.py | 24 +++++++++---------- .../test_resolver_resources.py | 14 +++++++++-- .../test_search_resources.py | 2 +- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/tests/integration/resources_permissions/test_modules_resources.py b/tests/integration/resources_permissions/test_modules_resources.py index 34ad7369..68f552f4 100644 --- a/tests/integration/resources_permissions/test_modules_resources.py +++ b/tests/integration/resources_permissions/test_modules_resources.py @@ -211,18 +211,18 @@ def test_modules_patch(client, data): ] with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): - patch_data = json.dumps({"att": "test"}) - results = helper_test_http_method(client, 'patch', public_url, patch_data, users) - assert results == [401, 403, 403, 403, 204] + patch_data = json.dumps({"att": "test"}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 403, 204] - patch_data = json.dumps({"att": "test"}) - results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) - assert results == [401, 403, 403, 403, 204] + patch_data = json.dumps({"att": "test"}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 403, 204] - patch_data = json.dumps({"att": "test"}) - results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) - assert results == [404, 404, 404, 403, 204] + patch_data = json.dumps({"att": "test"}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [404, 404, 404, 403, 204] - patch_data = json.dumps({"att": "test"}) - results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) - assert results == [404, 404, 404, 403, 451] + patch_data = json.dumps({"att": "test"}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [404, 404, 404, 403, 451] diff --git a/tests/integration/resources_permissions/test_resolver_resources.py b/tests/integration/resources_permissions/test_resolver_resources.py index b8c43d04..7e98b692 100644 --- a/tests/integration/resources_permissions/test_resolver_resources.py +++ b/tests/integration/resources_permissions/test_resolver_resources.py @@ -100,6 +100,7 @@ def data(): role__project=m.private_project2, role__permissions=["view_project"]) + m.epic = f.EpicFactory(project=m.private_project2, ref=4) m.us = f.UserStoryFactory(project=m.private_project2, ref=1) m.task = f.TaskFactory(project=m.private_project2, ref=2) m.issue = f.IssueFactory(project=m.private_project2, ref=3) @@ -127,8 +128,9 @@ def test_resolver_list(client, data): assert results == [401, 403, 403, 200, 200] client.login(data.other_user) - response = client.json.get("{}?project={}&us={}&task={}&issue={}&milestone={}".format(url, + response = client.json.get("{}?project={}&epic={}&us={}&task={}&issue={}&milestone={}".format(url, data.private_project2.slug, + data.epic.ref, data.us.ref, data.task.ref, data.issue.ref, @@ -136,18 +138,26 @@ def test_resolver_list(client, data): assert response.data == {"project": data.private_project2.pk} client.login(data.project_owner) - response = client.json.get("{}?project={}&us={}&task={}&issue={}&milestone={}".format(url, + response = client.json.get("{}?project={}&epic={}&us={}&task={}&issue={}&milestone={}".format(url, data.private_project2.slug, + data.epic.ref, data.us.ref, data.task.ref, data.issue.ref, data.milestone.slug)) assert response.data == {"project": data.private_project2.pk, + "epic": data.epic.pk, "us": data.us.pk, "task": data.task.pk, "issue": data.issue.pk, "milestone": data.milestone.pk} + response = client.json.get("{}?project={}&ref={}".format(url, + data.private_project2.slug, + data.epic.ref)) + assert response.data == {"project": data.private_project2.pk, + "epic": data.epic.pk} + response = client.json.get("{}?project={}&ref={}".format(url, data.private_project2.slug, data.us.ref)) diff --git a/tests/integration/resources_permissions/test_search_resources.py b/tests/integration/resources_permissions/test_search_resources.py index 7ce732be..633b3131 100644 --- a/tests/integration/resources_permissions/test_search_resources.py +++ b/tests/integration/resources_permissions/test_search_resources.py @@ -126,7 +126,7 @@ def test_search_list(client, data): ] results = helper_test_http_method_and_keys(client, 'get', url, {'project': data.public_project.pk}, users) - all_keys = set(['count', 'userstories', 'issues', 'tasks', 'wikipages']) + all_keys = set(['count', 'userstories', 'issues', 'tasks', 'wikipages', 'epics']) assert results == [(200, all_keys), (200, all_keys), (200, all_keys), (200, all_keys), (200, all_keys)] results = helper_test_http_method_and_keys(client, 'get', url, {'project': data.private_project1.pk}, users) assert results == [(200, all_keys), (200, all_keys), (200, all_keys), (200, all_keys), (200, all_keys)] From f2bfa82c4475527c6479e90c69973ac380371340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 20 Jul 2016 10:02:50 +0200 Subject: [PATCH 14/83] Fix sampledata --- taiga/projects/management/commands/sample_data.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index 70ec16a1..ea48c4e9 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -557,7 +557,11 @@ class Command(BaseCommand): filters = {"project": epic.project} n = self.sd.choice(list(range(self.sd.int(*NUM_USS_EPICS)))) - epic.user_stories.add(*UserStory.objects.filter(**filters).order_by("?")[:n]) + user_stories = UserStory.objects.filter(**filters).order_by("?")[:n] + for idx, us in enumerate(list(user_stories)): + RelatedUserStory.objects.create(epic=epic, + user_story=us, + order=idx+1) return epic From 5d5a42a077e816ba7b16ffae92f7c5cfed9c770c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 20 Jul 2016 11:01:08 +0200 Subject: [PATCH 15/83] Create voters and watcher for epics --- taiga/projects/management/commands/sample_data.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index ea48c4e9..12e3f4e1 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -546,10 +546,8 @@ class Command(BaseCommand): # comment=self.sd.paragraph(), # user=epic.owner) - # TODO: Epic voters - #self.create_votes(epic) - # TODO: Epic watchers - #self.create_watchers(epic) + self.create_votes(epic) + self.create_watchers(epic) if self.sd.choice([True, True, False, True, True]): filters = {} From 0d3de76c8ed6d1d14a7ba8093ec2dc006adc4c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 20 Jul 2016 11:13:13 +0200 Subject: [PATCH 16/83] Fix resolve endpoint to work with epics --- taiga/projects/epics/models.py | 1 - taiga/projects/references/api.py | 8 ++++++++ taiga/projects/references/validators.py | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/taiga/projects/epics/models.py b/taiga/projects/epics/models.py index d55c1dff..75380556 100644 --- a/taiga/projects/epics/models.py +++ b/taiga/projects/epics/models.py @@ -88,7 +88,6 @@ class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M return "" % (self.id) - class RelatedUserStory(models.Model): user_story = models.ForeignKey("userstories.UserStory", on_delete=models.CASCADE) epic = models.ForeignKey("epics.Epic", on_delete=models.CASCADE) diff --git a/taiga/projects/references/api.py b/taiga/projects/references/api.py index ff114ac6..a4ae20ec 100644 --- a/taiga/projects/references/api.py +++ b/taiga/projects/references/api.py @@ -45,6 +45,9 @@ class ResolverViewSet(viewsets.ViewSet): result = {"project": project.pk} + if data["epic"] and user_has_perm(request.user, "view_epics", project): + result["epic"] = get_object_or_404(project.epics.all(), + ref=data["epic"]).pk if data["us"] and user_has_perm(request.user, "view_us", project): result["us"] = get_object_or_404(project.user_stories.all(), ref=data["us"]).pk @@ -63,6 +66,11 @@ class ResolverViewSet(viewsets.ViewSet): if data["ref"]: ref_found = False # No need to continue once one ref is found + if ref_found is False and user_has_perm(request.user, "view_epics", project): + epic = project.epics.filter(ref=data["ref"]).first() + if epic: + result["epic"] = epic.pk + ref_found = True if user_has_perm(request.user, "view_us", project): us = project.user_stories.filter(ref=data["ref"]).first() if us: diff --git a/taiga/projects/references/validators.py b/taiga/projects/references/validators.py index 85456c4c..e91adb21 100644 --- a/taiga/projects/references/validators.py +++ b/taiga/projects/references/validators.py @@ -24,6 +24,7 @@ from taiga.base.exceptions import ValidationError class ResolverValidator(validators.Validator): project = serializers.CharField(max_length=512, required=True) milestone = serializers.CharField(max_length=512, required=False) + epic = serializers.IntegerField(required=False) us = serializers.IntegerField(required=False) task = serializers.IntegerField(required=False) issue = serializers.IntegerField(required=False) @@ -32,6 +33,8 @@ class ResolverValidator(validators.Validator): def validate(self, attrs): if "ref" in attrs: + if "epic" in attrs: + raise ValidationError("'epic' param is incompatible with 'ref' in the same request") if "us" in attrs: raise ValidationError("'us' param is incompatible with 'ref' in the same request") if "task" in attrs: From bb1a43e63731faa0fea1bfab02efbe163207d662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 20 Jul 2016 12:10:49 +0200 Subject: [PATCH 17/83] Fix print --- taiga/timeline/management/commands/rebuild_timeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taiga/timeline/management/commands/rebuild_timeline.py b/taiga/timeline/management/commands/rebuild_timeline.py index d3772a4f..674f6b9d 100644 --- a/taiga/timeline/management/commands/rebuild_timeline.py +++ b/taiga/timeline/management/commands/rebuild_timeline.py @@ -119,7 +119,7 @@ def generate_timeline(initial_date, final_date, project_id): _push_to_timelines(project, membership.user, membership, "create", membership.created_at) for project in projects.iterator(): - print("Project:", bulk_creator.created) + print("Project:", project) extra_data = { "values_diff": {}, "user": extract_user_info(project.owner), From d104e12b014749c2e6e97cd1d9461ebc11b1dbb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 20 Jul 2016 12:12:14 +0200 Subject: [PATCH 18/83] Add histori entries for epics --- .../management/commands/sample_data.py | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index 12e3f4e1..00103c6e 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -534,17 +534,16 @@ class Command(BaseCommand): user__isnull=False)).user epic.save() - # TODO: Epic history - #take_snapshot(epic, - # comment=self.sd.paragraph(), - # user=epic.owner) - # + take_snapshot(epic, + comment=self.sd.paragraph(), + user=epic.owner) + # Add history entry - #epic.status=self.sd.db_object_from_queryset(project.epic_statuses.filter(is_closed=False)) - #epic.save() - #take_snapshot(epic, - # comment=self.sd.paragraph(), - # user=epic.owner) + epic.status=self.sd.db_object_from_queryset(project.epic_statuses.filter(is_closed=False)) + epic.save() + take_snapshot(epic, + comment=self.sd.paragraph(), + user=epic.owner) self.create_votes(epic) self.create_watchers(epic) @@ -561,6 +560,11 @@ class Command(BaseCommand): user_story=us, order=idx+1) + # Add history entry + take_snapshot(epic, + comment=self.sd.paragraph(), + user=epic.owner) + return epic def create_project(self, counter, is_private=None, blocked_code=None): From c3570e944675a29c994c59e9b1591788bda6f96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 20 Jul 2016 13:19:36 +0200 Subject: [PATCH 19/83] Add test of epic history endpoint --- .../test_history_resources.py | 243 +++++++++++++++++- 1 file changed, 239 insertions(+), 4 deletions(-) diff --git a/tests/integration/resources_permissions/test_history_resources.py b/tests/integration/resources_permissions/test_history_resources.py index bf659f69..95d77928 100644 --- a/tests/integration/resources_permissions/test_history_resources.py +++ b/tests/integration/resources_permissions/test_history_resources.py @@ -101,9 +101,247 @@ def data(): ######################################################### -## User stories +## Epics ######################################################### +@pytest.fixture +def data_epic(data): + m = type("Models", (object,), {}) + m.public_epic = f.EpicFactory(project=data.public_project, ref=22) + m.public_history_entry = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.public_project, + comment="testing public", + key=make_key_from_model_object(m.public_epic), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + + m.private_epic1 = f.EpicFactory(project=data.private_project1, ref=26) + m.private_history_entry1 = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.private_project1, + comment="testing 1", + key=make_key_from_model_object(m.private_epic1), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + m.private_epic2 = f.EpicFactory(project=data.private_project2, ref=210) + m.private_history_entry2 = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.private_project2, + comment="testing 2", + key=make_key_from_model_object(m.private_epic2), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + return m + + +def test_epic_history_retrieve(client, data, data_epic): + public_url = reverse('epic-history-detail', kwargs={"pk": data_epic.public_epic.pk}) + private_url1 = reverse('epic-history-detail', kwargs={"pk": data_epic.private_epic1.pk}) + private_url2 = reverse('epic-history-detail', kwargs={"pk": data_epic.private_epic2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_action_edit_comment(client, data, data_epic): + public_url = "{}?id={}".format( + reverse('epic-history-edit-comment', kwargs={"pk": data_epic.public_epic.pk}), + data_epic.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('epic-history-edit-comment', kwargs={"pk": data_epic.private_epic1.pk}), + data_epic.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('epic-history-edit-comment', kwargs={"pk": data_epic.private_epic2.pk}), + data_epic.private_history_entry2.id + ) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + data = json.dumps({"comment": "testing update comment"}) + + results = helper_test_http_method(client, 'post', public_url, data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, data, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_action_delete_comment(client, data, data_epic): + public_url = "{}?id={}".format( + reverse('epic-history-delete-comment', kwargs={"pk": data_epic.public_epic.pk}), + data_epic.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('epic-history-delete-comment', kwargs={"pk": data_epic.private_epic1.pk}), + data_epic.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('epic-history-delete-comment', kwargs={"pk": data_epic.private_epic2.pk}), + data_epic.private_history_entry2.id + ) + + users_and_statuses = [ + (None, 401), + (data.registered_user, 403), + (data.project_member_without_perms, 403), + (data.project_member_with_perms, 200), + (data.project_owner, 200), + ] + + for user, status_code in users_and_statuses: + data_epic.public_history_entry.delete_comment_date = None + data_epic.public_history_entry.delete_comment_user = None + data_epic.public_history_entry.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(public_url) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_epic.private_history_entry1.delete_comment_date = None + data_epic.private_history_entry1.delete_comment_user = None + data_epic.private_history_entry1.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url1) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_epic.private_history_entry2.delete_comment_date = None + data_epic.private_history_entry2.delete_comment_user = None + data_epic.private_history_entry2.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url2) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + +def test_epic_action_undelete_comment(client, data, data_epic): + public_url = "{}?id={}".format( + reverse('epic-history-undelete-comment', kwargs={"pk": data_epic.public_epic.pk}), + data_epic.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('epic-history-undelete-comment', kwargs={"pk": data_epic.private_epic1.pk}), + data_epic.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('epic-history-undelete-comment', kwargs={"pk": data_epic.private_epic2.pk}), + data_epic.private_history_entry2.id + ) + + users_and_statuses = [ + (None, 401), + (data.registered_user, 403), + (data.project_member_without_perms, 403), + (data.project_member_with_perms, 200), + (data.project_owner, 200), + ] + + for user, status_code in users_and_statuses: + data_epic.public_history_entry.delete_comment_date = timezone.now() + data_epic.public_history_entry.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_epic.public_history_entry.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(public_url) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_epic.private_history_entry1.delete_comment_date = timezone.now() + data_epic.private_history_entry1.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_epic.private_history_entry1.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url1) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_epic.private_history_entry2.delete_comment_date = timezone.now() + data_epic.private_history_entry2.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_epic.private_history_entry2.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url2) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + +def test_epic_action_comment_versions(client, data, data_epic): + public_url = "{}?id={}".format( + reverse('epic-history-comment-versions', kwargs={"pk": data_epic.public_epic.pk}), + data_epic.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('epic-history-comment-versions', kwargs={"pk": data_epic.private_epic1.pk}), + data_epic.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('epic-history-comment-versions', kwargs={"pk": data_epic.private_epic2.pk}), + data_epic.private_history_entry2.id + ) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner, + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +######################################################### +## User stories +######################################################### @pytest.fixture def data_us(data): @@ -344,7 +582,6 @@ def test_user_story_action_comment_versions(client, data, data_us): ## Tasks ######################################################### - @pytest.fixture def data_task(data): m = type("Models", (object,), {}) @@ -584,7 +821,6 @@ def test_task_action_comment_versions(client, data, data_task): ## Issues ######################################################### - @pytest.fixture def data_issue(data): m = type("Models", (object,), {}) @@ -824,7 +1060,6 @@ def test_issue_action_comment_versions(client, data, data_issue): ## Wiki pages ######################################################### - @pytest.fixture def data_wiki(data): m = type("Models", (object,), {}) From 79eeccf3799b5c3b716b3aeef89b9a45aa6cbbe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 20 Jul 2016 13:48:47 +0200 Subject: [PATCH 20/83] Add epic notifications --- taiga/projects/notifications/services.py | 3 +++ .../emails/epics/epic-change-body-html.jinja | 10 ++++++++++ .../emails/epics/epic-change-body-text.jinja | 8 ++++++++ .../templates/emails/epics/epic-change-subject.jinja | 3 +++ .../emails/epics/epic-create-body-html.jinja | 11 +++++++++++ .../emails/epics/epic-create-body-text.jinja | 8 ++++++++ .../templates/emails/epics/epic-create-subject.jinja | 3 +++ .../emails/epics/epic-delete-body-html.jinja | 11 +++++++++++ .../emails/epics/epic-delete-body-text.jinja | 8 ++++++++ .../templates/emails/epics/epic-delete-subject.jinja | 3 +++ 10 files changed, 68 insertions(+) create mode 100644 taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja create mode 100644 taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja create mode 100644 taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja create mode 100644 taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja create mode 100644 taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja create mode 100644 taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja create mode 100644 taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja create mode 100644 taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja create mode 100644 taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja diff --git a/taiga/projects/notifications/services.py b/taiga/projects/notifications/services.py index a4128622..4c7012d1 100644 --- a/taiga/projects/notifications/services.py +++ b/taiga/projects/notifications/services.py @@ -112,6 +112,7 @@ def _filter_by_permissions(obj, user): UserStory = apps.get_model("userstories", "UserStory") Issue = apps.get_model("issues", "Issue") Task = apps.get_model("tasks", "Task") + Epic = apps.get_model("epics", "Epic") WikiPage = apps.get_model("wiki", "WikiPage") if isinstance(obj, UserStory): @@ -120,6 +121,8 @@ def _filter_by_permissions(obj, user): return user_has_perm(user, "view_issues", obj, cache="project") elif isinstance(obj, Task): return user_has_perm(user, "view_tasks", obj, cache="project") + elif isinstance(obj, Epic): + return user_has_perm(user, "view_epics", obj, cache="project") elif isinstance(obj, WikiPage): return user_has_perm(user, "view_wiki_pages", obj, cache="project") return False diff --git a/taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja b/taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja new file mode 100644 index 00000000..5c84885d --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja @@ -0,0 +1,10 @@ +{% extends "emails/updates-body-html.jinja" %} + +{% block head %} + {% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("epic", project.slug, snapshot.ref) %} +

Epic updated

+

Hello {{ user }},
{{ changer }} has updated a epic on {{ project }}

+

Epic #{{ ref }} {{ subject }}

+ See epic + {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja b/taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja new file mode 100644 index 00000000..1d6800e2 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja @@ -0,0 +1,8 @@ +{% extends "emails/updates-body-text.jinja" %} +{% block head %} +{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("epic", project.slug, snapshot.ref) %} +Epic updated +Hello {{ user }}, {{ changer }} has updated a epic on {{ project }} +See epic #{{ ref }} {{ subject }} at {{ url }} +{% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja b/taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja new file mode 100644 index 00000000..d66464e0 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja @@ -0,0 +1,3 @@ +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Updated the epic #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja b/taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja new file mode 100644 index 00000000..0484ee0b --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja @@ -0,0 +1,11 @@ +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("epic", project.slug, snapshot.ref) %} +

New epic created

+

Hello {{ user }},
{{ changer }} has created a new epic on {{ project }}

+

Epic #{{ ref }} {{ subject }}

+ See epic +

The Taiga Team

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja b/taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja new file mode 100644 index 00000000..51748107 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja @@ -0,0 +1,8 @@ +{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("epic", project.slug, snapshot.ref) %} +New epic created +Hello {{ user }}, {{ changer }} has created a new epic on {{ project }} +See epic #{{ ref }} {{ subject }} at {{ url }} + +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja b/taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja new file mode 100644 index 00000000..d41e9c78 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja @@ -0,0 +1,3 @@ +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Created the epic #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja new file mode 100644 index 00000000..0debb545 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja @@ -0,0 +1,11 @@ +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject %} +

Epic deleted

+

Hello {{ user }},
{{ changer }} has deleted a epic on {{ project }}

+

Epic #{{ ref }} {{ subject }}

+

The Taiga Team

+ {% endtrans %} +{% endblock %} + diff --git a/taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja new file mode 100644 index 00000000..b5855eba --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja @@ -0,0 +1,8 @@ +{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject %} +Epic deleted +Hello {{ user }}, {{ changer }} has deleted a epic on {{ project }} +Epic #{{ ref }} {{ subject }} + +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja b/taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja new file mode 100644 index 00000000..65286ec2 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja @@ -0,0 +1,3 @@ +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Deleted the epic #{{ ref }} "{{ subject }}" +{% endtrans %} From 0a5ba7b3ae21b4f380e2effbc006cd29c6d9a008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 20 Jul 2016 20:47:54 +0200 Subject: [PATCH 21/83] Add csv for Epics --- taiga/front/urls.py | 3 + taiga/projects/api.py | 36 +++--- taiga/projects/epics/api.py | 22 ++-- taiga/projects/epics/services.py | 111 ++++++++---------- .../migrations/0050_project_epics_csv_uuid.py | 20 ++++ taiga/projects/models.py | 2 + taiga/projects/permissions.py | 1 + taiga/projects/serializers.py | 9 +- .../test_epics_resources.py | 66 +++++------ .../test_projects_resource.py | 25 ++++ tests/integration/test_epics.py | 68 +++++++++++ 11 files changed, 241 insertions(+), 122 deletions(-) create mode 100644 taiga/projects/migrations/0050_project_epics_csv_uuid.py create mode 100644 tests/integration/test_epics.py diff --git a/taiga/front/urls.py b/taiga/front/urls.py index ab1cec8c..77d53dab 100644 --- a/taiga/front/urls.py +++ b/taiga/front/urls.py @@ -33,6 +33,9 @@ urls = { "project": "/project/{0}", # project.slug + "epics": "/project/{0}/epics/", # project.slug + "epic": "/project/{0}/epic/{1}", # project.slug, epic.ref + "backlog": "/project/{0}/backlog/", # project.slug "taskboard": "/project/{0}/taskboard/{1}", # project.slug, milestone.slug "kanban": "/project/{0}/kanban/", # project.slug diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 34920df4..5284f44e 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -243,6 +243,20 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, services.remove_user_from_project(request.user, project) return response.Ok() + def _regenerate_csv_uuid(self, project, field): + uuid_value = uuid.uuid4().hex + setattr(project, field, uuid_value) + project.save() + return uuid_value + + @detail_route(methods=["POST"]) + def regenerate_epics_csv_uuid(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "regenerate_epics_csv_uuid", project) + self.pre_conditions_on_save(project) + data = {"uuid": self._regenerate_csv_uuid(project, "epics_csv_uuid")} + return response.Ok(data) + @detail_route(methods=["POST"]) def regenerate_userstories_csv_uuid(self, request, pk=None): project = self.get_object() @@ -251,14 +265,6 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, data = {"uuid": self._regenerate_csv_uuid(project, "userstories_csv_uuid")} return response.Ok(data) - @detail_route(methods=["POST"]) - def regenerate_issues_csv_uuid(self, request, pk=None): - project = self.get_object() - self.check_permissions(request, "regenerate_issues_csv_uuid", project) - self.pre_conditions_on_save(project) - data = {"uuid": self._regenerate_csv_uuid(project, "issues_csv_uuid")} - return response.Ok(data) - @detail_route(methods=["POST"]) def regenerate_tasks_csv_uuid(self, request, pk=None): project = self.get_object() @@ -267,6 +273,14 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, data = {"uuid": self._regenerate_csv_uuid(project, "tasks_csv_uuid")} return response.Ok(data) + @detail_route(methods=["POST"]) + def regenerate_issues_csv_uuid(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "regenerate_issues_csv_uuid", project) + self.pre_conditions_on_save(project) + data = {"uuid": self._regenerate_csv_uuid(project, "issues_csv_uuid")} + return response.Ok(data) + @list_route(methods=["GET"]) def by_slug(self, request, *args, **kwargs): slug = request.QUERY_PARAMS.get("slug", None) @@ -293,12 +307,6 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, self.check_permissions(request, "stats", project) return response.Ok(services.get_stats_for_project(project)) - def _regenerate_csv_uuid(self, project, field): - uuid_value = uuid.uuid4().hex - setattr(project, field, uuid_value) - project.save() - return uuid_value - @detail_route(methods=["GET"]) def member_stats(self, request, pk=None): project = self.get_object() diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index e71106d1..2c2760ba 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -154,18 +154,18 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, return self.retrieve(request, **retrieve_kwargs) - #@list_route(methods=["GET"]) - #def csv(self, request): - # uuid = request.QUERY_PARAMS.get("uuid", None) - # if uuid is None: - # return response.NotFound() + @list_route(methods=["GET"]) + def csv(self, request): + uuid = request.QUERY_PARAMS.get("uuid", None) + if uuid is None: + return response.NotFound() - # project = get_object_or_404(Project, epics_csv_uuid=uuid) - # queryset = project.epics.all().order_by('ref') - # data = services.epics_to_csv(project, queryset) - # csv_response = HttpResponse(data.getvalue(), content_type='application/csv; charset=utf-8') - # csv_response['Content-Disposition'] = 'attachment; filename="epics.csv"' - # return csv_response + project = get_object_or_404(Project, epics_csv_uuid=uuid) + queryset = project.epics.all().order_by('ref') + data = services.epics_to_csv(project, queryset) + csv_response = HttpResponse(data.getvalue(), content_type='application/csv; charset=utf-8') + csv_response['Content-Disposition'] = 'attachment; filename="epics.csv"' + return csv_response @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): diff --git a/taiga/projects/epics/services.py b/taiga/projects/epics/services.py index 01a0f0fa..8674e79f 100644 --- a/taiga/projects/epics/services.py +++ b/taiga/projects/epics/services.py @@ -105,66 +105,57 @@ def snapshot_epics_in_bulk(bulk_data, user): ##################################################### # CSV ##################################################### -# -#def epics_to_csv(project, queryset): -# csv_data = io.StringIO() -# fieldnames = ["ref", "subject", "description", "user_story", "sprint", "sprint_estimated_start", -# "sprint_estimated_finish", "owner", "owner_full_name", "assigned_to", -# "assigned_to_full_name", "status", "is_iocaine", "is_closed", "us_order", -# "epicboard_order", "attachments", "external_reference", "tags", "watchers", "voters", -# "created_date", "modified_date", "finished_date"] -# -# custom_attrs = project.epiccustomattributes.all() -# for custom_attr in custom_attrs: -# fieldnames.append(custom_attr.name) -# -# queryset = queryset.prefetch_related("attachments", -# "custom_attributes_values") -# queryset = queryset.select_related("milestone", -# "owner", -# "assigned_to", -# "status", -# "project") -# -# queryset = attach_total_voters_to_queryset(queryset) -# queryset = attach_watchers_to_queryset(queryset) -# -# writer = csv.DictWriter(csv_data, fieldnames=fieldnames) -# writer.writeheader() -# for epic in queryset: -# epic_data = { -# "ref": epic.ref, -# "subject": epic.subject, -# "description": epic.description, -# "user_story": epic.user_story.ref if epic.user_story else None, -# "sprint": epic.milestone.name if epic.milestone else None, -# "sprint_estimated_start": epic.milestone.estimated_start if epic.milestone else None, -# "sprint_estimated_finish": epic.milestone.estimated_finish if epic.milestone else None, -# "owner": epic.owner.username if epic.owner else None, -# "owner_full_name": epic.owner.get_full_name() if epic.owner else None, -# "assigned_to": epic.assigned_to.username if epic.assigned_to else None, -# "assigned_to_full_name": epic.assigned_to.get_full_name() if epic.assigned_to else None, -# "status": epic.status.name if epic.status else None, -# "is_iocaine": epic.is_iocaine, -# "is_closed": epic.status is not None and epic.status.is_closed, -# "us_order": epic.us_order, -# "epicboard_order": epic.epicboard_order, -# "attachments": epic.attachments.count(), -# "external_reference": epic.external_reference, -# "tags": ",".join(epic.tags or []), -# "watchers": epic.watchers, -# "voters": epic.total_voters, -# "created_date": epic.created_date, -# "modified_date": epic.modified_date, -# "finished_date": epic.finished_date, -# } -# for custom_attr in custom_attrs: -# value = epic.custom_attributes_values.attributes_values.get(str(custom_attr.id), None) -# epic_data[custom_attr.name] = value -# -# writer.writerow(epic_data) -# -# return csv_data + +def epics_to_csv(project, queryset): + csv_data = io.StringIO() + fieldnames = ["ref", "subject", "description", "owner", "owner_full_name", "assigned_to", + "assigned_to_full_name", "status", "epics_order", "client_requirement", + "team_requirement", "attachments", "tags", "watchers", "voters", + "created_date", "modified_date"] + + custom_attrs = project.epiccustomattributes.all() + for custom_attr in custom_attrs: + fieldnames.append(custom_attr.name) + + queryset = queryset.prefetch_related("attachments", + "custom_attributes_values") + queryset = queryset.select_related("owner", + "assigned_to", + "status", + "project") + + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + + writer = csv.DictWriter(csv_data, fieldnames=fieldnames) + writer.writeheader() + for epic in queryset: + epic_data = { + "ref": epic.ref, + "subject": epic.subject, + "description": epic.description, + "owner": epic.owner.username if epic.owner else None, + "owner_full_name": epic.owner.get_full_name() if epic.owner else None, + "assigned_to": epic.assigned_to.username if epic.assigned_to else None, + "assigned_to_full_name": epic.assigned_to.get_full_name() if epic.assigned_to else None, + "status": epic.status.name if epic.status else None, + "epics_order": epic.epics_order, + "client_requirement": epic.client_requirement, + "team_requirement": epic.team_requirement, + "attachments": epic.attachments.count(), + "tags": ",".join(epic.tags or []), + "watchers": epic.watchers, + "voters": epic.total_voters, + "created_date": epic.created_date, + "modified_date": epic.modified_date, + } + for custom_attr in custom_attrs: + value = epic.custom_attributes_values.attributes_values.get(str(custom_attr.id), None) + epic_data[custom_attr.name] = value + + writer.writerow(epic_data) + + return csv_data ##################################################### diff --git a/taiga/projects/migrations/0050_project_epics_csv_uuid.py b/taiga/projects/migrations/0050_project_epics_csv_uuid.py new file mode 100644 index 00000000..2dc87674 --- /dev/null +++ b/taiga/projects/migrations/0050_project_epics_csv_uuid.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-07-20 17:57 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0049_auto_20160629_1443'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='epics_csv_uuid', + field=models.CharField(blank=True, db_index=True, default=None, editable=False, max_length=32, null=True), + ), + ] diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 02d24519..080cd411 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -202,6 +202,8 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): looking_for_people_note = models.TextField(default="", null=False, blank=True, verbose_name=_("loking for people note")) + epics_csv_uuid = models.CharField(max_length=32, editable=False, null=True, + blank=True, default=None, db_index=True) userstories_csv_uuid = models.CharField(max_length=32, editable=False, null=True, blank=True, default=None, db_index=True) diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py index 5e3b44db..7c10b5c2 100644 --- a/taiga/projects/permissions.py +++ b/taiga/projects/permissions.py @@ -62,6 +62,7 @@ class ProjectPermission(TaigaResourcePermission): stats_perms = HasProjectPerm('view_project') member_stats_perms = HasProjectPerm('view_project') issues_stats_perms = HasProjectPerm('view_project') + regenerate_epics_csv_uuid_perms = IsProjectAdmin() regenerate_userstories_csv_uuid_perms = IsProjectAdmin() regenerate_issues_csv_uuid_perms = IsProjectAdmin() regenerate_tasks_csv_uuid_perms = IsProjectAdmin() diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 58999578..a560ed36 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -373,9 +373,10 @@ class ProjectDetailSerializer(ProjectSerializer): # Admin fields is_private_extra_info = MethodField() max_memberships = MethodField() - issues_csv_uuid = Field() - tasks_csv_uuid = Field() + epics_csv_uuid = Field() userstories_csv_uuid = Field() + tasks_csv_uuid = Field() + issues_csv_uuid = Field() transfer_token = Field() milestones = MethodField() @@ -404,8 +405,8 @@ class ProjectDetailSerializer(ProjectSerializer): ret = super().to_value(instance) admin_fields = [ - "is_private_extra_info", "max_memberships", "issues_csv_uuid", - "tasks_csv_uuid", "userstories_csv_uuid", "transfer_token" + "epics_csv_uuid", "userstories_csv_uuid", "tasks_csv_uuid", "issues_csv_uuid", + "is_private_extra_info", "max_memberships", "transfer_token", ] is_admin_user = False diff --git a/tests/integration/resources_permissions/test_epics_resources.py b/tests/integration/resources_permissions/test_epics_resources.py index ceffe206..f4e33813 100644 --- a/tests/integration/resources_permissions/test_epics_resources.py +++ b/tests/integration/resources_permissions/test_epics_resources.py @@ -59,29 +59,29 @@ def data(): m.public_project = f.ProjectFactory(is_private=False, anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), - owner=m.project_owner) - #epics_csv_uuid=uuid.uuid4().hex) + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex) m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) m.private_project1 = f.ProjectFactory(is_private=True, anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), - owner=m.project_owner) - #epics_csv_uuid=uuid.uuid4().hex) + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex) m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id) m.private_project2 = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], - owner=m.project_owner) - #epics_csv_uuid=uuid.uuid4().hex) + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex) m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) m.blocked_project = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], owner=m.project_owner, - #epics_csv_uuid=uuid.uuid4().hex, + epics_csv_uuid=uuid.uuid4().hex, blocked_code=project_choices.BLOCKED_BY_STAFF) m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) @@ -873,29 +873,29 @@ def test_epic_watchers_retrieve(client, data): assert results == [401, 403, 403, 200, 200] -#def test_epics_csv(client, data): -# url = reverse('epics-csv') -# csv_public_uuid = data.public_project.epics_csv_uuid -# csv_private1_uuid = data.private_project1.epics_csv_uuid -# csv_private2_uuid = data.private_project1.epics_csv_uuid -# csv_blocked_uuid = data.blocked_project.epics_csv_uuid -# -# users = [ -# None, -# data.registered_user, -# data.project_member_without_perms, -# data.project_member_with_perms, -# data.project_owner -# ] -# -# results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users) -# assert results == [200, 200, 200, 200, 200] -# -# results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users) -# assert results == [200, 200, 200, 200, 200] -# -# results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) -# assert results == [200, 200, 200, 200, 200] -# -# results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_blocked_uuid), None, users) -# assert results == [200, 200, 200, 200, 200] +def test_epics_csv(client, data): + url = reverse('epics-csv') + csv_public_uuid = data.public_project.epics_csv_uuid + csv_private1_uuid = data.private_project1.epics_csv_uuid + csv_private2_uuid = data.private_project1.epics_csv_uuid + csv_blocked_uuid = data.blocked_project.epics_csv_uuid + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_blocked_uuid), None, users) + assert results == [200, 200, 200, 200, 200] diff --git a/tests/integration/resources_permissions/test_projects_resource.py b/tests/integration/resources_permissions/test_projects_resource.py index 1410c86d..3d62dc6e 100644 --- a/tests/integration/resources_permissions/test_projects_resource.py +++ b/tests/integration/resources_permissions/test_projects_resource.py @@ -486,6 +486,31 @@ def test_invitations_retrieve(client, data): assert results == [200, 200, 200, 200] +def test_regenerate_epics_csv_uuid(client, data): + public_url = reverse('projects-regenerate-epics-csv-uuid', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-regenerate-epics-csv-uuid', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-regenerate-epics-csv-uuid', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-regenerate-epics-csv-uuid', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [404, 404, 403, 200] + + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [404, 404, 403, 451] + + def test_regenerate_userstories_csv_uuid(client, data): public_url = reverse('projects-regenerate-userstories-csv-uuid', kwargs={"pk": data.public_project.pk}) private1_url = reverse('projects-regenerate-userstories-csv-uuid', kwargs={"pk": data.private_project1.pk}) diff --git a/tests/integration/test_epics.py b/tests/integration/test_epics.py new file mode 100644 index 00000000..5f22f62f --- /dev/null +++ b/tests/integration/test_epics.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# Copyright (C) 2014-2016 Anler Hernández +# 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 uuid +import csv + +from unittest import mock + +from django.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.projects.epics import services + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +def test_get_invalid_csv(client): + url = reverse("epics-csv") + + response = client.get(url) + assert response.status_code == 404 + + response = client.get("{}?uuid={}".format(url, "not-valid-uuid")) + assert response.status_code == 404 + + +def test_get_valid_csv(client): + url = reverse("epics-csv") + project = f.ProjectFactory.create(epics_csv_uuid=uuid.uuid4().hex) + + response = client.get("{}?uuid={}".format(url, project.epics_csv_uuid)) + assert response.status_code == 200 + + +def test_custom_fields_csv_generation(): + project = f.ProjectFactory.create(epics_csv_uuid=uuid.uuid4().hex) + attr = f.EpicCustomAttributeFactory.create(project=project, name="attr1", description="desc") + epic = f.EpicFactory.create(project=project) + attr_values = epic.custom_attributes_values + attr_values.attributes_values = {str(attr.id):"val1"} + attr_values.save() + queryset = project.epics.all() + data = services.epics_to_csv(project, queryset) + data.seek(0) + reader = csv.reader(data) + row = next(reader) + assert row[17] == attr.name + row = next(reader) + assert row[17] == "val1" From 5a5d393815cdac563069f0501038effd6280a752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 21 Jul 2016 12:57:24 +0200 Subject: [PATCH 22/83] Add epic references signals --- taiga/projects/references/models.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/taiga/projects/references/models.py b/taiga/projects/references/models.py index 40aea018..61097ecb 100644 --- a/taiga/projects/references/models.py +++ b/taiga/projects/references/models.py @@ -21,10 +21,11 @@ from django.utils import timezone from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey +from taiga.projects.models import Project +from taiga.projects.epics.models import Epic from taiga.projects.userstories.models import UserStory from taiga.projects.tasks.models import Task from taiga.projects.issues.models import Issue -from taiga.projects.models import Project from . import sequences as seq @@ -103,11 +104,22 @@ def attach_sequence(sender, instance, created, **kwargs): instance.save(update_fields=['ref']) +# Project models.signals.post_save.connect(create_sequence, sender=Project, dispatch_uid="refproj") -models.signals.pre_save.connect(store_previous_project, sender=UserStory, dispatch_uid="refus") -models.signals.pre_save.connect(store_previous_project, sender=Issue, dispatch_uid="refissue") -models.signals.pre_save.connect(store_previous_project, sender=Task, dispatch_uid="reftask") -models.signals.post_save.connect(attach_sequence, sender=UserStory, dispatch_uid="refus") -models.signals.post_save.connect(attach_sequence, sender=Issue, dispatch_uid="refissue") -models.signals.post_save.connect(attach_sequence, sender=Task, dispatch_uid="reftask") models.signals.post_delete.connect(delete_sequence, sender=Project, dispatch_uid="refprojdel") + +# Epic +models.signals.pre_save.connect(store_previous_project, sender=Epic, dispatch_uid="refepic") +models.signals.post_save.connect(attach_sequence, sender=Epic, dispatch_uid="refepic") + +# User Story +models.signals.pre_save.connect(store_previous_project, sender=UserStory, dispatch_uid="refus") +models.signals.post_save.connect(attach_sequence, sender=UserStory, dispatch_uid="refus") + +# Task +models.signals.pre_save.connect(store_previous_project, sender=Task, dispatch_uid="reftask") +models.signals.post_save.connect(attach_sequence, sender=Task, dispatch_uid="reftask") + +# Issue +models.signals.pre_save.connect(store_previous_project, sender=Issue, dispatch_uid="refissue") +models.signals.post_save.connect(attach_sequence, sender=Issue, dispatch_uid="refissue") From d307318b9012e1a9644f85ae8303d2ec2d279d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 21 Jul 2016 19:00:20 +0200 Subject: [PATCH 23/83] Add user story counts in epic serializer --- taiga/projects/epics/serializers.py | 5 +++++ taiga/projects/epics/utils.py | 17 ++++++++++++++++- .../projects/management/commands/sample_data.py | 4 ++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/taiga/projects/epics/serializers.py b/taiga/projects/epics/serializers.py index 4118553e..081245d9 100644 --- a/taiga/projects/epics/serializers.py +++ b/taiga/projects/epics/serializers.py @@ -49,10 +49,15 @@ class EpicListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, blocked_note = Field() tags = Field() is_closed = MethodField() + user_stories_counts = MethodField() def get_is_closed(self, obj): return obj.status is not None and obj.status.is_closed + def get_user_stories_counts(self, obj): + assert hasattr(obj, "user_stories_counts"), "instance must have a user_stories_counts attribute" + return obj.user_stories_counts + class EpicSerializer(EpicListSerializer): comment = MethodField() diff --git a/taiga/projects/epics/utils.py b/taiga/projects/epics/utils.py index d10dddab..20f45046 100644 --- a/taiga/projects/epics/utils.py +++ b/taiga/projects/epics/utils.py @@ -26,14 +26,29 @@ from taiga.projects.votes.utils import attach_is_voter_to_queryset def attach_extra_info(queryset, user=None, include_attachments=False): - if include_attachments: queryset = attach_basic_attachments(queryset) queryset = queryset.extra(select={"include_attachments": "True"}) + queryset = attach_user_stories_counts_to_queryset(queryset) queryset = attach_total_voters_to_queryset(queryset) queryset = attach_watchers_to_queryset(queryset) queryset = attach_total_watchers_to_queryset(queryset) queryset = attach_is_voter_to_queryset(queryset, user) queryset = attach_is_watcher_to_queryset(queryset, user) return queryset + + +def attach_user_stories_counts_to_queryset(queryset, as_field="user_stories_counts"): + model = queryset.model + sql = """SELECT json_build_object( + 'opened', COALESCE(SUM(CASE WHEN is_closed IS FALSE THEN 1 ELSE 0 END), 0), + 'closed', COALESCE(SUM(CASE WHEN is_closed IS TRUE THEN 1 ELSE 0 END), 0) + ) + FROM epics_relateduserstory + INNER JOIN userstories_userstory ON epics_relateduserstory.user_story_id = userstories_userstory.id + WHERE epics_relateduserstory.epic_id = {tbl}.id""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index 00103c6e..54b4e027 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -111,7 +111,7 @@ NUM_EMPTY_PROJECTS = getattr(settings, "SAMPLE_DATA_NUM_EMPTY_PROJECTS", 2) NUM_BLOCKED_PROJECTS = getattr(settings, "SAMPLE_DATA_NUM_BLOCKED_PROJECTS", 1) NUM_MILESTONES = getattr(settings, "SAMPLE_DATA_NUM_MILESTONES", (1, 5)) NUM_EPICS = getattr(settings, "SAMPLE_DATA_NUM_EPICS", (4, 8)) -NUM_USS_EPICS = getattr(settings, "SAMPLE_DATA_NUM_USS_EPICS", (2, 6)) +NUM_USS_EPICS = getattr(settings, "SAMPLE_DATA_NUM_USS_EPICS", (2, 12)) NUM_USS = getattr(settings, "SAMPLE_DATA_NUM_USS", (3, 7)) NUM_TASKS_FINISHED = getattr(settings, "SAMPLE_DATA_NUM_TASKS_FINISHED", (1, 5)) NUM_TASKS = getattr(settings, "SAMPLE_DATA_NUM_TASKS", (0, 4)) @@ -553,7 +553,7 @@ class Command(BaseCommand): if self.sd.choice([True, True, False, True, True]): filters = {"project": epic.project} n = self.sd.choice(list(range(self.sd.int(*NUM_USS_EPICS)))) - + print (n) user_stories = UserStory.objects.filter(**filters).order_by("?")[:n] for idx, us in enumerate(list(user_stories)): RelatedUserStory.objects.create(epic=epic, From 9a1b422a42fdb6eae2e0527341f78cd904ab2acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 21 Jul 2016 20:06:39 +0200 Subject: [PATCH 24/83] Add project extra info to the user story serializer --- taiga/projects/mixins/serializers.py | 29 +++++++++++++++++++++-- taiga/projects/serializers.py | 12 ++++++---- taiga/projects/userstories/serializers.py | 7 +++--- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/taiga/projects/mixins/serializers.py b/taiga/projects/mixins/serializers.py index 945c1119..cec50b2b 100644 --- a/taiga/projects/mixins/serializers.py +++ b/taiga/projects/mixins/serializers.py @@ -16,12 +16,13 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from django.utils.translation import ugettext as _ + from taiga.base.api import serializers from taiga.base.fields import Field, MethodField +from taiga.projects import services from taiga.users.serializers import UserBasicInfoSerializer -from django.utils.translation import ugettext as _ - class CachedUsersSerializerMixin(serializers.LightSerializer): def to_value(self, instance): @@ -77,3 +78,27 @@ class StatusExtraInfoSerializerMixin(serializers.LightSerializer): self._serialized_status[obj.status_id] = serialized_status return serialized_status + + +class ProjectExtraInfoSerializerMixin(serializers.LightSerializer): + project = Field(attr="project_id") + project_extra_info = MethodField() + + def to_value(self, instance): + self._serialized_project = {} + return super().to_value(instance) + + def get_project_extra_info(self, obj): + if obj.project_id is None: + return None + + serialized_project = self._serialized_project.get(obj.project_id, None) + if serialized_project is None: + serialized_project = { + "name": obj.project.name, + "slug": obj.project.slug, + "logo_small_url": services.get_logo_small_thumbnail_url(obj.project) + } + self._serialized_project[obj.project_id] = serialized_project + + return serialized_project diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index a560ed36..eb7b2e54 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -434,8 +434,10 @@ class ProjectDetailSerializer(ProjectSerializer): return len(obj.members_attr) def get_is_out_of_owner_limits(self, obj): - assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute" - assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute" + assert hasattr(obj, "private_projects_same_owner_attr"), ("instance must have a private_projects_same" + "_owner_attr attribute") + assert hasattr(obj, "public_projects_same_owner_attr"), ("instance must have a public_projects_same_" + "owner_attr attribute") return services.check_if_project_is_out_of_owner_limits( obj, current_memberships=self.get_total_memberships(obj), @@ -444,8 +446,10 @@ class ProjectDetailSerializer(ProjectSerializer): ) def get_is_private_extra_info(self, obj): - assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute" - assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute" + assert hasattr(obj, "private_projects_same_owner_attr"), ("instance must have a private_projects_same_" + "owner_attr attribute") + assert hasattr(obj, "public_projects_same_owner_attr"), ("instance must have a public_projects_same" + "_owner_attr attribute") return services.check_if_project_privacity_can_be_changed( obj, current_memberships=self.get_total_memberships(obj), diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index abb10592..bc977a2e 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -22,11 +22,12 @@ from taiga.base.neighbors import NeighborsSerializerMixin from taiga.mdrender.service import render as mdrender from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin -from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer -from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import ProjectExtraInfoSerializerMixin from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin from taiga.projects.notifications.mixins import WatchedResourceSerializer +from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin @@ -42,7 +43,7 @@ class OriginIssueSerializer(serializers.LightSerializer): return super().to_value(instance) -class UserStoryListSerializer( +class UserStoryListSerializer(ProjectExtraInfoSerializerMixin, VoteResourceSerializerMixin, WatchedResourceSerializer, OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, StatusExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin, From fb673ab844f57ca3edee933788e4bd31e39c20b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 22 Jul 2016 12:19:56 +0200 Subject: [PATCH 25/83] Ability to order stories inside an Epic --- taiga/projects/userstories/api.py | 1 + .../migrations/0013_auto_20160722_1018.py | 25 +++++++++++++++++++ taiga/projects/userstories/models.py | 4 ++- taiga/projects/userstories/serializers.py | 1 + 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 taiga/projects/userstories/migrations/0013_auto_20160722_1018.py diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 3b6e108a..d391e08f 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -83,6 +83,7 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi order_by_fields = ["backlog_order", "sprint_order", "kanban_order", + "epic_order", "total_voters"] def get_serializer_class(self, *args, **kwargs): diff --git a/taiga/projects/userstories/migrations/0013_auto_20160722_1018.py b/taiga/projects/userstories/migrations/0013_auto_20160722_1018.py new file mode 100644 index 00000000..45e11419 --- /dev/null +++ b/taiga/projects/userstories/migrations/0013_auto_20160722_1018.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-07-22 10:18 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0012_auto_20160614_1201'), + ] + + operations = [ + migrations.AddField( + model_name='userstory', + name='epic_order', + field=models.IntegerField(default=10000, verbose_name='epic order'), + ), + migrations.AlterField( + model_name='userstory', + name='kanban_order', + field=models.IntegerField(default=10000, verbose_name='kanban order'), + ), + ] diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py index d774e5b4..f096c885 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -80,7 +80,9 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod sprint_order = models.IntegerField(null=False, blank=False, default=10000, verbose_name=_("sprint order")) kanban_order = models.IntegerField(null=False, blank=False, default=10000, - verbose_name=_("sprint order")) + verbose_name=_("kanban order")) + epic_order = models.IntegerField(null=False, blank=False, default=10000, + verbose_name=_("epic order")) created_date = models.DateTimeField(null=False, blank=False, verbose_name=_("created date"), diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index bc977a2e..f60fb2b6 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -61,6 +61,7 @@ class UserStoryListSerializer(ProjectExtraInfoSerializerMixin, backlog_order = Field() sprint_order = Field() kanban_order = Field() + epic_order = Field() created_date = Field() modified_date = Field() finish_date = Field() From 5cda117c1b26c280179de2bf3f1fba7d99c1af2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 22 Jul 2016 12:39:02 +0200 Subject: [PATCH 26/83] Remove deprecate code --- taiga/projects/epics/api.py | 9 +++------ taiga/projects/issues/api.py | 14 ++------------ taiga/projects/tasks/api.py | 5 ----- taiga/projects/userstories/api.py | 9 ++------- 4 files changed, 7 insertions(+), 30 deletions(-) diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index 2c2760ba..bbfd164f 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -53,12 +53,9 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, filters.StatusesFilter, filters.TagsFilter, filters.WatchersFilter, - filters.QFilter) - retrieve_exclude_filters = (filters.OwnersFilter, - filters.AssignedToFilter, - filters.StatusesFilter, - filters.TagsFilter, - filters.WatchersFilter) + filters.QFilter, + filters.CreatedDateFilter, + filters.ModifiedDateFilter) filter_fields = ["project", "project__slug", "assigned_to", diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index 08533e24..617b13ec 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -58,23 +58,13 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W filters.TagsFilter, filters.WatchersFilter, filters.QFilter, - filters.OrderByFilterMixin, filters.CreatedDateFilter, filters.ModifiedDateFilter, - filters.FinishedDateFilter) - retrieve_exclude_filters = (filters.OwnersFilter, - filters.AssignedToFilter, - filters.StatusesFilter, - filters.IssueTypesFilter, - filters.SeveritiesFilter, - filters.PrioritiesFilter, - filters.TagsFilter, - filters.WatchersFilter,) - + filters.FinishedDateFilter, + filters.OrderByFilterMixin) filter_fields = ("project", "project__slug", "status__is_closed") - order_by_fields = ("type", "status", "severity", diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index 3cf43913..bc76f24f 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -60,11 +60,6 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, filters.CreatedDateFilter, filters.ModifiedDateFilter, filters.FinishedDateFilter) - retrieve_exclude_filters = (filters.OwnersFilter, - filters.AssignedToFilter, - filters.StatusesFilter, - filters.TagsFilter, - filters.WatchersFilter) filter_fields = ["user_story", "milestone", "project", diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index d391e08f..32b798a1 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -64,15 +64,10 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi filters.TagsFilter, filters.WatchersFilter, filters.QFilter, - filters.OrderByFilterMixin, filters.CreatedDateFilter, filters.ModifiedDateFilter, - filters.FinishDateFilter) - retrieve_exclude_filters = (filters.OwnersFilter, - filters.AssignedToFilter, - filters.StatusesFilter, - filters.TagsFilter, - filters.WatchersFilter) + filters.FinishDateFilter, + filters.OrderByFilterMixin) filter_fields = ["project", "project__slug", "milestone", From ff7d241a3755762795bdb2687095d58f7e1ad1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 26 Jul 2016 13:05:19 +0200 Subject: [PATCH 27/83] Filter userstories by epics --- taiga/projects/userstories/api.py | 36 +++++++------ taiga/projects/userstories/filters.py | 24 +++++++++ taiga/projects/userstories/services.py | 71 ++++++++++++++++++++++++- tests/factories.py | 13 ++++- tests/integration/test_userstories.py | 73 +++++++++++++++++--------- 5 files changed, 174 insertions(+), 43 deletions(-) create mode 100644 taiga/projects/userstories/filters.py diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 32b798a1..45d78f28 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -22,7 +22,7 @@ from django.db import transaction from django.utils.translation import ugettext as _ from django.http import HttpResponse -from taiga.base import filters +from taiga.base import filters as base_filters from taiga.base import exceptions as exc from taiga.base import response from taiga.base import status @@ -45,6 +45,7 @@ from taiga.projects.votes.mixins.viewsets import VotedResourceMixin from taiga.projects.votes.mixins.viewsets import VotersViewSetMixin from taiga.projects.userstories.utils import attach_extra_info +from . import filters from . import models from . import permissions from . import serializers @@ -57,17 +58,18 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi validator_class = validators.UserStoryValidator queryset = models.UserStory.objects.all() permission_classes = (permissions.UserStoryPermission,) - filter_backends = (filters.CanViewUsFilterBackend, - filters.OwnersFilter, - filters.AssignedToFilter, - filters.StatusesFilter, - filters.TagsFilter, - filters.WatchersFilter, - filters.QFilter, - filters.CreatedDateFilter, - filters.ModifiedDateFilter, - filters.FinishDateFilter, - filters.OrderByFilterMixin) + filter_backends = (base_filters.CanViewUsFilterBackend, + filters.EpicsFilter, + base_filters.OwnersFilter, + base_filters.AssignedToFilter, + base_filters.StatusesFilter, + base_filters.TagsFilter, + base_filters.WatchersFilter, + base_filters.QFilter, + base_filters.CreatedDateFilter, + base_filters.ModifiedDateFilter, + base_filters.FinishDateFilter, + base_filters.OrderByFilterMixin) filter_fields = ["project", "project__slug", "milestone", @@ -270,16 +272,18 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi project = get_object_or_404(Project, id=project_id) filter_backends = self.get_filter_backends() - statuses_filter_backends = (f for f in filter_backends if f != filters.StatusesFilter) - assigned_to_filter_backends = (f for f in filter_backends if f != filters.AssignedToFilter) - owners_filter_backends = (f for f in filter_backends if f != filters.OwnersFilter) + statuses_filter_backends = (f for f in filter_backends if f != base_filters.StatusesFilter) + assigned_to_filter_backends = (f for f in filter_backends if f != base_filters.AssignedToFilter) + owners_filter_backends = (f for f in filter_backends if f != base_filters.OwnersFilter) + epics_filter_backends = (f for f in filter_backends if f != filters.EpicsFilter) queryset = self.get_queryset() querysets = { "statuses": self.filter_queryset(queryset, filter_backends=statuses_filter_backends), "assigned_to": self.filter_queryset(queryset, filter_backends=assigned_to_filter_backends), "owners": self.filter_queryset(queryset, filter_backends=owners_filter_backends), - "tags": self.filter_queryset(queryset) + "tags": self.filter_queryset(queryset), + "epics": self.filter_queryset(queryset, filter_backends=epics_filter_backends) } return response.Ok(services.get_userstories_filters_data(project, querysets)) diff --git a/taiga/projects/userstories/filters.py b/taiga/projects/userstories/filters.py new file mode 100644 index 00000000..5f877618 --- /dev/null +++ b/taiga/projects/userstories/filters.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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 taiga.base import filters + + +class EpicsFilter(filters.BaseRelatedFieldsFilter): + filter_name = 'epics' diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py index 0f260e2c..11fb2a2f 100644 --- a/taiga/projects/userstories/services.py +++ b/taiga/projects/userstories/services.py @@ -244,7 +244,7 @@ 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 } us_role_points_by_role_id = {us_rp.role.id: us_rp.points.value for us_rp in us.role_points.all()} @@ -456,6 +456,74 @@ def _get_userstories_tags(project, queryset): return sorted(result, key=itemgetter("name")) +def _get_userstories_epics(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT "epics_relateduserstory"."epic_id" AS "epic_id", + count("epics_relateduserstory"."id") AS "counter" + FROM "epics_relateduserstory" + INNER JOIN "userstories_userstory" + ON ("userstories_userstory"."id" = "epics_relateduserstory"."user_story_id") + WHERE {where} + GROUP BY "epics_relateduserstory"."epic_id" + ) + SELECT "epics_epic"."id" AS "id", + "epics_epic"."ref" AS "ref", + "epics_epic"."subject" AS "subject", + "epics_epic"."epics_order" AS "order", + COALESCE("counters"."counter", 0) AS "counter" + FROM "epics_epic" + LEFT OUTER JOIN "counters" + ON ("counters"."epic_id" = "epics_epic"."id") + WHERE "epics_epic"."project_id" = %s + + -- User stories with no epics (return results only if there are userstories) + UNION + SELECT NULL AS "id", + NULL AS "ref", + NULL AS "subject", + 0 AS "order", + count(COALESCE("epics_relateduserstory"."epic_id", -1)) AS "counter" + FROM "userstories_userstory" + LEFT OUTER JOIN "epics_relateduserstory" + ON ("epics_relateduserstory"."user_story_id" = "userstories_userstory"."id") + WHERE {where} AND "epics_relateduserstory"."epic_id" IS NULL + GROUP BY "epics_relateduserstory"."epic_id" + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id] + where_params) + rows = cursor.fetchall() + + result = [] + for id, ref, subject, order, count in rows: + result.append({ + "id": id, + "ref": ref, + "subject": subject, + "order": order, + "count": count, + }) + + result = sorted(result, key=itemgetter("order")) + + # Add row when there is no user stories with no epics + if result[0]["id"] is not None: + result.insert(0, { + "id": None, + "ref": None, + "subject": None, + "order": 0, + "count": 0, + }) + return result + + def get_userstories_filters_data(project, querysets): """ Given a project and an userstories queryset, return a simple data structure @@ -466,6 +534,7 @@ def get_userstories_filters_data(project, querysets): ("assigned_to", _get_userstories_assigned_to(project, querysets["assigned_to"])), ("owners", _get_userstories_owners(project, querysets["owners"])), ("tags", _get_userstories_tags(project, querysets["tags"])), + ("epics", _get_userstories_epics(project, querysets["epics"])), ]) return data diff --git a/tests/factories.py b/tests/factories.py index 2e831c13..5cec5800 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -249,11 +249,20 @@ class EpicFactory(Factory): ref = factory.Sequence(lambda n: n) project = factory.SubFactory("tests.factories.ProjectFactory") owner = factory.SubFactory("tests.factories.UserFactory") - subject = factory.Sequence(lambda n: "User Story {}".format(n)) - description = factory.Sequence(lambda n: "User Story {} description".format(n)) + subject = factory.Sequence(lambda n: "Epic {}".format(n)) + description = factory.Sequence(lambda n: "Epic {} description".format(n)) status = factory.SubFactory("tests.factories.EpicStatusFactory") +class RelatedUserStory(Factory): + class Meta: + model = "epics.RelatedUserStory" + strategy = factory.CREATE_STRATEGY + + epic = factory.SubFactory("tests.factories.EpicFactory") + user_story = factory.SubFactory("tests.factories.UserStoryFactory") + + class MilestoneFactory(Factory): class Meta: model = "milestones.Milestone" diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index c9cd4bb1..e158b802 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -625,45 +625,55 @@ def test_api_filters_data(client): status2 = f.UserStoryStatusFactory.create(project=project) status3 = f.UserStoryStatusFactory.create(project=project) + epic0 = f.EpicFactory.create(project=project) + epic1 = f.EpicFactory.create(project=project) + epic2 = f.EpicFactory.create(project=project) + tag0 = "test1test2test3" tag1 = "test1" tag2 = "test2" tag3 = "test3" - # ------------------------------------------------------ - # | US | Owner | Assigned To | Tags | - # |-------#--------#-------------#---------------------| - # | 0 | user2 | None | tag1 | - # | 1 | user1 | None | tag2 | - # | 2 | user3 | None | tag1 tag2 | - # | 3 | user2 | None | tag3 | - # | 4 | user1 | user1 | tag1 tag2 tag3 | - # | 5 | user3 | user1 | tag3 | - # | 6 | user2 | user1 | tag1 tag2 | - # | 7 | user1 | user2 | tag3 | - # | 8 | user3 | user2 | tag1 | - # | 9 | user2 | user3 | tag0 | - # ------------------------------------------------------ + # ------------------------------------------------------------------------------ + # | US | Status | Owner | Assigned To | Tags | Epic | + # |-------#---------#--------#-------------#---------------------#-------------- + # | 0 | status3 | user2 | None | tag1 | epic0 | + # | 1 | status3 | user1 | None | tag2 | None | + # | 2 | status1 | user3 | None | tag1 tag2 | epic1 | + # | 3 | status0 | user2 | None | tag3 | None | + # | 4 | status0 | user1 | user1 | tag1 tag2 tag3 | epic0 | + # | 5 | status2 | user3 | user1 | tag3 | None | + # | 6 | status3 | user2 | user1 | tag1 tag2 | epic0 epic2 | + # | 7 | status0 | user1 | user2 | tag3 | None | + # | 8 | status3 | user3 | user2 | tag1 | epic2 | + # | 9 | status1 | user2 | user3 | tag0 | none | + # ------------------------------------------------------------------------------ - f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None, + us0 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None, status=status3, tags=[tag1]) - f.UserStoryFactory.create(project=project, owner=user1, assigned_to=None, + f.RelatedUserStory.create(user_story=us0, epic=epic0) + us1 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=None, status=status3, tags=[tag2]) - f.UserStoryFactory.create(project=project, owner=user3, assigned_to=None, + us2 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=None, status=status1, tags=[tag1, tag2]) - f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None, + f.RelatedUserStory.create(user_story=us2, epic=epic1) + us3 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None, status=status0, tags=[tag3]) - f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user1, + us4 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user1, status=status0, tags=[tag1, tag2, tag3]) - f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user1, + f.RelatedUserStory.create(user_story=us4, epic=epic0) + us5 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user1, status=status2, tags=[tag3]) - f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user1, + us6 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user1, status=status3, tags=[tag1, tag2]) - f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user2, + f.RelatedUserStory.create(user_story=us6, epic=epic0) + f.RelatedUserStory.create(user_story=us6, epic=epic2) + us7 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user2, status=status0, tags=[tag3]) - f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user2, + us8 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user2, status=status3, tags=[tag1]) - f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user3, + f.RelatedUserStory.create(user_story=us8, epic=epic2) + us9 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user3, status=status1, tags=[tag0]) url = reverse("userstories-filters-data") + "?project={}".format(project.id) @@ -693,6 +703,11 @@ def test_api_filters_data(client): assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 4 assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 4 + assert next(filter(lambda i: i['id'] is None, response.data["epics"]))["count"] == 5 + assert next(filter(lambda i: i['id'] == epic0.id, response.data["epics"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == epic1.id, response.data["epics"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == epic2.id, response.data["epics"]))["count"] == 2 + # Filter ((status0 or status3) response = client.get(url + "&status={},{}".format(status3.id, status0.id)) assert response.status_code == 200 @@ -716,6 +731,11 @@ def test_api_filters_data(client): assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 3 assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 3 + assert next(filter(lambda i: i['id'] is None, response.data["epics"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == epic0.id, response.data["epics"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == epic1.id, response.data["epics"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == epic2.id, response.data["epics"]))["count"] == 2 + # Filter ((tag1 and tag2) and (user1 or user2)) response = client.get(url + "&tags={},{}&owner={},{}".format(tag1, tag2, user1.id, user2.id)) assert response.status_code == 200 @@ -739,6 +759,11 @@ def test_api_filters_data(client): assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 2 assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1 + assert next(filter(lambda i: i['id'] is None, response.data["epics"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == epic0.id, response.data["epics"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == epic1.id, response.data["epics"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == epic2.id, response.data["epics"]))["count"] == 1 + def test_get_invalid_csv(client): url = reverse("userstories-csv") From f5f8f66b579670065ea4a70ecfd2e81f3eb81ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 27 Jul 2016 12:09:17 +0200 Subject: [PATCH 28/83] Add color to Epics --- taiga/base/utils/colors.py | 56 +++++++++++++++++++ .../epics/migrations/0002_epic_color.py | 21 +++++++ taiga/projects/epics/models.py | 4 ++ taiga/projects/epics/serializers.py | 1 + .../management/commands/sample_data.py | 1 - taiga/users/models.py | 5 +- 6 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 taiga/base/utils/colors.py create mode 100644 taiga/projects/epics/migrations/0002_epic_color.py diff --git a/taiga/base/utils/colors.py b/taiga/base/utils/colors.py new file mode 100644 index 00000000..517c8add --- /dev/null +++ b/taiga/base/utils/colors.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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 random + +from django.conf import settings + + +DEFAULT_PREDEFINED_COLORS = ( + "#fce94f", + "#edd400", + "#c4a000", + "#8ae234", + "#73d216", + "#4e9a06", + "#d3d7cf", + "#fcaf3e", + "#f57900", + "#ce5c00", + "#729fcf", + "#3465a4", + "#204a87", + "#888a85", + "#ad7fa8", + "#75507b", + "#5c3566", + "#ef2929", + "#cc0000", + "#a40000" +) + +PREDEFINED_COLORS = getattr(settings, "PREDEFINED_COLORS", DEFAULT_PREDEFINED_COLORS) + + +def generate_random_hex_color(): + return "#{:06x}".format(random.randint(0,0xFFFFFF)) + + +def generate_random_predefined_hex_color(): + return random.choice(PREDEFINED_COLORS) + diff --git a/taiga/projects/epics/migrations/0002_epic_color.py b/taiga/projects/epics/migrations/0002_epic_color.py new file mode 100644 index 00000000..b9cd2ced --- /dev/null +++ b/taiga/projects/epics/migrations/0002_epic_color.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-07-27 09:37 +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.base.utils.colors + + +class Migration(migrations.Migration): + + dependencies = [ + ('epics', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='epic', + name='color', + field=models.CharField(blank=True, default=taiga.base.utils.colors.generate_random_predefined_hex_color, max_length=32, verbose_name='color'), + ), + ] diff --git a/taiga/projects/epics/models.py b/taiga/projects/epics/models.py index 75380556..6b7d9231 100644 --- a/taiga/projects/epics/models.py +++ b/taiga/projects/epics/models.py @@ -22,6 +22,7 @@ from django.conf import settings from django.utils.translation import ugettext_lazy as _ from django.utils import timezone +from taiga.base.utils.colors import generate_random_predefined_hex_color from taiga.projects.tagging.models import TaggedMixin from taiga.projects.occ import OCCModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin @@ -51,6 +52,9 @@ class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M subject = models.TextField(null=False, blank=False, verbose_name=_("subject")) description = models.TextField(null=False, blank=True, verbose_name=_("description")) + color = models.CharField(max_length=32, null=False, blank=True, + default=generate_random_predefined_hex_color, + verbose_name=_("color")) assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, default=None, related_name="epics_assigned_to_me", verbose_name=_("assigned to")) diff --git a/taiga/projects/epics/serializers.py b/taiga/projects/epics/serializers.py index 081245d9..9b845bbe 100644 --- a/taiga/projects/epics/serializers.py +++ b/taiga/projects/epics/serializers.py @@ -40,6 +40,7 @@ class EpicListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, created_date = Field() modified_date = Field() subject = Field() + color = Field() epics_order = Field() client_requirement = Field() team_requirement = Field() diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index 54b4e027..b659096a 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -553,7 +553,6 @@ class Command(BaseCommand): if self.sd.choice([True, True, False, True, True]): filters = {"project": epic.project} n = self.sd.choice(list(range(self.sd.int(*NUM_USS_EPICS)))) - print (n) user_stories = UserStory.objects.filter(**filters).order_by("?")[:n] for idx, us in enumerate(list(user_stories)): RelatedUserStory.objects.create(epic=epic, diff --git a/taiga/users/models.py b/taiga/users/models.py index 98a71dc8..45908a10 100644 --- a/taiga/users/models.py +++ b/taiga/users/models.py @@ -38,6 +38,7 @@ from django_pgjson.fields import JsonField from django_pglocks import advisory_lock from taiga.auth.tokens import get_token_for_user +from taiga.base.utils.colors import generate_random_hex_color from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.files import get_file_path from taiga.permissions.choices import MEMBERS_PERMISSIONS @@ -82,10 +83,6 @@ def get_user_model_safe(): raise -def generate_random_hex_color(): - return "#{:06x}".format(random.randint(0,0xFFFFFF)) - - def get_user_file_path(instance, filename): return get_file_path(instance, filename, "user") From b014c25454637fa90d5b3381d0547023a67c42b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 27 Jul 2016 13:25:31 +0200 Subject: [PATCH 29/83] Remove epic order from user story --- taiga/projects/userstories/api.py | 1 - .../userstories/migrations/0013_auto_20160722_1018.py | 5 ----- taiga/projects/userstories/models.py | 2 -- taiga/projects/userstories/serializers.py | 1 - 4 files changed, 9 deletions(-) diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 45d78f28..23a471fb 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -80,7 +80,6 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi order_by_fields = ["backlog_order", "sprint_order", "kanban_order", - "epic_order", "total_voters"] def get_serializer_class(self, *args, **kwargs): diff --git a/taiga/projects/userstories/migrations/0013_auto_20160722_1018.py b/taiga/projects/userstories/migrations/0013_auto_20160722_1018.py index 45e11419..64a73be8 100644 --- a/taiga/projects/userstories/migrations/0013_auto_20160722_1018.py +++ b/taiga/projects/userstories/migrations/0013_auto_20160722_1018.py @@ -12,11 +12,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='userstory', - name='epic_order', - field=models.IntegerField(default=10000, verbose_name='epic order'), - ), migrations.AlterField( model_name='userstory', name='kanban_order', diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py index f096c885..d0216d21 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -81,8 +81,6 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod verbose_name=_("sprint order")) kanban_order = models.IntegerField(null=False, blank=False, default=10000, verbose_name=_("kanban order")) - epic_order = models.IntegerField(null=False, blank=False, default=10000, - verbose_name=_("epic order")) created_date = models.DateTimeField(null=False, blank=False, verbose_name=_("created date"), diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index f60fb2b6..bc977a2e 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -61,7 +61,6 @@ class UserStoryListSerializer(ProjectExtraInfoSerializerMixin, backlog_order = Field() sprint_order = Field() kanban_order = Field() - epic_order = Field() created_date = Field() modified_date = Field() finish_date = Field() From d461b1962d62e7fe3c8b348e830f80c7de7ad7f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 27 Jul 2016 13:41:34 +0200 Subject: [PATCH 30/83] Add epics to user stories serializers --- taiga/projects/userstories/serializers.py | 6 ++++- taiga/projects/userstories/utils.py | 31 ++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index bc977a2e..97fcea42 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -77,9 +77,13 @@ class UserStoryListSerializer(ProjectExtraInfoSerializerMixin, total_points = MethodField() comment = MethodField() origin_issue = OriginIssueSerializer(attr="generated_from_issue") - + epics = MethodField() tasks = MethodField() + def get_epics(self, obj): + assert hasattr(obj, "epics_attr"), "instance must have a epics_attr attribute" + return obj.epics_attr + def get_milestone_slug(self, obj): return obj.milestone.slug if obj.milestone else None diff --git a/taiga/projects/userstories/utils.py b/taiga/projects/userstories/utils.py index 35456b71..ea7637f4 100644 --- a/taiga/projects/userstories/utils.py +++ b/taiga/projects/userstories/utils.py @@ -71,7 +71,7 @@ def attach_tasks(queryset, as_field="tasks_attr"): """Attach tasks as json column to each object of the queryset. :param queryset: A Django user stories queryset object. - :param as_field: Attach the role points as an attribute with this name. + :param as_field: Attach tasks as an attribute with this name. :return: Queryset object with the additional `as_field` field. """ @@ -99,9 +99,38 @@ def attach_tasks(queryset, as_field="tasks_attr"): return queryset +def attach_epics(queryset, as_field="epics_attr"): + """Attach epics as json column to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param as_field: Attach the epics as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + + model = queryset.model + sql = """SELECT json_agg(row_to_json(t)) + FROM (SELECT "epics_epic"."id" AS "id", + "epics_epic"."ref" AS "ref", + "epics_epic"."subject" AS "subject", + "epics_epic"."color" AS "color", + json_build_object('id', "projects_project"."id", + 'name', "projects_project"."name", + 'slug', "projects_project"."slug") AS "project" + FROM "epics_relateduserstory" + INNER JOIN "epics_epic" ON "epics_epic"."id" = "epics_relateduserstory"."epic_id" + INNER JOIN "projects_project" ON "projects_project"."id" = "epics_epic"."project_id" + WHERE "epics_relateduserstory"."user_story_id" = {tbl}.id) t""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + def attach_extra_info(queryset, user=None, include_attachments=False, include_tasks=False): queryset = attach_total_points(queryset) queryset = attach_role_points(queryset) + queryset = attach_epics(queryset) if include_attachments: queryset = attach_basic_attachments(queryset) From e2f6245fbbca7dc5e516bb7343ce10ee8420f0d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 27 Jul 2016 19:00:32 +0200 Subject: [PATCH 31/83] Add epic order in userstories if filter by epic ide is enabled --- taiga/base/filters.py | 11 +++++++-- taiga/projects/userstories/api.py | 6 ++++- taiga/projects/userstories/filters.py | 7 +++++- taiga/projects/userstories/serializers.py | 12 +++++++++ taiga/projects/userstories/utils.py | 30 +++++++++++++++++++++-- 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/taiga/base/filters.py b/taiga/base/filters.py index a30e2dcf..06274128 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -337,10 +337,16 @@ class IsProjectAdminFromWebhookLogFilterBackend(FilterBackend, BaseIsProjectAdmi ##################################################################### class BaseRelatedFieldsFilter(FilterBackend): - def __init__(self, filter_name=None): + filter_name = None + param_name = None + + def __init__(self, filter_name=None, param_name=None): if filter_name: self.filter_name = filter_name + if param_name: + self.param_name = param_name + def _prepare_filter_data(self, query_param_value): def _transform_value(value): try: @@ -355,7 +361,8 @@ class BaseRelatedFieldsFilter(FilterBackend): return list(values) def _get_queryparams(self, params): - raw_value = params.get(self.filter_name, None) + param_name = self.param_name or self.filter_name + raw_value = params.get(param_name, None) if raw_value: value = self._prepare_filter_data(raw_value) diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 23a471fb..758fa98c 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -60,6 +60,7 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi permission_classes = (permissions.UserStoryPermission,) filter_backends = (base_filters.CanViewUsFilterBackend, filters.EpicsFilter, + filters.EpicFilter, base_filters.OwnersFilter, base_filters.AssignedToFilter, base_filters.StatusesFilter, @@ -80,6 +81,7 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi order_by_fields = ["backlog_order", "sprint_order", "kanban_order", + "epic_order", "total_voters"] def get_serializer_class(self, *args, **kwargs): @@ -102,9 +104,11 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi include_attachments = "include_attachments" in self.request.QUERY_PARAMS include_tasks = "include_tasks" in self.request.QUERY_PARAMS + epic_id = self.request.QUERY_PARAMS.get("epic", None) qs = attach_extra_info(qs, user=self.request.user, include_attachments=include_attachments, - include_tasks=include_tasks) + include_tasks=include_tasks, + epic_id=epic_id) return qs diff --git a/taiga/projects/userstories/filters.py b/taiga/projects/userstories/filters.py index 5f877618..f061667e 100644 --- a/taiga/projects/userstories/filters.py +++ b/taiga/projects/userstories/filters.py @@ -20,5 +20,10 @@ from taiga.base import filters +class EpicFilter(filters.BaseRelatedFieldsFilter): + filter_name = "epics" + param_name = "epic" + + class EpicsFilter(filters.BaseRelatedFieldsFilter): - filter_name = 'epics' + filter_name = "epics" diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index 97fcea42..b7c43466 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -78,8 +78,20 @@ class UserStoryListSerializer(ProjectExtraInfoSerializerMixin, comment = MethodField() origin_issue = OriginIssueSerializer(attr="generated_from_issue") epics = MethodField() + epic_order = MethodField() tasks = MethodField() + def get_epic_order(self, obj): + include_epic_order = getattr(obj, "include_epic_order", False) + + if include_epic_order: + assert hasattr(obj, "epic_order"), "instance must have a epic_order attribute" + + if not include_epic_order or obj.epic_order is None: + return None + + return obj.epic_order + def get_epics(self, obj): assert hasattr(obj, "epics_attr"), "instance must have a epics_attr attribute" return obj.epics_attr diff --git a/taiga/projects/userstories/utils.py b/taiga/projects/userstories/utils.py index ea7637f4..ef5e8ba0 100644 --- a/taiga/projects/userstories/utils.py +++ b/taiga/projects/userstories/utils.py @@ -120,14 +120,36 @@ def attach_epics(queryset, as_field="epics_attr"): FROM "epics_relateduserstory" INNER JOIN "epics_epic" ON "epics_epic"."id" = "epics_relateduserstory"."epic_id" INNER JOIN "projects_project" ON "projects_project"."id" = "epics_epic"."project_id" - WHERE "epics_relateduserstory"."user_story_id" = {tbl}.id) t""" + WHERE "epics_relateduserstory"."user_story_id" = {tbl}.id + ORDER BY "projects_project"."name", "epics_epic"."ref") t""" sql = sql.format(tbl=model._meta.db_table) queryset = queryset.extra(select={as_field: sql}) return queryset -def attach_extra_info(queryset, user=None, include_attachments=False, include_tasks=False): +def attach_epic_order(queryset, epic_id, as_field="epic_order"): + """Attach epic_order column to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param epic_id: Order related to this epic. + :param as_field: Attach order as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + + model = queryset.model + sql = """SELECT "epics_relateduserstory"."order" AS "epic_order" + FROM "epics_relateduserstory" + WHERE "epics_relateduserstory"."user_story_id" = {tbl}.id and + "epics_relateduserstory"."epic_id" = {epic_id}""" + + sql = sql.format(tbl=model._meta.db_table, epic_id=epic_id) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_extra_info(queryset, user=None, include_attachments=False, include_tasks=False, epic_id=None): queryset = attach_total_points(queryset) queryset = attach_role_points(queryset) queryset = attach_epics(queryset) @@ -140,6 +162,10 @@ def attach_extra_info(queryset, user=None, include_attachments=False, include_ta queryset = attach_tasks(queryset) queryset = queryset.extra(select={"include_tasks": "True"}) + if epic_id is not None: + queryset = attach_epic_order(queryset, epic_id) + queryset = queryset.extra(select={"include_epic_order": "True"}) + queryset = attach_total_voters_to_queryset(queryset) queryset = attach_watchers_to_queryset(queryset) queryset = attach_total_watchers_to_queryset(queryset) From 15aa7da858cf433ce4add5ae067b46acd10bfaca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 28 Jul 2016 12:23:44 +0200 Subject: [PATCH 32/83] Apply some fixes over epic custom attributes --- .../migrations/0008_auto_20160728_0540.py | 95 ++++++++++--------- ...630_0849.py => 0009_auto_20160728_1002.py} | 26 ++--- taiga/projects/custom_attributes/models.py | 1 + 3 files changed, 65 insertions(+), 57 deletions(-) rename taiga/projects/custom_attributes/migrations/{0008_auto_20160630_0849.py => 0009_auto_20160728_1002.py} (88%) diff --git a/taiga/projects/custom_attributes/migrations/0008_auto_20160728_0540.py b/taiga/projects/custom_attributes/migrations/0008_auto_20160728_0540.py index 6f2d86f7..4c0509bb 100644 --- a/taiga/projects/custom_attributes/migrations/0008_auto_20160728_0540.py +++ b/taiga/projects/custom_attributes/migrations/0008_auto_20160728_0540.py @@ -15,50 +15,50 @@ class Migration(migrations.Migration): # Function: Remove a key in a json field migrations.RunSQL( """ - CREATE OR REPLACE FUNCTION "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) - RETURNS json - LANGUAGE sql - IMMUTABLE - STRICT - AS $function$ - SELECT COALESCE ((SELECT ('{' || string_agg(to_json("key") || ':' || "value", ',') || '}') - FROM json_each("json") - WHERE "key" <> ALL ("keys_to_delete")), - '{}')::json $function$; + CREATE OR REPLACE FUNCTION "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) + RETURNS json + LANGUAGE sql + IMMUTABLE + STRICT + AS $function$ + SELECT COALESCE ((SELECT ('{' || string_agg(to_json("key") || ':' || "value", ',') || '}') + FROM json_each("json") + WHERE "key" <> ALL ("keys_to_delete")), + '{}')::json $function$; """, - reverse_sql="""DROP FUNCTION IF EXISTS "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) - CASCADE;""" + reverse_sql=""" + DROP FUNCTION IF EXISTS "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) + CASCADE;""" ), # Function: Romeve a key in the json field of *_custom_attributes_values.values migrations.RunSQL( """ - CREATE OR REPLACE FUNCTION "clean_key_in_custom_attributes_values"() - RETURNS trigger - AS $clean_key_in_custom_attributes_values$ - DECLARE - key text; - project_id int; - object_id int; - attribute text; - tablename text; - custom_attributes_tablename text; - BEGIN - key := OLD.id::text; - project_id := OLD.project_id; - attribute := TG_ARGV[0]::text; - tablename := TG_ARGV[1]::text; - custom_attributes_tablename := TG_ARGV[2]::text; - - EXECUTE 'UPDATE ' || quote_ident(custom_attributes_tablename) || ' - SET attributes_values = json_object_delete_keys(attributes_values, ' || quote_literal(key) || ') - FROM ' || quote_ident(tablename) || ' - WHERE ' || quote_ident(tablename) || '.project_id = ' || project_id || ' - AND ' || quote_ident(custom_attributes_tablename) || '.' || quote_ident(attribute) || ' = ' || quote_ident(tablename) || '.id'; - RETURN NULL; - END; $clean_key_in_custom_attributes_values$ - LANGUAGE plpgsql; + CREATE OR REPLACE FUNCTION "clean_key_in_custom_attributes_values"() + RETURNS trigger + AS $clean_key_in_custom_attributes_values$ + DECLARE + key text; + project_id int; + object_id int; + attribute text; + tablename text; + custom_attributes_tablename text; + BEGIN + key := OLD.id::text; + project_id := OLD.project_id; + attribute := TG_ARGV[0]::text; + tablename := TG_ARGV[1]::text; + custom_attributes_tablename := TG_ARGV[2]::text; + EXECUTE 'UPDATE ' || quote_ident(custom_attributes_tablename) || ' + SET attributes_values = json_object_delete_keys(attributes_values, ' || quote_literal(key) || ') + FROM ' || quote_ident(tablename) || ' + WHERE ' || quote_ident(tablename) || '.project_id = ' || project_id || ' + AND ' || quote_ident(custom_attributes_tablename) || '.' || quote_ident(attribute) || ' = ' || quote_ident(tablename) || '.id'; + RETURN NULL; + END; $clean_key_in_custom_attributes_values$ + LANGUAGE plpgsql; """ ), @@ -66,13 +66,14 @@ class Migration(migrations.Migration): migrations.RunSQL( """ DROP TRIGGER IF EXISTS "update_userstorycustomvalues_after_remove_userstorycustomattribute" - ON custom_attributes_userstorycustomattribute - CASCADE; + ON custom_attributes_userstorycustomattribute + CASCADE; CREATE TRIGGER "update_userstorycustomvalues_after_remove_userstorycustomattribute" AFTER DELETE ON custom_attributes_userstorycustomattribute FOR EACH ROW - EXECUTE PROCEDURE clean_key_in_custom_attributes_values('user_story_id', 'userstories_userstory', 'custom_attributes_userstorycustomattributesvalues'); + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('user_story_id', 'userstories_userstory', + 'custom_attributes_userstorycustomattributesvalues'); """ ), @@ -80,13 +81,14 @@ class Migration(migrations.Migration): migrations.RunSQL( """ DROP TRIGGER IF EXISTS "update_taskcustomvalues_after_remove_taskcustomattribute" - ON custom_attributes_taskcustomattribute - CASCADE; + ON custom_attributes_taskcustomattribute + CASCADE; CREATE TRIGGER "update_taskcustomvalues_after_remove_taskcustomattribute" AFTER DELETE ON custom_attributes_taskcustomattribute FOR EACH ROW - EXECUTE PROCEDURE clean_key_in_custom_attributes_values('task_id', 'tasks_task', 'custom_attributes_taskcustomattributesvalues'); + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('task_id', 'tasks_task', + 'custom_attributes_taskcustomattributesvalues'); """ ), @@ -94,13 +96,14 @@ class Migration(migrations.Migration): migrations.RunSQL( """ DROP TRIGGER IF EXISTS "update_issuecustomvalues_after_remove_issuecustomattribute" - ON custom_attributes_issuecustomattribute - CASCADE; + ON custom_attributes_issuecustomattribute + CASCADE; CREATE TRIGGER "update_issuecustomvalues_after_remove_issuecustomattribute" AFTER DELETE ON custom_attributes_issuecustomattribute FOR EACH ROW - EXECUTE PROCEDURE clean_key_in_custom_attributes_values('issue_id', 'issues_issue', 'custom_attributes_issuecustomattributesvalues'); + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('issue_id', 'issues_issue', + 'custom_attributes_issuecustomattributesvalues'); """ ), migrations.AlterIndexTogether( diff --git a/taiga/projects/custom_attributes/migrations/0008_auto_20160630_0849.py b/taiga/projects/custom_attributes/migrations/0009_auto_20160728_1002.py similarity index 88% rename from taiga/projects/custom_attributes/migrations/0008_auto_20160630_0849.py rename to taiga/projects/custom_attributes/migrations/0009_auto_20160728_1002.py index bcb7668c..313e22fd 100644 --- a/taiga/projects/custom_attributes/migrations/0008_auto_20160630_0849.py +++ b/taiga/projects/custom_attributes/migrations/0009_auto_20160728_1002.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-06-30 08:49 +# Generated by Django 1.9.2 on 2016-07-28 10:02 from __future__ import unicode_literals from django.db import migrations, models @@ -11,12 +11,13 @@ import django_pgjson.fields class Migration(migrations.Migration): dependencies = [ - ('epics', '0001_initial'), - ('projects', '0049_auto_20160629_1443'), - ('custom_attributes', '0007_auto_20160208_1751'), + ('epics', '0002_epic_color'), + ('projects', '0050_project_epics_csv_uuid'), + ('custom_attributes', '0008_auto_20160728_0540'), ] operations = [ + # Change some verbose names migrations.AlterModelOptions( name='issuecustomattributesvalues', options={'ordering': ['id'], 'verbose_name': 'issue custom attributes values', 'verbose_name_plural': 'issue custom attributes values'}, @@ -29,7 +30,7 @@ class Migration(migrations.Migration): name='userstorycustomattributesvalues', options={'ordering': ['id'], 'verbose_name': 'user story custom attributes values', 'verbose_name_plural': 'user story custom attributes values'}, ), - + # Custom attributes for epics migrations.CreateModel( name='EpicCustomAttribute', fields=[ @@ -43,10 +44,10 @@ class Migration(migrations.Migration): ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='epiccustomattributes', to='projects.Project', verbose_name='project')), ], options={ - 'verbose_name_plural': 'epic custom attributes', 'verbose_name': 'epic custom attribute', 'abstract': False, 'ordering': ['project', 'order', 'name'], + 'verbose_name_plural': 'epic custom attributes', }, ), migrations.CreateModel( @@ -58,24 +59,27 @@ class Migration(migrations.Migration): ('epic', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='custom_attributes_values', to='epics.Epic', verbose_name='epic')), ], options={ - 'verbose_name_plural': 'epic custom attributes values', - 'verbose_name': 'epic custom attributes values', 'abstract': False, + 'verbose_name': 'epic custom attributes values', 'ordering': ['id'], + 'verbose_name_plural': 'epic custom attributes values', }, ), + migrations.AlterIndexTogether( + name='epiccustomattributesvalues', + index_together=set([('epic',)]), + ), migrations.AlterUniqueTogether( name='epiccustomattribute', unique_together=set([('project', 'name')]), ), - - # Trigger: Clean epiccustomattributes values before remove a epiccustomattribute migrations.RunSQL( """ CREATE TRIGGER "update_epiccustomvalues_after_remove_epiccustomattribute" AFTER DELETE ON custom_attributes_epiccustomattribute FOR EACH ROW - EXECUTE PROCEDURE clean_key_in_custom_attributes_values('custom_attributes_epiccustomattributesvalues'); + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('epic_id', 'epics_epic', + 'custom_attributes_epiccustomattributesvalues'); """, reverse_sql="""DROP TRIGGER IF EXISTS "update_epiccustomvalues_after_remove_epiccustomattribute" ON custom_attributes_epiccustomattribute diff --git a/taiga/projects/custom_attributes/models.py b/taiga/projects/custom_attributes/models.py index 8b5747f0..4fa6978b 100644 --- a/taiga/projects/custom_attributes/models.py +++ b/taiga/projects/custom_attributes/models.py @@ -106,6 +106,7 @@ class EpicCustomAttributesValues(AbstractCustomAttributesValues): class Meta(AbstractCustomAttributesValues.Meta): verbose_name = "epic custom attributes values" verbose_name_plural = "epic custom attributes values" + index_together = [("epic",)] @property def project(self): From 3dbde420d8d68b2966c0fb2f529a9209d6dd56fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 29 Jul 2016 10:02:57 +0200 Subject: [PATCH 33/83] Add missed migrations --- .../migrations/0051_auto_20160729_0802.py | 19 +++++++++++++++++++ taiga/projects/models.py | 16 ++++++---------- 2 files changed, 25 insertions(+), 10 deletions(-) create mode 100644 taiga/projects/migrations/0051_auto_20160729_0802.py diff --git a/taiga/projects/migrations/0051_auto_20160729_0802.py b/taiga/projects/migrations/0051_auto_20160729_0802.py new file mode 100644 index 00000000..24767fdb --- /dev/null +++ b/taiga/projects/migrations/0051_auto_20160729_0802.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-07-29 08:02 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0050_project_epics_csv_uuid'), + ] + + operations = [ + migrations.AlterModelOptions( + name='project', + options={'ordering': ['name', 'id'], 'verbose_name': 'project', 'verbose_name_plural': 'projects'}, + ), + ] diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 080cd411..1642426c 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -87,6 +87,12 @@ class Membership(models.Model): user_order = models.IntegerField(default=10000, null=False, blank=False, verbose_name=_("user order")) + class Meta: + verbose_name = "membership" + verbose_name_plural = "memberships" + unique_together = ("user", "project",) + ordering = ["project", "user__full_name", "user__username", "user__email", "email"] + def get_related_people(self): related_people = get_user_model().objects.filter(id=self.user.id) return related_people @@ -97,12 +103,6 @@ class Membership(models.Model): if self.user and memberships.count() > 0 and memberships[0].id != self.id: raise ValidationError(_('The user is already member of the project')) - class Meta: - verbose_name = "membership" - verbose_name_plural = "memberships" - unique_together = ("user", "project",) - ordering = ["project", "user__full_name", "user__username", "user__email", "email"] - class ProjectDefaults(models.Model): default_epic_status = models.OneToOneField("projects.EpicStatus", @@ -258,10 +258,6 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): ["name", "id"], ] - permissions = ( - ("view_project", "Can view project"), - ) - def __str__(self): return self.name From 133cf149fd73f9e69019bc4b4f8724cd66680864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 29 Jul 2016 12:39:51 +0200 Subject: [PATCH 34/83] Improve Epic sort feature --- taiga/projects/epics/api.py | 5 ++--- taiga/projects/epics/services.py | 26 ++++++++++---------------- tests/unit/test_order_updates.py | 2 -- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index bbfd164f..6f4b18e9 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -200,12 +200,11 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, if project.blocked_code is not None: raise exc.Blocked(_("Blocked element")) - services.update_epics_order_in_bulk(data["bulk_epics"], + ret = services.update_epics_order_in_bulk(data["bulk_epics"], project=project, field=order_field) - services.snapshot_epics_in_bulk(data["bulk_epics"], request.user) - return response.NoContent() + return response.Ok(ret) @list_route(methods=["POST"]) def bulk_update_epics_order(self, request, **kwargs): diff --git a/taiga/projects/epics/services.py b/taiga/projects/epics/services.py index 8674e79f..ea470e8f 100644 --- a/taiga/projects/epics/services.py +++ b/taiga/projects/epics/services.py @@ -27,6 +27,7 @@ from django.utils.translation import ugettext as _ from taiga.base.utils import db, text from taiga.projects.history.services import take_snapshot +from taiga.projects.services import apply_order_updates from taiga.projects.epics.apps import connect_epics_signals from taiga.projects.epics.apps import disconnect_epics_signals from taiga.events import events @@ -78,28 +79,21 @@ def update_epics_order_in_bulk(bulk_data: list, field: str, project: object): Update the order of some epics. `bulk_data` should be a list of tuples with the following format: - [(, {: , ...}), ...] + [{'epic_id': , 'order': }, ...] """ - epic_ids = [] - new_order_values = [] - for epic_data in bulk_data: - epic_ids.append(epic_data["epic_id"]) - new_order_values.append({field: epic_data["order"]}) + epics = project.epics.all() + epic_orders = {e.id: getattr(e, field) for e in epics} + new_epic_orders = {d["epic_id"]: d["order"] for d in bulk_data} + apply_order_updates(epic_orders, new_epic_orders) + + epic_ids = epic_orders.keys() events.emit_event_for_ids(ids=epic_ids, content_type="epics.epic", projectid=project.pk) - db.update_in_bulk_with_ids(epic_ids, new_order_values, model=models.Epic) - - -def snapshot_epics_in_bulk(bulk_data, user): - for epic_data in bulk_data: - try: - epic = models.Epic.objects.get(pk=epic_data['epic_id']) - take_snapshot(epic, user=user) - except models.Epic.DoesNotExist: - pass + db.update_attr_in_bulk_for_ids(epic_orders, field, models.Epic) + return epic_orders ##################################################### diff --git a/tests/unit/test_order_updates.py b/tests/unit/test_order_updates.py index f7660bf0..1a4a571c 100644 --- a/tests/unit/test_order_updates.py +++ b/tests/unit/test_order_updates.py @@ -131,7 +131,6 @@ def test_apply_order_updates_duplicated_orders(): "a": 3 } apply_order_updates(orders, new_orders) - print(orders) assert orders == { "a": 3, "c": 4, @@ -155,7 +154,6 @@ def test_apply_order_updates_multiple_elements_duplicated_orders(): "a": 4 } apply_order_updates(orders, new_orders) - print(orders) assert orders == { "c": 3, "d": 3, From 783dca147265c57e6d0cdd8dec9a7c1e2f7ca610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 29 Jul 2016 18:56:33 +0200 Subject: [PATCH 35/83] Minor fixes --- taiga/projects/epics/models.py | 12 ++++++------ taiga/projects/milestones/utils.py | 4 ++++ taiga/projects/userstories/services.py | 4 ++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/taiga/projects/epics/models.py b/taiga/projects/epics/models.py index 6b7d9231..eb18c6c4 100644 --- a/taiga/projects/epics/models.py +++ b/taiga/projects/epics/models.py @@ -76,6 +76,12 @@ class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M verbose_name_plural = "epics" ordering = ["project", "epics_order", "ref"] + def __str__(self): + return "#{0} {1}".format(self.ref, self.subject) + + def __repr__(self): + return "" % (self.id) + def save(self, *args, **kwargs): if not self._importing or not self.modified_date: self.modified_date = timezone.now() @@ -85,12 +91,6 @@ class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M super().save(*args, **kwargs) - def __str__(self): - return "#{0} {1}".format(self.ref, self.subject) - - def __repr__(self): - return "" % (self.id) - class RelatedUserStory(models.Model): user_story = models.ForeignKey("userstories.UserStory", on_delete=models.CASCADE) diff --git a/taiga/projects/milestones/utils.py b/taiga/projects/milestones/utils.py index b292b1bd..bea1cf12 100644 --- a/taiga/projects/milestones/utils.py +++ b/taiga/projects/milestones/utils.py @@ -80,6 +80,8 @@ def attach_extra_info(queryset, user=None): us_queryset = userstories_utils.attach_total_points(us_queryset) us_queryset = userstories_utils.attach_role_points(us_queryset) + us_queryset = userstories_utils.attach_epics(us_queryset) + us_queryset = attach_total_voters_to_queryset(us_queryset) us_queryset = attach_watchers_to_queryset(us_queryset) us_queryset = attach_total_watchers_to_queryset(us_queryset) @@ -87,6 +89,7 @@ def attach_extra_info(queryset, user=None): us_queryset = attach_is_watcher_to_queryset(us_queryset, user) queryset = queryset.prefetch_related(Prefetch("user_stories", queryset=us_queryset)) + queryset = attach_total_points(queryset) queryset = attach_closed_points(queryset) @@ -95,4 +98,5 @@ def attach_extra_info(queryset, user=None): queryset = attach_total_watchers_to_queryset(queryset) queryset = attach_is_voter_to_queryset(queryset, user) queryset = attach_is_watcher_to_queryset(queryset, user) + return queryset diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py index 11fb2a2f..5998a57e 100644 --- a/taiga/projects/userstories/services.py +++ b/taiga/projects/userstories/services.py @@ -469,6 +469,8 @@ def _get_userstories_epics(project, queryset): FROM "epics_relateduserstory" INNER JOIN "userstories_userstory" ON ("userstories_userstory"."id" = "epics_relateduserstory"."user_story_id") + INNER JOIN "projects_project" + ON ("userstories_userstory"."project_id" = "projects_project"."id") WHERE {where} GROUP BY "epics_relateduserstory"."epic_id" ) @@ -492,6 +494,8 @@ def _get_userstories_epics(project, queryset): FROM "userstories_userstory" LEFT OUTER JOIN "epics_relateduserstory" ON ("epics_relateduserstory"."user_story_id" = "userstories_userstory"."id") + INNER JOIN "projects_project" + ON ("userstories_userstory"."project_id" = "projects_project"."id") WHERE {where} AND "epics_relateduserstory"."epic_id" IS NULL GROUP BY "epics_relateduserstory"."epic_id" """.format(where=where) From 0ff7ce8975bfdbd66a83623baf020a0f4142bb47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 1 Aug 2016 13:10:15 +0200 Subject: [PATCH 36/83] Add is_closed to StatusEstraInfoSerializer --- taiga/projects/mixins/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/taiga/projects/mixins/serializers.py b/taiga/projects/mixins/serializers.py index cec50b2b..67949877 100644 --- a/taiga/projects/mixins/serializers.py +++ b/taiga/projects/mixins/serializers.py @@ -73,7 +73,8 @@ class StatusExtraInfoSerializerMixin(serializers.LightSerializer): if serialized_status is None: serialized_status = { "name": _(obj.status.name), - "color": obj.status.color + "color": obj.status.color, + "is_closed": obj.status.is_closed } self._serialized_status[obj.status_id] = serialized_status From 9d0e0180efa9a2ca68a7b890b7069c1c7ec1c318 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 1 Aug 2016 15:10:11 +0200 Subject: [PATCH 37/83] Import export including epics --- taiga/export_import/serializers/cache.py | 2 +- .../export_import/serializers/serializers.py | 63 ++++++++ taiga/export_import/services/render.py | 8 +- taiga/export_import/services/store.py | 140 +++++++++++++++--- taiga/export_import/validators/__init__.py | 4 + taiga/export_import/validators/cache.py | 1 + taiga/export_import/validators/validators.py | 53 +++++++ tests/unit/test_export.py | 14 ++ tests/unit/test_import.py | 57 +++++++ 9 files changed, 319 insertions(+), 23 deletions(-) create mode 100644 tests/unit/test_import.py diff --git a/taiga/export_import/serializers/cache.py b/taiga/export_import/serializers/cache.py index c4eb5bfa..f22978f8 100644 --- a/taiga/export_import/serializers/cache.py +++ b/taiga/export_import/serializers/cache.py @@ -23,7 +23,7 @@ _cache_user_by_email = {} _custom_tasks_attributes_cache = {} _custom_issues_attributes_cache = {} _custom_userstories_attributes_cache = {} - +_custom_epics_attributes_cache = {} def cached_get_user_by_pk(pk): if pk not in _cache_user_by_pk: diff --git a/taiga/export_import/serializers/serializers.py b/taiga/export_import/serializers/serializers.py index ff7e791c..f4f46e52 100644 --- a/taiga/export_import/serializers/serializers.py +++ b/taiga/export_import/serializers/serializers.py @@ -29,6 +29,7 @@ from .mixins import (HistoryExportSerializerMixin, WatcheableObjectLightSerializerMixin) from .cache import (_custom_tasks_attributes_cache, _custom_userstories_attributes_cache, + _custom_epics_attributes_cache, _custom_issues_attributes_cache) @@ -55,6 +56,14 @@ class UserStoryStatusExportSerializer(RelatedExportSerializer): wip_limit = Field() +class EpicStatusExportSerializer(RelatedExportSerializer): + name = Field() + slug = Field() + order = Field() + is_closed = Field() + color = Field() + + class TaskStatusExportSerializer(RelatedExportSerializer): name = Field() slug = Field() @@ -97,6 +106,15 @@ class RoleExportSerializer(RelatedExportSerializer): permissions = Field() +class EpicCustomAttributesExportSerializer(RelatedExportSerializer): + name = Field() + description = Field() + type = Field() + order = Field() + created_date = DateTimeField() + modified_date = DateTimeField() + + class UserStoryCustomAttributeExportSerializer(RelatedExportSerializer): name = Field() description = Field() @@ -238,6 +256,45 @@ class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin, return _custom_userstories_attributes_cache[project.id] +class EpicRelatedUserStoryExportSerializer(RelatedExportSerializer): + user_story = SlugRelatedField(slug_field="ref") + order = Field() + + +class EpicExportSerializer(CustomAttributesValuesExportSerializerMixin, + HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, + WatcheableObjectLightSerializerMixin, + RelatedExportSerializer): + ref = Field() + owner = UserRelatedField() + status = SlugRelatedField(slug_field="name") + epics_order = Field() + created_date = DateTimeField() + modified_date = DateTimeField() + subject = Field() + description = Field() + color = Field() + assigned_to = UserRelatedField() + client_requirement = Field() + team_requirement = Field() + version = Field() + blocked_note = Field() + is_blocked = Field() + tags = Field() + related_user_stories = MethodField() + + def get_related_user_stories(self, obj): + return EpicRelatedUserStoryExportSerializer(obj.relateduserstory_set.all(), many=True).data + + def custom_attributes_queryset(self, project): + if project.id not in _custom_epics_attributes_cache: + _custom_epics_attributes_cache[project.id] = list( + project.userstorycustomattributes.all().values('id', 'name') + ) + return _custom_epics_attributes_cache[project.id] + + class IssueExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin, AttachmentExportSerializerMixin, @@ -307,6 +364,7 @@ class ProjectExportSerializer(WatcheableObjectLightSerializerMixin): logo = FileField() total_milestones = Field() total_story_points = Field() + is_epics_activated = Field() is_backlog_activated = Field() is_kanban_activated = Field() is_wiki_activated = Field() @@ -318,6 +376,7 @@ class ProjectExportSerializer(WatcheableObjectLightSerializerMixin): is_featured = Field() is_looking_for_people = Field() looking_for_people_note = Field() + epics_csv_uuid = Field() userstories_csv_uuid = Field() tasks_csv_uuid = Field() issues_csv_uuid = Field() @@ -339,6 +398,7 @@ class ProjectExportSerializer(WatcheableObjectLightSerializerMixin): owner = UserRelatedField() memberships = MembershipExportSerializer(many=True) points = PointsExportSerializer(many=True) + epic_statuses = EpicStatusExportSerializer(many=True) us_statuses = UserStoryStatusExportSerializer(many=True) task_statuses = TaskStatusExportSerializer(many=True) issue_types = IssueTypeExportSerializer(many=True) @@ -347,15 +407,18 @@ class ProjectExportSerializer(WatcheableObjectLightSerializerMixin): severities = SeverityExportSerializer(many=True) tags_colors = Field() default_points = SlugRelatedField(slug_field="name") + default_epic_status = SlugRelatedField(slug_field="name") default_us_status = SlugRelatedField(slug_field="name") default_task_status = SlugRelatedField(slug_field="name") default_priority = SlugRelatedField(slug_field="name") default_severity = SlugRelatedField(slug_field="name") default_issue_status = SlugRelatedField(slug_field="name") default_issue_type = SlugRelatedField(slug_field="name") + epiccustomattributes = EpicCustomAttributesExportSerializer(many=True) userstorycustomattributes = UserStoryCustomAttributeExportSerializer(many=True) taskcustomattributes = TaskCustomAttributeExportSerializer(many=True) issuecustomattributes = IssueCustomAttributeExportSerializer(many=True) + epics = EpicExportSerializer(many=True) user_stories = UserStoryExportSerializer(many=True) tasks = TaskExportSerializer(many=True) milestones = MilestoneExportSerializer(many=True) diff --git a/taiga/export_import/services/render.py b/taiga/export_import/services/render.py index 0b56f3f5..cb757dd0 100644 --- a/taiga/export_import/services/render.py +++ b/taiga/export_import/services/render.py @@ -45,12 +45,16 @@ def render_project(project, outfile, chunk_size=8190): # field.initialize(parent=serializer, field_name=field_name) # These four "special" fields hava attachments so we use them in a special way - if field_name in ["wiki_pages", "user_stories", "tasks", "issues"]: + if field_name in ["wiki_pages", "user_stories", "tasks", "issues", "epics"]: value = get_component(project, field_name) if field_name != "wiki_pages": - value = value.select_related('owner', 'status', 'milestone', + value = value.select_related('owner', 'status', 'project', 'assigned_to', 'custom_attributes_values') + + if field_name in ["user_stories", "tasks", "issues"]: + value = value.select_related('milestone') + if field_name == "issues": value = value.select_related('severity', 'priority', 'type') value = value.prefetch_related('history_entry', 'attachments') diff --git a/taiga/export_import/services/store.py b/taiga/export_import/services/store.py index 9739bb1e..e28353bc 100644 --- a/taiga/export_import/services/store.py +++ b/taiga/export_import/services/store.py @@ -80,11 +80,17 @@ def store_project(data): excluded_fields = [ "default_points", "default_us_status", "default_task_status", "default_priority", "default_severity", "default_issue_status", - "default_issue_type", "memberships", "points", "us_statuses", - "task_statuses", "issue_statuses", "priorities", "severities", - "issue_types", "userstorycustomattributes", "taskcustomattributes", - "issuecustomattributes", "roles", "milestones", "wiki_pages", - "wiki_links", "notify_policies", "user_stories", "issues", "tasks", + "default_issue_type", "default_epic_status", + "memberships", "points", + "epic_statuses", "us_statuses", "task_statuses", "issue_statuses", + "priorities", "severities", + "issue_types", + "epiccustomattributes", "userstorycustomattributes", + "taskcustomattributes", "issuecustomattributes", + "roles", "milestones", + "wiki_pages", "wiki_links", + "notify_policies", + "epics", "user_stories", "issues", "tasks", "is_featured" ] if key not in excluded_fields: @@ -195,7 +201,7 @@ def _store_membership(project, membership): validator.object._importing = True validator.object.token = str(uuid.uuid1()) validator.object.user = find_invited_user(validator.object.email, - default=validator.object.user) + default=validator.object.user) validator.save() return validator @@ -219,6 +225,7 @@ def _store_project_attribute_value(project, data, field, serializer): validator.object._importing = True validator.save() return validator.object + add_errors(field, validator.errors) return None @@ -239,10 +246,10 @@ def store_default_project_attributes_values(project, data): else: value = related.all().first() setattr(project, field, value) - helper(project, "default_points", project.points, data) helper(project, "default_issue_type", project.issue_types, data) helper(project, "default_issue_status", project.issue_statuses, data) + helper(project, "default_epic_status", project.epic_statuses, data) helper(project, "default_us_status", project.us_statuses, data) helper(project, "default_task_status", project.task_statuses, data) helper(project, "default_priority", project.priorities, data) @@ -317,12 +324,14 @@ def _store_role_point(project, us, role_point): add_errors("role_points", validator.errors) return None + def store_user_story(project, data): if "status" not in data and project.default_us_status: data["status"] = project.default_us_status.name us_data = {key: value for key, value in data.items() if key not in - ["role_points", "custom_attributes_values"]} + ["role_points", "custom_attributes_values"]} + validator = validators.UserStoryExportValidator(data=us_data, context={"project": project}) if validator.is_valid(): @@ -360,10 +369,13 @@ def store_user_story(project, data): custom_attributes_values = data.get("custom_attributes_values", None) if custom_attributes_values: custom_attributes = validator.object.project.userstorycustomattributes.all().values('id', 'name') - custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values( - custom_attributes, custom_attributes_values) + custom_attributes_values = \ + _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + _store_custom_attributes_values(validator.object, custom_attributes_values, - "user_story", validators.UserStoryCustomAttributesValuesExportValidator) + "user_story", + validators.UserStoryCustomAttributesValuesExportValidator) return validator @@ -379,6 +391,81 @@ def store_user_stories(project, data): return results +## EPICS + +def _store_epic_related_user_story(project, epic, related_user_story): + validator = validators.EpicRelatedUserStoryExportValidator(data=related_user_story, + context={"project": project}) + if validator.is_valid(): + validator.object.epic = epic + validator.object.save() + return validator.object + + add_errors("epic_related_user_stories", validator.errors) + return None + + +def store_epic(project, data): + if "status" not in data and project.default_epic_status: + data["status"] = project.default_epic_status.name + + validator = validators.EpicExportValidator(data=data, context={"project": project}) + if validator.is_valid(): + validator.object.project = project + if validator.object.owner is None: + validator.object.owner = validator.object.project.owner + validator.object._importing = True + validator.object._not_notify = True + + validator.save() + validator.save_watchers() + + if validator.object.ref: + sequence_name = refs.make_sequence_name(project) + if not seq.exists(sequence_name): + seq.create(sequence_name) + seq.set_max(sequence_name, validator.object.ref) + else: + validator.object.ref, _ = refs.make_reference(validator.object, project) + validator.object.save() + + for epic_attachment in data.get("attachments", []): + _store_attachment(project, validator.object, epic_attachment) + + for related_user_story in data.get("related_user_stories", []): + _store_epic_related_user_story(project, validator.object, related_user_story) + + history_entries = data.get("history", []) + for history in history_entries: + _store_history(project, validator.object, history) + + if not history_entries: + take_snapshot(validator.object, user=validator.object.owner) + + custom_attributes_values = data.get("custom_attributes_values", None) + if custom_attributes_values: + custom_attributes = validator.object.project.epiccustomattributes.all().values('id', 'name') + custom_attributes_values = \ + _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + _store_custom_attributes_values(validator.object, custom_attributes_values, + "epic", + validators.EpicCustomAttributesValuesExportValidator) + + return validator + + add_errors("epics", validator.errors) + return None + + +def store_epics(project, data): + results = [] + for epic in data.get("epics", []): + epic = store_epic(project, epic) + results.append(epic) + return results + + ## TASKS def store_task(project, data): @@ -418,10 +505,13 @@ def store_task(project, data): custom_attributes_values = data.get("custom_attributes_values", None) if custom_attributes_values: custom_attributes = validator.object.project.taskcustomattributes.all().values('id', 'name') - custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values( - custom_attributes, custom_attributes_values) + custom_attributes_values = \ + _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + _store_custom_attributes_values(validator.object, custom_attributes_values, - "task", validators.TaskCustomAttributesValuesExportValidator) + "task", + validators.TaskCustomAttributesValuesExportValidator) return validator @@ -486,10 +576,12 @@ def store_issue(project, data): custom_attributes_values = data.get("custom_attributes_values", None) if custom_attributes_values: custom_attributes = validator.object.project.issuecustomattributes.all().values('id', 'name') - custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values( - custom_attributes, custom_attributes_values) + custom_attributes_values = \ + _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) _store_custom_attributes_values(validator.object, custom_attributes_values, - "issue", validators.IssueCustomAttributesValuesExportValidator) + "issue", + validators.IssueCustomAttributesValuesExportValidator) return validator @@ -605,8 +697,9 @@ def _validate_if_owner_have_enought_space_to_this_project(owner, data): is_private = data.get("is_private", False) total_memberships = len([m for m in data.get("memberships", []) - if m.get("email", None) != data["owner"]]) - total_memberships = total_memberships + 1 # 1 is the owner + if m.get("email", None) != data["owner"]]) + + total_memberships = total_memberships + 1 # 1 is the owner (enough_slots, error_message) = users_service.has_available_slot_for_import_new_project( owner, is_private, @@ -652,9 +745,10 @@ def _populate_project_object(project, data): # Create memberships store_memberships(project, data) _create_membership_for_project_owner(project) - check_if_there_is_some_error(_("error importing memberships"), project) + check_if_there_is_some_error(_("error importing memberships"), project) # Create project attributes values + store_project_attributes_values(project, data, "epic_statuses", validators.EpicStatusExportValidator) store_project_attributes_values(project, data, "us_statuses", validators.UserStoryStatusExportValidator) store_project_attributes_values(project, data, "points", validators.PointsExportValidator) store_project_attributes_values(project, data, "task_statuses", validators.TaskStatusExportValidator) @@ -669,6 +763,8 @@ def _populate_project_object(project, data): check_if_there_is_some_error(_("error importing default project attributes values"), project) # Create custom attributes + store_custom_attributes(project, data, "epiccustomattributes", + validators.EpicCustomAttributeExportValidator) store_custom_attributes(project, data, "userstorycustomattributes", validators.UserStoryCustomAttributeExportValidator) store_custom_attributes(project, data, "taskcustomattributes", @@ -689,6 +785,10 @@ def _populate_project_object(project, data): store_user_stories(project, data) check_if_there_is_some_error(_("error importing user stories"), project) + # Creat epics + store_epics(project, data) + check_if_there_is_some_error(_("error importing epics"), project) + # Createer tasks store_tasks(project, data) check_if_there_is_some_error(_("error importing tasks"), project) diff --git a/taiga/export_import/validators/__init__.py b/taiga/export_import/validators/__init__.py index 969a8d0c..0948ade0 100644 --- a/taiga/export_import/validators/__init__.py +++ b/taiga/export_import/validators/__init__.py @@ -1,4 +1,5 @@ from .validators import PointsExportValidator +from .validators import EpicStatusExportValidator from .validators import UserStoryStatusExportValidator from .validators import TaskStatusExportValidator from .validators import IssueStatusExportValidator @@ -6,6 +7,7 @@ from .validators import PriorityExportValidator from .validators import SeverityExportValidator from .validators import IssueTypeExportValidator from .validators import RoleExportValidator +from .validators import EpicCustomAttributeExportValidator from .validators import UserStoryCustomAttributeExportValidator from .validators import TaskCustomAttributeExportValidator from .validators import IssueCustomAttributeExportValidator @@ -17,6 +19,8 @@ from .validators import MembershipExportValidator from .validators import RolePointsExportValidator from .validators import MilestoneExportValidator from .validators import TaskExportValidator +from .validators import EpicRelatedUserStoryExportValidator +from .validators import EpicExportValidator from .validators import UserStoryExportValidator from .validators import IssueExportValidator from .validators import WikiPageExportValidator diff --git a/taiga/export_import/validators/cache.py b/taiga/export_import/validators/cache.py index c4eb5bfa..d82e943d 100644 --- a/taiga/export_import/validators/cache.py +++ b/taiga/export_import/validators/cache.py @@ -22,6 +22,7 @@ _cache_user_by_pk = {} _cache_user_by_email = {} _custom_tasks_attributes_cache = {} _custom_issues_attributes_cache = {} +_custom_epics_attributes_cache = {} _custom_userstories_attributes_cache = {} diff --git a/taiga/export_import/validators/validators.py b/taiga/export_import/validators/validators.py index 818df0c3..c821b531 100644 --- a/taiga/export_import/validators/validators.py +++ b/taiga/export_import/validators/validators.py @@ -25,6 +25,7 @@ from taiga.base.exceptions import ValidationError from taiga.projects import models as projects_models from taiga.projects.custom_attributes import models as custom_attributes_models +from taiga.projects.epics import models as epics_models from taiga.projects.userstories import models as userstories_models from taiga.projects.tasks import models as tasks_models from taiga.projects.issues import models as issues_models @@ -38,6 +39,7 @@ from .fields import (FileField, UserRelatedField, TimelineDataField, ContentTypeField) from .mixins import WatcheableObjectModelValidatorMixin from .cache import (_custom_tasks_attributes_cache, + _custom_epics_attributes_cache, _custom_userstories_attributes_cache, _custom_issues_attributes_cache) @@ -48,6 +50,12 @@ class PointsExportValidator(validators.ModelValidator): exclude = ('id', 'project') +class EpicStatusExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.EpicStatus + exclude = ('id', 'project') + + class UserStoryStatusExportValidator(validators.ModelValidator): class Meta: model = projects_models.UserStoryStatus @@ -92,6 +100,14 @@ class RoleExportValidator(validators.ModelValidator): exclude = ('id', 'project') +class EpicCustomAttributeExportValidator(validators.ModelValidator): + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = custom_attributes_models.EpicCustomAttribute + exclude = ('id', 'project') + + class UserStoryCustomAttributeExportValidator(validators.ModelValidator): modified_date = serializers.DateTimeField(required=False) @@ -151,6 +167,15 @@ class BaseCustomAttributesValuesExportValidator(validators.ModelValidator): return attrs +class EpicCustomAttributesValuesExportValidator(BaseCustomAttributesValuesExportValidator): + _custom_attribute_model = custom_attributes_models.EpicCustomAttribute + _container_model = "epics.Epic" + _container_field = "epic" + + class Meta(BaseCustomAttributesValuesExportValidator.Meta): + model = custom_attributes_models.EpicCustomAttributesValues + + class UserStoryCustomAttributesValuesExportValidator(BaseCustomAttributesValuesExportValidator): _custom_attribute_model = custom_attributes_models.UserStoryCustomAttribute _container_model = "userstories.UserStory" @@ -244,6 +269,34 @@ class TaskExportValidator(WatcheableObjectModelValidatorMixin): return _custom_tasks_attributes_cache[project.id] +class EpicRelatedUserStoryExportValidator(validators.ModelValidator): + user_story = ProjectRelatedField(slug_field="ref") + order = serializers.IntegerField() + + class Meta: + model = epics_models.RelatedUserStory + exclude = ('id', 'epic') + + +class EpicExportValidator(WatcheableObjectModelValidatorMixin): + owner = UserRelatedField(required=False) + assigned_to = UserRelatedField(required=False) + status = ProjectRelatedField(slug_field="name") + modified_date = serializers.DateTimeField(required=False) + user_stories = EpicRelatedUserStoryExportValidator(many=True, required=False) + + class Meta: + model = epics_models.Epic + exclude = ('id', 'project') + + def custom_attributes_queryset(self, project): + if project.id not in _custom_epics_attributes_cache: + _custom_epics_attributes_cache[project.id] = list( + project.epiccustomattributes.all().values('id', 'name') + ) + return _custom_epics_attributes_cache[project.id] + + class UserStoryExportValidator(WatcheableObjectModelValidatorMixin): role_points = RolePointsExportValidator(many=True, required=False) owner = UserRelatedField(required=False) diff --git a/tests/unit/test_export.py b/tests/unit/test_export.py index a8ce775f..1088550f 100644 --- a/tests/unit/test_export.py +++ b/tests/unit/test_export.py @@ -42,3 +42,17 @@ def test_export_user_story_finish_date(client): project_data = json.loads(output.getvalue()) finish_date = project_data["user_stories"][0]["finish_date"] assert finish_date == "2014-10-22T00:00:00+0000" + + +def test_export_epic_with_user_stories(client): + epic = f.EpicFactory.create(subject="test epic export") + user_story = f.UserStoryFactory.create(project=epic.project) + f.RelatedUserStory.create(epic=epic, user_story=user_story) + output = io.BytesIO() + render_project(user_story.project, output) + project_data = json.loads(output.getvalue()) + assert project_data["epics"][0]["subject"] == "test epic export" + assert len(project_data["epics"]) == 1 + + assert project_data["epics"][0]["related_user_stories"][0]["user_story"] == user_story.ref + assert len(project_data["epics"][0]["related_user_stories"]) == 1 diff --git a/tests/unit/test_import.py b/tests/unit/test_import.py new file mode 100644 index 00000000..58f9b9db --- /dev/null +++ b/tests/unit/test_import.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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 pytest +import io +from .. import factories as f + +from taiga.base.utils import json +from taiga.export_import.services import render_project, store_project_from_dict + +pytestmark = pytest.mark.django_db + + +def test_import_epic_with_user_stories(client): + project = f.ProjectFactory() + project.default_points = f.PointsFactory.create(project=project) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.default_epic_status = f.EpicStatusFactory.create(project=project) + project.default_us_status = f.UserStoryStatusFactory.create(project=project) + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + + epic = f.EpicFactory.create(subject="test epic export", project=project, status=project.default_epic_status) + user_story = f.UserStoryFactory.create(project=project, status=project.default_us_status, milestone=None) + f.RelatedUserStory.create(epic=epic, user_story=user_story, order=55) + output = io.BytesIO() + render_project(user_story.project, output) + project_data = json.loads(output.getvalue()) + + epic.project.delete() + + project = store_project_from_dict(project_data) + assert project.epics.count() == 1 + assert project.epics.first().ref == epic.ref + + assert project.epics.first().user_stories.count() == 1 + related_userstory = project.epics.first().relateduserstory_set.first() + assert related_userstory.user_story.ref == user_story.ref + assert related_userstory.order == 55 + assert related_userstory.epic.ref == epic.ref From dd3b098d4e0f2ad002ed39172ac96229d857ea6f Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 2 Aug 2016 10:33:37 +0200 Subject: [PATCH 38/83] Fixing _get_userstories_epics for projects without userstories --- taiga/projects/userstories/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py index 5998a57e..71377b52 100644 --- a/taiga/projects/userstories/services.py +++ b/taiga/projects/userstories/services.py @@ -517,7 +517,7 @@ def _get_userstories_epics(project, queryset): result = sorted(result, key=itemgetter("order")) # Add row when there is no user stories with no epics - if result[0]["id"] is not None: + if result == [] or result[0]["id"] is not None: result.insert(0, { "id": None, "ref": None, From 46f6fa71e6c99c83f7265965082c33ce495d1cfb Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 2 Aug 2016 12:49:33 +0200 Subject: [PATCH 39/83] Adding bulk_create_related_userstories endpoint to epics API --- taiga/base/utils/db.py | 2 ++ taiga/projects/epics/api.py | 29 ++++++++++++++-- taiga/projects/epics/permissions.py | 5 +-- taiga/projects/epics/services.py | 33 ++++++++++++++++++- taiga/projects/epics/validators.py | 5 +++ .../test_epics_resources.py | 28 ++++++++++++++++ tests/integration/test_epics.py | 18 ++++++++++ 7 files changed, 114 insertions(+), 6 deletions(-) diff --git a/taiga/base/utils/db.py b/taiga/base/utils/db.py index 9769abee..a6c21d6b 100644 --- a/taiga/base/utils/db.py +++ b/taiga/base/utils/db.py @@ -83,6 +83,7 @@ def save_in_bulk(instances, callback=None, precall=None, **save_options): :params callback: Callback to call after each save. :params save_options: Additional options to use when saving each instance. """ + ret = [] if callback is None: callback = functions.noop @@ -98,6 +99,7 @@ def save_in_bulk(instances, callback=None, precall=None, **save_options): instance.save(**save_options) callback(instance, created=created) + return ret @transaction.atomic def update_in_bulk(instances, list_of_new_values, callback=None, precall=None): diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index 6f4b18e9..5eba3406 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -22,7 +22,7 @@ from django.utils.translation import ugettext as _ from taiga.base.api.utils import get_object_or_404 from taiga.base import filters, response from taiga.base import exceptions as exc -from taiga.base.decorators import list_route +from taiga.base.decorators import list_route, detail_route from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api.mixins import BlockedByProjectMixin @@ -201,8 +201,8 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, raise exc.Blocked(_("Blocked element")) ret = services.update_epics_order_in_bulk(data["bulk_epics"], - project=project, - field=order_field) + project=project, + field=order_field) return response.Ok(ret) @@ -210,6 +210,29 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, def bulk_update_epics_order(self, request, **kwargs): return self._bulk_update_order("epics_order", request, **kwargs) + @detail_route(methods=["POST"]) + def bulk_create_related_userstories(self, request, **kwargs): + validator = validators.CrateRelatedUserStoriesBulkValidator(data=request.DATA) + if validator.is_valid(): + data = validator.data + obj = self.get_object() + project = obj.project + self.check_permissions(request, 'bulk_create_userstories', project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + services.create_related_userstories_in_bulk( + data["userstories"], + obj, + project=project, + owner=request.user + ) + obj = self.get_queryset().get(id=obj.id) + epic_serialized = self.get_serializer_class()(obj) + return response.Ok(epic_serialized.data) + + return response.BadRequest(validator.errors) + class EpicVotersViewSet(VotersViewSetMixin, ModelListViewSet): permission_classes = (permissions.EpicVotersPermission,) diff --git a/taiga/projects/epics/permissions.py b/taiga/projects/epics/permissions.py index 86f2626e..489aeae2 100644 --- a/taiga/projects/epics/permissions.py +++ b/taiga/projects/epics/permissions.py @@ -16,8 +16,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from taiga.base.api.permissions import TaigaResourcePermission, AllowAny, IsAuthenticated, IsSuperUser -from taiga.permissions.permissions import HasProjectPerm, IsProjectAdmin +from taiga.base.api.permissions import TaigaResourcePermission, AllowAny, IsAuthenticated +from taiga.base.api.permissions import IsSuperUser, HasProjectPerm, IsProjectAdmin from taiga.permissions.permissions import CommentAndOrUpdatePerm @@ -35,6 +35,7 @@ class EpicPermission(TaigaResourcePermission): csv_perms = AllowAny() bulk_create_perms = HasProjectPerm('add_epic') bulk_update_order_perms = HasProjectPerm('modify_epic') + bulk_create_userstories_perms = HasProjectPerm('modify_epic') & (HasProjectPerm('add_us_to_project') | HasProjectPerm('add_us')) upvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') downvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') watch_perms = IsAuthenticated() & HasProjectPerm('view_epics') diff --git a/taiga/projects/epics/services.py b/taiga/projects/epics/services.py index ea470e8f..145e2339 100644 --- a/taiga/projects/epics/services.py +++ b/taiga/projects/epics/services.py @@ -26,10 +26,12 @@ from django.db import connection from django.utils.translation import ugettext as _ from taiga.base.utils import db, text -from taiga.projects.history.services import take_snapshot from taiga.projects.services import apply_order_updates from taiga.projects.epics.apps import connect_epics_signals from taiga.projects.epics.apps import disconnect_epics_signals +from taiga.projects.userstories.apps import connect_userstories_signals +from taiga.projects.userstories.apps import disconnect_userstories_signals +from taiga.projects.userstories.services import get_userstories_from_bulk from taiga.events import events from taiga.projects.votes.utils import attach_total_voters_to_queryset from taiga.projects.notifications.utils import attach_watchers_to_queryset @@ -96,6 +98,35 @@ def update_epics_order_in_bulk(bulk_data: list, field: str, project: object): return epic_orders +def create_related_userstories_in_bulk(bulk_data, epic, **additional_fields): + """Create user stories from `bulk_data`. + + :param epic: Element where all the user stories will be contained + :param bulk_data: List of user stories in bulk format. + :param additional_fields: Additional fields when instantiating each user story. + + :return: List of created `Task` instances. + """ + userstories = get_userstories_from_bulk(bulk_data, **additional_fields) + disconnect_userstories_signals() + + try: + db.save_in_bulk(userstories) + related_userstories = [] + for userstory in userstories: + related_userstories.append( + models.RelatedUserStory( + user_story=userstory, + epic=epic + ) + ) + db.save_in_bulk(related_userstories) + finally: + connect_userstories_signals() + + return userstories + + ##################################################### # CSV ##################################################### diff --git a/taiga/projects/epics/validators.py b/taiga/projects/epics/validators.py index 9d7a617f..6652928c 100644 --- a/taiga/projects/epics/validators.py +++ b/taiga/projects/epics/validators.py @@ -55,6 +55,11 @@ class EpicsBulkValidator(ProjectExistsValidator, EpicExistsValidator, bulk_epics = serializers.CharField() +class CrateRelatedUserStoriesBulkValidator(ProjectExistsValidator, EpicExistsValidator, + validators.Validator): + userstories = serializers.CharField() + + # Order bulk validators class _EpicOrderBulkValidator(EpicExistsValidator, validators.Validator): diff --git a/tests/integration/resources_permissions/test_epics_resources.py b/tests/integration/resources_permissions/test_epics_resources.py index f4e33813..1d128485 100644 --- a/tests/integration/resources_permissions/test_epics_resources.py +++ b/tests/integration/resources_permissions/test_epics_resources.py @@ -664,6 +664,34 @@ def test_epic_action_bulk_create(client, data): assert results == [401, 403, 403, 451, 451] +def test_bulk_create_related_userstories(client, data): + public_url = reverse('epics-bulk-create-related-userstories', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-bulk-create-related-userstories', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-bulk-create-related-userstories', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-bulk-create-related-userstories', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + bulk_data = json.dumps({ + "userstories": "test1\ntest2", + }) + + results = helper_test_http_method(client, 'post', public_url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, bulk_data, users) + assert results == [404, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, bulk_data, users) + assert results == [404, 404, 404, 451, 451] + + def test_epic_action_upvote(client, data): public_url = reverse('epics-upvote', kwargs={"pk": data.public_epic.pk}) private_url1 = reverse('epics-upvote', kwargs={"pk": data.private_epic1.pk}) diff --git a/tests/integration/test_epics.py b/tests/integration/test_epics.py index 5f22f62f..d8e09999 100644 --- a/tests/integration/test_epics.py +++ b/tests/integration/test_epics.py @@ -66,3 +66,21 @@ def test_custom_fields_csv_generation(): assert row[17] == attr.name row = next(reader) assert row[17] == "val1" + + +def test_bulk_create_related_userstories(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + epic = f.EpicFactory.create(project=project) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + url = reverse('epics-bulk-create-related-userstories', kwargs={"pk": epic.pk}) + + data = { + "userstories": "test1\ntest2" + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + print(response.data) + assert response.status_code == 200 + assert response.data['user_stories_counts'] == {'opened': 2, 'closed': 0} From 4d4f8a44a12f11addf690d2fc60fe62d8f9ea5c1 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 2 Aug 2016 13:13:33 +0200 Subject: [PATCH 40/83] Removing the bulk update order for epics and doing that processing on the update --- taiga/projects/epics/api.py | 60 ++++++++++++++++++----------- taiga/projects/epics/permissions.py | 1 - taiga/projects/epics/validators.py | 12 ------ tests/integration/test_epics.py | 24 +++++++++++- 4 files changed, 61 insertions(+), 36 deletions(-) diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index 5eba3406..f76d8c81 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -25,6 +25,7 @@ from taiga.base import exceptions as exc from taiga.base.decorators import list_route, detail_route from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api.mixins import BlockedByProjectMixin +from taiga.base.utils import json from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.models import Project, EpicStatus @@ -89,11 +90,48 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, if obj.status and obj.status.project != obj.project: raise exc.WrongArguments(_("You don't have permissions to set this status to this epic.")) + """ + Updating the epic order attribute can affect the ordering of another epics + This method generate a key for the epic and can be used to be compared before and after + saving + If there is any difference it means an extra ordering update must be done + """ + def _epics_order_key(self, obj): + return "{}-{}".format(obj.project_id, obj.epics_order) + def pre_save(self, obj): if not obj.id: obj.owner = self.request.user + else: + self._old_epics_order_key = self._epics_order_key(self.get_object()) + super().pre_save(obj) + def _reorder_if_needed(self, obj, old_order_key, order_key, order_attr, project): + # Executes the extra ordering if there is a difference in the ordering keys + if old_order_key != order_key: + extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}")) + data = [{"epic_id": obj.id, "order": getattr(obj, order_attr)}] + for id, order in extra_orders.items(): + data.append({"epic_id": int(id), "order": order}) + + return services.update_epics_order_in_bulk(data, + field=order_attr, + project=project) + return {} + + def post_save(self, obj, created=False): + if not created: + # Let's reorder the related stuff after edit the element + orders_updated = self._reorder_if_needed(obj, + self._old_epics_order_key, + self._epics_order_key(obj), + "epics_order", + obj.project) + self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated) + + super().post_save(obj, created) + def update(self, request, *args, **kwargs): self.object = self.get_object_or_none() project_id = request.DATA.get('project', None) @@ -188,28 +226,6 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, return response.BadRequest(validator.errors) - def _bulk_update_order(self, order_field, request, **kwargs): - validator = validators.UpdateEpicsOrderBulkValidator(data=request.DATA) - if not validator.is_valid(): - return response.BadRequest(validator.errors) - - data = validator.data - project = get_object_or_404(Project, pk=data["project_id"]) - - self.check_permissions(request, "bulk_update_order", project) - if project.blocked_code is not None: - raise exc.Blocked(_("Blocked element")) - - ret = services.update_epics_order_in_bulk(data["bulk_epics"], - project=project, - field=order_field) - - return response.Ok(ret) - - @list_route(methods=["POST"]) - def bulk_update_epics_order(self, request, **kwargs): - return self._bulk_update_order("epics_order", request, **kwargs) - @detail_route(methods=["POST"]) def bulk_create_related_userstories(self, request, **kwargs): validator = validators.CrateRelatedUserStoriesBulkValidator(data=request.DATA) diff --git a/taiga/projects/epics/permissions.py b/taiga/projects/epics/permissions.py index 489aeae2..b009cfc2 100644 --- a/taiga/projects/epics/permissions.py +++ b/taiga/projects/epics/permissions.py @@ -34,7 +34,6 @@ class EpicPermission(TaigaResourcePermission): filters_data_perms = AllowAny() csv_perms = AllowAny() bulk_create_perms = HasProjectPerm('add_epic') - bulk_update_order_perms = HasProjectPerm('modify_epic') bulk_create_userstories_perms = HasProjectPerm('modify_epic') & (HasProjectPerm('add_us_to_project') | HasProjectPerm('add_us')) upvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') downvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') diff --git a/taiga/projects/epics/validators.py b/taiga/projects/epics/validators.py index 6652928c..450fefd6 100644 --- a/taiga/projects/epics/validators.py +++ b/taiga/projects/epics/validators.py @@ -58,15 +58,3 @@ class EpicsBulkValidator(ProjectExistsValidator, EpicExistsValidator, class CrateRelatedUserStoriesBulkValidator(ProjectExistsValidator, EpicExistsValidator, validators.Validator): userstories = serializers.CharField() - - -# Order bulk validators - -class _EpicOrderBulkValidator(EpicExistsValidator, validators.Validator): - epic_id = serializers.IntegerField() - order = serializers.IntegerField() - - -class UpdateEpicsOrderBulkValidator(ProjectExistsValidator, validators.Validator): - project_id = serializers.IntegerField() - bulk_epics = _EpicOrderBulkValidator(many=True) diff --git a/tests/integration/test_epics.py b/tests/integration/test_epics.py index d8e09999..b766fbf1 100644 --- a/tests/integration/test_epics.py +++ b/tests/integration/test_epics.py @@ -68,6 +68,29 @@ def test_custom_fields_csv_generation(): assert row[17] == "val1" +def test_update_epic_order(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + epic_1 = f.EpicFactory.create(project=project, epics_order=1, status=project.default_us_status) + epic_2 = f.EpicFactory.create(project=project, epics_order=2, status=project.default_us_status) + epic_3 = f.EpicFactory.create(project=project, epics_order=3, status=project.default_us_status) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + url = reverse('epics-detail', kwargs={"pk": epic_1.pk}) + data = { + "epics_order": 2, + "version": epic_1.version + } + + client.login(user) + response = client.json.patch(url, json.dumps(data)) + assert json.loads(response.get("taiga-info-order-updated")) == { + str(epic_1.id): 2, + str(epic_2.id): 3, + str(epic_3.id): 4 + } + + def test_bulk_create_related_userstories(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) @@ -81,6 +104,5 @@ def test_bulk_create_related_userstories(client): } client.login(user) response = client.json.post(url, json.dumps(data)) - print(response.data) assert response.status_code == 200 assert response.data['user_stories_counts'] == {'opened': 2, 'closed': 0} From 2f7384a76ba656113986eef4b0102d6a47afdb28 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 2 Aug 2016 15:20:49 +0200 Subject: [PATCH 41/83] Adding set_related_userstory end point to epics API --- taiga/projects/epics/api.py | 29 ++++++++++- taiga/projects/epics/permissions.py | 1 + taiga/projects/epics/validators.py | 7 ++- .../test_epics_resources.py | 48 +++++++++++++++++++ tests/integration/test_epics.py | 44 +++++++++++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index f76d8c81..ea78c86a 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -32,6 +32,7 @@ from taiga.projects.models import Project, EpicStatus from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.occ import OCCResourceMixin from taiga.projects.tagging.api import TaggedResourceMixin +from taiga.projects.userstories.models import UserStory from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin from . import models @@ -208,7 +209,7 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, if validator.is_valid(): data = validator.data project = Project.objects.get(id=data["project_id"]) - self.check_permissions(request, 'bulk_create', project) + self.check_permissions(request, "bulk_create", project) if project.blocked_code is not None: raise exc.Blocked(_("Blocked element")) @@ -249,6 +250,32 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, return response.BadRequest(validator.errors) + @detail_route(methods=["POST"]) + def set_related_userstory(self, request, **kwargs): + validator = validators.SetRelatedUserStoryValidator(data=request.DATA) + if validator.is_valid(): + data = validator.data + epic = self.get_object() + project = epic.project + user_story = UserStory.objects.get(id=data["us_id"]) + self.check_permissions(request, "update", epic) + self.check_permissions(request, "select_related_userstory", user_story.project) + + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + obj, created = models.RelatedUserStory.objects.update_or_create( + epic=epic, + user_story=user_story, + defaults={ + "order": data["order"] + }) + epic = self.get_queryset().get(id=epic.id) + epic_serialized = self.get_serializer_class()(epic) + return response.Ok(epic_serialized.data) + + return response.BadRequest(validator.errors) + class EpicVotersViewSet(VotersViewSetMixin, ModelListViewSet): permission_classes = (permissions.EpicVotersPermission,) diff --git a/taiga/projects/epics/permissions.py b/taiga/projects/epics/permissions.py index b009cfc2..3cd31b07 100644 --- a/taiga/projects/epics/permissions.py +++ b/taiga/projects/epics/permissions.py @@ -35,6 +35,7 @@ class EpicPermission(TaigaResourcePermission): csv_perms = AllowAny() bulk_create_perms = HasProjectPerm('add_epic') bulk_create_userstories_perms = HasProjectPerm('modify_epic') & (HasProjectPerm('add_us_to_project') | HasProjectPerm('add_us')) + select_related_userstory_perms = HasProjectPerm('view_us') upvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') downvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') watch_perms = IsAuthenticated() & HasProjectPerm('view_epics') diff --git a/taiga/projects/epics/validators.py b/taiga/projects/epics/validators.py index 450fefd6..674d030f 100644 --- a/taiga/projects/epics/validators.py +++ b/taiga/projects/epics/validators.py @@ -22,10 +22,10 @@ from taiga.base.api import serializers from taiga.base.api import validators from taiga.base.exceptions import ValidationError from taiga.base.fields import PgArrayField -from taiga.projects.milestones.validators import MilestoneExistsValidator from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer from taiga.projects.notifications.validators import WatchersValidator from taiga.projects.tagging.fields import TagsAndTagsColorsField +from taiga.projects.userstories.validators import UserStoryExistsValidator from taiga.projects.validators import ProjectExistsValidator from . import models @@ -58,3 +58,8 @@ class EpicsBulkValidator(ProjectExistsValidator, EpicExistsValidator, class CrateRelatedUserStoriesBulkValidator(ProjectExistsValidator, EpicExistsValidator, validators.Validator): userstories = serializers.CharField() + + +class SetRelatedUserStoryValidator(UserStoryExistsValidator, validators.Validator): + us_id = serializers.IntegerField() + order = serializers.IntegerField(required=False, default=10000) diff --git a/tests/integration/resources_permissions/test_epics_resources.py b/tests/integration/resources_permissions/test_epics_resources.py index 1d128485..7b7f87ee 100644 --- a/tests/integration/resources_permissions/test_epics_resources.py +++ b/tests/integration/resources_permissions/test_epics_resources.py @@ -153,6 +153,12 @@ def data(): status__project=m.blocked_project) m.blocked_epic = attach_epic_extra_info(Epic.objects.all()).get(id=m.blocked_epic.id) + + m.public_us = f.UserStoryFactory(project=m.public_project) + m.private_us1 = f.UserStoryFactory(project=m.private_project1) + m.private_us2 = f.UserStoryFactory(project=m.private_project2) + m.blocked_us = f.UserStoryFactory(project=m.blocked_project) + m.public_project.default_epic_status = m.public_epic.status m.public_project.save() m.private_project1.default_epic_status = m.private_epic1.status @@ -692,6 +698,48 @@ def test_bulk_create_related_userstories(client, data): assert results == [404, 404, 404, 451, 451] +def test_set_related_user_story(client, data): + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + url = reverse('epics-set-related-userstory', kwargs={"pk": data.public_epic.pk}) + edit_data = json.dumps({ + "us_id": data.public_us.pk, + "order": 33, + }) + results = helper_test_http_method(client, 'post', url, edit_data, users) + assert results == [401, 403, 403, 200, 200] + + url = reverse('epics-set-related-userstory', kwargs={"pk": data.private_epic1.pk}) + edit_data = json.dumps({ + "us_id": data.private_us1.pk, + "order": 33, + }) + results = helper_test_http_method(client, 'post', url, edit_data, users) + assert results == [401, 403, 403, 200, 200] + + url = reverse('epics-set-related-userstory', kwargs={"pk": data.private_epic2.pk}) + edit_data = json.dumps({ + "us_id": data.private_us2.pk, + "order": 33, + }) + results = helper_test_http_method(client, 'post', url, edit_data, users) + assert results == [404, 404, 404, 200, 200] + + url = reverse('epics-set-related-userstory', kwargs={"pk": data.blocked_epic.pk}) + edit_data = json.dumps({ + "us_id": data.blocked_us.pk, + "order": 33, + }) + results = helper_test_http_method(client, 'post', url, edit_data, users) + assert results == [404, 404, 404, 451, 451] + + def test_epic_action_upvote(client, data): public_url = reverse('epics-upvote', kwargs={"pk": data.public_epic.pk}) private_url1 = reverse('epics-upvote', kwargs={"pk": data.private_epic1.pk}) diff --git a/tests/integration/test_epics.py b/tests/integration/test_epics.py index b766fbf1..6f0df014 100644 --- a/tests/integration/test_epics.py +++ b/tests/integration/test_epics.py @@ -26,6 +26,7 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json from taiga.projects.epics import services +from taiga.projects.epics import models from .. import factories as f @@ -106,3 +107,46 @@ def test_bulk_create_related_userstories(client): response = client.json.post(url, json.dumps(data)) assert response.status_code == 200 assert response.data['user_stories_counts'] == {'opened': 2, 'closed': 0} + + +def test_set_related_userstory(client): + user = f.UserFactory.create() + epic = f.EpicFactory.create() + us = f.UserStoryFactory.create() + f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) + f.MembershipFactory.create(project=us.project, user=user, is_admin=True) + + url = reverse('epics-set-related-userstory', kwargs={"pk": epic.pk}) + + data = { + "us_id": us.id + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + print(response.data) + assert response.status_code == 200 + assert response.data['user_stories_counts'] == {'opened': 1, 'closed': 0} + + +def test_set_related_userstory_existing(client): + user = f.UserFactory.create() + epic = f.EpicFactory.create() + us = f.UserStoryFactory.create() + related_us = f.RelatedUserStory.create(epic=epic, user_story=us, order=55) + f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) + f.MembershipFactory.create(project=us.project, user=user, is_admin=True) + + url = reverse('epics-set-related-userstory', kwargs={"pk": epic.pk}) + + data = { + "us_id": us.id, + "order": 77 + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + print(response.data) + assert response.status_code == 200 + assert response.data['user_stories_counts'] == {'opened': 1, 'closed': 0} + + related_us = models.RelatedUserStory.objects.get(id=related_us.id) + assert related_us.order == 77 From 9bba8efde90478310ef1eba0df461fb87e635193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 2 Aug 2016 19:05:15 +0200 Subject: [PATCH 42/83] Fix tests --- taiga/projects/userstories/validators.py | 9 +++++++++ tests/integration/test_epics.py | 2 -- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/taiga/projects/userstories/validators.py b/taiga/projects/userstories/validators.py index 60bedaf7..9ff6b2f3 100644 --- a/taiga/projects/userstories/validators.py +++ b/taiga/projects/userstories/validators.py @@ -36,6 +36,15 @@ from . import models import json +class UserStoryExistsValidator: + def validate_us_id(self, attrs, source): + value = attrs[source] + if not models.UserStory.objects.filter(pk=value).exists(): + msg = _("There's no user story with that id") + raise ValidationError(msg) + return attrs + + class RolePointsField(serializers.WritableField): def to_native(self, obj): return {str(o.role.id): o.points.id for o in obj.all()} diff --git a/tests/integration/test_epics.py b/tests/integration/test_epics.py index 6f0df014..57a80d10 100644 --- a/tests/integration/test_epics.py +++ b/tests/integration/test_epics.py @@ -123,7 +123,6 @@ def test_set_related_userstory(client): } client.login(user) response = client.json.post(url, json.dumps(data)) - print(response.data) assert response.status_code == 200 assert response.data['user_stories_counts'] == {'opened': 1, 'closed': 0} @@ -144,7 +143,6 @@ def test_set_related_userstory_existing(client): } client.login(user) response = client.json.post(url, json.dumps(data)) - print(response.data) assert response.status_code == 200 assert response.data['user_stories_counts'] == {'opened': 1, 'closed': 0} From 0e6daf09f695d6717fb6f8fc1d2908625ea61f80 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 3 Aug 2016 08:40:33 +0200 Subject: [PATCH 43/83] Adding unset_related_userstory end point to epics API --- taiga/projects/epics/api.py | 25 +++++++++++ taiga/projects/epics/validators.py | 4 ++ .../test_epics_resources.py | 42 +++++++++++++++++++ tests/integration/test_epics.py | 22 +++++++++- 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index ea78c86a..c14e7792 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -276,6 +276,31 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, return response.BadRequest(validator.errors) + @detail_route(methods=["POST"]) + def unset_related_userstory(self, request, **kwargs): + validator = validators.UnsetRelatedUserStoryValidator(data=request.DATA) + if validator.is_valid(): + data = validator.data + epic = self.get_object() + project = epic.project + user_story = UserStory.objects.get(id=data["us_id"]) + self.check_permissions(request, "update", epic) + + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + related_us = get_object_or_404( + models.RelatedUserStory, + epic=epic, + user_story=user_story + ) + related_us.delete() + epic = self.get_queryset().get(id=epic.id) + epic_serialized = self.get_serializer_class()(epic) + return response.Ok(epic_serialized.data) + + return response.BadRequest(validator.errors) + class EpicVotersViewSet(VotersViewSetMixin, ModelListViewSet): permission_classes = (permissions.EpicVotersPermission,) diff --git a/taiga/projects/epics/validators.py b/taiga/projects/epics/validators.py index 674d030f..975f1e4d 100644 --- a/taiga/projects/epics/validators.py +++ b/taiga/projects/epics/validators.py @@ -63,3 +63,7 @@ class CrateRelatedUserStoriesBulkValidator(ProjectExistsValidator, EpicExistsVal class SetRelatedUserStoryValidator(UserStoryExistsValidator, validators.Validator): us_id = serializers.IntegerField() order = serializers.IntegerField(required=False, default=10000) + + +class UnsetRelatedUserStoryValidator(UserStoryExistsValidator, validators.Validator): + us_id = serializers.IntegerField() diff --git a/tests/integration/resources_permissions/test_epics_resources.py b/tests/integration/resources_permissions/test_epics_resources.py index 7b7f87ee..89b56bb5 100644 --- a/tests/integration/resources_permissions/test_epics_resources.py +++ b/tests/integration/resources_permissions/test_epics_resources.py @@ -159,6 +159,11 @@ def data(): m.private_us2 = f.UserStoryFactory(project=m.private_project2) m.blocked_us = f.UserStoryFactory(project=m.blocked_project) + m.public_related_us = f.RelatedUserStory(epic=m.public_epic, user_story=m.public_us) + m.private_related_us1 = f.RelatedUserStory(epic=m.private_epic1, user_story=m.private_us1) + m.private_related_us2 = f.RelatedUserStory(epic=m.private_epic2, user_story=m.private_us2) + m.blocked_related_us = f.RelatedUserStory(epic=m.blocked_epic, user_story=m.blocked_us) + m.public_project.default_epic_status = m.public_epic.status m.public_project.save() m.private_project1.default_epic_status = m.private_epic1.status @@ -740,6 +745,43 @@ def test_set_related_user_story(client, data): assert results == [404, 404, 404, 451, 451] +def test_unset_related_user_story(client, data): + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms + ] + + url = reverse('epics-unset-related-userstory', kwargs={"pk": data.public_epic.pk}) + edit_data = json.dumps({ + "us_id": data.public_related_us.user_story.pk, + }) + results = helper_test_http_method(client, 'post', url, edit_data, users) + assert results == [401, 403, 403, 200] + + url = reverse('epics-set-related-userstory', kwargs={"pk": data.private_epic1.pk}) + edit_data = json.dumps({ + "us_id": data.private_related_us1.user_story.pk, + }) + results = helper_test_http_method(client, 'post', url, edit_data, users) + assert results == [401, 403, 403, 200] + + url = reverse('epics-set-related-userstory', kwargs={"pk": data.private_epic2.pk}) + edit_data = json.dumps({ + "us_id": data.private_related_us2.user_story.pk, + }) + results = helper_test_http_method(client, 'post', url, edit_data, users) + assert results == [404, 404, 404, 200] + + url = reverse('epics-set-related-userstory', kwargs={"pk": data.blocked_epic.pk}) + edit_data = json.dumps({ + "us_id": data.blocked_related_us.user_story.pk, + }) + results = helper_test_http_method(client, 'post', url, edit_data, users) + assert results == [404, 404, 404, 451] + + def test_epic_action_upvote(client, data): public_url = reverse('epics-upvote', kwargs={"pk": data.public_epic.pk}) private_url1 = reverse('epics-upvote', kwargs={"pk": data.private_epic1.pk}) diff --git a/tests/integration/test_epics.py b/tests/integration/test_epics.py index 57a80d10..6d0e3107 100644 --- a/tests/integration/test_epics.py +++ b/tests/integration/test_epics.py @@ -133,7 +133,6 @@ def test_set_related_userstory_existing(client): us = f.UserStoryFactory.create() related_us = f.RelatedUserStory.create(epic=epic, user_story=us, order=55) f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) - f.MembershipFactory.create(project=us.project, user=user, is_admin=True) url = reverse('epics-set-related-userstory', kwargs={"pk": epic.pk}) @@ -148,3 +147,24 @@ def test_set_related_userstory_existing(client): related_us = models.RelatedUserStory.objects.get(id=related_us.id) assert related_us.order == 77 + + +def test_unset_related_userstory(client): + user = f.UserFactory.create() + epic = f.EpicFactory.create() + us = f.UserStoryFactory.create() + related_us = f.RelatedUserStory.create(epic=epic, user_story=us, order=55) + f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) + + url = reverse('epics-unset-related-userstory', kwargs={"pk": epic.pk}) + + data = { + "us_id": us.id + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + print(response.data) + assert response.status_code == 200 + assert response.data['user_stories_counts'] == {'opened': 0, 'closed': 0} + + assert not models.RelatedUserStory.objects.filter(id=related_us.id).exists() From 3656acd7f32d5fa0b0f9fb01a8d3a83feb3cd48a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 3 Aug 2016 09:40:33 +0200 Subject: [PATCH 44/83] Fixing tests --- tests/integration/test_epics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/test_epics.py b/tests/integration/test_epics.py index 6d0e3107..dd15a28d 100644 --- a/tests/integration/test_epics.py +++ b/tests/integration/test_epics.py @@ -133,6 +133,7 @@ def test_set_related_userstory_existing(client): us = f.UserStoryFactory.create() related_us = f.RelatedUserStory.create(epic=epic, user_story=us, order=55) f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) + f.MembershipFactory.create(project=us.project, user=user, is_admin=True) url = reverse('epics-set-related-userstory', kwargs={"pk": epic.pk}) From bc69ecd8860a5d6ce589292d90987ed363b7b0ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 3 Aug 2016 17:46:53 +0200 Subject: [PATCH 45/83] Improve userstories.validators --- taiga/projects/userstories/validators.py | 94 ++++++++++++++++------- tests/integration/test_userstories.py | 96 +++++++++++++++++------- 2 files changed, 133 insertions(+), 57 deletions(-) diff --git a/taiga/projects/userstories/validators.py b/taiga/projects/userstories/validators.py index 9ff6b2f3..9f6780c8 100644 --- a/taiga/projects/userstories/validators.py +++ b/taiga/projects/userstories/validators.py @@ -20,16 +20,17 @@ from django.utils.translation import ugettext as _ from taiga.base.api import serializers from taiga.base.api import validators -from taiga.base.api.utils import get_object_or_404 from taiga.base.exceptions import ValidationError from taiga.base.fields import PgArrayField from taiga.base.fields import PickledObjectField -from taiga.projects.milestones.validators import MilestoneExistsValidator -from taiga.projects.models import Project +from taiga.projects.milestones.models import Milestone +from taiga.projects.models import UserStoryStatus from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer from taiga.projects.notifications.validators import WatchersValidator from taiga.projects.tagging.fields import TagsAndTagsColorsField -from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator +from taiga.projects.userstories.models import UserStory +from taiga.projects.validators import ProjectExistsValidator +from taiga.projects.validators import UserStoryStatusExistsValidator from . import models @@ -67,12 +68,22 @@ class UserStoryValidator(WatchersValidator, EditableWatchedResourceSerializer, v read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner') -class UserStoriesBulkValidator(ProjectExistsValidator, UserStoryStatusExistsValidator, - validators.Validator): +class UserStoriesBulkValidator(ProjectExistsValidator, validators.Validator): project_id = serializers.IntegerField() status_id = serializers.IntegerField(required=False) bulk_stories = serializers.CharField() + def validate_status_id(self, attrs, source): + filters = {"project__id": attrs["project_id"]} + if source in attrs: + filters["id"] = attrs[source] + + if not UserStoryStatus.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story status id. The status must belong to " + "the same project.")) + + return attrs + # Order bulk validators @@ -88,20 +99,42 @@ class UpdateUserStoriesOrderBulkValidator(ProjectExistsValidator, UserStoryStatu milestone_id = serializers.IntegerField(required=False) bulk_stories = _UserStoryOrderBulkValidator(many=True) - def validate(self, data): - filters = {"project__id": data["project_id"]} - if "status_id" in data: - filters["status__id"] = data["status_id"] - if "milestone_id" in data: - filters["milestone__id"] = data["milestone_id"] + def validate_status_id(self, attrs, source): + filters = {"project__id": attrs["project_id"]} + if source in attrs: + filters["id"] = attrs[source] - filters["id__in"] = [us["us_id"] for us in data["bulk_stories"]] + if not UserStoryStatus.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story status id. The status must belong " + "to the same project.")) + + return attrs + + def validate_milestone_id(self, attrs, source): + filters = {"project__id": attrs["project_id"]} + if source in attrs: + filters["id"] = attrs[source] + + if not Milestone.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid milestone id. The milistone must belong to the " + "same project.")) + + return attrs + + def validate_bulk_stories(self, attrs, source): + filters = {"project__id": attrs["project_id"]} + if "status_id" in attrs: + filters["status__id"] = attrs["status_id"] + if "milestone_id" in attrs: + filters["milestone__id"] = attrs["milestone_id"] + + filters["id__in"] = [us["us_id"] for us in attrs[source]] if models.UserStory.objects.filter(**filters).count() != len(filters["id__in"]): - raise ValidationError(_("Invalid user story ids. All stories must belong to the same project and, " - "if it exists, to the same status and milestone.")) + raise ValidationError(_("Invalid user story ids. All stories must belong to the same project " + "and, if it exists, to the same status and milestone.")) - return data + return attrs # Milestone bulk validators @@ -111,22 +144,27 @@ class _UserStoryMilestoneBulkValidator(validators.Validator): order = serializers.IntegerField() -class UpdateMilestoneBulkValidator(ProjectExistsValidator, MilestoneExistsValidator, validators.Validator): +class UpdateMilestoneBulkValidator(ProjectExistsValidator, validators.Validator): project_id = serializers.IntegerField() milestone_id = serializers.IntegerField() bulk_stories = _UserStoryMilestoneBulkValidator(many=True) - def validate(self, data): - """ - All the userstories and the milestone are from the same project - """ - user_story_ids = [us["us_id"] for us in data["bulk_stories"]] - project = get_object_or_404(Project, pk=data["project_id"]) + def validate_milestone_id(self, attrs, source): + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } + if not Milestone.objects.filter(**filters).exists(): + raise ValidationError(_("The milestone isn't valid for the project")) + return attrs - if project.user_stories.filter(id__in=user_story_ids).count() != len(user_story_ids): + def validate_bulk_stories(self, attrs, source): + filters = { + "project__id": attrs["project_id"], + "id__in": [us["us_id"] for us in attrs[source]] + } + + if UserStory.objects.filter(**filters).count() != len(filters["id__in"]): raise ValidationError(_("All the user stories must be from the same project")) - if project.milestones.filter(id=data["milestone_id"]).count() != 1: - raise ValidationError(_("The milestone isn't valid for the project")) - - return data + return attrs diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index e158b802..57a2f520 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -157,6 +157,24 @@ def test_api_create_in_bulk_with_status(client): assert response.data[0]["status"] == project.default_us_status.id +def test_api_create_in_bulk_with_invalid_status(client): + project = f.create_project() + status = f.UserStoryStatusFactory.create() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + url = reverse("userstories-bulk-create") + data = { + "bulk_stories": "Story #1\nStory #2", + "project_id": project.id, + "status_id": status.id + } + + client.login(project.owner) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400, response.data + assert "status_id" in response.data + + def test_api_update_orders_in_bulk(client): project = f.create_project() f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) @@ -175,13 +193,14 @@ def test_api_update_orders_in_bulk(client): client.login(project.owner) - response1 = client.json.post(url1, json.dumps(data)) - response2 = client.json.post(url2, json.dumps(data)) - response3 = client.json.post(url3, json.dumps(data)) + response = client.json.post(url1, json.dumps(data)) + assert response.status_code == 200, response.data - assert response1.status_code == 200, response1.data - assert response2.status_code == 200, response2.data - assert response3.status_code == 200, response3.data + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 200, response.data + + response = client.json.post(url3, json.dumps(data)) + assert response.status_code == 200, response.data def test_api_update_orders_in_bulk_invalid_userstories(client): @@ -204,19 +223,24 @@ def test_api_update_orders_in_bulk_invalid_userstories(client): client.login(project.owner) - response1 = client.json.post(url1, json.dumps(data)) - response2 = client.json.post(url2, json.dumps(data)) - response3 = client.json.post(url3, json.dumps(data)) + response = client.json.post(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_stories" in response.data - assert response1.status_code == 400, response1.data - assert response2.status_code == 400, response2.data - assert response3.status_code == 400, response3.data + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_stories" in response.data + + response = client.json.post(url3, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_stories" in response.data def test_api_update_orders_in_bulk_invalid_status(client): project = f.create_project() f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) - us1 = f.create_userstory(project=project) + status = f.UserStoryStatusFactory.create() + us1 = f.create_userstory(project=project, status=status) us2 = f.create_userstory(project=project, status=us1.status) us3 = f.create_userstory(project=project) @@ -226,7 +250,7 @@ def test_api_update_orders_in_bulk_invalid_status(client): data = { "project_id": project.id, - "status_id": us1.status.id, + "status_id": status.id, "bulk_stories": [{"us_id": us1.id, "order": 1}, {"us_id": us2.id, "order": 2}, {"us_id": us3.id, "order": 3}] @@ -234,19 +258,26 @@ def test_api_update_orders_in_bulk_invalid_status(client): client.login(project.owner) - response1 = client.json.post(url1, json.dumps(data)) - response2 = client.json.post(url2, json.dumps(data)) - response3 = client.json.post(url3, json.dumps(data)) + response = client.json.post(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "status_id" in response.data + assert "bulk_stories" in response.data - assert response1.status_code == 400, response1.data - assert response2.status_code == 400, response2.data - assert response3.status_code == 400, response3.data + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "status_id" in response.data + assert "bulk_stories" in response.data + + response = client.json.post(url3, json.dumps(data)) + assert response.status_code == 400, response.data + assert "status_id" in response.data + assert "bulk_stories" in response.data def test_api_update_orders_in_bulk_invalid_milestione(client): project = f.create_project() f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) - mil1 = f.MilestoneFactory.create(project=project) + mil1 = f.MilestoneFactory.create() us1 = f.create_userstory(project=project, milestone=mil1) us2 = f.create_userstory(project=project, milestone=mil1) us3 = f.create_userstory(project=project) @@ -265,13 +296,20 @@ def test_api_update_orders_in_bulk_invalid_milestione(client): client.login(project.owner) - response1 = client.json.post(url1, json.dumps(data)) - response2 = client.json.post(url2, json.dumps(data)) - response3 = client.json.post(url3, json.dumps(data)) + response = client.json.post(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "milestone_id" in response.data + assert "bulk_stories" in response.data - assert response1.status_code == 400, response1.data - assert response2.status_code == 400, response2.data - assert response3.status_code == 400, response3.data + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "milestone_id" in response.data + assert "bulk_stories" in response.data + + response = client.json.post(url3, json.dumps(data)) + assert response.status_code == 400, response.data + assert "milestone_id" in response.data + assert "bulk_stories" in response.data def test_api_update_milestone_in_bulk(client): @@ -322,7 +360,7 @@ def test_api_update_milestone_in_bulk_invalid_milestone(client): response = client.json.post(url, json.dumps(data)) assert response.status_code == 400 - assert len(response.data["non_field_errors"]) == 1 + assert "milestone_id" in response.data def test_api_update_milestone_in_bulk_invalid_userstories(client): @@ -344,7 +382,7 @@ def test_api_update_milestone_in_bulk_invalid_userstories(client): response = client.json.post(url, json.dumps(data)) assert response.status_code == 400 - assert len(response.data["non_field_errors"]) == 1 + assert "bulk_stories" in response.data def test_update_userstory_points(client): From abff91e6bbe601866d34a211cc31a36df6570ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 4 Aug 2016 10:31:38 +0200 Subject: [PATCH 46/83] Fix memeverships-bulk-create validator --- taiga/projects/validators.py | 17 ++++++++++--- taiga/users/validators.py | 10 +------- .../test_projects_choices_resources.py | 1 + tests/integration/test_memberships.py | 24 +++++++++++++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/taiga/projects/validators.py b/taiga/projects/validators.py index fdf917ea..7d3c48cc 100644 --- a/taiga/projects/validators.py +++ b/taiga/projects/validators.py @@ -24,7 +24,7 @@ from taiga.base.api import validators from taiga.base.exceptions import ValidationError from taiga.base.fields import JsonField from taiga.base.fields import PgArrayField -from taiga.users.validators import RoleExistsValidator +from taiga.users.models import Role from .tagging.fields import TagsField @@ -200,16 +200,27 @@ class MembershipAdminValidator(MembershipValidator): exclude = ("token",) -class MemberBulkValidator(RoleExistsValidator, validators.Validator): +class _MemberBulkValidator(validators.Validator): email = serializers.EmailField() role_id = serializers.IntegerField() class MembersBulkValidator(ProjectExistsValidator, validators.Validator): project_id = serializers.IntegerField() - bulk_memberships = MemberBulkValidator(many=True) + bulk_memberships = _MemberBulkValidator(many=True) invitation_extra_text = serializers.CharField(required=False, max_length=255) + def validate_bulk_memberships(self, attrs, source): + filters = { + "project__id": attrs["project_id"], + "id__in": [r["role_id"] for r in attrs["bulk_memberships"]] + } + + if Role.objects.filter(**filters).count() != len(set(filters["id__in"])): + raise ValidationError(_("Invalid role ids. All roles must belong to the same project.")) + + return attrs + ###################################################### # Projects diff --git a/taiga/users/validators.py b/taiga/users/validators.py index f23da47a..279e6ce4 100644 --- a/taiga/users/validators.py +++ b/taiga/users/validators.py @@ -29,18 +29,10 @@ from .models import User, Role import re -class RoleExistsValidator: - def validate_role_id(self, attrs, source): - value = attrs[source] - if not Role.objects.filter(pk=value).exists(): - msg = _("There's no role with that id") - raise ValidationError(msg) - return attrs - - ###################################################### # User ###################################################### + class UserValidator(validators.ModelValidator): class Meta: model = User diff --git a/tests/integration/resources_permissions/test_projects_choices_resources.py b/tests/integration/resources_permissions/test_projects_choices_resources.py index 75c0f39d..28e1f483 100644 --- a/tests/integration/resources_permissions/test_projects_choices_resources.py +++ b/tests/integration/resources_permissions/test_projects_choices_resources.py @@ -2055,6 +2055,7 @@ def test_membership_action_bulk_create(client, data): results = helper_test_http_method(client, 'post', url, bulk_data, users) assert results == [401, 403, 403, 403, 451] + def test_membership_action_resend_invitation(client, data): public_invitation = f.InvitationFactory(project=data.public_project, role__project=data.public_project) private_invitation1 = f.InvitationFactory(project=data.private_project1, role__project=data.private_project1) diff --git a/tests/integration/test_memberships.py b/tests/integration/test_memberships.py index c6b2ca5e..70d3d198 100644 --- a/tests/integration/test_memberships.py +++ b/tests/integration/test_memberships.py @@ -72,6 +72,30 @@ def test_api_create_bulk_members(client): assert response.data[1]["email"] == joseph.email +def test_api_create_bulk_members_with_invalid_roles(client): + project = f.ProjectFactory() + john = f.UserFactory.create() + joseph = f.UserFactory.create() + tester = f.RoleFactory(name="Tester") + gamer = f.RoleFactory(name="Gamer") + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": tester.pk, "email": john.email}, + {"role_id": gamer.pk, "email": joseph.email}, + ] + } + client.login(project.owner) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "bulk_memberships" in response.data + + def test_api_create_bulk_members_with_allowed_domain(client): project = f.ProjectFactory() john = f.UserFactory.create() From 79f74135f76fc3ae11ac6a760e85e838a26af482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 4 Aug 2016 12:14:14 +0200 Subject: [PATCH 47/83] Remove unneeded code --- taiga/projects/userstories/validators.py | 4 +--- taiga/projects/validators.py | 19 ------------------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/taiga/projects/userstories/validators.py b/taiga/projects/userstories/validators.py index 9f6780c8..bc52db1a 100644 --- a/taiga/projects/userstories/validators.py +++ b/taiga/projects/userstories/validators.py @@ -30,7 +30,6 @@ from taiga.projects.notifications.validators import WatchersValidator from taiga.projects.tagging.fields import TagsAndTagsColorsField from taiga.projects.userstories.models import UserStory from taiga.projects.validators import ProjectExistsValidator -from taiga.projects.validators import UserStoryStatusExistsValidator from . import models @@ -92,8 +91,7 @@ class _UserStoryOrderBulkValidator(validators.Validator): order = serializers.IntegerField() -class UpdateUserStoriesOrderBulkValidator(ProjectExistsValidator, UserStoryStatusExistsValidator, - validators.Validator): +class UpdateUserStoriesOrderBulkValidator(ProjectExistsValidator, validators.Validator): project_id = serializers.IntegerField() status_id = serializers.IntegerField(required=False) milestone_id = serializers.IntegerField(required=False) diff --git a/taiga/projects/validators.py b/taiga/projects/validators.py index 7d3c48cc..54a43178 100644 --- a/taiga/projects/validators.py +++ b/taiga/projects/validators.py @@ -33,7 +33,6 @@ from . import services class DuplicatedNameInProjectValidator: - def validate_name(self, attrs, source): """ Check the points name is not duplicated in the project on creation @@ -64,24 +63,6 @@ class ProjectExistsValidator: return attrs -class UserStoryStatusExistsValidator: - def validate_status_id(self, attrs, source): - value = attrs[source] - if not models.UserStoryStatus.objects.filter(pk=value).exists(): - msg = _("There's no user story status with that id") - raise ValidationError(msg) - return attrs - - -class TaskStatusExistsValidator: - def validate_status_id(self, attrs, source): - value = attrs[source] - if not models.TaskStatus.objects.filter(pk=value).exists(): - msg = _("There's no task status with that id") - raise ValidationError(msg) - return attrs - - ###################################################### # Custom values for selectors ###################################################### From 890c668e7e364914427df06209737c82aacf7c5b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 4 Aug 2016 09:50:24 +0200 Subject: [PATCH 48/83] WIP: epics admin --- taiga/projects/epics/admin.py | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 taiga/projects/epics/admin.py diff --git a/taiga/projects/epics/admin.py b/taiga/projects/epics/admin.py new file mode 100644 index 00000000..af299f37 --- /dev/null +++ b/taiga/projects/epics/admin.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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.contrib import admin + +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline + +from . import models + + +class EpicAdmin(admin.ModelAdmin): + list_display = ["project", "ref", "subject"] + list_display_links = ["ref", "subject"] + inlines = [WatchedInline, VoteInline] + raw_id_fields = ["project"] + search_fields = ["subject", "description", "id", "ref"] + + def get_object(self, *args, **kwargs): + self.obj = super().get_object(*args, **kwargs) + return self.obj + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if (db_field.name in ["status"] and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related.model.objects.filter(project=self.obj.project) + + elif (db_field.name in ["owner", "assigned_to"] and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related.model.objects.filter(memberships__project=self.obj.project) + + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def formfield_for_manytomany(self, db_field, request, **kwargs): + if (db_field.name in ["watchers"] and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related.parent_model.objects.filter(memberships__project=self.obj.project) + return super().formfield_for_manytomany(db_field, request, **kwargs) + + +admin.site.register(models.Epic, EpicAdmin) From 32267af4f4acb7a80ed934e3d41ea0d93d740e88 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 4 Aug 2016 14:08:34 +0200 Subject: [PATCH 49/83] Refactoring epics API --- taiga/base/api/viewsets.py | 19 +++ taiga/base/routers.py | 53 ++++++- taiga/projects/epics/admin.py | 9 +- taiga/projects/epics/api.py | 131 +++++++++--------- taiga/projects/epics/models.py | 2 +- taiga/projects/epics/permissions.py | 14 +- taiga/projects/epics/serializers.py | 5 + taiga/projects/epics/validators.py | 11 +- taiga/routers.py | 8 +- .../test_epics_resources.py | 12 +- tests/integration/test_epics.py | 9 +- 11 files changed, 183 insertions(+), 90 deletions(-) diff --git a/taiga/base/api/viewsets.py b/taiga/base/api/viewsets.py index 95b09055..d37bfc50 100644 --- a/taiga/base/api/viewsets.py +++ b/taiga/base/api/viewsets.py @@ -134,6 +134,25 @@ class ViewSetMixin(object): return super().check_permissions(request, action=action, obj=obj) +class NestedViewSetMixin(object): + def get_queryset(self): + return self._filter_queryset_by_parents_lookups(super().get_queryset()) + + def _filter_queryset_by_parents_lookups(self, queryset): + parents_query_dict = self._get_parents_query_dict() + if parents_query_dict: + return queryset.filter(**parents_query_dict) + else: + return queryset + + def _get_parents_query_dict(self): + result = {} + for kwarg_name in self.kwargs: + query_value = self.kwargs.get(kwarg_name) + result[kwarg_name] = query_value + return result + + class ViewSet(ViewSetMixin, views.APIView): """ The base ViewSet class does not provide any actions by default. diff --git a/taiga/base/routers.py b/taiga/base/routers.py index 56b80f8e..a7ccbdc4 100644 --- a/taiga/base/routers.py +++ b/taiga/base/routers.py @@ -318,7 +318,58 @@ class DRFDefaultRouter(SimpleRouter): return urls -class DefaultRouter(DRFDefaultRouter): +class NestedRegistryItem(object): + def __init__(self, router, parent_prefix, parent_item=None): + self.router = router + self.parent_prefix = parent_prefix + self.parent_item = parent_item + + def register(self, prefix, viewset, base_name, parents_query_lookups): + self.router._register( + prefix=self.get_prefix(current_prefix=prefix, parents_query_lookups=parents_query_lookups), + viewset=viewset, + base_name=base_name, + ) + return NestedRegistryItem( + router=self.router, + parent_prefix=prefix, + parent_item=self + ) + + def get_prefix(self, current_prefix, parents_query_lookups): + return "{0}/{1}".format( + self.get_parent_prefix(parents_query_lookups), + current_prefix + ) + + def get_parent_prefix(self, parents_query_lookups): + prefix = "/" + current_item = self + i = len(parents_query_lookups) - 1 + while current_item: + prefix = "{parent_prefix}/(?P<{parent_pk_kwarg_name}>[^/.]+)/{prefix}".format( + parent_prefix=current_item.parent_prefix, + parent_pk_kwarg_name=parents_query_lookups[i], + prefix=prefix + ) + i -= 1 + current_item = current_item.parent_item + return prefix.strip("/") + + +class NestedRouterMixin: + def _register(self, *args, **kwargs): + return super().register(*args, **kwargs) + + def register(self, *args, **kwargs): + self._register(*args, **kwargs) + return NestedRegistryItem( + router=self, + parent_prefix=self.registry[-1][0] + ) + + +class DefaultRouter(NestedRouterMixin, DRFDefaultRouter): pass __all__ = ["DefaultRouter"] diff --git a/taiga/projects/epics/admin.py b/taiga/projects/epics/admin.py index af299f37..69aea806 100644 --- a/taiga/projects/epics/admin.py +++ b/taiga/projects/epics/admin.py @@ -24,10 +24,17 @@ from taiga.projects.votes.admin import VoteInline from . import models +class RelatedUserStoriesInline(admin.TabularInline): + model = models.RelatedUserStory + sortable_field_name = "order" + raw_id_fields = ["user_story", ] + extra = 0 + + class EpicAdmin(admin.ModelAdmin): list_display = ["project", "ref", "subject"] list_display_links = ["ref", "subject"] - inlines = [WatchedInline, VoteInline] + inlines = [WatchedInline, VoteInline, RelatedUserStoriesInline] raw_id_fields = ["project"] search_fields = ["subject", "description", "id", "ref"] diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index c14e7792..f98e4303 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -25,6 +25,7 @@ from taiga.base import exceptions as exc from taiga.base.decorators import list_route, detail_route from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api.mixins import BlockedByProjectMixin +from taiga.base.api.viewsets import NestedViewSetMixin from taiga.base.utils import json from taiga.projects.history.mixins import HistoryResourceMixin @@ -108,17 +109,16 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, super().pre_save(obj) - def _reorder_if_needed(self, obj, old_order_key, order_key, order_attr, project): + def _reorder_if_needed(self, obj, old_order_key, order_key): # Executes the extra ordering if there is a difference in the ordering keys if old_order_key != order_key: extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}")) - data = [{"epic_id": obj.id, "order": getattr(obj, order_attr)}] + data = [{"epic_id": obj.id, "order": getattr(obj, "epics_order")}] for id, order in extra_orders.items(): data.append({"epic_id": int(id), "order": order}) return services.update_epics_order_in_bulk(data, - field=order_attr, - project=project) + project=obj.project) return {} def post_save(self, obj, created=False): @@ -126,9 +126,7 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, # Let's reorder the related stuff after edit the element orders_updated = self._reorder_if_needed(obj, self._old_epics_order_key, - self._epics_order_key(obj), - "epics_order", - obj.project) + self._epics_order_key(obj)) self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated) super().post_save(obj, created) @@ -227,77 +225,78 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, return response.BadRequest(validator.errors) - @detail_route(methods=["POST"]) - def bulk_create_related_userstories(self, request, **kwargs): + +class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, ModelCrudViewSet): + queryset = models.RelatedUserStory.objects.all() + serializer_class = serializers.EpicRelatedUserStorySerializer + validator_class = validators.EpicRelatedUserStoryValidator + model = models.RelatedUserStory + permission_classes = (permissions.EpicRelatedUserStoryPermission,) + + """ + Updating the order attribute can affect the ordering of another userstories in the epic + This method generate a key for the userstory and can be used to be compared before and after + saving + If there is any difference it means an extra ordering update must be done + """ + def _order_key(self, obj): + return "{}-{}".format(obj.user_story.project_id, obj.order) + + def pre_save(self, obj): + if not obj.id: + obj.epic_id = self.kwargs["epic"] + else: + self._old_order_key = self._order_key(self.get_object()) + + super().pre_save(obj) + + def _reorder_if_needed(self, obj, old_order_key, order_key): + # Executes the extra ordering if there is a difference in the ordering keys + if old_order_key != order_key: + extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}")) + data = [{"us_id": obj.id, "order": getattr(obj, "order")}] + for id, order in extra_orders.items(): + data.append({"epic_id": int(id), "order": order}) + + return services.update_epic_related_userstories_order_in_bulk( + data, + epic=obj.epic + ) + + return {} + + def post_save(self, obj, created=False): + if not created: + # Let's reorder the related stuff after edit the element + orders_updated = self._reorder_if_needed(obj, + self._old_epics_order_key, + self._epics_order_key(obj)) + self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated) + + super().post_save(obj, created) + + @list_route(methods=["POST"]) + def bulk_create(self, request, **kwargs): validator = validators.CrateRelatedUserStoriesBulkValidator(data=request.DATA) if validator.is_valid(): data = validator.data - obj = self.get_object() - project = obj.project - self.check_permissions(request, 'bulk_create_userstories', project) + + epic = get_object_or_404(models.Epic, id=kwargs["epic"]) + project = epic.project + + self.check_permissions(request, 'bulk_create', project) if project.blocked_code is not None: raise exc.Blocked(_("Blocked element")) services.create_related_userstories_in_bulk( data["userstories"], - obj, + epic, project=project, owner=request.user ) - obj = self.get_queryset().get(id=obj.id) - epic_serialized = self.get_serializer_class()(obj) - return response.Ok(epic_serialized.data) - return response.BadRequest(validator.errors) - - @detail_route(methods=["POST"]) - def set_related_userstory(self, request, **kwargs): - validator = validators.SetRelatedUserStoryValidator(data=request.DATA) - if validator.is_valid(): - data = validator.data - epic = self.get_object() - project = epic.project - user_story = UserStory.objects.get(id=data["us_id"]) - self.check_permissions(request, "update", epic) - self.check_permissions(request, "select_related_userstory", user_story.project) - - if project.blocked_code is not None: - raise exc.Blocked(_("Blocked element")) - - obj, created = models.RelatedUserStory.objects.update_or_create( - epic=epic, - user_story=user_story, - defaults={ - "order": data["order"] - }) - epic = self.get_queryset().get(id=epic.id) - epic_serialized = self.get_serializer_class()(epic) - return response.Ok(epic_serialized.data) - - return response.BadRequest(validator.errors) - - @detail_route(methods=["POST"]) - def unset_related_userstory(self, request, **kwargs): - validator = validators.UnsetRelatedUserStoryValidator(data=request.DATA) - if validator.is_valid(): - data = validator.data - epic = self.get_object() - project = epic.project - user_story = UserStory.objects.get(id=data["us_id"]) - self.check_permissions(request, "update", epic) - - if project.blocked_code is not None: - raise exc.Blocked(_("Blocked element")) - - related_us = get_object_or_404( - models.RelatedUserStory, - epic=epic, - user_story=user_story - ) - related_us.delete() - epic = self.get_queryset().get(id=epic.id) - epic_serialized = self.get_serializer_class()(epic) - return response.Ok(epic_serialized.data) + related_uss_serialized = self.get_serializer_class()(epic.relateduserstory_set.all(), many=True) + return response.Ok(related_uss_serialized.data) return response.BadRequest(validator.errors) diff --git a/taiga/projects/epics/models.py b/taiga/projects/epics/models.py index eb18c6c4..de501ffc 100644 --- a/taiga/projects/epics/models.py +++ b/taiga/projects/epics/models.py @@ -105,4 +105,4 @@ class RelatedUserStory(models.Model): ordering = ["user_story", "order", "id"] def __str__(self): - return "{0} - {1}".format(self.epic, self.user_story) + return "{0} - {1}".format(self.epic_id, self.user_story_id) diff --git a/taiga/projects/epics/permissions.py b/taiga/projects/epics/permissions.py index 3cd31b07..fd473e18 100644 --- a/taiga/projects/epics/permissions.py +++ b/taiga/projects/epics/permissions.py @@ -34,14 +34,24 @@ class EpicPermission(TaigaResourcePermission): filters_data_perms = AllowAny() csv_perms = AllowAny() bulk_create_perms = HasProjectPerm('add_epic') - bulk_create_userstories_perms = HasProjectPerm('modify_epic') & (HasProjectPerm('add_us_to_project') | HasProjectPerm('add_us')) - select_related_userstory_perms = HasProjectPerm('view_us') upvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') downvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') watch_perms = IsAuthenticated() & HasProjectPerm('view_epics') unwatch_perms = IsAuthenticated() & HasProjectPerm('view_epics') +class EpicRelatedUserStoryPermission(TaigaResourcePermission): + enought_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_epics') + create_perms = HasProjectPerm('modify_epic') + update_perms = HasProjectPerm('modify_epic') + partial_update_perms = HasProjectPerm('modify_epic') + destroy_perms = HasProjectPerm('modify_epic') + list_perms = AllowAny() + bulk_create_perms = HasProjectPerm('modify_epic') + + class EpicVotersPermission(TaigaResourcePermission): enought_perms = IsProjectAdmin() | IsSuperUser() global_perms = None diff --git a/taiga/projects/epics/serializers.py b/taiga/projects/epics/serializers.py index 9b845bbe..29a94348 100644 --- a/taiga/projects/epics/serializers.py +++ b/taiga/projects/epics/serializers.py @@ -78,3 +78,8 @@ class EpicSerializer(EpicListSerializer): class EpicNeighborsSerializer(NeighborsSerializerMixin, EpicSerializer): pass + + +class EpicRelatedUserStorySerializer(serializers.LightSerializer): + user_story = Field(attr="user_story_id") + order = Field() diff --git a/taiga/projects/epics/validators.py b/taiga/projects/epics/validators.py index 975f1e4d..88214aa6 100644 --- a/taiga/projects/epics/validators.py +++ b/taiga/projects/epics/validators.py @@ -60,10 +60,7 @@ class CrateRelatedUserStoriesBulkValidator(ProjectExistsValidator, EpicExistsVal userstories = serializers.CharField() -class SetRelatedUserStoryValidator(UserStoryExistsValidator, validators.Validator): - us_id = serializers.IntegerField() - order = serializers.IntegerField(required=False, default=10000) - - -class UnsetRelatedUserStoryValidator(UserStoryExistsValidator, validators.Validator): - us_id = serializers.IntegerField() +class EpicRelatedUserStoryValidator(validators.ModelValidator): + class Meta: + model = models.RelatedUserStory + read_only_fields = ('id', 'epic', 'user_story') diff --git a/taiga/routers.py b/taiga/routers.py index e0d0baa1..92f2d58c 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -146,6 +146,7 @@ from taiga.projects.milestones.api import MilestoneViewSet from taiga.projects.milestones.api import MilestoneWatchersViewSet from taiga.projects.epics.api import EpicViewSet +from taiga.projects.epics.api import EpicRelatedUserStoryViewSet from taiga.projects.epics.api import EpicVotersViewSet from taiga.projects.epics.api import EpicWatchersViewSet @@ -170,8 +171,11 @@ router.register(r"milestones", MilestoneViewSet, router.register(r"milestones/(?P\d+)/watchers", MilestoneWatchersViewSet, base_name="milestone-watchers") -router.register(r"epics", EpicViewSet, - base_name="epics") +router.register(r"epics", EpicViewSet, base_name="epics")\ + .register(r"related_userstories", EpicRelatedUserStoryViewSet, + base_name="epics-related-userstories", + parents_query_lookups=["epic"]) + router.register(r"epics/(?P\d+)/voters", EpicVotersViewSet, base_name="epic-voters") router.register(r"epics/(?P\d+)/watchers", EpicWatchersViewSet, diff --git a/tests/integration/resources_permissions/test_epics_resources.py b/tests/integration/resources_permissions/test_epics_resources.py index 89b56bb5..dff20c09 100644 --- a/tests/integration/resources_permissions/test_epics_resources.py +++ b/tests/integration/resources_permissions/test_epics_resources.py @@ -676,10 +676,10 @@ def test_epic_action_bulk_create(client, data): def test_bulk_create_related_userstories(client, data): - public_url = reverse('epics-bulk-create-related-userstories', kwargs={"pk": data.public_epic.pk}) - private_url1 = reverse('epics-bulk-create-related-userstories', kwargs={"pk": data.private_epic1.pk}) - private_url2 = reverse('epics-bulk-create-related-userstories', kwargs={"pk": data.private_epic2.pk}) - blocked_url = reverse('epics-bulk-create-related-userstories', kwargs={"pk": data.blocked_epic.pk}) + public_url = reverse('epics-related-userstories-bulk-create', args=[data.public_epic.pk]) + private_url1 = reverse('epics-related-userstories-bulk-create', args=[data.private_epic1.pk]) + private_url2 = reverse('epics-related-userstories-bulk-create', args=[data.private_epic2.pk]) + blocked_url = reverse('epics-related-userstories-bulk-create', args=[data.blocked_epic.pk]) users = [ None, @@ -698,9 +698,9 @@ def test_bulk_create_related_userstories(client, data): results = helper_test_http_method(client, 'post', private_url1, bulk_data, users) assert results == [401, 403, 403, 200, 200] results = helper_test_http_method(client, 'post', private_url2, bulk_data, users) - assert results == [404, 404, 404, 200, 200] + assert results == [401, 403, 403, 200, 200] results = helper_test_http_method(client, 'post', blocked_url, bulk_data, users) - assert results == [404, 404, 404, 451, 451] + assert results == [401, 403, 403, 451, 451] def test_set_related_user_story(client, data): diff --git a/tests/integration/test_epics.py b/tests/integration/test_epics.py index dd15a28d..01f1e483 100644 --- a/tests/integration/test_epics.py +++ b/tests/integration/test_epics.py @@ -98,7 +98,7 @@ def test_bulk_create_related_userstories(client): epic = f.EpicFactory.create(project=project) f.MembershipFactory.create(project=project, user=user, is_admin=True) - url = reverse('epics-bulk-create-related-userstories', kwargs={"pk": epic.pk}) + url = reverse('epics-related-userstories-bulk-create', args=[epic.pk]) data = { "userstories": "test1\ntest2" @@ -106,7 +106,7 @@ def test_bulk_create_related_userstories(client): client.login(user) response = client.json.post(url, json.dumps(data)) assert response.status_code == 200 - assert response.data['user_stories_counts'] == {'opened': 2, 'closed': 0} + assert len(response.data) == 2 def test_set_related_userstory(client): @@ -116,13 +116,14 @@ def test_set_related_userstory(client): f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) f.MembershipFactory.create(project=us.project, user=user, is_admin=True) - url = reverse('epics-set-related-userstory', kwargs={"pk": epic.pk}) + url = reverse('epics-related-userstories-list', args=[epic.pk]) data = { - "us_id": us.id + "user_story": us.id } client.login(user) response = client.json.post(url, json.dumps(data)) + print(response.data) assert response.status_code == 200 assert response.data['user_stories_counts'] == {'opened': 1, 'closed': 0} From e83e4b8beb7c2d2306fbf9c3e951bb46b903afa6 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 4 Aug 2016 14:33:28 +0200 Subject: [PATCH 50/83] Refactoring epics API --- taiga/projects/epics/api.py | 1 + taiga/projects/epics/models.py | 4 ++++ tests/integration/test_epics.py | 16 +++++----------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index f98e4303..242b3ebe 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -232,6 +232,7 @@ class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, Mod validator_class = validators.EpicRelatedUserStoryValidator model = models.RelatedUserStory permission_classes = (permissions.EpicRelatedUserStoryPermission,) + lookup_field = "user_story_id" """ Updating the order attribute can affect the ordering of another userstories in the epic diff --git a/taiga/projects/epics/models.py b/taiga/projects/epics/models.py index de501ffc..3d1a2ce4 100644 --- a/taiga/projects/epics/models.py +++ b/taiga/projects/epics/models.py @@ -106,3 +106,7 @@ class RelatedUserStory(models.Model): def __str__(self): return "{0} - {1}".format(self.epic_id, self.user_story_id) + + @property + def project(self): + return self.epic.project diff --git a/tests/integration/test_epics.py b/tests/integration/test_epics.py index 01f1e483..5d4657df 100644 --- a/tests/integration/test_epics.py +++ b/tests/integration/test_epics.py @@ -136,10 +136,10 @@ def test_set_related_userstory_existing(client): f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) f.MembershipFactory.create(project=us.project, user=user, is_admin=True) - url = reverse('epics-set-related-userstory', kwargs={"pk": epic.pk}) + url = reverse('epics-related-userstories-list', args=[epic.pk]) data = { - "us_id": us.id, + "user_story": us.id, "order": 77 } client.login(user) @@ -158,15 +158,9 @@ def test_unset_related_userstory(client): related_us = f.RelatedUserStory.create(epic=epic, user_story=us, order=55) f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) - url = reverse('epics-unset-related-userstory', kwargs={"pk": epic.pk}) + url = reverse('epics-related-userstories-detail', args=[epic.pk, us.pk]) - data = { - "us_id": us.id - } client.login(user) - response = client.json.post(url, json.dumps(data)) - print(response.data) - assert response.status_code == 200 - assert response.data['user_stories_counts'] == {'opened': 0, 'closed': 0} - + response = client.delete(url) + assert response.status_code == 204 assert not models.RelatedUserStory.objects.filter(id=related_us.id).exists() From 4c6f49aaabfa507739a66ea053a22f83fdfce605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 4 Aug 2016 17:13:30 +0200 Subject: [PATCH 51/83] Improve more validators --- taiga/projects/tasks/api.py | 6 +- taiga/projects/tasks/validators.py | 81 +++++++--- taiga/projects/userstories/validators.py | 39 ++--- tests/integration/test_tasks.py | 197 +++++++++++++++++++---- 4 files changed, 254 insertions(+), 69 deletions(-) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index bc76f24f..9584a17b 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -179,8 +179,8 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, self.check_permissions(request, "destroy", self.object) self.check_permissions(request, "create", new_project) - sprint_id = request.DATA.get('milestone', None) - if sprint_id is not None and new_project.milestones.filter(pk=sprint_id).count() == 0: + milestone_id = request.DATA.get('milestone', None) + if milestone_id is not None and new_project.milestones.filter(pk=milestone_id).count() == 0: request.DATA['milestone'] = None us_id = request.DATA.get('user_story', None) @@ -259,7 +259,7 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, raise exc.Blocked(_("Blocked element")) tasks = services.create_tasks_in_bulk( - data["bulk_tasks"], milestone_id=data["sprint_id"], user_story_id=data["us_id"], + data["bulk_tasks"], milestone_id=data["milestone_id"], user_story_id=data["us_id"], status_id=data.get("status_id") or project.default_task_status_id, project=project, owner=request.user, callback=self.post_save, precall=self.pre_save) diff --git a/taiga/projects/tasks/validators.py b/taiga/projects/tasks/validators.py index 033b72e3..b9061bde 100644 --- a/taiga/projects/tasks/validators.py +++ b/taiga/projects/tasks/validators.py @@ -30,7 +30,6 @@ from taiga.projects.tagging.fields import TagsAndTagsColorsField from taiga.projects.userstories.models import UserStory from taiga.projects.validators import ProjectExistsValidator - from . import models @@ -45,23 +44,27 @@ class TaskValidator(WatchersValidator, EditableWatchedResourceSerializer, valida class TasksBulkValidator(ProjectExistsValidator, validators.Validator): project_id = serializers.IntegerField() - sprint_id = serializers.IntegerField() + milestone_id = serializers.IntegerField() status_id = serializers.IntegerField(required=False) us_id = serializers.IntegerField(required=False) bulk_tasks = serializers.CharField() - def validate_sprint_id(self, attrs, source): - filters = {"project__id": attrs["project_id"]} - filters["id"] = attrs["sprint_id"] + def validate_milestone_id(self, attrs, source): + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } if not Milestone.objects.filter(**filters).exists(): - raise ValidationError(_("Invalid sprint id.")) + raise ValidationError(_("Invalid milestone id.")) return attrs def validate_status_id(self, attrs, source): - filters = {"project__id": attrs["project_id"]} - filters["id"] = attrs["status_id"] + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } if not TaskStatus.objects.filter(**filters).exists(): raise ValidationError(_("Invalid task status id.")) @@ -71,13 +74,13 @@ class TasksBulkValidator(ProjectExistsValidator, validators.Validator): def validate_us_id(self, attrs, source): filters = {"project__id": attrs["project_id"]} - if "sprint_id" in attrs: - filters["milestone__id"] = attrs["sprint_id"] + if "milestone_id" in attrs: + filters["milestone__id"] = attrs["milestone_id"] filters["id"] = attrs["us_id"] if not UserStory.objects.filter(**filters).exists(): - raise ValidationError(_("Invalid sprint id.")) + raise ValidationError(_("Invalid user story id.")) return attrs @@ -96,19 +99,55 @@ class UpdateTasksOrderBulkValidator(ProjectExistsValidator, validators.Validator milestone_id = serializers.IntegerField(required=False) bulk_tasks = _TaskOrderBulkValidator(many=True) - def validate(self, data): - filters = {"project__id": data["project_id"]} - if "status_id" in data: - filters["status__id"] = data["status_id"] - if "us_id" in data: - filters["user_story__id"] = data["us_id"] - if "milestone_id" in data: - filters["milestone__id"] = data["milestone_id"] + def validate_status_id(self, attrs, source): + filters = {"project__id": attrs["project_id"]} + filters["id"] = attrs[source] - filters["id__in"] = [t["task_id"] for t in data["bulk_tasks"]] + if not TaskStatus.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid task status id. The status must belong to " + "the same project.")) + + return attrs + + def validate_us_id(self, attrs, source): + filters = {"project__id": attrs["project_id"]} + + if "milestone_id" in attrs: + filters["milestone__id"] = attrs["milestone_id"] + + filters["id"] = attrs[source] + + if not UserStory.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story id. The user story must belong to " + "the same project.")) + + return attrs + + def validate_milestone_id(self, attrs, source): + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } + + if not Milestone.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid milestone id. The milestone must belong to " + "the same project.")) + + return attrs + + def validate_bulk_tasks(self, attrs, source): + filters = {"project__id": attrs["project_id"]} + if "status_id" in attrs: + filters["status__id"] = attrs["status_id"] + if "us_id" in attrs: + filters["user_story__id"] = attrs["us_id"] + if "milestone_id" in attrs: + filters["milestone__id"] = attrs["milestone_id"] + + filters["id__in"] = [t["task_id"] for t in attrs[source]] if models.Task.objects.filter(**filters).count() != len(filters["id__in"]): raise ValidationError(_("Invalid task ids. All tasks must belong to the same project and, " "if it exists, to the same status, user story and/or milestone.")) - return data + return attrs diff --git a/taiga/projects/userstories/validators.py b/taiga/projects/userstories/validators.py index bc52db1a..e20a704e 100644 --- a/taiga/projects/userstories/validators.py +++ b/taiga/projects/userstories/validators.py @@ -73,13 +73,14 @@ class UserStoriesBulkValidator(ProjectExistsValidator, validators.Validator): bulk_stories = serializers.CharField() def validate_status_id(self, attrs, source): - filters = {"project__id": attrs["project_id"]} - if source in attrs: - filters["id"] = attrs[source] + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } - if not UserStoryStatus.objects.filter(**filters).exists(): - raise ValidationError(_("Invalid user story status id. The status must belong to " - "the same project.")) + if not UserStoryStatus.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story status id. The status must belong to " + "the same project.")) return attrs @@ -98,24 +99,26 @@ class UpdateUserStoriesOrderBulkValidator(ProjectExistsValidator, validators.Val bulk_stories = _UserStoryOrderBulkValidator(many=True) def validate_status_id(self, attrs, source): - filters = {"project__id": attrs["project_id"]} - if source in attrs: - filters["id"] = attrs[source] + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } - if not UserStoryStatus.objects.filter(**filters).exists(): - raise ValidationError(_("Invalid user story status id. The status must belong " - "to the same project.")) + if not UserStoryStatus.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story status id. The status must belong " + "to the same project.")) return attrs def validate_milestone_id(self, attrs, source): - filters = {"project__id": attrs["project_id"]} - if source in attrs: - filters["id"] = attrs[source] + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } - if not Milestone.objects.filter(**filters).exists(): - raise ValidationError(_("Invalid milestone id. The milistone must belong to the " - "same project.")) + if not Milestone.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid milestone id. The milistone must belong to the " + "same project.")) return attrs diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py index c9fd4d01..04546233 100644 --- a/tests/integration/test_tasks.py +++ b/tests/integration/test_tasks.py @@ -104,7 +104,7 @@ def test_api_create_in_bulk_with_status_milestone_userstory(client): "bulk_tasks": "Story #1\nStory #2", "us_id": us.id, "project_id": us.project.id, - "sprint_id": us.milestone.id, + "milestone_id": us.milestone.id, "status_id": us.project.default_task_status.id } @@ -129,7 +129,7 @@ def test_api_create_in_bulk_with_status_milestone(client): data = { "bulk_tasks": "Story #1\nStory #2", "project_id": us.project.id, - "sprint_id": us.milestone.id, + "milestone_id": us.milestone.id, "status_id": us.project.default_task_status.id } @@ -158,7 +158,7 @@ def test_api_create_in_bulk_with_invalid_status(client): "bulk_tasks": "Story #1\nStory #2", "us_id": us.id, "project_id": project.id, - "sprint_id": milestone.id, + "milestone_id": milestone.id, "status_id": status.id } @@ -166,6 +166,7 @@ def test_api_create_in_bulk_with_invalid_status(client): response = client.json.post(url, json.dumps(data)) assert response.status_code == 400 + assert "status_id" in response.data def test_api_create_in_bulk_with_invalid_milestone(client): @@ -183,7 +184,7 @@ def test_api_create_in_bulk_with_invalid_milestone(client): "bulk_tasks": "Story #1\nStory #2", "us_id": us.id, "project_id": project.id, - "sprint_id": milestone.id, + "milestone_id": milestone.id, "status_id": project.default_task_status.id } @@ -191,6 +192,7 @@ def test_api_create_in_bulk_with_invalid_milestone(client): response = client.json.post(url, json.dumps(data)) assert response.status_code == 400 + assert "milestone_id" in response.data def test_api_create_in_bulk_with_invalid_userstory_1(client): @@ -208,7 +210,7 @@ def test_api_create_in_bulk_with_invalid_userstory_1(client): "bulk_tasks": "Story #1\nStory #2", "us_id": us.id, "project_id": project.id, - "sprint_id": milestone.id, + "milestone_id": milestone.id, "status_id": project.default_task_status.id } @@ -216,6 +218,7 @@ def test_api_create_in_bulk_with_invalid_userstory_1(client): response = client.json.post(url, json.dumps(data)) assert response.status_code == 400 + assert "us_id" in response.data def test_api_create_in_bulk_with_invalid_userstory_2(client): @@ -233,7 +236,7 @@ def test_api_create_in_bulk_with_invalid_userstory_2(client): "bulk_tasks": "Story #1\nStory #2", "us_id": us.id, "project_id": us.project.id, - "sprint_id": milestone.id, + "milestone_id": milestone.id, "status_id": us.project.default_task_status.id } @@ -241,6 +244,7 @@ def test_api_create_in_bulk_with_invalid_userstory_2(client): response = client.json.post(url, json.dumps(data)) assert response.status_code == 400 + assert "us_id" in response.data def test_api_create_invalid_task(client): @@ -309,14 +313,17 @@ def test_api_update_order_in_bulk_invalid_tasks(client): client.login(project.owner) - response1 = client.json.post(url1, json.dumps(data)) - response2 = client.json.post(url2, json.dumps(data)) + response = client.json.post(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data - assert response1.status_code == 400, response1.data - assert response2.status_code == 400, response2.data + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data -def test_api_update_order_in_bulk_invalid_status(client): + +def test_api_update_order_in_bulk_invalid_tasks_for_status(client): project = f.create_project() f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) task1 = f.create_task(project=project) @@ -336,14 +343,16 @@ def test_api_update_order_in_bulk_invalid_status(client): client.login(project.owner) - response1 = client.json.post(url1, json.dumps(data)) - response2 = client.json.post(url2, json.dumps(data)) + response = client.json.post(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data - assert response1.status_code == 400, response1.data - assert response2.status_code == 400, response2.data + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data -def test_api_update_order_in_bulk_invalid_milestone(client): +def test_api_update_order_in_bulk_invalid_tasks_for_milestone(client): project = f.create_project() f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) mil1 = f.MilestoneFactory.create(project=project) @@ -364,19 +373,21 @@ def test_api_update_order_in_bulk_invalid_milestone(client): client.login(project.owner) - response1 = client.json.post(url1, json.dumps(data)) - response2 = client.json.post(url2, json.dumps(data)) + response = client.json.post(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data - assert response1.status_code == 400, response1.data - assert response2.status_code == 400, response2.data + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data -def test_api_update_order_in_bulk_invalid_user_story(client): +def test_api_update_order_in_bulk_invalid_tasks_for_user_story(client): project = f.create_project() f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) us1 = f.create_userstory(project=project) - task1 = f.create_task(project=project, user_story=us1) - task2 = f.create_task(project=project, user_story=us1) + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) task3 = f.create_task(project=project) url1 = reverse("tasks-bulk-update-taskboard-order") @@ -392,11 +403,143 @@ def test_api_update_order_in_bulk_invalid_user_story(client): client.login(project.owner) - response1 = client.json.post(url1, json.dumps(data)) - response2 = client.json.post(url2, json.dumps(data)) + response = client.json.post(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data - assert response1.status_code == 400, response1.data - assert response2.status_code == 400, response2.data + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data + + +def test_api_update_order_in_bulk_invalid_status(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.TaskStatusFactory.create() + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) + task3 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "bulk_tasks": [{"task_id": task1.id, "order": 1}, + {"task_id": task2.id, "order": 2}, + {"task_id": task3.id, "order": 3}] + } + + client.login(project.owner) + + response = client.json.post(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "status_id" in response.data + assert "bulk_tasks" in response.data + + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "status_id" in response.data + assert "bulk_tasks" in response.data + + +def test_api_update_order_in_bulk_invalid_milestone(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + mil1 = f.MilestoneFactory.create() + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) + task3 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "milestone_id": mil1.id, + "bulk_tasks": [{"task_id": task1.id, "order": 1}, + {"task_id": task2.id, "order": 2}, + {"task_id": task3.id, "order": 3}] + } + + client.login(project.owner) + + response = client.json.post(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "milestone_id" in response.data + assert "bulk_tasks" in response.data + + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "milestone_id" in response.data + assert "bulk_tasks" in response.data + + +def test_api_update_order_in_bulk_invalid_user_story_1(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us1 = f.create_userstory() + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) + task3 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "us_id": us1.id, + "bulk_tasks": [{"task_id": task1.id, "order": 1}, + {"task_id": task2.id, "order": 2}, + {"task_id": task3.id, "order": 3}] + } + + client.login(project.owner) + + response = client.json.post(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "us_id" in response.data + assert "bulk_tasks" in response.data + + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "us_id" in response.data + assert "bulk_tasks" in response.data + + +def test_api_update_order_in_bulk_invalid_user_story_2(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + milestone = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project) + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) + task3 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "us_id": us1.id, + "milestone_id": milestone.id, + "bulk_tasks": [{"task_id": task1.id, "order": 1}, + {"task_id": task2.id, "order": 2}, + {"task_id": task3.id, "order": 3}] + } + + client.login(project.owner) + + response = client.json.post(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "us_id" in response.data + assert "bulk_tasks" in response.data + + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "us_id" in response.data + assert "bulk_tasks" in response.data def test_get_invalid_csv(client): From 7611785c3b0407d38c36033a95cd67305afc2890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 16 Aug 2016 17:45:31 +0200 Subject: [PATCH 52/83] Add user_story_extra_info to task serializer --- taiga/projects/tasks/api.py | 1 + taiga/projects/tasks/serializers.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index 9584a17b..5f9a06ae 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -79,6 +79,7 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, def get_queryset(self): qs = super().get_queryset() qs = qs.select_related("milestone", + "user_story", "project", "status", "owner", diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py index b7232cf8..358b88ee 100644 --- a/taiga/projects/tasks/serializers.py +++ b/taiga/projects/tasks/serializers.py @@ -29,6 +29,17 @@ from taiga.projects.notifications.mixins import WatchedResourceSerializer from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin +class UserStoryExtraInfoSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + + def to_value(self, instance): + if instance is None: + return None + + return super().to_value(instance) + class TaskListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, @@ -54,6 +65,7 @@ class TaskListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, is_blocked = Field() blocked_note = Field() is_closed = MethodField() + user_story_extra_info = UserStoryExtraInfoSerializer(attr="user_story") def get_milestone_slug(self, obj): return obj.milestone.slug if obj.milestone else None From e03de182f3a3e077a19b2421b8a437b17c9d84e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 16 Aug 2016 17:46:23 +0200 Subject: [PATCH 53/83] Fix an entrophy ploblem with custom attributes for epics in sample data --- taiga/projects/management/commands/sample_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index b659096a..9a4d2b22 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -521,7 +521,7 @@ class Command(BaseCommand): epic.save() custom_attributes_values = {str(ca.id): self.get_custom_attributes_value(ca.type) for ca - in project.epiccustomattributes.all() if self.sd.boolean()} + in project.epiccustomattributes.all().order_by("id") if self.sd.boolean()} if custom_attributes_values: epic.custom_attributes_values.attributes_values = custom_attributes_values epic.custom_attributes_values.save() From e68079b76911eb4fc1cb29947370da276518eec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 17 Aug 2016 12:00:00 +0200 Subject: [PATCH 54/83] Use new tags mixing in EpicSerializer --- taiga/projects/epics/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/taiga/projects/epics/serializers.py b/taiga/projects/epics/serializers.py index 29a94348..b02238cd 100644 --- a/taiga/projects/epics/serializers.py +++ b/taiga/projects/epics/serializers.py @@ -26,13 +26,14 @@ from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin from taiga.projects.notifications.mixins import WatchedResourceSerializer +from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin class EpicListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, StatusExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin, - serializers.LightSerializer): + TaggedInProjectResourceSerializer, serializers.LightSerializer): id = Field() ref = Field() @@ -48,7 +49,6 @@ class EpicListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, watchers = Field() is_blocked = Field() blocked_note = Field() - tags = Field() is_closed = MethodField() user_stories_counts = MethodField() From 94ea299c393c404662559fe6fb62df0cd2600fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 17 Aug 2016 14:21:07 +0200 Subject: [PATCH 55/83] Add epics info in user_story_extra_info in task serializer --- taiga/projects/tasks/api.py | 1 - taiga/projects/tasks/serializers.py | 14 +---------- taiga/projects/tasks/utils.py | 39 ++++++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index 5f9a06ae..9584a17b 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -79,7 +79,6 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, def get_queryset(self): qs = super().get_queryset() qs = qs.select_related("milestone", - "user_story", "project", "status", "owner", diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py index 358b88ee..f0621581 100644 --- a/taiga/projects/tasks/serializers.py +++ b/taiga/projects/tasks/serializers.py @@ -29,18 +29,6 @@ from taiga.projects.notifications.mixins import WatchedResourceSerializer from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin -class UserStoryExtraInfoSerializer(serializers.LightSerializer): - id = Field() - ref = Field() - subject = Field() - - def to_value(self, instance): - if instance is None: - return None - - return super().to_value(instance) - - class TaskListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, StatusExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin, @@ -65,7 +53,7 @@ class TaskListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, is_blocked = Field() blocked_note = Field() is_closed = MethodField() - user_story_extra_info = UserStoryExtraInfoSerializer(attr="user_story") + user_story_extra_info = Field() def get_milestone_slug(self, obj): return obj.milestone.slug if obj.milestone else None diff --git a/taiga/projects/tasks/utils.py b/taiga/projects/tasks/utils.py index d10dddab..d5775d19 100644 --- a/taiga/projects/tasks/utils.py +++ b/taiga/projects/tasks/utils.py @@ -25,8 +25,44 @@ from taiga.projects.votes.utils import attach_total_voters_to_queryset from taiga.projects.votes.utils import attach_is_voter_to_queryset -def attach_extra_info(queryset, user=None, include_attachments=False): +def attach_user_story_extra_info(queryset, as_field="user_story_extra_info"): + """Attach userstory extra info as json column to each object of the queryset. + :param queryset: A Django user stories queryset object. + :param as_field: Attach the userstory extra info as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + + model = queryset.model + sql = """SELECT row_to_json(u) + FROM (SELECT "userstories_userstory"."id" AS "id", + "userstories_userstory"."ref" AS "ref", + "userstories_userstory"."subject" AS "subject", + (SELECT json_agg(row_to_json(t)) + FROM (SELECT "epics_epic"."id" AS "id", + "epics_epic"."ref" AS "ref", + "epics_epic"."subject" AS "subject", + "epics_epic"."color" AS "color", + json_build_object('id', "projects_project"."id", + 'name', "projects_project"."name", + 'slug', "projects_project"."slug") AS "project" + FROM "epics_relateduserstory" + INNER JOIN "epics_epic" + ON "epics_epic"."id" = "epics_relateduserstory"."epic_id" + INNER JOIN "projects_project" + ON "projects_project"."id" = "epics_epic"."project_id" + WHERE "epics_relateduserstory"."user_story_id" = "{tbl}"."user_story_id" + ORDER BY "projects_project"."name", "epics_epic"."ref") t) AS "epics" + FROM "userstories_userstory" + WHERE "userstories_userstory"."id" = "{tbl}"."user_story_id") u""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_extra_info(queryset, user=None, include_attachments=False): if include_attachments: queryset = attach_basic_attachments(queryset) queryset = queryset.extra(select={"include_attachments": "True"}) @@ -36,4 +72,5 @@ def attach_extra_info(queryset, user=None, include_attachments=False): queryset = attach_total_watchers_to_queryset(queryset) queryset = attach_is_voter_to_queryset(queryset, user) queryset = attach_is_watcher_to_queryset(queryset, user) + queryset = attach_user_story_extra_info(queryset) return queryset From f7c74fc63e52369361025e6767db4e7e2837ac2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 17 Aug 2016 15:40:52 +0200 Subject: [PATCH 56/83] Fix some tests --- taiga/projects/epics/api.py | 1 + .../test_epics_resources.py | 79 ------------------- .../test_projects_choices_resources.py | 4 +- 3 files changed, 3 insertions(+), 81 deletions(-) diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index 242b3ebe..cf7bed6e 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -118,6 +118,7 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, data.append({"epic_id": int(id), "order": order}) return services.update_epics_order_in_bulk(data, + "epics_order", project=obj.project) return {} diff --git a/tests/integration/resources_permissions/test_epics_resources.py b/tests/integration/resources_permissions/test_epics_resources.py index dff20c09..9183df1c 100644 --- a/tests/integration/resources_permissions/test_epics_resources.py +++ b/tests/integration/resources_permissions/test_epics_resources.py @@ -703,85 +703,6 @@ def test_bulk_create_related_userstories(client, data): assert results == [401, 403, 403, 451, 451] -def test_set_related_user_story(client, data): - users = [ - None, - data.registered_user, - data.project_member_without_perms, - data.project_member_with_perms, - data.project_owner - ] - - url = reverse('epics-set-related-userstory', kwargs={"pk": data.public_epic.pk}) - edit_data = json.dumps({ - "us_id": data.public_us.pk, - "order": 33, - }) - results = helper_test_http_method(client, 'post', url, edit_data, users) - assert results == [401, 403, 403, 200, 200] - - url = reverse('epics-set-related-userstory', kwargs={"pk": data.private_epic1.pk}) - edit_data = json.dumps({ - "us_id": data.private_us1.pk, - "order": 33, - }) - results = helper_test_http_method(client, 'post', url, edit_data, users) - assert results == [401, 403, 403, 200, 200] - - url = reverse('epics-set-related-userstory', kwargs={"pk": data.private_epic2.pk}) - edit_data = json.dumps({ - "us_id": data.private_us2.pk, - "order": 33, - }) - results = helper_test_http_method(client, 'post', url, edit_data, users) - assert results == [404, 404, 404, 200, 200] - - url = reverse('epics-set-related-userstory', kwargs={"pk": data.blocked_epic.pk}) - edit_data = json.dumps({ - "us_id": data.blocked_us.pk, - "order": 33, - }) - results = helper_test_http_method(client, 'post', url, edit_data, users) - assert results == [404, 404, 404, 451, 451] - - -def test_unset_related_user_story(client, data): - users = [ - None, - data.registered_user, - data.project_member_without_perms, - data.project_member_with_perms - ] - - url = reverse('epics-unset-related-userstory', kwargs={"pk": data.public_epic.pk}) - edit_data = json.dumps({ - "us_id": data.public_related_us.user_story.pk, - }) - results = helper_test_http_method(client, 'post', url, edit_data, users) - assert results == [401, 403, 403, 200] - - url = reverse('epics-set-related-userstory', kwargs={"pk": data.private_epic1.pk}) - edit_data = json.dumps({ - "us_id": data.private_related_us1.user_story.pk, - }) - results = helper_test_http_method(client, 'post', url, edit_data, users) - assert results == [401, 403, 403, 200] - - url = reverse('epics-set-related-userstory', kwargs={"pk": data.private_epic2.pk}) - edit_data = json.dumps({ - "us_id": data.private_related_us2.user_story.pk, - }) - results = helper_test_http_method(client, 'post', url, edit_data, users) - assert results == [404, 404, 404, 200] - - url = reverse('epics-set-related-userstory', kwargs={"pk": data.blocked_epic.pk}) - edit_data = json.dumps({ - "us_id": data.blocked_related_us.user_story.pk, - }) - results = helper_test_http_method(client, 'post', url, edit_data, users) - assert results == [404, 404, 404, 451] - - def test_epic_action_upvote(client, data): public_url = reverse('epics-upvote', kwargs={"pk": data.public_epic.pk}) private_url1 = reverse('epics-upvote', kwargs={"pk": data.private_epic1.pk}) diff --git a/tests/integration/resources_permissions/test_projects_choices_resources.py b/tests/integration/resources_permissions/test_projects_choices_resources.py index 28e1f483..504b6a6f 100644 --- a/tests/integration/resources_permissions/test_projects_choices_resources.py +++ b/tests/integration/resources_permissions/test_projects_choices_resources.py @@ -2047,8 +2047,8 @@ def test_membership_action_bulk_create(client, data): bulk_data = { "project_id": data.blocked_project.id, "bulk_memberships": [ - {"role_id": data.private_membership2.role.pk, "email": "test1@test.com"}, - {"role_id": data.private_membership2.role.pk, "email": "test2@test.com"}, + {"role_id": data.blocked_membership.role.pk, "email": "test1@test.com"}, + {"role_id": data.blocked_membership.role.pk, "email": "test2@test.com"}, ] } bulk_data = json.dumps(bulk_data) From 275ce381f975869846214252da70d0d24b21f87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 17 Aug 2016 17:01:02 +0200 Subject: [PATCH 57/83] Fix tasks creation in bulk test --- taiga/projects/tasks/api.py | 29 ++++++++++--------- .../test_tasks_resources.py | 8 ++--- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index 9584a17b..e722f935 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -251,24 +251,25 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): validator = validators.TasksBulkValidator(data=request.DATA) - if validator.is_valid(): - data = validator.data - project = Project.objects.get(id=data["project_id"]) - self.check_permissions(request, 'bulk_create', project) - if project.blocked_code is not None: - raise exc.Blocked(_("Blocked element")) + if not validator.is_valid(): + return response.BadRequest(validator.errors) - tasks = services.create_tasks_in_bulk( - data["bulk_tasks"], milestone_id=data["milestone_id"], user_story_id=data["us_id"], - status_id=data.get("status_id") or project.default_task_status_id, - project=project, owner=request.user, callback=self.post_save, precall=self.pre_save) + data = validator.data + project = Project.objects.get(id=data["project_id"]) + self.check_permissions(request, 'bulk_create', project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) - tasks = self.get_queryset().filter(id__in=[i.id for i in tasks]) - tasks_serialized = self.get_serializer_class()(tasks, many=True) + tasks = services.create_tasks_in_bulk( + data["bulk_tasks"], milestone_id=data["milestone_id"], user_story_id=data["us_id"], + status_id=data.get("status_id") or project.default_task_status_id, + project=project, owner=request.user, callback=self.post_save, precall=self.pre_save) - return response.Ok(tasks_serialized.data) + tasks = self.get_queryset().filter(id__in=[i.id for i in tasks]) + tasks_serialized = self.get_serializer_class()(tasks, many=True) + + return response.Ok(tasks_serialized.data) - return response.BadRequest(validator.errors) def _bulk_update_order(self, order_field, request, **kwargs): validator = validators.UpdateTasksOrderBulkValidator(data=request.DATA) diff --git a/tests/integration/resources_permissions/test_tasks_resources.py b/tests/integration/resources_permissions/test_tasks_resources.py index 4d6427dd..d2fcc2e3 100644 --- a/tests/integration/resources_permissions/test_tasks_resources.py +++ b/tests/integration/resources_permissions/test_tasks_resources.py @@ -656,7 +656,7 @@ def test_task_action_bulk_create(client, data): "bulk_tasks": "test1\ntest2", "us_id": data.public_task.user_story.pk, "project_id": data.public_task.project.pk, - "sprint_id": data.public_task.milestone.pk, + "milestone_id": data.public_task.milestone.pk, }) results = helper_test_http_method(client, 'post', url, bulk_data, users) assert results == [401, 403, 403, 200, 200] @@ -665,7 +665,7 @@ def test_task_action_bulk_create(client, data): "bulk_tasks": "test1\ntest2", "us_id": data.private_task1.user_story.pk, "project_id": data.private_task1.project.pk, - "sprint_id": data.private_task1.milestone.pk, + "milestone_id": data.private_task1.milestone.pk, }) results = helper_test_http_method(client, 'post', url, bulk_data, users) assert results == [401, 403, 403, 200, 200] @@ -674,7 +674,7 @@ def test_task_action_bulk_create(client, data): "bulk_tasks": "test1\ntest2", "us_id": data.private_task2.user_story.pk, "project_id": data.private_task2.project.pk, - "sprint_id": data.private_task2.milestone.pk, + "milestone_id": data.private_task2.milestone.pk, }) results = helper_test_http_method(client, 'post', url, bulk_data, users) assert results == [401, 403, 403, 200, 200] @@ -683,7 +683,7 @@ def test_task_action_bulk_create(client, data): "bulk_tasks": "test1\ntest2", "us_id": data.blocked_task.user_story.pk, "project_id": data.blocked_task.project.pk, - "sprint_id": data.blocked_task.milestone.pk, + "milestone_id": data.blocked_task.milestone.pk, }) results = helper_test_http_method(client, 'post', url, bulk_data, users) assert results == [401, 403, 403, 451, 451] From 1cb924891285cbd75ee185db32bfcb83819c843a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 19 Aug 2016 09:59:41 +0200 Subject: [PATCH 58/83] Minor codee improvements --- taiga/permissions/choices.py | 2 +- taiga/projects/epics/api.py | 99 +++++++++++++++++------------------- 2 files changed, 49 insertions(+), 52 deletions(-) diff --git a/taiga/permissions/choices.py b/taiga/permissions/choices.py index 3564acc8..594d48ee 100644 --- a/taiga/permissions/choices.py +++ b/taiga/permissions/choices.py @@ -37,7 +37,7 @@ MEMBERS_PERMISSIONS = [ ('add_milestone', _('Add milestone')), ('modify_milestone', _('Modify milestone')), ('delete_milestone', _('Delete milestone')), - # US permissions + # Epic permissions ('view_epics', _('View epic')), ('add_epic', _('Add epic')), ('modify_epic', _('Modify epic')), diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index cf7bed6e..cc1b1c5d 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -111,16 +111,15 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, def _reorder_if_needed(self, obj, old_order_key, order_key): # Executes the extra ordering if there is a difference in the ordering keys - if old_order_key != order_key: - extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}")) - data = [{"epic_id": obj.id, "order": getattr(obj, "epics_order")}] - for id, order in extra_orders.items(): - data.append({"epic_id": int(id), "order": order}) + if old_order_key == order_key: + return {} - return services.update_epics_order_in_bulk(data, - "epics_order", - project=obj.project) - return {} + extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}")) + data = [{"epic_id": obj.id, "order": getattr(obj, "epics_order")}] + for id, order in extra_orders.items(): + data.append({"epic_id": int(id), "order": order}) + + return services.update_epics_order_in_bulk(data, "epics_order", project=obj.project) def post_save(self, obj, created=False): if not created: @@ -205,26 +204,26 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): validator = validators.EpicsBulkValidator(data=request.DATA) - if validator.is_valid(): - data = validator.data - project = Project.objects.get(id=data["project_id"]) - self.check_permissions(request, "bulk_create", project) - if project.blocked_code is not None: - raise exc.Blocked(_("Blocked element")) + if not validator.is_valid(): + return response.BadRequest(validator.errors) - epics = services.create_epics_in_bulk( - data["bulk_epics"], - status_id=data.get("status_id") or project.default_epic_status_id, - project=project, - owner=request.user, - callback=self.post_save, precall=self.pre_save) + data = validator.data + project = Project.objects.get(id=data["project_id"]) + self.check_permissions(request, "bulk_create", project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) - epics = self.get_queryset().filter(id__in=[i.id for i in epics]) - epics_serialized = self.get_serializer_class()(epics, many=True) + epics = services.create_epics_in_bulk( + data["bulk_epics"], + status_id=data.get("status_id") or project.default_epic_status_id, + project=project, + owner=request.user, + callback=self.post_save, precall=self.pre_save) - return response.Ok(epics_serialized.data) + epics = self.get_queryset().filter(id__in=[i.id for i in epics]) + epics_serialized = self.get_serializer_class()(epics, many=True) - return response.BadRequest(validator.errors) + return response.Ok(epics_serialized.data) class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, ModelCrudViewSet): @@ -254,18 +253,16 @@ class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, Mod def _reorder_if_needed(self, obj, old_order_key, order_key): # Executes the extra ordering if there is a difference in the ordering keys - if old_order_key != order_key: - extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}")) - data = [{"us_id": obj.id, "order": getattr(obj, "order")}] - for id, order in extra_orders.items(): - data.append({"epic_id": int(id), "order": order}) + if old_order_key == order_key: + return {} - return services.update_epic_related_userstories_order_in_bulk( - data, - epic=obj.epic - ) + extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}")) + data = [{"us_id": obj.id, "order": getattr(obj, "order")}] + for id, order in extra_orders.items(): + data.append({"epic_id": int(id), "order": order}) + + return services.update_epic_related_userstories_order_in_bulk(data, epic=obj.epic) - return {} def post_save(self, obj, created=False): if not created: @@ -280,27 +277,27 @@ class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, Mod @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): validator = validators.CrateRelatedUserStoriesBulkValidator(data=request.DATA) - if validator.is_valid(): - data = validator.data + if not validator.is_valid(): + return response.BadRequest(validator.errors) - epic = get_object_or_404(models.Epic, id=kwargs["epic"]) - project = epic.project + data = validator.data - self.check_permissions(request, 'bulk_create', project) - if project.blocked_code is not None: - raise exc.Blocked(_("Blocked element")) + epic = get_object_or_404(models.Epic, id=kwargs["epic"]) + project = epic.project - services.create_related_userstories_in_bulk( - data["userstories"], - epic, - project=project, - owner=request.user - ) + self.check_permissions(request, 'bulk_create', project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) - related_uss_serialized = self.get_serializer_class()(epic.relateduserstory_set.all(), many=True) - return response.Ok(related_uss_serialized.data) + services.create_related_userstories_in_bulk( + data["userstories"], + epic, + project=project, + owner=request.user + ) - return response.BadRequest(validator.errors) + related_uss_serialized = self.get_serializer_class()(epic.relateduserstory_set.all(), many=True) + return response.Ok(related_uss_serialized.data) class EpicVotersViewSet(VotersViewSetMixin, ModelListViewSet): From 8d0c9ee1c1a89aff35bd0ab19d45d93675246f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 22 Aug 2016 13:05:44 +0200 Subject: [PATCH 59/83] Fix test and RelatedUserStoyr API nested endpoint --- taiga/base/api/mixins.py | 29 ++++++++++++++++++++++++---- taiga/base/utils/db.py | 6 +++--- taiga/permissions/services.py | 1 - taiga/projects/epics/api.py | 8 ++++---- taiga/projects/epics/serializers.py | 1 + taiga/projects/epics/services.py | 30 ++++++++++++++++++++++++++++- taiga/projects/epics/validators.py | 2 +- tests/integration/test_epics.py | 19 ++++++++---------- 8 files changed, 71 insertions(+), 25 deletions(-) diff --git a/taiga/base/api/mixins.py b/taiga/base/api/mixins.py index c38b5cb7..b01d7cf2 100644 --- a/taiga/base/api/mixins.py +++ b/taiga/base/api/mixins.py @@ -211,14 +211,14 @@ class UpdateModelMixin: Set any attributes on the object that are implicit in the request. """ # pk and/or slug attributes are implicit in the URL. - lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field - lookup = self.kwargs.get(lookup_url_kwarg, None) + ##lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + ##lookup = self.kwargs.get(lookup_url_kwarg, None) pk = self.kwargs.get(self.pk_url_kwarg, None) slug = self.kwargs.get(self.slug_url_kwarg, None) slug_field = slug and self.slug_field or None - if lookup: - setattr(obj, self.lookup_field, lookup) + ##if lookup: + ## setattr(obj, self.lookup_field, lookup) if pk: setattr(obj, 'pk', pk) @@ -253,6 +253,27 @@ class DestroyModelMixin: return response.NoContent() +class NestedViewSetMixin(object): + def get_queryset(self): + return self._filter_queryset_by_parents_lookups(super().get_queryset()) + + def _filter_queryset_by_parents_lookups(self, queryset): + parents_query_dict = self._get_parents_query_dict() + if parents_query_dict: + return queryset.filter(**parents_query_dict) + else: + return queryset + + def _get_parents_query_dict(self): + result = {} + for kwarg_name in self.kwargs: + query_value = self.kwargs.get(kwarg_name) + result[kwarg_name] = query_value + return result + + +## TODO: Move blocked mixind out of the base module because is related to project + class BlockeableModelMixin: def is_blocked(self, obj): raise NotImplementedError("is_blocked must be overridden") diff --git a/taiga/base/utils/db.py b/taiga/base/utils/db.py index a6c21d6b..eb610fff 100644 --- a/taiga/base/utils/db.py +++ b/taiga/base/utils/db.py @@ -132,13 +132,13 @@ def update_attr_in_bulk_for_ids(values, attr, model): """ values = [str((id, order)) for id, order in values.items()] sql = """ - UPDATE {tbl} - SET {attr}=update_values.column2 + UPDATE "{tbl}" + SET "{attr}"=update_values.column2 FROM ( VALUES {values} ) AS update_values - WHERE {tbl}.id=update_values.column1; + WHERE "{tbl}"."id"=update_values.column1; """.format(tbl=model._meta.db_table, values=', '.join(values), attr=attr) diff --git a/taiga/permissions/services.py b/taiga/permissions/services.py index 6d89c168..50d8d72d 100644 --- a/taiga/permissions/services.py +++ b/taiga/permissions/services.py @@ -78,7 +78,6 @@ def user_has_perm(user, perm, obj=None, cache="user"): in cache """ project = _get_object_project(obj) - if not project: return False diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index cc1b1c5d..5888865d 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -232,7 +232,7 @@ class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, Mod validator_class = validators.EpicRelatedUserStoryValidator model = models.RelatedUserStory permission_classes = (permissions.EpicRelatedUserStoryPermission,) - lookup_field = "user_story_id" + lookup_field = "user_story" """ Updating the order attribute can affect the ordering of another userstories in the epic @@ -259,7 +259,7 @@ class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, Mod extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}")) data = [{"us_id": obj.id, "order": getattr(obj, "order")}] for id, order in extra_orders.items(): - data.append({"epic_id": int(id), "order": order}) + data.append({"us_id": int(id), "order": order}) return services.update_epic_related_userstories_order_in_bulk(data, epic=obj.epic) @@ -268,8 +268,8 @@ class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, Mod if not created: # Let's reorder the related stuff after edit the element orders_updated = self._reorder_if_needed(obj, - self._old_epics_order_key, - self._epics_order_key(obj)) + self._old_order_key, + self._order_key(obj)) self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated) super().post_save(obj, created) diff --git a/taiga/projects/epics/serializers.py b/taiga/projects/epics/serializers.py index b02238cd..339272de 100644 --- a/taiga/projects/epics/serializers.py +++ b/taiga/projects/epics/serializers.py @@ -81,5 +81,6 @@ class EpicNeighborsSerializer(NeighborsSerializerMixin, EpicSerializer): class EpicRelatedUserStorySerializer(serializers.LightSerializer): + epic = Field(attr="epic_id") user_story = Field(attr="user_story_id") order = Field() diff --git a/taiga/projects/epics/services.py b/taiga/projects/epics/services.py index 145e2339..f953a574 100644 --- a/taiga/projects/epics/services.py +++ b/taiga/projects/epics/services.py @@ -26,9 +26,9 @@ from django.db import connection from django.utils.translation import ugettext as _ from taiga.base.utils import db, text -from taiga.projects.services import apply_order_updates from taiga.projects.epics.apps import connect_epics_signals from taiga.projects.epics.apps import disconnect_epics_signals +from taiga.projects.services import apply_order_updates from taiga.projects.userstories.apps import connect_userstories_signals from taiga.projects.userstories.apps import disconnect_userstories_signals from taiga.projects.userstories.services import get_userstories_from_bulk @@ -127,6 +127,34 @@ def create_related_userstories_in_bulk(bulk_data, epic, **additional_fields): return userstories +def update_epic_related_userstories_order_in_bulk(bulk_data: list, epic: object): + """ + Updates the order of the related userstories of an specific epic. + `bulk_data` should be a list of dicts with the following format: + `epic` is the epic with related stories. + + [{'us_id': , 'order': }, ...] + """ + related_user_stories = epic.relateduserstory_set.all() + rus_orders = {rus.id: rus.order for rus in related_user_stories} + + rus_conversion = {rus.user_story_id: rus.id for rus in related_user_stories} + new_rus_orders = {rus_conversion[e["us_id"]]: e["order"] for e in bulk_data + if e["us_id"] in rus_conversion} + + apply_order_updates(rus_orders, new_rus_orders) + + if rus_orders: + related_user_story_ids = rus_orders.keys() + events.emit_event_for_ids(ids=related_user_story_ids, + content_type="epics.relateduserstory", + projectid=epic.project_id) + + db.update_attr_in_bulk_for_ids(rus_orders, "order", models.RelatedUserStory) + + return rus_orders + + ##################################################### # CSV ##################################################### diff --git a/taiga/projects/epics/validators.py b/taiga/projects/epics/validators.py index 88214aa6..175f1c3c 100644 --- a/taiga/projects/epics/validators.py +++ b/taiga/projects/epics/validators.py @@ -63,4 +63,4 @@ class CrateRelatedUserStoriesBulkValidator(ProjectExistsValidator, EpicExistsVal class EpicRelatedUserStoryValidator(validators.ModelValidator): class Meta: model = models.RelatedUserStory - read_only_fields = ('id', 'epic', 'user_story') + read_only_fields = ('id',) diff --git a/tests/integration/test_epics.py b/tests/integration/test_epics.py index 5d4657df..0ca87d09 100644 --- a/tests/integration/test_epics.py +++ b/tests/integration/test_epics.py @@ -119,13 +119,13 @@ def test_set_related_userstory(client): url = reverse('epics-related-userstories-list', args=[epic.pk]) data = { - "user_story": us.id + "user_story": us.id, + "epic": epic.pk } client.login(user) response = client.json.post(url, json.dumps(data)) - print(response.data) - assert response.status_code == 200 - assert response.data['user_stories_counts'] == {'opened': 1, 'closed': 0} + + assert response.status_code == 201 def test_set_related_userstory_existing(client): @@ -136,18 +136,15 @@ def test_set_related_userstory_existing(client): f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) f.MembershipFactory.create(project=us.project, user=user, is_admin=True) - url = reverse('epics-related-userstories-list', args=[epic.pk]) - + url = reverse('epics-related-userstories-detail', args=[epic.pk, us.pk]) data = { - "user_story": us.id, "order": 77 } client.login(user) - response = client.json.post(url, json.dumps(data)) + response = client.json.patch(url, json.dumps(data)) assert response.status_code == 200 - assert response.data['user_stories_counts'] == {'opened': 1, 'closed': 0} - related_us = models.RelatedUserStory.objects.get(id=related_us.id) + related_us.refresh_from_db() assert related_us.order == 77 @@ -158,7 +155,7 @@ def test_unset_related_userstory(client): related_us = f.RelatedUserStory.create(epic=epic, user_story=us, order=55) f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) - url = reverse('epics-related-userstories-detail', args=[epic.pk, us.pk]) + url = reverse('epics-related-userstories-detail', args=[epic.pk, us.id]) client.login(user) response = client.delete(url) From 5e1ca747d38f9c96cf1d27fdef181fb69b8054fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 24 Aug 2016 18:27:47 +0200 Subject: [PATCH 60/83] [i18n] Update locales --- taiga/locale/en/LC_MESSAGES/django.po | 1812 ++++++++++++++----------- 1 file changed, 1031 insertions(+), 781 deletions(-) diff --git a/taiga/locale/en/LC_MESSAGES/django.po b/taiga/locale/en/LC_MESSAGES/django.po index 4cde7d23..627ffe46 100644 --- a/taiga/locale/en/LC_MESSAGES/django.po +++ b/taiga/locale/en/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: taiga-back\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2016-05-01 19:09+0200\n" +"POT-Creation-Date: 2016-08-24 18:09+0200\n" "PO-Revision-Date: 2015-03-25 20:09+0100\n" "Last-Translator: Taiga Dev Team \n" "Language-Team: Taiga Dev Team \n" @@ -16,339 +16,340 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: taiga/auth/api.py:100 +#: taiga/auth/api.py:102 msgid "Public register is disabled." msgstr "" -#: taiga/auth/api.py:133 +#: taiga/auth/api.py:135 msgid "invalid register type" msgstr "" -#: taiga/auth/api.py:146 +#: taiga/auth/api.py:148 msgid "invalid login type" msgstr "" -#: taiga/auth/serializers.py:35 taiga/users/serializers.py:64 +#: taiga/auth/services.py:76 +msgid "Username is already in use." +msgstr "" + +#: taiga/auth/services.py:79 +msgid "Email is already in use." +msgstr "" + +#: taiga/auth/services.py:95 +msgid "Token not matches any valid invitation." +msgstr "" + +#: taiga/auth/services.py:123 +msgid "User is already registered." +msgstr "" + +#: taiga/auth/services.py:147 +msgid "This user is already a member of the project." +msgstr "" + +#: taiga/auth/services.py:173 +msgid "Error on creating new user." +msgstr "" + +#: taiga/auth/tokens.py:49 taiga/auth/tokens.py:56 +#: taiga/external_apps/services.py:36 taiga/projects/api.py:364 +#: taiga/projects/api.py:385 +msgid "Invalid token" +msgstr "" + +#: taiga/auth/validators.py:37 taiga/users/validators.py:44 msgid "invalid username" msgstr "" -#: taiga/auth/serializers.py:40 taiga/users/serializers.py:70 +#: taiga/auth/validators.py:42 taiga/users/validators.py:50 msgid "" "Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" msgstr "" -#: taiga/auth/services.py:75 -msgid "Username is already in use." -msgstr "" - -#: taiga/auth/services.py:78 -msgid "Email is already in use." -msgstr "" - -#: taiga/auth/services.py:94 -msgid "Token not matches any valid invitation." -msgstr "" - -#: taiga/auth/services.py:122 -msgid "User is already registered." -msgstr "" - -#: taiga/auth/services.py:146 -msgid "This user is already a member of the project." -msgstr "" - -#: taiga/auth/services.py:172 -msgid "Error on creating new user." -msgstr "" - -#: taiga/auth/tokens.py:48 taiga/auth/tokens.py:55 -#: taiga/external_apps/services.py:35 taiga/projects/api.py:376 -#: taiga/projects/api.py:397 -msgid "Invalid token" -msgstr "" - -#: taiga/base/api/fields.py:292 +#: taiga/base/api/fields.py:294 msgid "This field is required." msgstr "" -#: taiga/base/api/fields.py:293 taiga/base/api/relations.py:335 +#: taiga/base/api/fields.py:295 taiga/base/api/relations.py:337 msgid "Invalid value." msgstr "" -#: taiga/base/api/fields.py:477 +#: taiga/base/api/fields.py:479 #, python-format msgid "'%s' value must be either True or False." msgstr "" -#: taiga/base/api/fields.py:541 +#: taiga/base/api/fields.py:543 msgid "" "Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." msgstr "" -#: taiga/base/api/fields.py:556 +#: taiga/base/api/fields.py:558 #, python-format msgid "Select a valid choice. %(value)s is not one of the available choices." msgstr "" -#: taiga/base/api/fields.py:619 +#: taiga/base/api/fields.py:621 +msgid "You email domain is not allowed" +msgstr "" + +#: taiga/base/api/fields.py:630 msgid "Enter a valid email address." msgstr "" -#: taiga/base/api/fields.py:661 +#: taiga/base/api/fields.py:672 #, python-format msgid "Date has wrong format. Use one of these formats instead: %s" msgstr "" -#: taiga/base/api/fields.py:725 +#: taiga/base/api/fields.py:736 #, python-format msgid "Datetime has wrong format. Use one of these formats instead: %s" msgstr "" -#: taiga/base/api/fields.py:795 +#: taiga/base/api/fields.py:806 #, python-format msgid "Time has wrong format. Use one of these formats instead: %s" msgstr "" -#: taiga/base/api/fields.py:852 +#: taiga/base/api/fields.py:863 msgid "Enter a whole number." msgstr "" -#: taiga/base/api/fields.py:853 taiga/base/api/fields.py:906 +#: taiga/base/api/fields.py:864 taiga/base/api/fields.py:917 #, python-format msgid "Ensure this value is less than or equal to %(limit_value)s." msgstr "" -#: taiga/base/api/fields.py:854 taiga/base/api/fields.py:907 +#: taiga/base/api/fields.py:865 taiga/base/api/fields.py:918 #, python-format msgid "Ensure this value is greater than or equal to %(limit_value)s." msgstr "" -#: taiga/base/api/fields.py:884 +#: taiga/base/api/fields.py:895 #, python-format msgid "\"%s\" value must be a float." msgstr "" -#: taiga/base/api/fields.py:905 +#: taiga/base/api/fields.py:916 msgid "Enter a number." msgstr "" -#: taiga/base/api/fields.py:908 +#: taiga/base/api/fields.py:919 #, python-format msgid "Ensure that there are no more than %s digits in total." msgstr "" -#: taiga/base/api/fields.py:909 +#: taiga/base/api/fields.py:920 #, python-format msgid "Ensure that there are no more than %s decimal places." msgstr "" -#: taiga/base/api/fields.py:910 +#: taiga/base/api/fields.py:921 #, python-format msgid "Ensure that there are no more than %s digits before the decimal point." msgstr "" -#: taiga/base/api/fields.py:977 +#: taiga/base/api/fields.py:988 msgid "No file was submitted. Check the encoding type on the form." msgstr "" -#: taiga/base/api/fields.py:978 +#: taiga/base/api/fields.py:989 msgid "No file was submitted." msgstr "" -#: taiga/base/api/fields.py:979 +#: taiga/base/api/fields.py:990 msgid "The submitted file is empty." msgstr "" -#: taiga/base/api/fields.py:980 +#: taiga/base/api/fields.py:991 #, python-format msgid "" "Ensure this filename has at most %(max)d characters (it has %(length)d)." msgstr "" -#: taiga/base/api/fields.py:981 +#: taiga/base/api/fields.py:992 msgid "Please either submit a file or check the clear checkbox, not both." msgstr "" -#: taiga/base/api/fields.py:1021 +#: taiga/base/api/fields.py:1032 msgid "" "Upload a valid image. The file you uploaded was either not an image or a " "corrupted image." msgstr "" -#: taiga/base/api/mixins.py:255 taiga/base/exceptions.py:209 -#: taiga/hooks/api.py:68 taiga/projects/api.py:642 -#: taiga/projects/issues/api.py:233 taiga/projects/mixins/ordering.py:58 -#: taiga/projects/tasks/api.py:152 taiga/projects/tasks/api.py:174 -#: taiga/projects/userstories/api.py:218 taiga/projects/userstories/api.py:238 -#: taiga/webhooks/api.py:68 +#: taiga/base/api/mixins.py:284 taiga/base/exceptions.py:211 +#: taiga/hooks/api.py:69 taiga/projects/api.py:396 taiga/projects/api.py:671 +#: taiga/projects/epics/api.py:212 taiga/projects/epics/api.py:288 +#: taiga/projects/issues/api.py:235 taiga/projects/mixins/ordering.py:59 +#: taiga/projects/tasks/api.py:258 taiga/projects/tasks/api.py:281 +#: taiga/projects/userstories/api.py:332 taiga/projects/userstories/api.py:381 +#: taiga/webhooks/api.py:71 msgid "Blocked element" msgstr "" -#: taiga/base/api/pagination.py:213 +#: taiga/base/api/pagination.py:214 msgid "Page is not 'last', nor can it be converted to an int." msgstr "" -#: taiga/base/api/pagination.py:217 +#: taiga/base/api/pagination.py:218 #, python-format msgid "Invalid page (%(page_number)s): %(message)s" msgstr "" -#: taiga/base/api/permissions.py:64 +#: taiga/base/api/permissions.py:66 msgid "Invalid permission definition." msgstr "" -#: taiga/base/api/relations.py:245 +#: taiga/base/api/relations.py:247 #, python-format msgid "Invalid pk '%s' - object does not exist." msgstr "" -#: taiga/base/api/relations.py:246 +#: taiga/base/api/relations.py:248 #, python-format msgid "Incorrect type. Expected pk value, received %s." msgstr "" -#: taiga/base/api/relations.py:334 +#: taiga/base/api/relations.py:336 #, python-format msgid "Object with %s=%s does not exist." msgstr "" -#: taiga/base/api/relations.py:370 +#: taiga/base/api/relations.py:372 msgid "Invalid hyperlink - No URL match" msgstr "" -#: taiga/base/api/relations.py:371 +#: taiga/base/api/relations.py:373 msgid "Invalid hyperlink - Incorrect URL match" msgstr "" -#: taiga/base/api/relations.py:372 +#: taiga/base/api/relations.py:374 msgid "Invalid hyperlink due to configuration error" msgstr "" -#: taiga/base/api/relations.py:373 +#: taiga/base/api/relations.py:375 msgid "Invalid hyperlink - object does not exist." msgstr "" -#: taiga/base/api/relations.py:374 +#: taiga/base/api/relations.py:376 #, python-format msgid "Incorrect type. Expected url string, received %s." msgstr "" -#: taiga/base/api/serializers.py:320 +#: taiga/base/api/serializers.py:324 msgid "Invalid data" msgstr "" -#: taiga/base/api/serializers.py:412 +#: taiga/base/api/serializers.py:416 msgid "No input provided" msgstr "" -#: taiga/base/api/serializers.py:575 +#: taiga/base/api/serializers.py:579 msgid "Cannot create a new item, only existing items may be updated." msgstr "" -#: taiga/base/api/serializers.py:586 +#: taiga/base/api/serializers.py:590 msgid "Expected a list of items." msgstr "" -#: taiga/base/api/views.py:125 +#: taiga/base/api/views.py:126 msgid "Not found" msgstr "" -#: taiga/base/api/views.py:128 +#: taiga/base/api/views.py:129 msgid "Permission denied" msgstr "" -#: taiga/base/api/views.py:476 +#: taiga/base/api/views.py:477 msgid "Server application error" msgstr "" -#: taiga/base/connectors/exceptions.py:25 +#: taiga/base/connectors/exceptions.py:26 msgid "Connection error." msgstr "" -#: taiga/base/exceptions.py:77 +#: taiga/base/exceptions.py:79 msgid "Malformed request." msgstr "" -#: taiga/base/exceptions.py:82 +#: taiga/base/exceptions.py:84 msgid "Incorrect authentication credentials." msgstr "" -#: taiga/base/exceptions.py:87 +#: taiga/base/exceptions.py:89 msgid "Authentication credentials were not provided." msgstr "" -#: taiga/base/exceptions.py:92 +#: taiga/base/exceptions.py:94 msgid "You do not have permission to perform this action." msgstr "" -#: taiga/base/exceptions.py:97 +#: taiga/base/exceptions.py:99 #, python-format msgid "Method '%s' not allowed." msgstr "" -#: taiga/base/exceptions.py:105 +#: taiga/base/exceptions.py:107 msgid "Could not satisfy the request's Accept header" msgstr "" -#: taiga/base/exceptions.py:114 +#: taiga/base/exceptions.py:116 #, python-format msgid "Unsupported media type '%s' in request." msgstr "" -#: taiga/base/exceptions.py:122 +#: taiga/base/exceptions.py:124 msgid "Request was throttled." msgstr "" -#: taiga/base/exceptions.py:123 +#: taiga/base/exceptions.py:125 #, python-format msgid "Expected available in %d second%s." msgstr "" -#: taiga/base/exceptions.py:137 +#: taiga/base/exceptions.py:139 msgid "Unexpected error" msgstr "" -#: taiga/base/exceptions.py:149 +#: taiga/base/exceptions.py:151 msgid "Not found." msgstr "" -#: taiga/base/exceptions.py:154 +#: taiga/base/exceptions.py:156 msgid "Method not supported for this endpoint." msgstr "" -#: taiga/base/exceptions.py:162 taiga/base/exceptions.py:170 +#: taiga/base/exceptions.py:164 taiga/base/exceptions.py:172 msgid "Wrong arguments." msgstr "" -#: taiga/base/exceptions.py:174 +#: taiga/base/exceptions.py:176 msgid "Data validation error" msgstr "" -#: taiga/base/exceptions.py:186 +#: taiga/base/exceptions.py:188 msgid "Integrity Error for wrong or invalid arguments" msgstr "" -#: taiga/base/exceptions.py:193 +#: taiga/base/exceptions.py:195 msgid "Precondition error" msgstr "" -#: taiga/base/exceptions.py:217 +#: taiga/base/exceptions.py:219 msgid "No room left for more projects." msgstr "" -#: taiga/base/filters.py:79 taiga/base/filters.py:444 +#: taiga/base/filters.py:79 taiga/base/filters.py:460 msgid "Error in filter params types." msgstr "" -#: taiga/base/filters.py:133 taiga/base/filters.py:232 -#: taiga/projects/filters.py:63 +#: taiga/base/filters.py:133 taiga/base/filters.py:240 +#: taiga/projects/filters.py:64 msgid "'project' must be an integer value." msgstr "" -#: taiga/base/tags.py:26 -msgid "tags" -msgstr "" - #: taiga/base/templates/emails/base-body-html.jinja:6 msgid "Taiga" msgstr "" @@ -403,7 +404,7 @@ msgid "" " Contact us:\n" " \n" +"%(support_email)s\" title=\"Support email\" style=\"color: #9dce0a\">\n" " %(support_email)s\n" " \n" "
\n" @@ -457,103 +458,88 @@ msgid "" " " msgstr "" -#: taiga/export_import/api.py:119 +#: taiga/export_import/api.py:127 msgid "We needed at least one role" msgstr "" -#: taiga/export_import/api.py:309 +#: taiga/export_import/api.py:323 msgid "Needed dump file" msgstr "" -#: taiga/export_import/api.py:316 +#: taiga/export_import/api.py:333 msgid "Invalid dump format" msgstr "" -#: taiga/export_import/serializers.py:178 -msgid "{}=\"{}\" not found in this project" -msgstr "" - -#: taiga/export_import/serializers.py:443 -#: taiga/projects/custom_attributes/serializers.py:104 -msgid "Invalid content. It must be {\"key\": \"value\",...}" -msgstr "" - -#: taiga/export_import/serializers.py:458 -#: taiga/projects/custom_attributes/serializers.py:119 -msgid "It contain invalid custom fields." -msgstr "" - -#: taiga/export_import/serializers.py:528 -#: taiga/projects/mixins/serializers.py:38 -msgid "Name duplicated for the project" -msgstr "" - -#: taiga/export_import/services/store.py:621 -#: taiga/export_import/services/store.py:639 +#: taiga/export_import/services/store.py:718 +#: taiga/export_import/services/store.py:736 msgid "error importing project data" msgstr "" -#: taiga/export_import/services/store.py:646 +#: taiga/export_import/services/store.py:743 msgid "error importing roles" msgstr "" -#: taiga/export_import/services/store.py:651 +#: taiga/export_import/services/store.py:748 msgid "error importing memberships" msgstr "" -#: taiga/export_import/services/store.py:661 +#: taiga/export_import/services/store.py:759 msgid "error importing lists of project attributes" msgstr "" -#: taiga/export_import/services/store.py:665 +#: taiga/export_import/services/store.py:763 msgid "error importing default project attributes values" msgstr "" -#: taiga/export_import/services/store.py:674 +#: taiga/export_import/services/store.py:774 msgid "error importing custom attributes" msgstr "" -#: taiga/export_import/services/store.py:679 +#: taiga/export_import/services/store.py:778 msgid "error importing sprints" msgstr "" -#: taiga/export_import/services/store.py:683 -msgid "error importing user stories" -msgstr "" - -#: taiga/export_import/services/store.py:687 -msgid "error importing tasks" -msgstr "" - -#: taiga/export_import/services/store.py:691 +#: taiga/export_import/services/store.py:782 msgid "error importing issues" msgstr "" -#: taiga/export_import/services/store.py:695 +#: taiga/export_import/services/store.py:786 +msgid "error importing user stories" +msgstr "" + +#: taiga/export_import/services/store.py:790 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:794 +msgid "error importing tasks" +msgstr "" + +#: taiga/export_import/services/store.py:798 msgid "error importing wiki pages" msgstr "" -#: taiga/export_import/services/store.py:699 +#: taiga/export_import/services/store.py:802 msgid "error importing wiki links" msgstr "" -#: taiga/export_import/services/store.py:703 +#: taiga/export_import/services/store.py:806 msgid "error importing tags" msgstr "" -#: taiga/export_import/services/store.py:707 +#: taiga/export_import/services/store.py:810 msgid "error importing timelines" msgstr "" -#: taiga/export_import/services/store.py:731 +#: taiga/export_import/services/store.py:832 msgid "unexpected error importing project" msgstr "" -#: taiga/export_import/tasks.py:56 taiga/export_import/tasks.py:57 +#: taiga/export_import/tasks.py:62 taiga/export_import/tasks.py:63 msgid "Error generating project dump" msgstr "" -#: taiga/export_import/tasks.py:81 +#: taiga/export_import/tasks.py:91 #, python-brace-format msgid "" "\n" @@ -573,15 +559,15 @@ msgid "" "------------" msgstr "" -#: taiga/export_import/tasks.py:110 +#: taiga/export_import/tasks.py:120 msgid "Error loading project dump" msgstr "" -#: taiga/export_import/tasks.py:111 +#: taiga/export_import/tasks.py:121 msgid "Error loading your project dump file" msgstr "" -#: taiga/export_import/tasks.py:125 +#: taiga/export_import/tasks.py:135 msgid " -- no detail info --" msgstr "" @@ -732,77 +718,97 @@ msgstr "" msgid "[%(project)s] Your project dump has been imported" msgstr "" -#: taiga/external_apps/api.py:41 taiga/external_apps/api.py:67 -#: taiga/external_apps/api.py:74 +#: taiga/export_import/validators/fields.py:144 +msgid "{}=\"{}\" not found in this project" +msgstr "" + +#: taiga/export_import/validators/validators.py:150 +#: taiga/projects/custom_attributes/validators.py:109 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "" + +#: taiga/export_import/validators/validators.py:165 +#: taiga/projects/custom_attributes/validators.py:124 +msgid "It contain invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:245 +#: taiga/projects/validators.py:52 +msgid "Name duplicated for the project" +msgstr "" + +#: taiga/external_apps/api.py:43 taiga/external_apps/api.py:70 +#: taiga/external_apps/api.py:77 msgid "Authentication required" msgstr "" -#: taiga/external_apps/models.py:34 +#: taiga/external_apps/models.py:35 #: taiga/projects/custom_attributes/models.py:35 -#: taiga/projects/milestones/models.py:38 taiga/projects/models.py:146 -#: taiga/projects/models.py:478 taiga/projects/models.py:517 -#: taiga/projects/models.py:542 taiga/projects/models.py:579 -#: taiga/projects/models.py:602 taiga/projects/models.py:625 -#: taiga/projects/models.py:660 taiga/projects/models.py:683 -#: taiga/users/admin.py:53 taiga/users/models.py:292 -#: taiga/webhooks/models.py:28 +#: taiga/projects/milestones/models.py:38 taiga/projects/models.py:144 +#: taiga/projects/models.py:510 taiga/projects/models.py:543 +#: taiga/projects/models.py:579 taiga/projects/models.py:601 +#: taiga/projects/models.py:635 taiga/projects/models.py:655 +#: taiga/projects/models.py:675 taiga/projects/models.py:707 +#: taiga/projects/models.py:727 taiga/users/admin.py:54 +#: taiga/users/models.py:292 taiga/webhooks/models.py:29 msgid "name" msgstr "" -#: taiga/external_apps/models.py:36 +#: taiga/external_apps/models.py:37 msgid "Icon url" msgstr "" -#: taiga/external_apps/models.py:37 +#: taiga/external_apps/models.py:38 msgid "web" msgstr "" -#: taiga/external_apps/models.py:38 taiga/projects/attachments/models.py:60 +#: taiga/external_apps/models.py:39 taiga/projects/attachments/models.py:61 #: taiga/projects/custom_attributes/models.py:36 -#: taiga/projects/history/templatetags/functions.py:24 -#: taiga/projects/issues/models.py:62 taiga/projects/models.py:150 -#: taiga/projects/models.py:687 taiga/projects/tasks/models.py:61 -#: taiga/projects/userstories/models.py:92 +#: taiga/projects/epics/models.py:54 +#: taiga/projects/history/templatetags/functions.py:25 +#: taiga/projects/issues/models.py:60 taiga/projects/models.py:148 +#: taiga/projects/models.py:731 taiga/projects/tasks/models.py:61 +#: taiga/projects/userstories/models.py:94 msgid "description" msgstr "" -#: taiga/external_apps/models.py:40 +#: taiga/external_apps/models.py:41 msgid "Next url" msgstr "" -#: taiga/external_apps/models.py:42 +#: taiga/external_apps/models.py:43 msgid "secret key for ciphering the application tokens" msgstr "" -#: taiga/external_apps/models.py:56 taiga/projects/likes/models.py:30 -#: taiga/projects/notifications/models.py:86 taiga/projects/votes/models.py:51 +#: taiga/external_apps/models.py:57 taiga/projects/likes/models.py:31 +#: taiga/projects/notifications/models.py:87 taiga/projects/votes/models.py:52 msgid "user" msgstr "" -#: taiga/external_apps/models.py:60 +#: taiga/external_apps/models.py:61 msgid "application" msgstr "" -#: taiga/feedback/models.py:24 taiga/users/models.py:138 +#: taiga/feedback/models.py:25 taiga/users/models.py:137 msgid "full name" msgstr "" -#: taiga/feedback/models.py:26 taiga/users/models.py:133 +#: taiga/feedback/models.py:27 taiga/users/models.py:132 msgid "email address" msgstr "" -#: taiga/feedback/models.py:28 +#: taiga/feedback/models.py:29 msgid "comment" msgstr "" -#: taiga/feedback/models.py:30 taiga/projects/attachments/models.py:47 +#: taiga/feedback/models.py:31 taiga/projects/attachments/models.py:48 #: taiga/projects/custom_attributes/models.py:45 -#: taiga/projects/issues/models.py:54 taiga/projects/likes/models.py:32 -#: taiga/projects/milestones/models.py:49 taiga/projects/models.py:157 -#: taiga/projects/models.py:689 taiga/projects/notifications/models.py:88 -#: taiga/projects/tasks/models.py:47 taiga/projects/userstories/models.py:84 -#: taiga/projects/votes/models.py:53 taiga/projects/wiki/models.py:40 -#: taiga/userstorage/models.py:28 +#: taiga/projects/epics/models.py:47 taiga/projects/issues/models.py:52 +#: taiga/projects/likes/models.py:33 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:155 taiga/projects/models.py:735 +#: taiga/projects/notifications/models.py:89 taiga/projects/tasks/models.py:47 +#: taiga/projects/userstories/models.py:86 taiga/projects/votes/models.py:54 +#: taiga/projects/wiki/models.py:43 taiga/userstorage/models.py:29 msgid "created date" msgstr "" @@ -825,7 +831,7 @@ msgid "" msgstr "" #: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:18 -#: taiga/users/admin.py:120 +#: taiga/projects/admin.py:103 taiga/users/admin.py:120 msgid "Extra info" msgstr "" @@ -851,504 +857,572 @@ msgid "" "[Taiga] Feedback from %(full_name)s <%(email)s>\n" msgstr "" -#: taiga/hooks/api.py:53 +#: taiga/hooks/api.py:54 msgid "The payload is not a valid json" msgstr "" -#: taiga/hooks/api.py:62 taiga/projects/issues/api.py:139 -#: taiga/projects/tasks/api.py:86 taiga/projects/userstories/api.py:111 +#: taiga/hooks/api.py:63 taiga/projects/epics/api.py:151 +#: taiga/projects/issues/api.py:135 taiga/projects/tasks/api.py:197 +#: taiga/projects/userstories/api.py:265 msgid "The project doesn't exist" msgstr "" -#: taiga/hooks/api.py:65 +#: taiga/hooks/api.py:66 msgid "Bad signature" msgstr "" -#: taiga/hooks/bitbucket/event_hooks.py:82 taiga/hooks/github/event_hooks.py:76 -#: taiga/hooks/gitlab/event_hooks.py:74 -msgid "The referenced element doesn't exist" -msgstr "" - -#: taiga/hooks/bitbucket/event_hooks.py:89 taiga/hooks/github/event_hooks.py:83 -#: taiga/hooks/gitlab/event_hooks.py:81 -msgid "The status doesn't exist" -msgstr "" - -#: taiga/hooks/bitbucket/event_hooks.py:95 -msgid "Status changed from BitBucket commit" -msgstr "" - -#: taiga/hooks/bitbucket/event_hooks.py:124 -#: taiga/hooks/github/event_hooks.py:142 taiga/hooks/gitlab/event_hooks.py:114 -msgid "Invalid issue information" -msgstr "" - -#: taiga/hooks/bitbucket/event_hooks.py:140 +#: taiga/hooks/event_hooks.py:65 #, python-brace-format msgid "" -"Issue created by [@{bitbucket_user_name}]({bitbucket_user_url} \"See " -"@{bitbucket_user_name}'s BitBucket profile\") from BitBucket.\n" -"Origin BitBucket issue: [bb#{number} - {subject}]({bitbucket_url} \"Go to " -"'bb#{number} - {subject}'\"):\n" +"[@{user_name}]({user_url} \"See @{user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" "\n" -"{description}" +"\"{comment_message}\"" msgstr "" -#: taiga/hooks/bitbucket/event_hooks.py:151 -msgid "Issue created from BitBucket." +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" msgstr "" -#: taiga/hooks/bitbucket/event_hooks.py:175 -#: taiga/hooks/github/event_hooks.py:178 taiga/hooks/github/event_hooks.py:193 -#: taiga/hooks/gitlab/event_hooks.py:153 +#: taiga/hooks/event_hooks.py:83 msgid "Invalid issue comment information" msgstr "" -#: taiga/hooks/bitbucket/event_hooks.py:183 +#: taiga/hooks/event_hooks.py:102 #, python-brace-format msgid "" -"Comment by [@{bitbucket_user_name}]({bitbucket_user_url} \"See " -"@{bitbucket_user_name}'s BitBucket profile\") from BitBucket.\n" -"Origin BitBucket issue: [bb#{number} - {subject}]({bitbucket_url} \"Go to " -"'bb#{number} - {subject}'\")\n" +"Issue created by [@{user_name}]({user_url} \"See @{user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:106 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:119 +msgid "Invalid issue information" +msgstr "" + +#: taiga/hooks/event_hooks.py:148 taiga/hooks/event_hooks.py:170 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:155 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_message}'\")\n" "\n" -"{message}" +" - Status: **{src_status}** → **{dst_status}**" msgstr "" -#: taiga/hooks/bitbucket/event_hooks.py:194 +#: taiga/hooks/event_hooks.py:160 #, python-brace-format msgid "" -"Comment From BitBucket:\n" +"Changed status from {platform} commit.\n" "\n" -"{message}" +" - Status: **{src_status}** → **{dst_status}**" msgstr "" -#: taiga/hooks/github/event_hooks.py:97 +#: taiga/hooks/event_hooks.py:178 #, python-brace-format msgid "" -"Status changed by [@{github_user_name}]({github_user_url} \"See " -"@{github_user_name}'s GitHub profile\") from GitHub commit [{commit_id}]" -"({commit_url} \"See commit '{commit_id} - {commit_message}'\")." +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_message}'\") " +"\"{commit_message}\"" msgstr "" -#: taiga/hooks/github/event_hooks.py:108 -msgid "Status changed from GitHub commit." -msgstr "" - -#: taiga/hooks/github/event_hooks.py:158 +#: taiga/hooks/event_hooks.py:183 #, python-brace-format msgid "" -"Issue created by [@{github_user_name}]({github_user_url} \"See " -"@{github_user_name}'s GitHub profile\") from GitHub.\n" -"Origin GitHub issue: [gh#{number} - {subject}]({github_url} \"Go to " -"'gh#{number} - {subject}'\"):\n" -"\n" -"{description}" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" msgstr "" -#: taiga/hooks/github/event_hooks.py:169 -msgid "Issue created from GitHub." +#: taiga/hooks/event_hooks.py:202 +msgid "The referenced element doesn't exist" msgstr "" -#: taiga/hooks/github/event_hooks.py:201 -#, python-brace-format -msgid "" -"Comment by [@{github_user_name}]({github_user_url} \"See " -"@{github_user_name}'s GitHub profile\") from GitHub.\n" -"Origin GitHub issue: [gh#{number} - {subject}]({github_url} \"Go to " -"'gh#{number} - {subject}'\")\n" -"\n" -"{message}" +#: taiga/hooks/event_hooks.py:218 +msgid "The status doesn't exist" msgstr "" -#: taiga/hooks/github/event_hooks.py:212 -#, python-brace-format -msgid "" -"Comment From GitHub:\n" -"\n" -"{message}" -msgstr "" - -#: taiga/hooks/gitlab/event_hooks.py:87 -msgid "Status changed from GitLab commit" -msgstr "" - -#: taiga/hooks/gitlab/event_hooks.py:129 -msgid "Created from GitLab" -msgstr "" - -#: taiga/hooks/gitlab/event_hooks.py:161 -#, python-brace-format -msgid "" -"Comment by [@{gitlab_user_name}]({gitlab_user_url} \"See " -"@{gitlab_user_name}'s GitLab profile\") from GitLab.\n" -"Origin GitLab issue: [gl#{number} - {subject}]({gitlab_url} \"Go to " -"'gl#{number} - {subject}'\")\n" -"\n" -"{message}" -msgstr "" - -#: taiga/hooks/gitlab/event_hooks.py:172 -#, python-brace-format -msgid "" -"Comment From GitLab:\n" -"\n" -"{message}" -msgstr "" - -#: taiga/permissions/permissions.py:22 taiga/permissions/permissions.py:32 -#: taiga/permissions/permissions.py:52 +#: taiga/permissions/choices.py:23 taiga/permissions/choices.py:34 msgid "View project" msgstr "" -#: taiga/permissions/permissions.py:23 taiga/permissions/permissions.py:33 -#: taiga/permissions/permissions.py:54 +#: taiga/permissions/choices.py:24 taiga/permissions/choices.py:36 msgid "View milestones" msgstr "" -#: taiga/permissions/permissions.py:24 taiga/permissions/permissions.py:34 +#: taiga/permissions/choices.py:25 taiga/permissions/choices.py:41 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:26 msgid "View user stories" msgstr "" -#: taiga/permissions/permissions.py:25 taiga/permissions/permissions.py:36 -#: taiga/permissions/permissions.py:64 +#: taiga/permissions/choices.py:27 taiga/permissions/choices.py:53 msgid "View tasks" msgstr "" -#: taiga/permissions/permissions.py:26 taiga/permissions/permissions.py:35 -#: taiga/permissions/permissions.py:69 +#: taiga/permissions/choices.py:28 taiga/permissions/choices.py:59 msgid "View issues" msgstr "" -#: taiga/permissions/permissions.py:27 taiga/permissions/permissions.py:37 -#: taiga/permissions/permissions.py:74 +#: taiga/permissions/choices.py:29 taiga/permissions/choices.py:65 msgid "View wiki pages" msgstr "" -#: taiga/permissions/permissions.py:28 taiga/permissions/permissions.py:38 -#: taiga/permissions/permissions.py:79 +#: taiga/permissions/choices.py:30 taiga/permissions/choices.py:71 msgid "View wiki links" msgstr "" -#: taiga/permissions/permissions.py:39 -msgid "Request membership" -msgstr "" - -#: taiga/permissions/permissions.py:40 -msgid "Add user story to project" -msgstr "" - -#: taiga/permissions/permissions.py:41 -msgid "Add comments to user stories" -msgstr "" - -#: taiga/permissions/permissions.py:42 -msgid "Add comments to tasks" -msgstr "" - -#: taiga/permissions/permissions.py:43 -msgid "Add issues" -msgstr "" - -#: taiga/permissions/permissions.py:44 -msgid "Add comments to issues" -msgstr "" - -#: taiga/permissions/permissions.py:45 taiga/permissions/permissions.py:75 -msgid "Add wiki page" -msgstr "" - -#: taiga/permissions/permissions.py:46 taiga/permissions/permissions.py:76 -msgid "Modify wiki page" -msgstr "" - -#: taiga/permissions/permissions.py:47 taiga/permissions/permissions.py:80 -msgid "Add wiki link" -msgstr "" - -#: taiga/permissions/permissions.py:48 taiga/permissions/permissions.py:81 -msgid "Modify wiki link" -msgstr "" - -#: taiga/permissions/permissions.py:55 +#: taiga/permissions/choices.py:37 msgid "Add milestone" msgstr "" -#: taiga/permissions/permissions.py:56 +#: taiga/permissions/choices.py:38 msgid "Modify milestone" msgstr "" -#: taiga/permissions/permissions.py:57 +#: taiga/permissions/choices.py:39 msgid "Delete milestone" msgstr "" -#: taiga/permissions/permissions.py:59 +#: taiga/permissions/choices.py:42 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:43 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:44 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:47 msgid "View user story" msgstr "" -#: taiga/permissions/permissions.py:60 +#: taiga/permissions/choices.py:48 msgid "Add user story" msgstr "" -#: taiga/permissions/permissions.py:61 +#: taiga/permissions/choices.py:49 msgid "Modify user story" msgstr "" -#: taiga/permissions/permissions.py:62 +#: taiga/permissions/choices.py:50 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:51 msgid "Delete user story" msgstr "" -#: taiga/permissions/permissions.py:65 +#: taiga/permissions/choices.py:54 msgid "Add task" msgstr "" -#: taiga/permissions/permissions.py:66 +#: taiga/permissions/choices.py:55 msgid "Modify task" msgstr "" -#: taiga/permissions/permissions.py:67 +#: taiga/permissions/choices.py:56 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:57 msgid "Delete task" msgstr "" -#: taiga/permissions/permissions.py:70 +#: taiga/permissions/choices.py:60 msgid "Add issue" msgstr "" -#: taiga/permissions/permissions.py:71 +#: taiga/permissions/choices.py:61 msgid "Modify issue" msgstr "" -#: taiga/permissions/permissions.py:72 +#: taiga/permissions/choices.py:62 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:63 msgid "Delete issue" msgstr "" -#: taiga/permissions/permissions.py:77 +#: taiga/permissions/choices.py:66 +msgid "Add wiki page" +msgstr "" + +#: taiga/permissions/choices.py:67 +msgid "Modify wiki page" +msgstr "" + +#: taiga/permissions/choices.py:68 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:69 msgid "Delete wiki page" msgstr "" -#: taiga/permissions/permissions.py:82 +#: taiga/permissions/choices.py:72 +msgid "Add wiki link" +msgstr "" + +#: taiga/permissions/choices.py:73 +msgid "Modify wiki link" +msgstr "" + +#: taiga/permissions/choices.py:74 msgid "Delete wiki link" msgstr "" -#: taiga/permissions/permissions.py:86 +#: taiga/permissions/choices.py:78 msgid "Modify project" msgstr "" -#: taiga/permissions/permissions.py:87 -msgid "Add member" -msgstr "" - -#: taiga/permissions/permissions.py:88 -msgid "Remove member" -msgstr "" - -#: taiga/permissions/permissions.py:89 +#: taiga/permissions/choices.py:79 msgid "Delete project" msgstr "" -#: taiga/permissions/permissions.py:90 +#: taiga/permissions/choices.py:80 +msgid "Add member" +msgstr "" + +#: taiga/permissions/choices.py:81 +msgid "Remove member" +msgstr "" + +#: taiga/permissions/choices.py:82 msgid "Admin project values" msgstr "" -#: taiga/permissions/permissions.py:91 +#: taiga/permissions/choices.py:83 msgid "Admin roles" msgstr "" -#: taiga/projects/admin.py:90 taiga/projects/attachments/models.py:38 -#: taiga/projects/issues/models.py:39 taiga/projects/milestones/models.py:43 -#: taiga/projects/models.py:162 taiga/projects/notifications/models.py:61 -#: taiga/projects/tasks/models.py:38 taiga/projects/userstories/models.py:66 -#: taiga/projects/wiki/models.py:36 taiga/users/admin.py:69 -#: taiga/userstorage/models.py:26 +#: taiga/projects/admin.py:97 +msgid "Privacity" +msgstr "" + +#: taiga/projects/admin.py:109 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:117 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:123 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:128 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:142 taiga/projects/attachments/models.py:39 +#: taiga/projects/epics/models.py:38 taiga/projects/issues/models.py:37 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:160 +#: taiga/projects/notifications/models.py:62 taiga/projects/tasks/models.py:38 +#: taiga/projects/userstories/models.py:68 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:69 taiga/userstorage/models.py:27 msgid "owner" msgstr "" -#: taiga/projects/api.py:165 taiga/users/api.py:220 +#: taiga/projects/admin.py:195 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:196 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:210 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/admin.py:211 +msgid "Make private" +msgstr "" + +#: taiga/projects/api.py:150 taiga/users/api.py:237 msgid "Incomplete arguments" msgstr "" -#: taiga/projects/api.py:169 taiga/users/api.py:225 +#: taiga/projects/api.py:154 taiga/users/api.py:242 msgid "Invalid image format" msgstr "" -#: taiga/projects/api.py:230 +#: taiga/projects/api.py:215 msgid "Not valid template name" msgstr "" -#: taiga/projects/api.py:233 +#: taiga/projects/api.py:218 msgid "Not valid template description" msgstr "" -#: taiga/projects/api.py:356 +#: taiga/projects/api.py:344 msgid "Invalid user id" msgstr "" -#: taiga/projects/api.py:362 +#: taiga/projects/api.py:350 msgid "The user doesn't exist" msgstr "" -#: taiga/projects/api.py:366 +#: taiga/projects/api.py:354 msgid "The user must be already a project member" msgstr "" -#: taiga/projects/api.py:672 +#: taiga/projects/api.py:701 msgid "" "The project must have an owner and at least one of the users must be an " "active admin" msgstr "" -#: taiga/projects/api.py:706 +#: taiga/projects/api.py:735 msgid "You don't have permisions to see that." msgstr "" -#: taiga/projects/attachments/api.py:51 +#: taiga/projects/attachments/api.py:54 msgid "Partial updates are not supported" msgstr "" -#: taiga/projects/attachments/api.py:66 +#: taiga/projects/attachments/api.py:69 +msgid "Object id issue isn't exists" +msgstr "" + +#: taiga/projects/attachments/api.py:72 msgid "Project ID not matches between object and project" msgstr "" -#: taiga/projects/attachments/models.py:40 +#: taiga/projects/attachments/models.py:41 #: taiga/projects/custom_attributes/models.py:42 -#: taiga/projects/issues/models.py:52 taiga/projects/milestones/models.py:45 -#: taiga/projects/models.py:466 taiga/projects/models.py:492 -#: taiga/projects/models.py:523 taiga/projects/models.py:552 -#: taiga/projects/models.py:585 taiga/projects/models.py:608 -#: taiga/projects/models.py:635 taiga/projects/models.py:666 -#: taiga/projects/notifications/models.py:73 -#: taiga/projects/notifications/models.py:90 taiga/projects/tasks/models.py:42 -#: taiga/projects/userstories/models.py:64 taiga/projects/wiki/models.py:30 -#: taiga/projects/wiki/models.py:68 taiga/users/models.py:305 +#: taiga/projects/epics/models.py:36 taiga/projects/issues/models.py:50 +#: taiga/projects/milestones/models.py:45 taiga/projects/models.py:498 +#: taiga/projects/models.py:520 taiga/projects/models.py:557 +#: taiga/projects/models.py:585 taiga/projects/models.py:611 +#: taiga/projects/models.py:641 taiga/projects/models.py:661 +#: taiga/projects/models.py:685 taiga/projects/models.py:713 +#: taiga/projects/notifications/models.py:74 +#: taiga/projects/notifications/models.py:91 taiga/projects/tasks/models.py:42 +#: taiga/projects/userstories/models.py:66 taiga/projects/wiki/models.py:33 +#: taiga/projects/wiki/models.py:71 taiga/users/models.py:303 msgid "project" msgstr "" -#: taiga/projects/attachments/models.py:42 +#: taiga/projects/attachments/models.py:43 msgid "content type" msgstr "" -#: taiga/projects/attachments/models.py:44 +#: taiga/projects/attachments/models.py:45 msgid "object id" msgstr "" -#: taiga/projects/attachments/models.py:50 +#: taiga/projects/attachments/models.py:51 #: taiga/projects/custom_attributes/models.py:47 -#: taiga/projects/issues/models.py:57 taiga/projects/milestones/models.py:52 -#: taiga/projects/models.py:160 taiga/projects/models.py:692 -#: taiga/projects/tasks/models.py:50 taiga/projects/userstories/models.py:87 -#: taiga/projects/wiki/models.py:43 taiga/userstorage/models.py:30 +#: taiga/projects/epics/models.py:50 taiga/projects/issues/models.py:55 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:158 +#: taiga/projects/models.py:738 taiga/projects/tasks/models.py:50 +#: taiga/projects/userstories/models.py:89 taiga/projects/wiki/models.py:46 +#: taiga/userstorage/models.py:31 msgid "modified date" msgstr "" -#: taiga/projects/attachments/models.py:55 +#: taiga/projects/attachments/models.py:56 msgid "attached file" msgstr "" -#: taiga/projects/attachments/models.py:57 +#: taiga/projects/attachments/models.py:58 msgid "sha1" msgstr "" -#: taiga/projects/attachments/models.py:59 +#: taiga/projects/attachments/models.py:60 msgid "is deprecated" msgstr "" -#: taiga/projects/attachments/models.py:61 +#: taiga/projects/attachments/models.py:62 #: taiga/projects/custom_attributes/models.py:40 -#: taiga/projects/milestones/models.py:58 taiga/projects/models.py:482 -#: taiga/projects/models.py:519 taiga/projects/models.py:546 -#: taiga/projects/models.py:581 taiga/projects/models.py:604 -#: taiga/projects/models.py:629 taiga/projects/models.py:662 -#: taiga/projects/wiki/models.py:73 taiga/users/models.py:300 +#: taiga/projects/epics/models.py:100 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:514 taiga/projects/models.py:547 +#: taiga/projects/models.py:581 taiga/projects/models.py:605 +#: taiga/projects/models.py:637 taiga/projects/models.py:657 +#: taiga/projects/models.py:679 taiga/projects/models.py:709 +#: taiga/projects/wiki/models.py:76 taiga/users/models.py:298 msgid "order" msgstr "" -#: taiga/projects/choices.py:22 +#: taiga/projects/choices.py:23 msgid "AppearIn" msgstr "" -#: taiga/projects/choices.py:23 +#: taiga/projects/choices.py:24 msgid "Jitsi" msgstr "" -#: taiga/projects/choices.py:24 +#: taiga/projects/choices.py:25 msgid "Custom" msgstr "" -#: taiga/projects/choices.py:25 +#: taiga/projects/choices.py:26 msgid "Talky" msgstr "" -#: taiga/projects/choices.py:32 +#: taiga/projects/choices.py:35 msgid "This project is blocked due to payment failure" msgstr "" -#: taiga/projects/choices.py:33 +#: taiga/projects/choices.py:36 msgid "This project is blocked by admin staff" msgstr "" -#: taiga/projects/choices.py:34 +#: taiga/projects/choices.py:37 msgid "This project is blocked because the owner left" msgstr "" -#: taiga/projects/custom_attributes/choices.py:27 -msgid "Text" +#: taiga/projects/choices.py:38 +msgid "This project is blocked while it's deleted" msgstr "" #: taiga/projects/custom_attributes/choices.py:28 -msgid "Multi-Line Text" +msgid "Text" msgstr "" #: taiga/projects/custom_attributes/choices.py:29 -msgid "Date" +msgid "Multi-Line Text" msgstr "" #: taiga/projects/custom_attributes/choices.py:30 +msgid "Date" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:31 msgid "Url" msgstr "" #: taiga/projects/custom_attributes/models.py:39 -#: taiga/projects/issues/models.py:47 +#: taiga/projects/issues/models.py:45 msgid "type" msgstr "" -#: taiga/projects/custom_attributes/models.py:88 +#: taiga/projects/custom_attributes/models.py:94 msgid "values" msgstr "" -#: taiga/projects/custom_attributes/models.py:98 -#: taiga/projects/tasks/models.py:34 taiga/projects/userstories/models.py:36 +#: taiga/projects/custom_attributes/models.py:104 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:120 +#: taiga/projects/tasks/models.py:34 taiga/projects/userstories/models.py:37 msgid "user story" msgstr "" -#: taiga/projects/custom_attributes/models.py:113 +#: taiga/projects/custom_attributes/models.py:136 msgid "task" msgstr "" -#: taiga/projects/custom_attributes/models.py:128 +#: taiga/projects/custom_attributes/models.py:152 msgid "issue" msgstr "" -#: taiga/projects/custom_attributes/serializers.py:58 +#: taiga/projects/custom_attributes/validators.py:58 msgid "Already exists one with the same name." msgstr "" -#: taiga/projects/history/api.py:71 +#: taiga/projects/epics/api.py:91 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:34 taiga/projects/issues/models.py:35 +#: taiga/projects/tasks/models.py:36 taiga/projects/userstories/models.py:61 +msgid "ref" +msgstr "" + +#: taiga/projects/epics/models.py:41 taiga/projects/issues/models.py:39 +#: taiga/projects/tasks/models.py:40 taiga/projects/userstories/models.py:71 +msgid "status" +msgstr "" + +#: taiga/projects/epics/models.py:44 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:53 taiga/projects/issues/models.py:59 +#: taiga/projects/tasks/models.py:54 taiga/projects/userstories/models.py:93 +msgid "subject" +msgstr "" + +#: taiga/projects/epics/models.py:57 taiga/projects/models.py:518 +#: taiga/projects/models.py:553 taiga/projects/models.py:609 +#: taiga/projects/models.py:639 taiga/projects/models.py:659 +#: taiga/projects/models.py:683 taiga/projects/models.py:711 +#: taiga/users/models.py:139 +msgid "color" +msgstr "" + +#: taiga/projects/epics/models.py:60 taiga/projects/issues/models.py:63 +#: taiga/projects/tasks/models.py:64 taiga/projects/userstories/models.py:97 +msgid "assigned to" +msgstr "" + +#: taiga/projects/epics/models.py:62 taiga/projects/userstories/models.py:99 +msgid "is client requirement" +msgstr "" + +#: taiga/projects/epics/models.py:64 taiga/projects/userstories/models.py:101 +msgid "is team requirement" +msgstr "" + +#: taiga/projects/epics/models.py:68 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/validators.py:37 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:93 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:96 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:130 msgid "Comment already deleted" msgstr "" -#: taiga/projects/history/api.py:90 +#: taiga/projects/history/api.py:151 msgid "Comment not deleted" msgstr "" -#: taiga/projects/history/choices.py:27 +#: taiga/projects/history/choices.py:31 msgid "Change" msgstr "" -#: taiga/projects/history/choices.py:28 +#: taiga/projects/history/choices.py:32 msgid "Create" msgstr "" -#: taiga/projects/history/choices.py:29 +#: taiga/projects/history/choices.py:33 msgid "Delete" msgstr "" @@ -1404,7 +1478,7 @@ msgstr "" #: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:135 #: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:146 -#: taiga/projects/services/stats.py:54 taiga/projects/services/stats.py:55 +#: taiga/projects/services/stats.py:55 taiga/projects/services/stats.py:56 msgid "Unassigned" msgstr "" @@ -1451,95 +1525,75 @@ msgstr "" msgid "To:" msgstr "" -#: taiga/projects/history/templatetags/functions.py:25 -#: taiga/projects/wiki/models.py:34 +#: taiga/projects/history/templatetags/functions.py:26 +#: taiga/projects/wiki/models.py:37 msgid "content" msgstr "" -#: taiga/projects/history/templatetags/functions.py:26 -#: taiga/projects/mixins/blocked.py:32 +#: taiga/projects/history/templatetags/functions.py:27 +#: taiga/projects/mixins/blocked.py:33 msgid "blocked note" msgstr "" -#: taiga/projects/history/templatetags/functions.py:27 +#: taiga/projects/history/templatetags/functions.py:28 msgid "sprint" msgstr "" -#: taiga/projects/issues/api.py:158 +#: taiga/projects/issues/api.py:153 msgid "You don't have permissions to set this sprint to this issue." msgstr "" -#: taiga/projects/issues/api.py:162 +#: taiga/projects/issues/api.py:157 msgid "You don't have permissions to set this status to this issue." msgstr "" -#: taiga/projects/issues/api.py:166 +#: taiga/projects/issues/api.py:161 msgid "You don't have permissions to set this severity to this issue." msgstr "" -#: taiga/projects/issues/api.py:170 +#: taiga/projects/issues/api.py:165 msgid "You don't have permissions to set this priority to this issue." msgstr "" -#: taiga/projects/issues/api.py:174 +#: taiga/projects/issues/api.py:169 msgid "You don't have permissions to set this type to this issue." msgstr "" -#: taiga/projects/issues/models.py:37 taiga/projects/tasks/models.py:36 -#: taiga/projects/userstories/models.py:59 -msgid "ref" -msgstr "" - -#: taiga/projects/issues/models.py:41 taiga/projects/tasks/models.py:40 -#: taiga/projects/userstories/models.py:69 -msgid "status" -msgstr "" - -#: taiga/projects/issues/models.py:43 +#: taiga/projects/issues/models.py:41 msgid "severity" msgstr "" -#: taiga/projects/issues/models.py:45 +#: taiga/projects/issues/models.py:43 msgid "priority" msgstr "" -#: taiga/projects/issues/models.py:50 taiga/projects/tasks/models.py:45 -#: taiga/projects/userstories/models.py:62 +#: taiga/projects/issues/models.py:48 taiga/projects/tasks/models.py:45 +#: taiga/projects/userstories/models.py:64 msgid "milestone" msgstr "" -#: taiga/projects/issues/models.py:59 taiga/projects/tasks/models.py:52 +#: taiga/projects/issues/models.py:57 taiga/projects/tasks/models.py:52 msgid "finished date" msgstr "" -#: taiga/projects/issues/models.py:61 taiga/projects/tasks/models.py:54 -#: taiga/projects/userstories/models.py:91 -msgid "subject" -msgstr "" - -#: taiga/projects/issues/models.py:65 taiga/projects/tasks/models.py:64 -#: taiga/projects/userstories/models.py:95 -msgid "assigned to" -msgstr "" - -#: taiga/projects/issues/models.py:67 taiga/projects/tasks/models.py:68 -#: taiga/projects/userstories/models.py:105 +#: taiga/projects/issues/models.py:66 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:108 msgid "external reference" msgstr "" -#: taiga/projects/likes/models.py:35 +#: taiga/projects/likes/models.py:36 msgid "Like" msgstr "" -#: taiga/projects/likes/models.py:36 +#: taiga/projects/likes/models.py:37 msgid "Likes" msgstr "" -#: taiga/projects/milestones/models.py:41 taiga/projects/models.py:148 -#: taiga/projects/models.py:480 taiga/projects/models.py:544 -#: taiga/projects/models.py:627 taiga/projects/models.py:685 -#: taiga/projects/wiki/models.py:32 taiga/users/admin.py:57 -#: taiga/users/models.py:294 +#: taiga/projects/milestones/models.py:41 taiga/projects/models.py:146 +#: taiga/projects/models.py:512 taiga/projects/models.py:545 +#: taiga/projects/models.py:603 taiga/projects/models.py:677 +#: taiga/projects/models.py:729 taiga/projects/wiki/models.py:35 +#: taiga/users/admin.py:58 taiga/users/models.py:294 msgid "slug" msgstr "" @@ -1551,8 +1605,9 @@ msgstr "" msgid "estimated finish date" msgstr "" -#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:484 -#: taiga/projects/models.py:548 taiga/projects/models.py:631 +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:516 +#: taiga/projects/models.py:549 taiga/projects/models.py:607 +#: taiga/projects/models.py:681 msgid "is closed" msgstr "" @@ -1564,120 +1619,132 @@ msgstr "" msgid "The estimated start must be previous to the estimated finish." msgstr "" -#: taiga/projects/milestones/validators.py:12 -msgid "There's no sprint with that id" +#: taiga/projects/milestones/validators.py:33 +msgid "There's no milestone with that id" msgstr "" -#: taiga/projects/mixins/blocked.py:30 +#: taiga/projects/mixins/blocked.py:31 msgid "is blocked" msgstr "" -#: taiga/projects/mixins/ordering.py:48 +#: taiga/projects/mixins/ordering.py:49 #, python-brace-format msgid "'{param}' parameter is mandatory" msgstr "" -#: taiga/projects/mixins/ordering.py:52 +#: taiga/projects/mixins/ordering.py:53 msgid "'project' parameter is mandatory" msgstr "" -#: taiga/projects/models.py:78 +#: taiga/projects/models.py:75 msgid "email" msgstr "" -#: taiga/projects/models.py:80 +#: taiga/projects/models.py:77 msgid "create at" msgstr "" -#: taiga/projects/models.py:82 taiga/users/models.py:155 +#: taiga/projects/models.py:79 taiga/users/models.py:154 msgid "token" msgstr "" -#: taiga/projects/models.py:88 +#: taiga/projects/models.py:85 msgid "invitation extra text" msgstr "" -#: taiga/projects/models.py:91 +#: taiga/projects/models.py:88 taiga/projects/models.py:733 msgid "user order" msgstr "" -#: taiga/projects/models.py:101 +#: taiga/projects/models.py:104 msgid "The user is already member of the project" msgstr "" -#: taiga/projects/models.py:116 -msgid "default points" +#: taiga/projects/models.py:111 +msgid "default epic status" msgstr "" -#: taiga/projects/models.py:120 +#: taiga/projects/models.py:115 msgid "default US status" msgstr "" -#: taiga/projects/models.py:124 +#: taiga/projects/models.py:118 +msgid "default points" +msgstr "" + +#: taiga/projects/models.py:122 msgid "default task status" msgstr "" -#: taiga/projects/models.py:127 +#: taiga/projects/models.py:125 msgid "default priority" msgstr "" -#: taiga/projects/models.py:130 +#: taiga/projects/models.py:128 msgid "default severity" msgstr "" -#: taiga/projects/models.py:134 +#: taiga/projects/models.py:132 msgid "default issue status" msgstr "" -#: taiga/projects/models.py:138 +#: taiga/projects/models.py:136 msgid "default issue type" msgstr "" -#: taiga/projects/models.py:154 +#: taiga/projects/models.py:152 msgid "logo" msgstr "" -#: taiga/projects/models.py:164 +#: taiga/projects/models.py:162 msgid "members" msgstr "" -#: taiga/projects/models.py:167 +#: taiga/projects/models.py:165 msgid "total of milestones" msgstr "" -#: taiga/projects/models.py:168 +#: taiga/projects/models.py:166 msgid "total story points" msgstr "" -#: taiga/projects/models.py:171 taiga/projects/models.py:698 +#: taiga/projects/models.py:169 taiga/projects/models.py:744 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:171 taiga/projects/models.py:746 msgid "active backlog panel" msgstr "" -#: taiga/projects/models.py:173 taiga/projects/models.py:700 +#: taiga/projects/models.py:173 taiga/projects/models.py:748 msgid "active kanban panel" msgstr "" -#: taiga/projects/models.py:175 taiga/projects/models.py:702 +#: taiga/projects/models.py:175 taiga/projects/models.py:750 msgid "active wiki panel" msgstr "" -#: taiga/projects/models.py:177 taiga/projects/models.py:704 +#: taiga/projects/models.py:177 taiga/projects/models.py:752 msgid "active issues panel" msgstr "" -#: taiga/projects/models.py:180 taiga/projects/models.py:707 +#: taiga/projects/models.py:180 taiga/projects/models.py:755 msgid "videoconference system" msgstr "" -#: taiga/projects/models.py:182 taiga/projects/models.py:709 +#: taiga/projects/models.py:182 taiga/projects/models.py:757 msgid "videoconference extra data" msgstr "" -#: taiga/projects/models.py:187 +#: taiga/projects/models.py:188 msgid "creation template" msgstr "" -#: taiga/projects/models.py:191 +#: taiga/projects/models.py:191 taiga/users/admin.py:62 +msgid "is private" +msgstr "" + +#: taiga/projects/models.py:193 msgid "anonymous permissions" msgstr "" @@ -1685,169 +1752,251 @@ msgstr "" msgid "user permissions" msgstr "" -#: taiga/projects/models.py:198 taiga/users/admin.py:61 -msgid "is private" -msgstr "" - -#: taiga/projects/models.py:201 +#: taiga/projects/models.py:198 msgid "is featured" msgstr "" -#: taiga/projects/models.py:204 +#: taiga/projects/models.py:201 msgid "is looking for people" msgstr "" -#: taiga/projects/models.py:206 +#: taiga/projects/models.py:203 msgid "loking for people note" msgstr "" -#: taiga/projects/models.py:218 -msgid "tags colors" -msgstr "" - -#: taiga/projects/models.py:221 +#: taiga/projects/models.py:217 msgid "project transfer token" msgstr "" -#: taiga/projects/models.py:225 +#: taiga/projects/models.py:221 msgid "blocked code" msgstr "" -#: taiga/projects/models.py:229 taiga/projects/notifications/models.py:65 +#: taiga/projects/models.py:225 taiga/projects/notifications/models.py:66 msgid "updated date time" msgstr "" -#: taiga/projects/models.py:232 taiga/projects/models.py:244 -#: taiga/projects/votes/models.py:29 +#: taiga/projects/models.py:228 taiga/projects/models.py:240 +#: taiga/projects/votes/models.py:30 msgid "count" msgstr "" -#: taiga/projects/models.py:235 +#: taiga/projects/models.py:231 msgid "fans last week" msgstr "" -#: taiga/projects/models.py:238 +#: taiga/projects/models.py:234 msgid "fans last month" msgstr "" -#: taiga/projects/models.py:241 +#: taiga/projects/models.py:237 msgid "fans last year" msgstr "" -#: taiga/projects/models.py:247 +#: taiga/projects/models.py:243 msgid "activity last week" msgstr "" -#: taiga/projects/models.py:250 +#: taiga/projects/models.py:246 msgid "activity last month" msgstr "" -#: taiga/projects/models.py:253 +#: taiga/projects/models.py:249 msgid "activity last year" msgstr "" -#: taiga/projects/models.py:467 +#: taiga/projects/models.py:499 msgid "modules config" msgstr "" -#: taiga/projects/models.py:486 +#: taiga/projects/models.py:551 msgid "is archived" msgstr "" -#: taiga/projects/models.py:488 taiga/projects/models.py:550 -#: taiga/projects/models.py:583 taiga/projects/models.py:606 -#: taiga/projects/models.py:633 taiga/projects/models.py:664 -#: taiga/users/models.py:140 -msgid "color" -msgstr "" - -#: taiga/projects/models.py:490 +#: taiga/projects/models.py:555 msgid "work in progress limit" msgstr "" -#: taiga/projects/models.py:521 taiga/userstorage/models.py:32 +#: taiga/projects/models.py:583 taiga/userstorage/models.py:33 msgid "value" msgstr "" -#: taiga/projects/models.py:695 +#: taiga/projects/models.py:741 msgid "default owner's role" msgstr "" -#: taiga/projects/models.py:711 +#: taiga/projects/models.py:759 msgid "default options" msgstr "" -#: taiga/projects/models.py:712 +#: taiga/projects/models.py:760 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:761 msgid "us statuses" msgstr "" -#: taiga/projects/models.py:713 taiga/projects/userstories/models.py:42 -#: taiga/projects/userstories/models.py:74 +#: taiga/projects/models.py:762 taiga/projects/userstories/models.py:43 +#: taiga/projects/userstories/models.py:76 msgid "points" msgstr "" -#: taiga/projects/models.py:714 +#: taiga/projects/models.py:763 msgid "task statuses" msgstr "" -#: taiga/projects/models.py:715 +#: taiga/projects/models.py:764 msgid "issue statuses" msgstr "" -#: taiga/projects/models.py:716 +#: taiga/projects/models.py:765 msgid "issue types" msgstr "" -#: taiga/projects/models.py:717 +#: taiga/projects/models.py:766 msgid "priorities" msgstr "" -#: taiga/projects/models.py:718 +#: taiga/projects/models.py:767 msgid "severities" msgstr "" -#: taiga/projects/models.py:719 +#: taiga/projects/models.py:768 msgid "roles" msgstr "" -#: taiga/projects/notifications/choices.py:29 +#: taiga/projects/notifications/choices.py:30 msgid "Involved" msgstr "" -#: taiga/projects/notifications/choices.py:30 +#: taiga/projects/notifications/choices.py:31 msgid "All" msgstr "" -#: taiga/projects/notifications/choices.py:31 +#: taiga/projects/notifications/choices.py:32 msgid "None" msgstr "" -#: taiga/projects/notifications/models.py:63 +#: taiga/projects/notifications/models.py:64 msgid "created date time" msgstr "" -#: taiga/projects/notifications/models.py:67 +#: taiga/projects/notifications/models.py:68 msgid "history entries" msgstr "" -#: taiga/projects/notifications/models.py:70 +#: taiga/projects/notifications/models.py:71 msgid "notify users" msgstr "" -#: taiga/projects/notifications/models.py:92 #: taiga/projects/notifications/models.py:93 +#: taiga/projects/notifications/models.py:94 msgid "Watched" msgstr "" -#: taiga/projects/notifications/services.py:64 -#: taiga/projects/notifications/services.py:78 +#: taiga/projects/notifications/services.py:65 +#: taiga/projects/notifications/services.py:79 msgid "Notify exists for specified user and project" msgstr "" -#: taiga/projects/notifications/services.py:427 +#: taiga/projects/notifications/services.py:425 msgid "Invalid value for notify level" msgstr "" +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:4 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:3 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:1 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:4 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

The Taiga Team

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:1 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"The Taiga Team\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:1 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:4 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

The Taiga Team

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:1 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"The Taiga Team\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:1 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + #: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:4 #, python-format msgid "" @@ -2319,141 +2468,135 @@ msgid "" "[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" msgstr "" -#: taiga/projects/notifications/validators.py:47 +#: taiga/projects/notifications/validators.py:48 msgid "Watchers contains invalid users" msgstr "" -#: taiga/projects/occ/mixins.py:36 +#: taiga/projects/occ/mixins.py:37 msgid "The version must be an integer" msgstr "" -#: taiga/projects/occ/mixins.py:59 +#: taiga/projects/occ/mixins.py:60 msgid "The version parameter is not valid" msgstr "" -#: taiga/projects/occ/mixins.py:75 +#: taiga/projects/occ/mixins.py:76 msgid "The version doesn't match with the current one" msgstr "" -#: taiga/projects/occ/mixins.py:94 +#: taiga/projects/occ/mixins.py:95 msgid "version" msgstr "" -#: taiga/projects/permissions.py:40 +#: taiga/projects/permissions.py:44 msgid "" "You can't leave the project if you are the owner or there are no more admins" msgstr "" -#: taiga/projects/serializers.py:172 -msgid "Email address is already taken" +#: taiga/projects/services/members.py:118 +msgid "Project without owner" msgstr "" -#: taiga/projects/serializers.py:184 -msgid "Invalid role for the project" -msgstr "" - -#: taiga/projects/serializers.py:195 -msgid "The project owner must be admin." -msgstr "" - -#: taiga/projects/serializers.py:198 -msgid "At least one user must be an active admin for this project." -msgstr "" - -#: taiga/projects/serializers.py:396 -msgid "Default options" -msgstr "" - -#: taiga/projects/serializers.py:397 -msgid "User story's statuses" -msgstr "" - -#: taiga/projects/serializers.py:398 -msgid "Points" -msgstr "" - -#: taiga/projects/serializers.py:399 -msgid "Task's statuses" -msgstr "" - -#: taiga/projects/serializers.py:400 -msgid "Issue's statuses" -msgstr "" - -#: taiga/projects/serializers.py:401 -msgid "Issue's types" -msgstr "" - -#: taiga/projects/serializers.py:402 -msgid "Priorities" -msgstr "" - -#: taiga/projects/serializers.py:403 -msgid "Severities" -msgstr "" - -#: taiga/projects/serializers.py:404 -msgid "Roles" -msgstr "" - -#: taiga/projects/services/members.py:116 +#: taiga/projects/services/members.py:123 msgid "You have reached your current limit of memberships for private projects" msgstr "" -#: taiga/projects/services/members.py:120 +#: taiga/projects/services/members.py:127 msgid "You have reached your current limit of memberships for public projects" msgstr "" -#: taiga/projects/services/projects.py:69 -#: taiga/projects/services/projects.py:106 taiga/users/services.py:582 +#: taiga/projects/services/projects.py:94 +#: taiga/projects/services/projects.py:134 taiga/users/services.py:581 msgid "You can't have more private projects" msgstr "" -#: taiga/projects/services/projects.py:73 -#: taiga/projects/services/projects.py:110 taiga/users/services.py:585 +#: taiga/projects/services/projects.py:98 +#: taiga/projects/services/projects.py:138 taiga/users/services.py:584 msgid "" "This project reaches your current limit of memberships for private projects" msgstr "" -#: taiga/projects/services/projects.py:77 -#: taiga/projects/services/projects.py:114 taiga/users/services.py:589 +#: taiga/projects/services/projects.py:102 +#: taiga/projects/services/projects.py:142 taiga/users/services.py:588 msgid "You can't have more public projects" msgstr "" -#: taiga/projects/services/projects.py:81 -#: taiga/projects/services/projects.py:118 taiga/users/services.py:592 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:146 taiga/users/services.py:591 msgid "" "This project reaches your current limit of memberships for public projects" msgstr "" -#: taiga/projects/services/stats.py:196 +#: taiga/projects/services/stats.py:197 msgid "Future sprint" msgstr "" -#: taiga/projects/services/stats.py:216 +#: taiga/projects/services/stats.py:217 msgid "Project End" msgstr "" -#: taiga/projects/services/transfer.py:61 -#: taiga/projects/services/transfer.py:68 -#: taiga/projects/services/transfer.py:71 taiga/users/api.py:169 -#: taiga/users/api.py:174 +#: taiga/projects/services/transfer.py:62 +#: taiga/projects/services/transfer.py:69 +#: taiga/projects/services/transfer.py:72 taiga/users/api.py:186 +#: taiga/users/api.py:191 msgid "Token is invalid" msgstr "" -#: taiga/projects/services/transfer.py:66 +#: taiga/projects/services/transfer.py:67 msgid "Token has expired" msgstr "" -#: taiga/projects/tasks/api.py:113 taiga/projects/tasks/api.py:122 +#: taiga/projects/tagging/fields.py:52 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:55 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:77 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:27 +msgid "tags" +msgstr "" + +#: taiga/projects/tagging/models.py:35 +msgid "tags colors" +msgstr "" + +#: taiga/projects/tagging/validators.py:47 +#: taiga/projects/tagging/validators.py:74 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:54 +#: taiga/projects/tagging/validators.py:81 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:67 +#: taiga/projects/tagging/validators.py:92 +#: taiga/projects/tagging/validators.py:105 +#: taiga/projects/tagging/validators.py:112 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:94 taiga/projects/tasks/api.py:103 msgid "You don't have permissions to set this sprint to this task." msgstr "" -#: taiga/projects/tasks/api.py:116 +#: taiga/projects/tasks/api.py:97 msgid "You don't have permissions to set this user story to this task." msgstr "" -#: taiga/projects/tasks/api.py:119 +#: taiga/projects/tasks/api.py:100 msgid "You don't have permissions to set this status to this task." msgstr "" @@ -2469,8 +2612,34 @@ msgstr "" msgid "is iocaine" msgstr "" -#: taiga/projects/tasks/validators.py:12 -msgid "There's no task with that id" +#: taiga/projects/tasks/validators.py:59 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:70 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:83 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:107 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:121 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:133 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:150 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." msgstr "" #: taiga/projects/templates/emails/membership_invitation-body-html.jinja:6 @@ -2812,12 +2981,12 @@ msgid "" msgstr "" #. Translators: Name of scrum project template. -#: taiga/projects/translations.py:29 +#: taiga/projects/translations.py:30 msgid "Scrum" msgstr "" #. Translators: Description of scrum project template. -#: taiga/projects/translations.py:31 +#: taiga/projects/translations.py:32 msgid "" "The agile product backlog in Scrum is a prioritized features list, " "containing short descriptions of all functionality desired in the product. " @@ -2828,12 +2997,12 @@ msgid "" msgstr "" #. Translators: Name of kanban project template. -#: taiga/projects/translations.py:34 +#: taiga/projects/translations.py:35 msgid "Kanban" msgstr "" #. Translators: Description of kanban project template. -#: taiga/projects/translations.py:36 +#: taiga/projects/translations.py:37 msgid "" "Kanban is a method for managing knowledge work with an emphasis on just-in-" "time delivery while not overloading the team members. In this approach, the " @@ -2842,303 +3011,388 @@ msgid "" msgstr "" #. Translators: User story point value (value = undefined) -#: taiga/projects/translations.py:44 +#: taiga/projects/translations.py:45 msgid "?" msgstr "" #. Translators: User story point value (value = 0) -#: taiga/projects/translations.py:46 +#: taiga/projects/translations.py:47 msgid "0" msgstr "" #. Translators: User story point value (value = 0.5) -#: taiga/projects/translations.py:48 +#: taiga/projects/translations.py:49 msgid "1/2" msgstr "" #. Translators: User story point value (value = 1) -#: taiga/projects/translations.py:50 +#: taiga/projects/translations.py:51 msgid "1" msgstr "" #. Translators: User story point value (value = 2) -#: taiga/projects/translations.py:52 +#: taiga/projects/translations.py:53 msgid "2" msgstr "" #. Translators: User story point value (value = 3) -#: taiga/projects/translations.py:54 +#: taiga/projects/translations.py:55 msgid "3" msgstr "" #. Translators: User story point value (value = 5) -#: taiga/projects/translations.py:56 +#: taiga/projects/translations.py:57 msgid "5" msgstr "" #. Translators: User story point value (value = 8) -#: taiga/projects/translations.py:58 +#: taiga/projects/translations.py:59 msgid "8" msgstr "" #. Translators: User story point value (value = 10) -#: taiga/projects/translations.py:60 +#: taiga/projects/translations.py:61 msgid "10" msgstr "" #. Translators: User story point value (value = 13) -#: taiga/projects/translations.py:62 +#: taiga/projects/translations.py:63 msgid "13" msgstr "" #. Translators: User story point value (value = 20) -#: taiga/projects/translations.py:64 +#: taiga/projects/translations.py:65 msgid "20" msgstr "" #. Translators: User story point value (value = 40) -#: taiga/projects/translations.py:66 +#: taiga/projects/translations.py:67 msgid "40" msgstr "" #. Translators: User story status #. Translators: Task status #. Translators: Issue status -#: taiga/projects/translations.py:74 taiga/projects/translations.py:97 -#: taiga/projects/translations.py:113 +#: taiga/projects/translations.py:75 taiga/projects/translations.py:98 +#: taiga/projects/translations.py:114 msgid "New" msgstr "" #. Translators: User story status -#: taiga/projects/translations.py:77 +#: taiga/projects/translations.py:78 msgid "Ready" msgstr "" #. Translators: User story status #. Translators: Task status #. Translators: Issue status -#: taiga/projects/translations.py:80 taiga/projects/translations.py:99 -#: taiga/projects/translations.py:115 +#: taiga/projects/translations.py:81 taiga/projects/translations.py:100 +#: taiga/projects/translations.py:116 msgid "In progress" msgstr "" #. Translators: User story status #. Translators: Task status #. Translators: Issue status -#: taiga/projects/translations.py:83 taiga/projects/translations.py:101 -#: taiga/projects/translations.py:117 +#: taiga/projects/translations.py:84 taiga/projects/translations.py:102 +#: taiga/projects/translations.py:118 msgid "Ready for test" msgstr "" #. Translators: User story status -#: taiga/projects/translations.py:86 +#: taiga/projects/translations.py:87 msgid "Done" msgstr "" #. Translators: User story status -#: taiga/projects/translations.py:89 +#: taiga/projects/translations.py:90 msgid "Archived" msgstr "" #. Translators: Task status #. Translators: Issue status -#: taiga/projects/translations.py:103 taiga/projects/translations.py:119 +#: taiga/projects/translations.py:104 taiga/projects/translations.py:120 msgid "Closed" msgstr "" #. Translators: Task status #. Translators: Issue status -#: taiga/projects/translations.py:105 taiga/projects/translations.py:121 +#: taiga/projects/translations.py:106 taiga/projects/translations.py:122 msgid "Needs Info" msgstr "" #. Translators: Issue status -#: taiga/projects/translations.py:123 +#: taiga/projects/translations.py:124 msgid "Postponed" msgstr "" #. Translators: Issue status -#: taiga/projects/translations.py:125 +#: taiga/projects/translations.py:126 msgid "Rejected" msgstr "" #. Translators: Issue type -#: taiga/projects/translations.py:133 +#: taiga/projects/translations.py:134 msgid "Bug" msgstr "" #. Translators: Issue type -#: taiga/projects/translations.py:135 +#: taiga/projects/translations.py:136 msgid "Question" msgstr "" #. Translators: Issue type -#: taiga/projects/translations.py:137 +#: taiga/projects/translations.py:138 msgid "Enhancement" msgstr "" #. Translators: Issue priority -#: taiga/projects/translations.py:145 +#: taiga/projects/translations.py:146 msgid "Low" msgstr "" #. Translators: Issue priority #. Translators: Issue severity -#: taiga/projects/translations.py:147 taiga/projects/translations.py:160 +#: taiga/projects/translations.py:148 taiga/projects/translations.py:161 msgid "Normal" msgstr "" #. Translators: Issue priority -#: taiga/projects/translations.py:149 +#: taiga/projects/translations.py:150 msgid "High" msgstr "" #. Translators: Issue severity -#: taiga/projects/translations.py:156 +#: taiga/projects/translations.py:157 msgid "Wishlist" msgstr "" #. Translators: Issue severity -#: taiga/projects/translations.py:158 +#: taiga/projects/translations.py:159 msgid "Minor" msgstr "" #. Translators: Issue severity -#: taiga/projects/translations.py:162 +#: taiga/projects/translations.py:163 msgid "Important" msgstr "" #. Translators: Issue severity -#: taiga/projects/translations.py:164 +#: taiga/projects/translations.py:165 msgid "Critical" msgstr "" #. Translators: User role -#: taiga/projects/translations.py:171 +#: taiga/projects/translations.py:172 msgid "UX" msgstr "" #. Translators: User role -#: taiga/projects/translations.py:173 +#: taiga/projects/translations.py:174 msgid "Design" msgstr "" #. Translators: User role -#: taiga/projects/translations.py:175 +#: taiga/projects/translations.py:176 msgid "Front" msgstr "" #. Translators: User role -#: taiga/projects/translations.py:177 +#: taiga/projects/translations.py:178 msgid "Back" msgstr "" #. Translators: User role -#: taiga/projects/translations.py:179 +#: taiga/projects/translations.py:180 msgid "Product Owner" msgstr "" #. Translators: User role -#: taiga/projects/translations.py:181 +#: taiga/projects/translations.py:182 msgid "Stakeholder" msgstr "" -#: taiga/projects/userstories/api.py:163 +#: taiga/projects/userstories/api.py:116 msgid "You don't have permissions to set this sprint to this user story." msgstr "" -#: taiga/projects/userstories/api.py:167 +#: taiga/projects/userstories/api.py:120 msgid "You don't have permissions to set this status to this user story." msgstr "" -#: taiga/projects/userstories/api.py:267 +#: taiga/projects/userstories/api.py:210 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:217 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:232 #, python-brace-format msgid "Generating the user story #{ref} - {subject}" msgstr "" -#: taiga/projects/userstories/models.py:39 +#: taiga/projects/userstories/api.py:293 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/userstories/api.py:296 +msgid "project or project_slug param is needed" +msgstr "" + +#: taiga/projects/userstories/models.py:40 msgid "role" msgstr "" -#: taiga/projects/userstories/models.py:77 +#: taiga/projects/userstories/models.py:79 msgid "backlog order" msgstr "" -#: taiga/projects/userstories/models.py:79 #: taiga/projects/userstories/models.py:81 msgid "sprint order" msgstr "" -#: taiga/projects/userstories/models.py:89 +#: taiga/projects/userstories/models.py:83 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:91 msgid "finish date" msgstr "" -#: taiga/projects/userstories/models.py:97 -msgid "is client requirement" -msgstr "" - -#: taiga/projects/userstories/models.py:99 -msgid "is team requirement" -msgstr "" - -#: taiga/projects/userstories/models.py:104 +#: taiga/projects/userstories/models.py:106 msgid "generated from issue" msgstr "" -#: taiga/projects/userstories/validators.py:29 +#: taiga/projects/userstories/validators.py:43 msgid "There's no user story with that id" msgstr "" -#: taiga/projects/validators.py:29 +#: taiga/projects/userstories/validators.py:82 +#: taiga/projects/userstories/validators.py:108 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:120 +msgid "Invalid milestone id. The milistone must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:135 +msgid "" +"Invalid user story ids. All stories must belong to the same project and, if " +"it exists, to the same status and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:159 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/userstories/validators.py:169 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/validators.py:61 msgid "There's no project with that id" msgstr "" -#: taiga/projects/validators.py:38 -msgid "There's no user story status with that id" +#: taiga/projects/validators.py:142 +msgid "Email address is already taken" msgstr "" -#: taiga/projects/validators.py:47 -msgid "There's no task status with that id" +#: taiga/projects/validators.py:154 +msgid "Invalid role for the project" msgstr "" -#: taiga/projects/votes/models.py:32 taiga/projects/votes/models.py:33 -#: taiga/projects/votes/models.py:57 +#: taiga/projects/validators.py:165 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:169 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:201 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:225 +msgid "Default options" +msgstr "" + +#: taiga/projects/validators.py:226 +msgid "User story's statuses" +msgstr "" + +#: taiga/projects/validators.py:227 +msgid "Points" +msgstr "" + +#: taiga/projects/validators.py:228 +msgid "Task's statuses" +msgstr "" + +#: taiga/projects/validators.py:229 +msgid "Issue's statuses" +msgstr "" + +#: taiga/projects/validators.py:230 +msgid "Issue's types" +msgstr "" + +#: taiga/projects/validators.py:231 +msgid "Priorities" +msgstr "" + +#: taiga/projects/validators.py:232 +msgid "Severities" +msgstr "" + +#: taiga/projects/validators.py:233 +msgid "Roles" +msgstr "" + +#: taiga/projects/votes/models.py:33 taiga/projects/votes/models.py:34 +#: taiga/projects/votes/models.py:58 msgid "Votes" msgstr "" -#: taiga/projects/votes/models.py:56 +#: taiga/projects/votes/models.py:57 msgid "Vote" msgstr "" -#: taiga/projects/wiki/api.py:70 +#: taiga/projects/wiki/api.py:77 msgid "'content' parameter is mandatory" msgstr "" -#: taiga/projects/wiki/api.py:73 +#: taiga/projects/wiki/api.py:80 msgid "'project_id' parameter is mandatory" msgstr "" -#: taiga/projects/wiki/models.py:38 +#: taiga/projects/wiki/models.py:41 msgid "last modifier" msgstr "" -#: taiga/projects/wiki/models.py:71 +#: taiga/projects/wiki/models.py:74 msgid "href" msgstr "" -#: taiga/timeline/signals.py:68 +#: taiga/timeline/signals.py:63 msgid "Check the history API for the exact diff" msgstr "" -#: taiga/users/admin.py:38 +#: taiga/users/admin.py:39 msgid "Project Member" msgstr "" -#: taiga/users/admin.py:39 +#: taiga/users/admin.py:40 msgid "Project Members" msgstr "" -#: taiga/users/admin.py:49 +#: taiga/users/admin.py:50 msgid "id" msgstr "" @@ -3166,145 +3420,137 @@ msgstr "" msgid "Important dates" msgstr "" -#: taiga/users/api.py:113 +#: taiga/users/api.py:123 msgid "Duplicated email" msgstr "" -#: taiga/users/api.py:115 +#: taiga/users/api.py:125 msgid "Not valid email" msgstr "" -#: taiga/users/api.py:148 +#: taiga/users/api.py:165 msgid "Invalid username or email" msgstr "" -#: taiga/users/api.py:157 +#: taiga/users/api.py:174 msgid "Mail sended successful!" msgstr "" -#: taiga/users/api.py:195 +#: taiga/users/api.py:212 msgid "Current password parameter needed" msgstr "" -#: taiga/users/api.py:198 +#: taiga/users/api.py:215 msgid "New password parameter needed" msgstr "" -#: taiga/users/api.py:201 +#: taiga/users/api.py:218 msgid "Invalid password length at least 6 charaters needed" msgstr "" -#: taiga/users/api.py:204 +#: taiga/users/api.py:221 msgid "Invalid current password" msgstr "" -#: taiga/users/api.py:251 taiga/users/api.py:257 +#: taiga/users/api.py:268 taiga/users/api.py:274 msgid "" "Invalid, are you sure the token is correct and you didn't use it before?" msgstr "" -#: taiga/users/api.py:284 taiga/users/api.py:292 taiga/users/api.py:295 +#: taiga/users/api.py:301 taiga/users/api.py:309 taiga/users/api.py:312 msgid "Invalid, are you sure the token is correct?" msgstr "" -#: taiga/users/models.py:96 +#: taiga/users/models.py:95 msgid "superuser status" msgstr "" -#: taiga/users/models.py:97 +#: taiga/users/models.py:96 msgid "" "Designates that this user has all permissions without explicitly assigning " "them." msgstr "" -#: taiga/users/models.py:127 +#: taiga/users/models.py:126 msgid "username" msgstr "" -#: taiga/users/models.py:128 +#: taiga/users/models.py:127 msgid "" "Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" msgstr "" -#: taiga/users/models.py:131 +#: taiga/users/models.py:130 msgid "Enter a valid username." msgstr "" -#: taiga/users/models.py:134 +#: taiga/users/models.py:133 msgid "active" msgstr "" -#: taiga/users/models.py:135 +#: taiga/users/models.py:134 msgid "" "Designates whether this user should be treated as active. Unselect this " "instead of deleting accounts." msgstr "" -#: taiga/users/models.py:141 +#: taiga/users/models.py:140 msgid "biography" msgstr "" -#: taiga/users/models.py:144 +#: taiga/users/models.py:143 msgid "photo" msgstr "" -#: taiga/users/models.py:145 +#: taiga/users/models.py:144 msgid "date joined" msgstr "" -#: taiga/users/models.py:147 +#: taiga/users/models.py:146 msgid "default language" msgstr "" -#: taiga/users/models.py:149 +#: taiga/users/models.py:148 msgid "default theme" msgstr "" -#: taiga/users/models.py:151 +#: taiga/users/models.py:150 msgid "default timezone" msgstr "" -#: taiga/users/models.py:153 +#: taiga/users/models.py:152 msgid "colorize tags" msgstr "" -#: taiga/users/models.py:158 +#: taiga/users/models.py:157 msgid "email token" msgstr "" -#: taiga/users/models.py:160 +#: taiga/users/models.py:159 msgid "new email address" msgstr "" -#: taiga/users/models.py:167 +#: taiga/users/models.py:166 msgid "max number of owned private projects" msgstr "" -#: taiga/users/models.py:170 +#: taiga/users/models.py:169 msgid "max number of owned public projects" msgstr "" -#: taiga/users/models.py:173 +#: taiga/users/models.py:172 msgid "max number of memberships for each owned private project" msgstr "" -#: taiga/users/models.py:177 +#: taiga/users/models.py:176 msgid "max number of memberships for each owned public project" msgstr "" -#: taiga/users/models.py:297 +#: taiga/users/models.py:296 msgid "permissions" msgstr "" -#: taiga/users/serializers.py:65 -msgid "invalid" -msgstr "" - -#: taiga/users/serializers.py:76 -msgid "Invalid username. Try with a different one." -msgstr "" - -#: taiga/users/services.py:53 taiga/users/services.py:70 +#: taiga/users/services.py:51 taiga/users/services.py:68 msgid "Username or password does not matches user." msgstr "" @@ -3425,47 +3671,51 @@ msgstr "" msgid "You've been Taigatized!" msgstr "" -#: taiga/users/validators.py:30 -msgid "There's no role with that id" +#: taiga/users/validators.py:45 +msgid "invalid" msgstr "" -#: taiga/userstorage/api.py:51 +#: taiga/users/validators.py:56 +msgid "Invalid username. Try with a different one." +msgstr "" + +#: taiga/userstorage/api.py:53 msgid "" "Duplicate key value violates unique constraint. Key '{}' already exists." msgstr "" -#: taiga/userstorage/models.py:31 +#: taiga/userstorage/models.py:32 msgid "key" msgstr "" -#: taiga/webhooks/models.py:29 taiga/webhooks/models.py:39 +#: taiga/webhooks/models.py:30 taiga/webhooks/models.py:40 msgid "URL" msgstr "" -#: taiga/webhooks/models.py:30 +#: taiga/webhooks/models.py:31 msgid "secret key" msgstr "" -#: taiga/webhooks/models.py:40 +#: taiga/webhooks/models.py:41 msgid "status code" msgstr "" -#: taiga/webhooks/models.py:41 +#: taiga/webhooks/models.py:42 msgid "request data" msgstr "" -#: taiga/webhooks/models.py:42 +#: taiga/webhooks/models.py:43 msgid "request headers" msgstr "" -#: taiga/webhooks/models.py:43 +#: taiga/webhooks/models.py:44 msgid "response data" msgstr "" -#: taiga/webhooks/models.py:44 +#: taiga/webhooks/models.py:45 msgid "response headers" msgstr "" -#: taiga/webhooks/models.py:45 +#: taiga/webhooks/models.py:46 msgid "duration" msgstr "" From 7313dfd9965a0c04cadb8cbd32161bbac2499ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 24 Aug 2016 18:29:36 +0200 Subject: [PATCH 61/83] Add comment permissions to default project templates --- .../fixtures/initial_project_templates.json | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/taiga/projects/fixtures/initial_project_templates.json b/taiga/projects/fixtures/initial_project_templates.json index 5c711c00..9bc6d969 100644 --- a/taiga/projects/fixtures/initial_project_templates.json +++ b/taiga/projects/fixtures/initial_project_templates.json @@ -8,7 +8,7 @@ "description": "The agile product backlog in Scrum is a prioritized features list, containing short descriptions of all functionality desired in the product. When applying Scrum, it's not necessary to start a project with a lengthy, upfront effort to document all requirements. The Scrum product backlog is then allowed to grow and change as more is learned about the product and its customers", "order": 1, "created_date": "2014-04-22T14:48:43.596Z", - "modified_date": "2016-07-07T13:18:25.350Z", + "modified_date": "2016-08-24T16:26:40.845Z", "default_owner_role": "product-owner", "is_epics_activated": true, "is_backlog_activated": true, @@ -17,16 +17,16 @@ "is_issues_activated": true, "videoconferences": null, "videoconferences_extra_data": "", - "default_options": "{\"issue_status\": \"New\", \"task_status\": \"New\", \"severity\": \"Normal\", \"issue_type\": \"Bug\", \"points\": \"?\", \"priority\": \"Normal\", \"us_status\": \"New\", \"epic_status\": \"New\"}", - "epic_statuses": "[{\"name\": \"New\", \"is_closed\": false, \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"Ready\", \"is_closed\": false, \"slug\": \"ready\", \"order\": 2, \"color\": \"#ff8a84\"}, {\"name\": \"In progress\", \"is_closed\": false, \"slug\": \"in-progress\", \"order\": 3, \"color\": \"#ff9900\"}, {\"name\": \"Ready for test\", \"is_closed\": false, \"slug\": \"ready-for-test\", \"order\": 4, \"color\": \"#fcc000\"}, {\"name\": \"Done\", \"is_closed\": true, \"slug\": \"done\", \"order\": 5, \"color\": \"#669900\"}]", - "us_statuses": "[{\"wip_limit\": null, \"name\": \"New\", \"slug\": \"new\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#999999\", \"order\": 1}, {\"wip_limit\": null, \"name\": \"Ready\", \"slug\": \"ready\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#ff8a84\", \"order\": 2}, {\"wip_limit\": null, \"name\": \"In progress\", \"slug\": \"in-progress\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#ff9900\", \"order\": 3}, {\"wip_limit\": null, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#fcc000\", \"order\": 4}, {\"wip_limit\": null, \"name\": \"Done\", \"slug\": \"done\", \"is_closed\": true, \"is_archived\": false, \"color\": \"#669900\", \"order\": 5}, {\"wip_limit\": null, \"name\": \"Archived\", \"slug\": \"archived\", \"is_closed\": true, \"is_archived\": true, \"color\": \"#5c3566\", \"order\": 6}]", - "points": "[{\"name\": \"?\", \"value\": null, \"order\": 1}, {\"name\": \"0\", \"value\": 0.0, \"order\": 2}, {\"name\": \"1/2\", \"value\": 0.5, \"order\": 3}, {\"name\": \"1\", \"value\": 1.0, \"order\": 4}, {\"name\": \"2\", \"value\": 2.0, \"order\": 5}, {\"name\": \"3\", \"value\": 3.0, \"order\": 6}, {\"name\": \"5\", \"value\": 5.0, \"order\": 7}, {\"name\": \"8\", \"value\": 8.0, \"order\": 8}, {\"name\": \"10\", \"value\": 10.0, \"order\": 9}, {\"name\": \"13\", \"value\": 13.0, \"order\": 10}, {\"name\": \"20\", \"value\": 20.0, \"order\": 11}, {\"name\": \"40\", \"value\": 40.0, \"order\": 12}]", - "task_statuses": "[{\"name\": \"New\", \"is_closed\": false, \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"In progress\", \"is_closed\": false, \"slug\": \"in-progress\", \"order\": 2, \"color\": \"#ff9900\"}, {\"name\": \"Ready for test\", \"is_closed\": true, \"slug\": \"ready-for-test\", \"order\": 3, \"color\": \"#ffcc00\"}, {\"name\": \"Closed\", \"is_closed\": true, \"slug\": \"closed\", \"order\": 4, \"color\": \"#669900\"}, {\"name\": \"Needs Info\", \"is_closed\": false, \"slug\": \"needs-info\", \"order\": 5, \"color\": \"#999999\"}]", - "issue_statuses": "[{\"name\": \"New\", \"is_closed\": false, \"slug\": \"new\", \"order\": 1, \"color\": \"#8C2318\"}, {\"name\": \"In progress\", \"is_closed\": false, \"slug\": \"in-progress\", \"order\": 2, \"color\": \"#5E8C6A\"}, {\"name\": \"Ready for test\", \"is_closed\": true, \"slug\": \"ready-for-test\", \"order\": 3, \"color\": \"#88A65E\"}, {\"name\": \"Closed\", \"is_closed\": true, \"slug\": \"closed\", \"order\": 4, \"color\": \"#BFB35A\"}, {\"name\": \"Needs Info\", \"is_closed\": false, \"slug\": \"needs-info\", \"order\": 5, \"color\": \"#89BAB4\"}, {\"name\": \"Rejected\", \"is_closed\": true, \"slug\": \"rejected\", \"order\": 6, \"color\": \"#CC0000\"}, {\"name\": \"Postponed\", \"is_closed\": false, \"slug\": \"posponed\", \"order\": 7, \"color\": \"#666666\"}]", - "issue_types": "[{\"name\": \"Bug\", \"order\": 1, \"color\": \"#89BAB4\"}, {\"name\": \"Question\", \"order\": 2, \"color\": \"#ba89a8\"}, {\"name\": \"Enhancement\", \"order\": 3, \"color\": \"#89a8ba\"}]", - "priorities": "[{\"name\": \"Low\", \"order\": 1, \"color\": \"#666666\"}, {\"name\": \"Normal\", \"order\": 3, \"color\": \"#669933\"}, {\"name\": \"High\", \"order\": 5, \"color\": \"#CC0000\"}]", - "severities": "[{\"name\": \"Wishlist\", \"order\": 1, \"color\": \"#666666\"}, {\"name\": \"Minor\", \"order\": 2, \"color\": \"#669933\"}, {\"name\": \"Normal\", \"order\": 3, \"color\": \"#0000FF\"}, {\"name\": \"Important\", \"order\": 4, \"color\": \"#FFA500\"}, {\"name\": \"Critical\", \"order\": 5, \"color\": \"#CC0000\"}]", - "roles": "[{\"name\": \"UX\", \"computable\": true, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 10}, {\"name\": \"Design\", \"computable\": true, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 20}, {\"name\": \"Front\", \"computable\": true, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 30}, {\"name\": \"Back\", \"computable\": true, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 40}, {\"name\": \"Product Owner\", \"computable\": false, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 50}, {\"name\": \"Stakeholder\", \"computable\": false, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\"], \"order\": 60}]" + "default_options": "{\"epic_status\": \"New\", \"issue_status\": \"New\", \"task_status\": \"New\", \"points\": \"?\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"priority\": \"Normal\", \"us_status\": \"New\"}", + "epic_statuses": "[{\"is_closed\": false, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"order\": 1}, {\"is_closed\": false, \"name\": \"Ready\", \"color\": \"#ff8a84\", \"slug\": \"ready\", \"order\": 2}, {\"is_closed\": false, \"name\": \"In progress\", \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"order\": 3}, {\"is_closed\": false, \"name\": \"Ready for test\", \"color\": \"#fcc000\", \"slug\": \"ready-for-test\", \"order\": 4}, {\"is_closed\": true, \"name\": \"Done\", \"color\": \"#669900\", \"slug\": \"done\", \"order\": 5}]", + "us_statuses": "[{\"is_archived\": false, \"name\": \"New\", \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"Ready\", \"slug\": \"ready\", \"order\": 2, \"color\": \"#ff8a84\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"In progress\", \"slug\": \"in-progress\", \"order\": 3, \"color\": \"#ff9900\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"order\": 4, \"color\": \"#fcc000\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"Done\", \"slug\": \"done\", \"order\": 5, \"color\": \"#669900\", \"wip_limit\": null, \"is_closed\": true}, {\"is_archived\": true, \"name\": \"Archived\", \"slug\": \"archived\", \"order\": 6, \"color\": \"#5c3566\", \"wip_limit\": null, \"is_closed\": true}]", + "points": "[{\"value\": null, \"name\": \"?\", \"order\": 1}, {\"value\": 0.0, \"name\": \"0\", \"order\": 2}, {\"value\": 0.5, \"name\": \"1/2\", \"order\": 3}, {\"value\": 1.0, \"name\": \"1\", \"order\": 4}, {\"value\": 2.0, \"name\": \"2\", \"order\": 5}, {\"value\": 3.0, \"name\": \"3\", \"order\": 6}, {\"value\": 5.0, \"name\": \"5\", \"order\": 7}, {\"value\": 8.0, \"name\": \"8\", \"order\": 8}, {\"value\": 10.0, \"name\": \"10\", \"order\": 9}, {\"value\": 13.0, \"name\": \"13\", \"order\": 10}, {\"value\": 20.0, \"name\": \"20\", \"order\": 11}, {\"value\": 40.0, \"name\": \"40\", \"order\": 12}]", + "task_statuses": "[{\"is_closed\": false, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"order\": 1}, {\"is_closed\": false, \"name\": \"In progress\", \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"order\": 2}, {\"is_closed\": true, \"name\": \"Ready for test\", \"color\": \"#ffcc00\", \"slug\": \"ready-for-test\", \"order\": 3}, {\"is_closed\": true, \"name\": \"Closed\", \"color\": \"#669900\", \"slug\": \"closed\", \"order\": 4}, {\"is_closed\": false, \"name\": \"Needs Info\", \"color\": \"#999999\", \"slug\": \"needs-info\", \"order\": 5}]", + "issue_statuses": "[{\"is_closed\": false, \"name\": \"New\", \"color\": \"#8C2318\", \"slug\": \"new\", \"order\": 1}, {\"is_closed\": false, \"name\": \"In progress\", \"color\": \"#5E8C6A\", \"slug\": \"in-progress\", \"order\": 2}, {\"is_closed\": true, \"name\": \"Ready for test\", \"color\": \"#88A65E\", \"slug\": \"ready-for-test\", \"order\": 3}, {\"is_closed\": true, \"name\": \"Closed\", \"color\": \"#BFB35A\", \"slug\": \"closed\", \"order\": 4}, {\"is_closed\": false, \"name\": \"Needs Info\", \"color\": \"#89BAB4\", \"slug\": \"needs-info\", \"order\": 5}, {\"is_closed\": true, \"name\": \"Rejected\", \"color\": \"#CC0000\", \"slug\": \"rejected\", \"order\": 6}, {\"is_closed\": false, \"name\": \"Postponed\", \"color\": \"#666666\", \"slug\": \"posponed\", \"order\": 7}]", + "issue_types": "[{\"name\": \"Bug\", \"color\": \"#89BAB4\", \"order\": 1}, {\"name\": \"Question\", \"color\": \"#ba89a8\", \"order\": 2}, {\"name\": \"Enhancement\", \"color\": \"#89a8ba\", \"order\": 3}]", + "priorities": "[{\"name\": \"Low\", \"color\": \"#666666\", \"order\": 1}, {\"name\": \"Normal\", \"color\": \"#669933\", \"order\": 3}, {\"name\": \"High\", \"color\": \"#CC0000\", \"order\": 5}]", + "severities": "[{\"name\": \"Wishlist\", \"color\": \"#666666\", \"order\": 1}, {\"name\": \"Minor\", \"color\": \"#669933\", \"order\": 2}, {\"name\": \"Normal\", \"color\": \"#0000FF\", \"order\": 3}, {\"name\": \"Important\", \"color\": \"#FFA500\", \"order\": 4}, {\"name\": \"Critical\", \"color\": \"#CC0000\", \"order\": 5}]", + "roles": "[{\"name\": \"UX\", \"computable\": true, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 10}, {\"name\": \"Design\", \"computable\": true, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 20}, {\"name\": \"Front\", \"computable\": true, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 30}, {\"name\": \"Back\", \"computable\": true, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 40}, {\"name\": \"Product Owner\", \"computable\": false, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 50}, {\"name\": \"Stakeholder\", \"computable\": false, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 60}]" } }, { @@ -38,7 +38,7 @@ "description": "Kanban is a method for managing knowledge work with an emphasis on just-in-time delivery while not overloading the team members. In this approach, the process, from definition of a task to its delivery to the customer, is displayed for participants to see and team members pull work from a queue.", "order": 2, "created_date": "2014-04-22T14:50:19.738Z", - "modified_date": "2016-07-07T13:18:28.186Z", + "modified_date": "2016-08-24T16:26:45.365Z", "default_owner_role": "product-owner", "is_epics_activated": true, "is_backlog_activated": false, @@ -47,16 +47,16 @@ "is_issues_activated": false, "videoconferences": null, "videoconferences_extra_data": "", - "default_options": "{\"issue_status\": \"New\", \"task_status\": \"New\", \"severity\": \"Normal\", \"issue_type\": \"Bug\", \"points\": \"?\", \"priority\": \"Normal\", \"us_status\": \"New\", \"epic_status\": \"New\"}", - "epic_statuses": "[{\"name\": \"New\", \"is_closed\": false, \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"Ready\", \"is_closed\": false, \"slug\": \"ready\", \"order\": 2, \"color\": \"#ff8a84\"}, {\"name\": \"In progress\", \"is_closed\": false, \"slug\": \"in-progress\", \"order\": 3, \"color\": \"#ff9900\"}, {\"name\": \"Ready for test\", \"is_closed\": false, \"slug\": \"ready-for-test\", \"order\": 4, \"color\": \"#fcc000\"}, {\"name\": \"Done\", \"is_closed\": true, \"slug\": \"done\", \"order\": 5, \"color\": \"#669900\"}]", - "us_statuses": "[{\"wip_limit\": null, \"name\": \"New\", \"slug\": \"new\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#999999\", \"order\": 1}, {\"wip_limit\": null, \"name\": \"Ready\", \"slug\": \"ready\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#f57900\", \"order\": 2}, {\"wip_limit\": null, \"name\": \"In progress\", \"slug\": \"in-progress\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#729fcf\", \"order\": 3}, {\"wip_limit\": null, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"is_closed\": false, \"is_archived\": false, \"color\": \"#4e9a06\", \"order\": 4}, {\"wip_limit\": null, \"name\": \"Done\", \"slug\": \"done\", \"is_closed\": true, \"is_archived\": false, \"color\": \"#cc0000\", \"order\": 5}, {\"wip_limit\": null, \"name\": \"Archived\", \"slug\": \"archived\", \"is_closed\": true, \"is_archived\": true, \"color\": \"#5c3566\", \"order\": 6}]", - "points": "[{\"name\": \"?\", \"value\": null, \"order\": 1}, {\"name\": \"0\", \"value\": 0.0, \"order\": 2}, {\"name\": \"1/2\", \"value\": 0.5, \"order\": 3}, {\"name\": \"1\", \"value\": 1.0, \"order\": 4}, {\"name\": \"2\", \"value\": 2.0, \"order\": 5}, {\"name\": \"3\", \"value\": 3.0, \"order\": 6}, {\"name\": \"5\", \"value\": 5.0, \"order\": 7}, {\"name\": \"8\", \"value\": 8.0, \"order\": 8}, {\"name\": \"10\", \"value\": 10.0, \"order\": 9}, {\"name\": \"13\", \"value\": 13.0, \"order\": 10}, {\"name\": \"20\", \"value\": 20.0, \"order\": 11}, {\"name\": \"40\", \"value\": 40.0, \"order\": 12}]", - "task_statuses": "[{\"name\": \"New\", \"is_closed\": false, \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"In progress\", \"is_closed\": false, \"slug\": \"in-progress\", \"order\": 2, \"color\": \"#729fcf\"}, {\"name\": \"Ready for test\", \"is_closed\": true, \"slug\": \"ready-for-test\", \"order\": 3, \"color\": \"#f57900\"}, {\"name\": \"Closed\", \"is_closed\": true, \"slug\": \"closed\", \"order\": 4, \"color\": \"#4e9a06\"}, {\"name\": \"Needs Info\", \"is_closed\": false, \"slug\": \"needs-info\", \"order\": 5, \"color\": \"#cc0000\"}]", - "issue_statuses": "[{\"name\": \"New\", \"is_closed\": false, \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"In progress\", \"is_closed\": false, \"slug\": \"in-progress\", \"order\": 2, \"color\": \"#729fcf\"}, {\"name\": \"Ready for test\", \"is_closed\": true, \"slug\": \"ready-for-test\", \"order\": 3, \"color\": \"#f57900\"}, {\"name\": \"Closed\", \"is_closed\": true, \"slug\": \"closed\", \"order\": 4, \"color\": \"#4e9a06\"}, {\"name\": \"Needs Info\", \"is_closed\": false, \"slug\": \"needs-info\", \"order\": 5, \"color\": \"#cc0000\"}, {\"name\": \"Rejected\", \"is_closed\": true, \"slug\": \"rejected\", \"order\": 6, \"color\": \"#d3d7cf\"}, {\"name\": \"Postponed\", \"is_closed\": false, \"slug\": \"posponed\", \"order\": 7, \"color\": \"#75507b\"}]", - "issue_types": "[{\"name\": \"Bug\", \"order\": 1, \"color\": \"#cc0000\"}, {\"name\": \"Question\", \"order\": 2, \"color\": \"#729fcf\"}, {\"name\": \"Enhancement\", \"order\": 3, \"color\": \"#4e9a06\"}]", - "priorities": "[{\"name\": \"Low\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"Normal\", \"order\": 3, \"color\": \"#4e9a06\"}, {\"name\": \"High\", \"order\": 5, \"color\": \"#CC0000\"}]", - "severities": "[{\"name\": \"Wishlist\", \"order\": 1, \"color\": \"#999999\"}, {\"name\": \"Minor\", \"order\": 2, \"color\": \"#729fcf\"}, {\"name\": \"Normal\", \"order\": 3, \"color\": \"#4e9a06\"}, {\"name\": \"Important\", \"order\": 4, \"color\": \"#f57900\"}, {\"name\": \"Critical\", \"order\": 5, \"color\": \"#CC0000\"}]", - "roles": "[{\"name\": \"UX\", \"computable\": true, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 10}, {\"name\": \"Design\", \"computable\": true, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 20}, {\"name\": \"Front\", \"computable\": true, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 30}, {\"name\": \"Back\", \"computable\": true, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 40}, {\"name\": \"Product Owner\", \"computable\": false, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\"], \"order\": 50}, {\"name\": \"Stakeholder\", \"computable\": false, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\"], \"order\": 60}]" + "default_options": "{\"epic_status\": \"New\", \"issue_status\": \"New\", \"task_status\": \"New\", \"points\": \"?\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"priority\": \"Normal\", \"us_status\": \"New\"}", + "epic_statuses": "[{\"is_closed\": false, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"order\": 1}, {\"is_closed\": false, \"name\": \"Ready\", \"color\": \"#ff8a84\", \"slug\": \"ready\", \"order\": 2}, {\"is_closed\": false, \"name\": \"In progress\", \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"order\": 3}, {\"is_closed\": false, \"name\": \"Ready for test\", \"color\": \"#fcc000\", \"slug\": \"ready-for-test\", \"order\": 4}, {\"is_closed\": true, \"name\": \"Done\", \"color\": \"#669900\", \"slug\": \"done\", \"order\": 5}]", + "us_statuses": "[{\"is_archived\": false, \"name\": \"New\", \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"Ready\", \"slug\": \"ready\", \"order\": 2, \"color\": \"#f57900\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"In progress\", \"slug\": \"in-progress\", \"order\": 3, \"color\": \"#729fcf\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"order\": 4, \"color\": \"#4e9a06\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"Done\", \"slug\": \"done\", \"order\": 5, \"color\": \"#cc0000\", \"wip_limit\": null, \"is_closed\": true}, {\"is_archived\": true, \"name\": \"Archived\", \"slug\": \"archived\", \"order\": 6, \"color\": \"#5c3566\", \"wip_limit\": null, \"is_closed\": true}]", + "points": "[{\"value\": null, \"name\": \"?\", \"order\": 1}, {\"value\": 0.0, \"name\": \"0\", \"order\": 2}, {\"value\": 0.5, \"name\": \"1/2\", \"order\": 3}, {\"value\": 1.0, \"name\": \"1\", \"order\": 4}, {\"value\": 2.0, \"name\": \"2\", \"order\": 5}, {\"value\": 3.0, \"name\": \"3\", \"order\": 6}, {\"value\": 5.0, \"name\": \"5\", \"order\": 7}, {\"value\": 8.0, \"name\": \"8\", \"order\": 8}, {\"value\": 10.0, \"name\": \"10\", \"order\": 9}, {\"value\": 13.0, \"name\": \"13\", \"order\": 10}, {\"value\": 20.0, \"name\": \"20\", \"order\": 11}, {\"value\": 40.0, \"name\": \"40\", \"order\": 12}]", + "task_statuses": "[{\"is_closed\": false, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"order\": 1}, {\"is_closed\": false, \"name\": \"In progress\", \"color\": \"#729fcf\", \"slug\": \"in-progress\", \"order\": 2}, {\"is_closed\": true, \"name\": \"Ready for test\", \"color\": \"#f57900\", \"slug\": \"ready-for-test\", \"order\": 3}, {\"is_closed\": true, \"name\": \"Closed\", \"color\": \"#4e9a06\", \"slug\": \"closed\", \"order\": 4}, {\"is_closed\": false, \"name\": \"Needs Info\", \"color\": \"#cc0000\", \"slug\": \"needs-info\", \"order\": 5}]", + "issue_statuses": "[{\"is_closed\": false, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"order\": 1}, {\"is_closed\": false, \"name\": \"In progress\", \"color\": \"#729fcf\", \"slug\": \"in-progress\", \"order\": 2}, {\"is_closed\": true, \"name\": \"Ready for test\", \"color\": \"#f57900\", \"slug\": \"ready-for-test\", \"order\": 3}, {\"is_closed\": true, \"name\": \"Closed\", \"color\": \"#4e9a06\", \"slug\": \"closed\", \"order\": 4}, {\"is_closed\": false, \"name\": \"Needs Info\", \"color\": \"#cc0000\", \"slug\": \"needs-info\", \"order\": 5}, {\"is_closed\": true, \"name\": \"Rejected\", \"color\": \"#d3d7cf\", \"slug\": \"rejected\", \"order\": 6}, {\"is_closed\": false, \"name\": \"Postponed\", \"color\": \"#75507b\", \"slug\": \"posponed\", \"order\": 7}]", + "issue_types": "[{\"name\": \"Bug\", \"color\": \"#cc0000\", \"order\": 1}, {\"name\": \"Question\", \"color\": \"#729fcf\", \"order\": 2}, {\"name\": \"Enhancement\", \"color\": \"#4e9a06\", \"order\": 3}]", + "priorities": "[{\"name\": \"Low\", \"color\": \"#999999\", \"order\": 1}, {\"name\": \"Normal\", \"color\": \"#4e9a06\", \"order\": 3}, {\"name\": \"High\", \"color\": \"#CC0000\", \"order\": 5}]", + "severities": "[{\"name\": \"Wishlist\", \"color\": \"#999999\", \"order\": 1}, {\"name\": \"Minor\", \"color\": \"#729fcf\", \"order\": 2}, {\"name\": \"Normal\", \"color\": \"#4e9a06\", \"order\": 3}, {\"name\": \"Important\", \"color\": \"#f57900\", \"order\": 4}, {\"name\": \"Critical\", \"color\": \"#CC0000\", \"order\": 5}]", + "roles": "[{\"name\": \"UX\", \"computable\": true, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 10}, {\"name\": \"Design\", \"computable\": true, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 20}, {\"name\": \"Front\", \"computable\": true, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 30}, {\"name\": \"Back\", \"computable\": true, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 40}, {\"name\": \"Product Owner\", \"computable\": false, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 50}, {\"name\": \"Stakeholder\", \"computable\": false, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 60}]" } } ] From 544cdc00d086041f2f328a9f6a143060dcba63fd Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 25 Aug 2016 10:08:27 +0200 Subject: [PATCH 62/83] Adding permission tests for epic related user stories --- taiga/projects/epics/services.py | 9 ++++-- .../test_epics_resources.py | 28 ------------------- 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/taiga/projects/epics/services.py b/taiga/projects/epics/services.py index f953a574..bf0edff5 100644 --- a/taiga/projects/epics/services.py +++ b/taiga/projects/epics/services.py @@ -136,11 +136,12 @@ def update_epic_related_userstories_order_in_bulk(bulk_data: list, epic: object) [{'us_id': , 'order': }, ...] """ related_user_stories = epic.relateduserstory_set.all() + # select_related rus_orders = {rus.id: rus.order for rus in related_user_stories} rus_conversion = {rus.user_story_id: rus.id for rus in related_user_stories} new_rus_orders = {rus_conversion[e["us_id"]]: e["order"] for e in bulk_data - if e["us_id"] in rus_conversion} + if e["us_id"] in rus_conversion} apply_order_updates(rus_orders, new_rus_orders) @@ -274,7 +275,8 @@ def _get_epics_assigned_to(project, queryset): FROM projects_membership LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."assigned_to_id") INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") - WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + WHERE "projects_membership"."project_id" = %s + AND "projects_membership"."user_id" IS NOT NULL -- unassigned epics UNION @@ -336,7 +338,8 @@ def _get_epics_owners(project, queryset): FROM projects_membership LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id") INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") - WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + WHERE "projects_membership"."project_id" = %s + AND "projects_membership"."user_id" IS NOT NULL -- System users UNION diff --git a/tests/integration/resources_permissions/test_epics_resources.py b/tests/integration/resources_permissions/test_epics_resources.py index 9183df1c..0becda53 100644 --- a/tests/integration/resources_permissions/test_epics_resources.py +++ b/tests/integration/resources_permissions/test_epics_resources.py @@ -675,34 +675,6 @@ def test_epic_action_bulk_create(client, data): assert results == [401, 403, 403, 451, 451] -def test_bulk_create_related_userstories(client, data): - public_url = reverse('epics-related-userstories-bulk-create', args=[data.public_epic.pk]) - private_url1 = reverse('epics-related-userstories-bulk-create', args=[data.private_epic1.pk]) - private_url2 = reverse('epics-related-userstories-bulk-create', args=[data.private_epic2.pk]) - blocked_url = reverse('epics-related-userstories-bulk-create', args=[data.blocked_epic.pk]) - - users = [ - None, - data.registered_user, - data.project_member_without_perms, - data.project_member_with_perms, - data.project_owner - ] - - bulk_data = json.dumps({ - "userstories": "test1\ntest2", - }) - - results = helper_test_http_method(client, 'post', public_url, bulk_data, users) - assert results == [401, 403, 403, 200, 200] - results = helper_test_http_method(client, 'post', private_url1, bulk_data, users) - assert results == [401, 403, 403, 200, 200] - results = helper_test_http_method(client, 'post', private_url2, bulk_data, users) - assert results == [401, 403, 403, 200, 200] - results = helper_test_http_method(client, 'post', blocked_url, bulk_data, users) - assert results == [401, 403, 403, 451, 451] - - def test_epic_action_upvote(client, data): public_url = reverse('epics-upvote', kwargs={"pk": data.public_epic.pk}) private_url1 = reverse('epics-upvote', kwargs={"pk": data.private_epic1.pk}) From 9e609ee20a6311dc5395e75c57d9fb38394e5bc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 25 Aug 2016 12:11:16 +0200 Subject: [PATCH 63/83] Disabled epics module by default in Project and ProjectTemplates --- taiga/projects/migrations/0049_auto_20160629_1443.py | 4 ++-- taiga/projects/models.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/taiga/projects/migrations/0049_auto_20160629_1443.py b/taiga/projects/migrations/0049_auto_20160629_1443.py index 417875e5..8c2117b0 100644 --- a/taiga/projects/migrations/0049_auto_20160629_1443.py +++ b/taiga/projects/migrations/0049_auto_20160629_1443.py @@ -66,7 +66,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='project', name='is_epics_activated', - field=models.BooleanField(default=True, verbose_name='active epics panel'), + field=models.BooleanField(default=False, verbose_name='active epics panel'), ), migrations.AddField( model_name='projecttemplate', @@ -76,7 +76,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='projecttemplate', name='is_epics_activated', - field=models.BooleanField(default=True, verbose_name='active epics panel'), + field=models.BooleanField(default=False, verbose_name='active epics panel'), ), migrations.AlterField( model_name='project', diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 1642426c..fe62c015 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -165,7 +165,7 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): verbose_name=_("total of milestones")) total_story_points = models.FloatField(null=True, blank=True, verbose_name=_("total story points")) - is_epics_activated = models.BooleanField(default=True, null=False, blank=True, + is_epics_activated = models.BooleanField(default=False, null=False, blank=True, verbose_name=_("active epics panel")) is_backlog_activated = models.BooleanField(default=True, null=False, blank=True, verbose_name=_("active backlog panel")) @@ -741,7 +741,7 @@ class ProjectTemplate(models.Model): blank=False, verbose_name=_("default owner's role")) - is_epics_activated = models.BooleanField(default=True, null=False, blank=True, + is_epics_activated = models.BooleanField(default=False, null=False, blank=True, verbose_name=_("active epics panel")) is_backlog_activated = models.BooleanField(default=True, null=False, blank=True, verbose_name=_("active backlog panel")) From 75ddf706ee527862eac71ceb7dc5b804b47ad267 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 25 Aug 2016 14:17:28 +0200 Subject: [PATCH 64/83] Adding epic related userstories tests file --- ...est_epics_related_userstories_resources.py | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 tests/integration/resources_permissions/test_epics_related_userstories_resources.py diff --git a/tests/integration/resources_permissions/test_epics_related_userstories_resources.py b/tests/integration/resources_permissions/test_epics_related_userstories_resources.py new file mode 100644 index 00000000..d1924ec8 --- /dev/null +++ b/tests/integration/resources_permissions/test_epics_related_userstories_resources.py @@ -0,0 +1,392 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# Copyright (C) 2014-2016 Anler Hernández +# 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 uuid + +from django.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects.models import Project +from taiga.projects.epics.serializers import EpicRelatedUserStorySerializer +from taiga.projects.epics.models import Epic +from taiga.projects.epics.utils import attach_extra_info as attach_epic_extra_info +from taiga.projects.utils import attach_extra_info as attach_project_extra_info +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.occ import OCCResourceMixin + +from tests import factories as f +from tests.utils import helper_test_http_method, reconnect_signals +from taiga.projects.votes.services import add_vote +from taiga.projects.notifications.services import add_watcher + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_function(function): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex) + m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) + + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex) + m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id) + + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex) + m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) + + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex, + blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) + + m.public_membership = f.MembershipFactory( + project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + m.private_membership1 = f.MembershipFactory( + project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory( + project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory( + project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory( + project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory( + project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_epic = f.EpicFactory(project=m.public_project, + status__project=m.public_project) + m.public_epic = attach_epic_extra_info(Epic.objects.all()).get(id=m.public_epic.id) + + m.private_epic1 = f.EpicFactory(project=m.private_project1, + status__project=m.private_project1) + m.private_epic1 = attach_epic_extra_info(Epic.objects.all()).get(id=m.private_epic1.id) + + m.private_epic2 = f.EpicFactory(project=m.private_project2, + status__project=m.private_project2) + m.private_epic2 = attach_epic_extra_info(Epic.objects.all()).get(id=m.private_epic2.id) + + m.blocked_epic = f.EpicFactory(project=m.blocked_project, + status__project=m.blocked_project) + m.blocked_epic = attach_epic_extra_info(Epic.objects.all()).get(id=m.blocked_epic.id) + + + m.public_us = f.UserStoryFactory(project=m.public_project) + m.private_us1 = f.UserStoryFactory(project=m.private_project1) + m.private_us2 = f.UserStoryFactory(project=m.private_project2) + m.blocked_us = f.UserStoryFactory(project=m.blocked_project) + + m.public_related_us = f.RelatedUserStory(epic=m.public_epic, user_story=m.public_us) + m.private_related_us1 = f.RelatedUserStory(epic=m.private_epic1, user_story=m.private_us1) + m.private_related_us2 = f.RelatedUserStory(epic=m.private_epic2, user_story=m.private_us2) + m.blocked_related_us = f.RelatedUserStory(epic=m.blocked_epic, user_story=m.blocked_us) + + m.public_project.default_epic_status = m.public_epic.status + m.public_project.save() + m.private_project1.default_epic_status = m.private_epic1.status + m.private_project1.save() + m.private_project2.default_epic_status = m.private_epic2.status + m.private_project2.save() + m.blocked_project.default_epic_status = m.blocked_epic.status + m.blocked_project.save() + + return m + + +def test_epic_related_userstories_list(client, data): + url = reverse('epics-related-userstories-list', args=[data.public_epic.pk]) + response = client.get(url) + related_uss_data = json.loads(response.content.decode('utf-8')) + assert len(related_uss_data) == 1 + assert response.status_code == 200 + + client.login(data.registered_user) + + url = reverse('epics-related-userstories-list', args=[data.private_epic1.pk]) + response = client.get(url) + related_uss_data = json.loads(response.content.decode('utf-8')) + assert len(related_uss_data) == 1 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + + url = reverse('epics-related-userstories-list', args=[data.private_epic2.pk]) + response = client.get(url) + related_uss_data = json.loads(response.content.decode('utf-8')) + assert len(related_uss_data) == 1 + assert response.status_code == 200 + + client.login(data.project_owner) + + url = reverse('epics-related-userstories-list', args=[data.blocked_epic.pk]) + response = client.get(url) + related_uss_data = json.loads(response.content.decode('utf-8')) + assert len(related_uss_data) == 1 + assert response.status_code == 200 + + +def test_epic_related_userstories_retrieve(client, data): + public_url = reverse('epics-related-userstories-detail', args=[data.public_epic.pk, data.public_us.pk]) + private_url1 = reverse('epics-related-userstories-detail', args=[data.private_epic1.pk, data.private_us1.pk]) + private_url2 = reverse('epics-related-userstories-detail', args=[data.private_epic2.pk, data.private_us2.pk]) + blocked_url = reverse('epics-related-userstories-detail', args=[data.blocked_epic.pk, data.blocked_us.pk]) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_related_userstories_create(client, data): + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + create_data = json.dumps({ + "user_story": data.public_us.id, + "epic": data.public_epic.id + }) + url = reverse('epics-related-userstories-list', args=[data.public_epic.pk]) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "user_story": data.private_us1.id, + "epic": data.private_epic1.id + }) + url = reverse('epics-related-userstories-list', args=[data.private_epic1.pk]) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "user_story": data.private_us2.id, + "epic": data.private_epic2.id + }) + url = reverse('epics-related-userstories-list', args=[data.private_epic2.pk]) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "user_story": data.blocked_us.id, + "epic": data.blocked_epic.id + }) + url = reverse('epics-related-userstories-list', args=[data.blocked_epic.pk]) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_related_userstories_put_update(client, data): + public_url = reverse('epics-related-userstories-detail', args=[data.public_epic.pk, data.public_us.pk]) + private_url1 = reverse('epics-related-userstories-detail', args=[data.private_epic1.pk, data.private_us1.pk]) + private_url2 = reverse('epics-related-userstories-detail', args=[data.private_epic2.pk, data.private_us2.pk]) + blocked_url = reverse('epics-related-userstories-detail', args=[data.blocked_epic.pk, data.blocked_us.pk]) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + epic_related_us_data = EpicRelatedUserStorySerializer(data.public_related_us).data + epic_related_us_data["order"] = 33 + epic_related_us_data = json.dumps(epic_related_us_data) + results = helper_test_http_method(client, 'put', public_url, epic_related_us_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_related_us_data = EpicRelatedUserStorySerializer(data.private_related_us1).data + epic_related_us_data["order"] = 33 + epic_related_us_data = json.dumps(epic_related_us_data) + results = helper_test_http_method(client, 'put', private_url1, epic_related_us_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_related_us_data = EpicRelatedUserStorySerializer(data.private_related_us2).data + epic_related_us_data["order"] = 33 + epic_related_us_data = json.dumps(epic_related_us_data) + results = helper_test_http_method(client, 'put', private_url2, epic_related_us_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_related_us_data = EpicRelatedUserStorySerializer(data.blocked_related_us).data + epic_related_us_data["order"] = 33 + epic_related_us_data = json.dumps(epic_related_us_data) + results = helper_test_http_method(client, 'put', blocked_url, epic_related_us_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_related_userstories_patch_update(client, data): + public_url = reverse('epics-related-userstories-detail', args=[data.public_epic.pk, data.public_us.pk]) + private_url1 = reverse('epics-related-userstories-detail', args=[data.private_epic1.pk, data.private_us1.pk]) + private_url2 = reverse('epics-related-userstories-detail', args=[data.private_epic2.pk, data.private_us2.pk]) + blocked_url = reverse('epics-related-userstories-detail', args=[data.blocked_epic.pk, data.blocked_us.pk]) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"order": 33}) + + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_related_userstories_delete(client, data): + public_url = reverse('epics-related-userstories-detail', args=[data.public_epic.pk, data.public_us.pk]) + private_url1 = reverse('epics-related-userstories-detail', args=[data.private_epic1.pk, data.private_us1.pk]) + private_url2 = reverse('epics-related-userstories-detail', args=[data.private_epic2.pk, data.private_us2.pk]) + blocked_url = reverse('epics-related-userstories-detail', args=[data.blocked_epic.pk, data.blocked_us.pk]) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_bulk_create_related_userstories(client, data): + public_url = reverse('epics-related-userstories-bulk-create', args=[data.public_epic.pk]) + private_url1 = reverse('epics-related-userstories-bulk-create', args=[data.private_epic1.pk]) + private_url2 = reverse('epics-related-userstories-bulk-create', args=[data.private_epic2.pk]) + blocked_url = reverse('epics-related-userstories-bulk-create', args=[data.blocked_epic.pk]) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + bulk_data = json.dumps({ + "userstories": "test1\ntest2", + }) + + results = helper_test_http_method(client, 'post', public_url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, bulk_data, users) + assert results == [401, 403, 403, 451, 451] From df174230a4792834da39a3ba33d7300620236dae Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 25 Aug 2016 14:18:22 +0200 Subject: [PATCH 65/83] Migration for adding epic status to existing projects --- taiga/projects/migrations/0052_epic_status.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 taiga/projects/migrations/0052_epic_status.py diff --git a/taiga/projects/migrations/0052_epic_status.py b/taiga/projects/migrations/0052_epic_status.py new file mode 100644 index 00000000..baa9ab46 --- /dev/null +++ b/taiga/projects/migrations/0052_epic_status.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-08-25 10:19 +from __future__ import unicode_literals + +from django.db import connection, migrations, models + +def update_epic_status(apps, schema_editor): + Project = apps.get_model("projects", "Project") + project_ids = Project.objects.filter(default_epic_status__isnull=True).values_list("id", flat=True) + if not project_ids: + return + + values_sql = [] + for project_id in project_ids: + values_sql.append("('New', 'new', 1, false, '#999999', {project_id})".format(project_id=project_id)) + values_sql.append("('Ready', 'ready', 2, false, '#ff8a84', {project_id})".format(project_id=project_id)) + values_sql.append("('In progress', 'in-progress', 3, false, '#ff9900', {project_id})".format(project_id=project_id)) + values_sql.append("('Ready for test', 'ready-for-test', 4, false, '#fcc000', {project_id})".format(project_id=project_id)) + values_sql.append("('Done', 'done', 5, true, '#669900', {project_id})".format(project_id=project_id)) + + sql = """ + INSERT INTO projects_epicstatus (name, slug, "order", is_closed, color, project_id) + VALUES + {values}; + """.format(values=','.join(values_sql)) + cursor = connection.cursor() + cursor.execute(sql) + + +def update_default_epic_status(apps, schema_editor): + sql = """ + UPDATE projects_project + SET default_epic_status_id = projects_epicstatus.id + FROM projects_epicstatus + WHERE + projects_project.default_epic_status_id IS NULL + AND + projects_epicstatus.order = 1 + AND + projects_epicstatus.project_id = projects_project.id; + """ + cursor = connection.cursor() + cursor.execute(sql) + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0051_auto_20160729_0802'), + ] + + operations = [ + migrations.RunPython(update_epic_status), + migrations.RunPython(update_default_epic_status) + ] From bf9aafaca3db2f0a0e527896ce73c00505add231 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 31 Aug 2016 07:53:41 +0200 Subject: [PATCH 66/83] Fixing HistoryEntry for epic edition --- taiga/projects/history/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/taiga/projects/history/models.py b/taiga/projects/history/models.py index aafaa915..4697875c 100644 --- a/taiga/projects/history/models.py +++ b/taiga/projects/history/models.py @@ -264,8 +264,8 @@ class HistoryEntry(models.Model): "deleted": [], } - olduss = {x["id"]:x for x in self.diff["user_stories"][0]} - newuss = {x["id"]:x for x in self.diff["user_stories"][1]} + olduss = {x["id"]: x for x in self.diff["user_stories"][0]} + newuss = {x["id"]: x for x in self.diff["user_stories"][1]} for usid in set(tuple(olduss.keys()) + tuple(newuss.keys())): if usid in olduss and usid not in newuss: @@ -273,7 +273,7 @@ class HistoryEntry(models.Model): elif usid not in olduss and usid in newuss: user_stories["new"].append(newuss[usid]) - if user_stories["new"] or user_stories["changed"] or user_stories["deleted"]: + if user_stories["new"] or user_stories["deleted"]: value = user_stories elif key in self.values: From 3648043f3700a922b192d07bf3ce1864eb7cbb2d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 1 Sep 2016 12:23:00 +0200 Subject: [PATCH 67/83] Adding unique restriction for epics --- .../migrations/0003_auto_20160901_1021.py | 19 +++++++++++++++++++ taiga/projects/epics/models.py | 1 + 2 files changed, 20 insertions(+) create mode 100644 taiga/projects/epics/migrations/0003_auto_20160901_1021.py diff --git a/taiga/projects/epics/migrations/0003_auto_20160901_1021.py b/taiga/projects/epics/migrations/0003_auto_20160901_1021.py new file mode 100644 index 00000000..e23169f2 --- /dev/null +++ b/taiga/projects/epics/migrations/0003_auto_20160901_1021.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-09-01 10:21 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('epics', '0002_epic_color'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='relateduserstory', + unique_together=set([('user_story', 'epic')]), + ), + ] diff --git a/taiga/projects/epics/models.py b/taiga/projects/epics/models.py index 3d1a2ce4..64bdbb84 100644 --- a/taiga/projects/epics/models.py +++ b/taiga/projects/epics/models.py @@ -103,6 +103,7 @@ class RelatedUserStory(models.Model): verbose_name = "related user story" verbose_name_plural = "related user stories" ordering = ["user_story", "order", "id"] + unique_together = (("user_story", "epic"), ) def __str__(self): return "{0} - {1}".format(self.epic_id, self.user_story_id) From 68f6cf08b290d8fff6c99f5007c7b9cfb088020d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 2 Sep 2016 10:47:06 +0200 Subject: [PATCH 68/83] Add epics to the sitemap generator --- taiga/front/sitemaps/__init__.py | 6 ++++ taiga/front/sitemaps/epics.py | 54 ++++++++++++++++++++++++++++++++ taiga/front/sitemaps/projects.py | 28 +++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 taiga/front/sitemaps/epics.py diff --git a/taiga/front/sitemaps/__init__.py b/taiga/front/sitemaps/__init__.py index abc78ffe..8c7adfa8 100644 --- a/taiga/front/sitemaps/__init__.py +++ b/taiga/front/sitemaps/__init__.py @@ -21,11 +21,14 @@ from collections import OrderedDict from .generics import GenericSitemap from .projects import ProjectsSitemap +from .projects import ProjectEpicsSitemap from .projects import ProjectBacklogsSitemap from .projects import ProjectKanbansSitemap from .projects import ProjectIssuesSitemap from .projects import ProjectTeamsSitemap +from .epics import EpicsSitemap + from .milestones import MilestonesSitemap from .userstories import UserStoriesSitemap @@ -43,11 +46,14 @@ sitemaps = OrderedDict([ ("generics", GenericSitemap), ("projects", ProjectsSitemap), + ("project-epics-list", ProjectEpicsSitemap), ("project-backlogs", ProjectBacklogsSitemap), ("project-kanbans", ProjectKanbansSitemap), ("project-issues-list", ProjectIssuesSitemap), ("project-teams", ProjectTeamsSitemap), + ("epics", EpicsSitemap), + ("milestones", MilestonesSitemap), ("userstories", UserStoriesSitemap), diff --git a/taiga/front/sitemaps/epics.py b/taiga/front/sitemaps/epics.py new file mode 100644 index 00000000..81f391f6 --- /dev/null +++ b/taiga/front/sitemaps/epics.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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.models import Q +from django.apps import apps + +from taiga.front.templatetags.functions import resolve + +from .base import Sitemap + + +class EpicsSitemap(Sitemap): + def items(self): + epic_model = apps.get_model("epics", "Epic") + + # Get epics of public projects OR private projects if anon user can view them + queryset = epic_model.objects.filter(Q(project__is_private=False) | + Q(project__is_private=True, + project__anon_permissions__contains=["view_epics"])) + + # Exclude blocked projects + queryset = queryset.filter(project__blocked_code__isnull=True) + + # Project data is needed + queryset = queryset.select_related("project") + + return queryset + + def location(self, obj): + return resolve("epic", obj.project.slug, obj.ref) + + def lastmod(self, obj): + return obj.modified_date + + def changefreq(self, obj): + return "daily" + + def priority(self, obj): + return 0.4 diff --git a/taiga/front/sitemaps/projects.py b/taiga/front/sitemaps/projects.py index a7e45d50..77785928 100644 --- a/taiga/front/sitemaps/projects.py +++ b/taiga/front/sitemaps/projects.py @@ -51,6 +51,34 @@ class ProjectsSitemap(Sitemap): return 0.9 +class ProjectEpicsSitemap(Sitemap): + def items(self): + project_model = apps.get_model("projects", "Project") + + # Get public projects OR private projects if anon user can view them and epics + queryset = project_model.objects.filter(Q(is_private=False) | + Q(is_private=True, + anon_permissions__contains=["view_project", + "view_epics"])) + + # Exclude projects without epics enabled + queryset = queryset.exclude(is_epics_activated=False) + + return queryset + + def location(self, obj): + return resolve("epics", obj.slug) + + def lastmod(self, obj): + return obj.modified_date + + def changefreq(self, obj): + return "daily" + + def priority(self, obj): + return 0.6 + + class ProjectBacklogsSitemap(Sitemap): def items(self): project_model = apps.get_model("projects", "Project") From b975a228592a1ab62bedb3b87c8dff86e6d989c5 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 5 Sep 2016 09:56:12 +0200 Subject: [PATCH 69/83] Allow setting project when creating related user stories for epics in bulk mode --- taiga/projects/epics/api.py | 7 +++---- taiga/projects/epics/validators.py | 8 +++++--- tests/integration/test_epics.py | 3 ++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index 5888865d..599252ce 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -263,7 +263,6 @@ class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, Mod return services.update_epic_related_userstories_order_in_bulk(data, epic=obj.epic) - def post_save(self, obj, created=False): if not created: # Let's reorder the related stuff after edit the element @@ -276,21 +275,21 @@ class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, Mod @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): - validator = validators.CrateRelatedUserStoriesBulkValidator(data=request.DATA) + validator = validators.CreateRelatedUserStoriesBulkValidator(data=request.DATA) if not validator.is_valid(): return response.BadRequest(validator.errors) data = validator.data epic = get_object_or_404(models.Epic, id=kwargs["epic"]) - project = epic.project + project = Project.objects.get(pk=data.get('project_id')) self.check_permissions(request, 'bulk_create', project) if project.blocked_code is not None: raise exc.Blocked(_("Blocked element")) services.create_related_userstories_in_bulk( - data["userstories"], + data["bulk_userstories"], epic, project=project, owner=request.user diff --git a/taiga/projects/epics/validators.py b/taiga/projects/epics/validators.py index 175f1c3c..7ed00481 100644 --- a/taiga/projects/epics/validators.py +++ b/taiga/projects/epics/validators.py @@ -55,9 +55,11 @@ class EpicsBulkValidator(ProjectExistsValidator, EpicExistsValidator, bulk_epics = serializers.CharField() -class CrateRelatedUserStoriesBulkValidator(ProjectExistsValidator, EpicExistsValidator, - validators.Validator): - userstories = serializers.CharField() +class CreateRelatedUserStoriesBulkValidator(ProjectExistsValidator, EpicExistsValidator, + validators.Validator): + project_id = serializers.IntegerField() + bulk_userstories = serializers.CharField() + class EpicRelatedUserStoryValidator(validators.ModelValidator): diff --git a/tests/integration/test_epics.py b/tests/integration/test_epics.py index 0ca87d09..27314f02 100644 --- a/tests/integration/test_epics.py +++ b/tests/integration/test_epics.py @@ -101,7 +101,8 @@ def test_bulk_create_related_userstories(client): url = reverse('epics-related-userstories-bulk-create', args=[epic.pk]) data = { - "userstories": "test1\ntest2" + "bulk_userstories": "test1\ntest2", + "project_id": project.id } client.login(user) response = client.json.post(url, json.dumps(data)) From f2a800a099ae9ecd0b36c3efbb7948a4c27a9dca Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 8 Sep 2016 15:12:25 +0200 Subject: [PATCH 70/83] Fixing issue on epic related userstories update --- taiga/projects/epics/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index 599252ce..81dd242e 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -257,7 +257,7 @@ class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, Mod return {} extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}")) - data = [{"us_id": obj.id, "order": getattr(obj, "order")}] + data = [{"us_id": obj.user_story.id, "order": getattr(obj, "order")}] for id, order in extra_orders.items(): data.append({"us_id": int(id), "order": order}) From 35b799e89d52a9c5a103bab396232791aad79171 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 9 Sep 2016 14:35:12 +0200 Subject: [PATCH 71/83] Adding color to epic freezer --- taiga/projects/history/freeze_impl.py | 1 + 1 file changed, 1 insertion(+) diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py index 1c2fae44..489f2c79 100644 --- a/taiga/projects/history/freeze_impl.py +++ b/taiga/projects/history/freeze_impl.py @@ -295,6 +295,7 @@ def milestone_freezer(milestone) -> dict: def epic_freezer(epic) -> dict: snapshot = { "ref": epic.ref, + "color": epic.color, "owner": epic.owner_id, "status": epic.status.id if epic.status else None, "epics_order": epic.epics_order, From db34ad32c98faf1d15e6c88eac37c47cf55e749d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 12 Sep 2016 14:10:50 +0200 Subject: [PATCH 72/83] Including epic in timelines --- taiga/projects/epics/api.py | 11 +++++++---- taiga/projects/epics/models.py | 10 +++++++++- taiga/projects/epics/services.py | 2 +- taiga/projects/history/freeze_impl.py | 15 ++++++++++++++- taiga/projects/history/services.py | 4 ++++ taiga/projects/mixins/serializers.py | 3 ++- taiga/projects/notifications/services.py | 1 + taiga/timeline/service.py | 17 +++++++++++++++-- taiga/timeline/timeline_implementations.py | 16 +++++++++++++++- 9 files changed, 68 insertions(+), 11 deletions(-) diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index 81dd242e..fce31802 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -22,7 +22,7 @@ from django.utils.translation import ugettext as _ from taiga.base.api.utils import get_object_or_404 from taiga.base import filters, response from taiga.base import exceptions as exc -from taiga.base.decorators import list_route, detail_route +from taiga.base.decorators import list_route from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api.mixins import BlockedByProjectMixin from taiga.base.api.viewsets import NestedViewSetMixin @@ -33,7 +33,6 @@ from taiga.projects.models import Project, EpicStatus from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.occ import OCCResourceMixin from taiga.projects.tagging.api import TaggedResourceMixin -from taiga.projects.userstories.models import UserStory from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin from . import models @@ -226,7 +225,8 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, return response.Ok(epics_serialized.data) -class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, ModelCrudViewSet): +class EpicRelatedUserStoryViewSet(NestedViewSetMixin, HistoryResourceMixin, + BlockedByProjectMixin, ModelCrudViewSet): queryset = models.RelatedUserStory.objects.all() serializer_class = serializers.EpicRelatedUserStorySerializer validator_class = validators.EpicRelatedUserStoryValidator @@ -288,13 +288,16 @@ class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, Mod if project.blocked_code is not None: raise exc.Blocked(_("Blocked element")) - services.create_related_userstories_in_bulk( + related_userstories = services.create_related_userstories_in_bulk( data["bulk_userstories"], epic, project=project, owner=request.user ) + for related_userstory in related_userstories: + self.persist_history_snapshot(obj=related_userstory) + related_uss_serialized = self.get_serializer_class()(epic.relateduserstory_set.all(), many=True) return response.Ok(related_uss_serialized.data) diff --git a/taiga/projects/epics/models.py b/taiga/projects/epics/models.py index 64bdbb84..f41cec30 100644 --- a/taiga/projects/epics/models.py +++ b/taiga/projects/epics/models.py @@ -92,7 +92,7 @@ class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M super().save(*args, **kwargs) -class RelatedUserStory(models.Model): +class RelatedUserStory(WatchedModelMixin, models.Model): user_story = models.ForeignKey("userstories.UserStory", on_delete=models.CASCADE) epic = models.ForeignKey("epics.Epic", on_delete=models.CASCADE) @@ -111,3 +111,11 @@ class RelatedUserStory(models.Model): @property def project(self): return self.epic.project + + @property + def owner_id(self): + return self.epic.owner_id + + @property + def assigned_to_id(self): + return self.epic.assigned_to_id diff --git a/taiga/projects/epics/services.py b/taiga/projects/epics/services.py index bf0edff5..610bfcfb 100644 --- a/taiga/projects/epics/services.py +++ b/taiga/projects/epics/services.py @@ -124,7 +124,7 @@ def create_related_userstories_in_bulk(bulk_data, epic, **additional_fields): finally: connect_userstories_signals() - return userstories + return related_userstories def update_epic_related_userstories_order_in_bulk(bulk_data: list, epic: object): diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py index 489f2c79..6804c249 100644 --- a/taiga/projects/history/freeze_impl.py +++ b/taiga/projects/history/freeze_impl.py @@ -112,8 +112,11 @@ def epic_values(diff): if "status" in diff: values["status"] = _get_us_status_values(diff["status"]) - # TODO EPICS: What happen with usr stories? + return values + +def epic_related_userstory_values(diff): + values = _common_users_values(diff) return values @@ -317,6 +320,16 @@ def epic_freezer(epic) -> dict: return snapshot +def epic_related_userstory_freezer(related_us) -> dict: + snapshot = { + "user_story": related_us.user_story.id, + "epic": related_us.epic.id, + "order": related_us.order + } + + return snapshot + + def userstory_freezer(us) -> dict: rp_cls = apps.get_model("userstories", "RolePoints") rpqsd = rp_cls.objects.filter(user_story=us) diff --git a/taiga/projects/history/services.py b/taiga/projects/history/services.py index 54dd78fc..019cf258 100644 --- a/taiga/projects/history/services.py +++ b/taiga/projects/history/services.py @@ -51,6 +51,7 @@ from .models import HistoryType from .freeze_impl import project_freezer from .freeze_impl import milestone_freezer from .freeze_impl import epic_freezer +from .freeze_impl import epic_related_userstory_freezer from .freeze_impl import userstory_freezer from .freeze_impl import issue_freezer from .freeze_impl import task_freezer @@ -60,6 +61,7 @@ from .freeze_impl import wikipage_freezer from .freeze_impl import project_values from .freeze_impl import milestone_values from .freeze_impl import epic_values +from .freeze_impl import epic_related_userstory_values from .freeze_impl import userstory_values from .freeze_impl import issue_values from .freeze_impl import task_values @@ -397,6 +399,7 @@ def prefetch_owners_in_history_queryset(qs): register_freeze_implementation("projects.project", project_freezer) register_freeze_implementation("milestones.milestone", milestone_freezer,) register_freeze_implementation("epics.epic", epic_freezer) +register_freeze_implementation("epics.relateduserstory", epic_related_userstory_freezer) register_freeze_implementation("userstories.userstory", userstory_freezer) register_freeze_implementation("issues.issue", issue_freezer) register_freeze_implementation("tasks.task", task_freezer) @@ -405,6 +408,7 @@ register_freeze_implementation("wiki.wikipage", wikipage_freezer) register_values_implementation("projects.project", project_values) register_values_implementation("milestones.milestone", milestone_values) register_values_implementation("epics.epic", epic_values) +register_values_implementation("epics.relateduserstory", epic_related_userstory_values) register_values_implementation("userstories.userstory", userstory_values) register_values_implementation("issues.issue", issue_values) register_values_implementation("tasks.task", task_values) diff --git a/taiga/projects/mixins/serializers.py b/taiga/projects/mixins/serializers.py index 67949877..c8a70932 100644 --- a/taiga/projects/mixins/serializers.py +++ b/taiga/projects/mixins/serializers.py @@ -98,7 +98,8 @@ class ProjectExtraInfoSerializerMixin(serializers.LightSerializer): serialized_project = { "name": obj.project.name, "slug": obj.project.slug, - "logo_small_url": services.get_logo_small_thumbnail_url(obj.project) + "logo_small_url": services.get_logo_small_thumbnail_url(obj.project), + "id": obj.project_id } self._serialized_project[obj.project_id] = serialized_project diff --git a/taiga/projects/notifications/services.py b/taiga/projects/notifications/services.py index 4c7012d1..e998d068 100644 --- a/taiga/projects/notifications/services.py +++ b/taiga/projects/notifications/services.py @@ -336,6 +336,7 @@ def get_related_people(obj): related_people = related_people.exclude(is_active=False) related_people = related_people.exclude(is_system=True) related_people = related_people.distinct() + return related_people diff --git a/taiga/timeline/service.py b/taiga/timeline/service.py index 94d37c80..03ca3e96 100644 --- a/taiga/timeline/service.py +++ b/taiga/timeline/service.py @@ -94,6 +94,7 @@ def _push_to_timeline(objects, instance: object, event_type: str, created_dateti @app.task def push_to_timelines(project_id, user_id, obj_app_label, obj_model_name, obj_id, event_type, created_datetime, extra_data={}): + ObjModel = apps.get_model(obj_app_label, obj_model_name) try: obj = ObjModel.objects.get(id=obj_id) @@ -266,13 +267,25 @@ def extract_epic_info(instance): } -def extract_userstory_info(instance): - return { +def extract_userstory_info(instance, include_project=False): + userstory_info = { "id": instance.pk, "ref": instance.ref, "subject": instance.subject, } + if include_project: + userstory_info["project"] = extract_project_info(instance.project) + + return userstory_info + + +def extract_related_userstory_info(instance): + return { + "id": instance.pk, + "subject": instance.user_story.subject + } + def extract_issue_info(instance): return { diff --git a/taiga/timeline/timeline_implementations.py b/taiga/timeline/timeline_implementations.py index 9b97fa06..1d480e82 100644 --- a/taiga/timeline/timeline_implementations.py +++ b/taiga/timeline/timeline_implementations.py @@ -47,7 +47,7 @@ def milestone_timeline(instance, extra_data={}): @register_timeline_implementation("epics.epic", "change") @register_timeline_implementation("epics.epic", "delete") def epic_timeline(instance, extra_data={}): - result ={ + result = { "epic": service.extract_epic_info(instance), "project": service.extract_project_info(instance.project), } @@ -55,6 +55,20 @@ def epic_timeline(instance, extra_data={}): return result +@register_timeline_implementation("epics.relateduserstory", "create") +@register_timeline_implementation("epics.relateduserstory", "change") +@register_timeline_implementation("epics.relateduserstory", "delete") +def epic_related_userstory_timeline(instance, extra_data={}): + result = { + "relateduserstory": service.extract_related_userstory_info(instance), + "epic": service.extract_epic_info(instance.epic), + "userstory": service.extract_userstory_info(instance.user_story, include_project=True), + "project": service.extract_project_info(instance.project), + } + result.update(extra_data) + return result + + @register_timeline_implementation("userstories.userstory", "create") @register_timeline_implementation("userstories.userstory", "change") @register_timeline_implementation("userstories.userstory", "delete") From e9ca1abf557060e73010d513624b0410e47da10d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 12 Sep 2016 15:34:15 +0200 Subject: [PATCH 73/83] Adding epics to user favorites and voted APIs --- taiga/users/services.py | 10 ++++++- tests/integration/test_users.py | 46 ++++++++++++++++++++++++--------- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/taiga/users/services.py b/taiga/users/services.py index 5923ff11..4b49353e 100644 --- a/taiga/users/services.py +++ b/taiga/users/services.py @@ -325,6 +325,8 @@ def get_watched_list(for_user, from_user, type=None, q=None): row_to_json(users_user) as assigned_to_extra_info FROM ( + {epics_sql} + UNION {userstories_sql} UNION {tasks_sql} @@ -365,6 +367,7 @@ def get_watched_list(for_user, from_user, type=None, q=None): OR (entities.type = 'task' AND 'view_tasks' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) OR (entities.type = 'userstory' AND 'view_us' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) OR (entities.type = 'project' AND 'view_project' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + OR (entities.type = 'epic' AND 'view_epic' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) ) )) -- END Permissions checking @@ -384,6 +387,7 @@ def get_watched_list(for_user, from_user, type=None, q=None): userstories_sql=_build_sql_for_type(for_user, "userstory", "userstories_userstory", "notifications_watched", slug_column="null"), tasks_sql=_build_sql_for_type(for_user, "task", "tasks_task", "notifications_watched", slug_column="null"), issues_sql=_build_sql_for_type(for_user, "issue", "issues_issue", "notifications_watched", slug_column="null"), + epics_sql=_build_sql_for_type(for_user, "epic", "epics_epic", "notifications_watched", slug_column="null"), projects_sql=_build_watched_sql_for_projects(for_user)) cursor = connection.cursor() @@ -503,6 +507,8 @@ def get_voted_list(for_user, from_user, type=None, q=None): users_user.id as assigned_to_id, row_to_json(users_user) as assigned_to_extra_info FROM ( + {epics_sql} + UNION {userstories_sql} UNION {tasks_sql} @@ -540,6 +546,7 @@ def get_voted_list(for_user, from_user, type=None, q=None): (entities.type = 'issue' AND 'view_issues' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) OR (entities.type = 'task' AND 'view_tasks' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) OR (entities.type = 'userstory' AND 'view_us' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + OR (entities.type = 'epic' AND 'view_epic' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) ) )) -- END Permissions checking @@ -558,7 +565,8 @@ def get_voted_list(for_user, from_user, type=None, q=None): filters_sql=filters_sql, userstories_sql=_build_sql_for_type(for_user, "userstory", "userstories_userstory", "votes_vote", slug_column="null"), tasks_sql=_build_sql_for_type(for_user, "task", "tasks_task", "votes_vote", slug_column="null"), - issues_sql=_build_sql_for_type(for_user, "issue", "issues_issue", "votes_vote", slug_column="null")) + issues_sql=_build_sql_for_type(for_user, "issue", "issues_issue", "votes_vote", slug_column="null"), + epics_sql=_build_sql_for_type(for_user, "epic", "epics_epic", "votes_vote", slug_column="null")) cursor = connection.cursor() params = { diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py index c9bf5fba..3accebb7 100644 --- a/tests/integration/test_users.py +++ b/tests/integration/test_users.py @@ -454,6 +454,9 @@ def test_get_watched_list(): membership = f.MembershipFactory(project=project, role=role, user=fav_user) project.add_watcher(fav_user) + epic = f.EpicFactory(project=project, subject="Testing epic") + epic.add_watcher(fav_user) + user_story = f.UserStoryFactory(project=project, subject="Testing user story") user_story.add_watcher(fav_user) @@ -463,11 +466,12 @@ def test_get_watched_list(): issue = f.IssueFactory(project=project, subject="Testing issue") issue.add_watcher(fav_user) - assert len(get_watched_list(fav_user, viewer_user)) == 4 + assert len(get_watched_list(fav_user, viewer_user)) == 5 assert len(get_watched_list(fav_user, viewer_user, type="project")) == 1 assert len(get_watched_list(fav_user, viewer_user, type="userstory")) == 1 assert len(get_watched_list(fav_user, viewer_user, type="task")) == 1 assert len(get_watched_list(fav_user, viewer_user, type="issue")) == 1 + assert len(get_watched_list(fav_user, viewer_user, type="epic")) == 1 assert len(get_watched_list(fav_user, viewer_user, type="unknown")) == 0 assert len(get_watched_list(fav_user, viewer_user, q="issue")) == 1 @@ -500,6 +504,11 @@ def test_get_voted_list(): role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) membership = f.MembershipFactory(project=project, role=role, user=fav_user) + epic = f.EpicFactory(project=project, subject="Testing epic") + content_type = ContentType.objects.get_for_model(epic) + f.VoteFactory(content_type=content_type, object_id=epic.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=epic.id, count=1) + user_story = f.UserStoryFactory(project=project, subject="Testing user story") content_type = ContentType.objects.get_for_model(user_story) f.VoteFactory(content_type=content_type, object_id=user_story.id, user=fav_user) @@ -515,7 +524,8 @@ def test_get_voted_list(): f.VoteFactory(content_type=content_type, object_id=issue.id, user=fav_user) f.VotesFactory(content_type=content_type, object_id=issue.id, count=1) - assert len(get_voted_list(fav_user, viewer_user)) == 3 + assert len(get_voted_list(fav_user, viewer_user)) == 4 + assert len(get_voted_list(fav_user, viewer_user, type="epic")) == 1 assert len(get_voted_list(fav_user, viewer_user, type="userstory")) == 1 assert len(get_voted_list(fav_user, viewer_user, type="task")) == 1 assert len(get_voted_list(fav_user, viewer_user, type="issue")) == 1 @@ -530,7 +540,7 @@ def test_get_watched_list_valid_info_for_project(): viewer_user = f.UserFactory() project = f.ProjectFactory(is_private=False, name="Testing project") - role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + role = f.RoleFactory(project=project, permissions=["view_project", "view_epic", "view_us", "view_tasks", "view_issues"]) project.add_watcher(fav_user) raw_project_watch_info = get_watched_list(fav_user, viewer_user)[0] @@ -568,7 +578,7 @@ def test_get_watched_list_for_project_with_ignored_notify_level(): viewer_user = f.UserFactory() project = f.ProjectFactory(is_private=False, name="Testing project", tags=['test', 'tag']) - role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + role = f.RoleFactory(project=project, permissions=["view_project", "view_epic", "view_us", "view_tasks", "view_issues"]) membership = f.MembershipFactory(project=project, role=role, user=fav_user) notify_policy = NotifyPolicy.objects.get(user=fav_user, project=project) notify_policy.notify_level=NotifyLevel.none @@ -624,6 +634,7 @@ def test_get_watched_list_valid_info_for_not_project_types(): project = f.ProjectFactory(is_private=False, name="Testing project") factories = { + "epic": f.EpicFactory, "userstory": f.UserStoryFactory, "task": f.TaskFactory, "issue": f.IssueFactory @@ -680,6 +691,7 @@ def test_get_voted_list_valid_info(): project = f.ProjectFactory(is_private=False, name="Testing project") factories = { + "epic": f.EpicFactory, "userstory": f.UserStoryFactory, "task": f.TaskFactory, "issue": f.IssueFactory @@ -743,6 +755,7 @@ def test_get_watched_list_with_liked_and_voted_objects(client): f.LikeFactory(content_type=content_type, object_id=project.id, user=fav_user) voted_elements_factories = { + "epic": f.EpicFactory, "userstory": f.UserStoryFactory, "task": f.TaskFactory, "issue": f.IssueFactory @@ -793,6 +806,7 @@ def test_get_voted_list_with_watched_objects(client): membership = f.MembershipFactory(project=project, role=role, user=fav_user) voted_elements_factories = { + "epic": f.EpicFactory, "userstory": f.UserStoryFactory, "task": f.TaskFactory, "issue": f.IssueFactory @@ -820,9 +834,12 @@ def test_get_watched_list_permissions(): project = f.ProjectFactory(is_private=True, name="Testing project") project.add_watcher(fav_user) - role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + role = f.RoleFactory(project=project, permissions=["view_project", "view_epic", "view_us", "view_tasks", "view_issues"]) membership = f.MembershipFactory(project=project, role=role, user=viewer_priviliged_user) + epic = f.EpicFactory(project=project, subject="Testing epic") + epic.add_watcher(fav_user) + user_story = f.UserStoryFactory(project=project, subject="Testing user story") user_story.add_watcher(fav_user) @@ -838,13 +855,13 @@ def test_get_watched_list_permissions(): #If the project is private but the viewer user has permissions the votes should # be accesible - assert len(get_watched_list(fav_user, viewer_priviliged_user)) == 4 + assert len(get_watched_list(fav_user, viewer_priviliged_user)) == 5 #If the project is private but has the required anon permissions the votes should # be accesible by any user too - project.anon_permissions = ["view_project", "view_us", "view_tasks", "view_issues"] + project.anon_permissions = ["view_project", "view_epic", "view_us", "view_tasks", "view_issues"] project.save() - assert len(get_watched_list(fav_user, viewer_unpriviliged_user)) == 4 + assert len(get_watched_list(fav_user, viewer_unpriviliged_user)) == 5 def test_get_liked_list_permissions(): @@ -879,9 +896,14 @@ def test_get_voted_list_permissions(): viewer_priviliged_user = f.UserFactory() project = f.ProjectFactory(is_private=True, name="Testing project") - role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + role = f.RoleFactory(project=project, permissions=["view_project", "view_epic", "view_us", "view_tasks", "view_issues"]) membership = f.MembershipFactory(project=project, role=role, user=viewer_priviliged_user) + epic = f.EpicFactory(project=project, subject="Testing epic") + content_type = ContentType.objects.get_for_model(epic) + f.VoteFactory(content_type=content_type, object_id=epic.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=epic.id, count=1) + user_story = f.UserStoryFactory(project=project, subject="Testing user story") content_type = ContentType.objects.get_for_model(user_story) f.VoteFactory(content_type=content_type, object_id=user_story.id, user=fav_user) @@ -903,10 +925,10 @@ def test_get_voted_list_permissions(): #If the project is private but the viewer user has permissions the votes should # be accesible - assert len(get_voted_list(fav_user, viewer_priviliged_user)) == 3 + assert len(get_voted_list(fav_user, viewer_priviliged_user)) == 4 #If the project is private but has the required anon permissions the votes should # be accesible by any user too - project.anon_permissions = ["view_project", "view_us", "view_tasks", "view_issues"] + project.anon_permissions = ["view_project", "view_epic", "view_us", "view_tasks", "view_issues"] project.save() - assert len(get_voted_list(fav_user, viewer_unpriviliged_user)) == 3 + assert len(get_voted_list(fav_user, viewer_unpriviliged_user)) == 4 From 8475015025eabf38bdffa1e1643a7ea094a5c567 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 13 Sep 2016 09:11:21 +0200 Subject: [PATCH 74/83] Adding timeline entries on bulk_create calls --- taiga/projects/epics/api.py | 3 +++ taiga/projects/tasks/api.py | 3 +++ taiga/projects/userstories/api.py | 3 +++ 3 files changed, 9 insertions(+) diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py index fce31802..fed57abd 100644 --- a/taiga/projects/epics/api.py +++ b/taiga/projects/epics/api.py @@ -220,6 +220,9 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, callback=self.post_save, precall=self.pre_save) epics = self.get_queryset().filter(id__in=[i.id for i in epics]) + for epic in epics: + self.persist_history_snapshot(obj=epic) + epics_serialized = self.get_serializer_class()(epics, many=True) return response.Ok(epics_serialized.data) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index e722f935..778e080d 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -266,6 +266,9 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, project=project, owner=request.user, callback=self.post_save, precall=self.pre_save) tasks = self.get_queryset().filter(id__in=[i.id for i in tasks]) + for task in tasks: + self.persist_history_snapshot(obj=task) + tasks_serialized = self.get_serializer_class()(tasks, many=True) return response.Ok(tasks_serialized.data) diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 758fa98c..df1b495a 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -340,6 +340,9 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi callback=self.post_save, precall=self.pre_save) user_stories = self.get_queryset().filter(id__in=[i.id for i in user_stories]) + for user_story in user_stories: + self.persist_history_snapshot(obj=user_story) + user_stories_serialized = self.get_serializer_class()(user_stories, many=True) return response.Ok(user_stories_serialized.data) From 21a7ca3e4c9a466b79e250d3dc5b5dc2029e14de Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 13 Sep 2016 10:39:17 +0200 Subject: [PATCH 75/83] Disabling epics module by default for new projects --- taiga/projects/fixtures/initial_project_templates.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/taiga/projects/fixtures/initial_project_templates.json b/taiga/projects/fixtures/initial_project_templates.json index 9bc6d969..6137792f 100644 --- a/taiga/projects/fixtures/initial_project_templates.json +++ b/taiga/projects/fixtures/initial_project_templates.json @@ -10,7 +10,7 @@ "created_date": "2014-04-22T14:48:43.596Z", "modified_date": "2016-08-24T16:26:40.845Z", "default_owner_role": "product-owner", - "is_epics_activated": true, + "is_epics_activated": false, "is_backlog_activated": true, "is_kanban_activated": false, "is_wiki_activated": true, @@ -40,7 +40,7 @@ "created_date": "2014-04-22T14:50:19.738Z", "modified_date": "2016-08-24T16:26:45.365Z", "default_owner_role": "product-owner", - "is_epics_activated": true, + "is_epics_activated": false, "is_backlog_activated": false, "is_kanban_activated": true, "is_wiki_activated": false, From 0b6a31271e69d3006d1d8422800d9402b6d691c8 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 13 Sep 2016 15:15:05 +0200 Subject: [PATCH 76/83] Fixing HistoryEntry generation for epic changes --- taiga/projects/epics/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/taiga/projects/epics/models.py b/taiga/projects/epics/models.py index f41cec30..09003097 100644 --- a/taiga/projects/epics/models.py +++ b/taiga/projects/epics/models.py @@ -112,6 +112,10 @@ class RelatedUserStory(WatchedModelMixin, models.Model): def project(self): return self.epic.project + @property + def project_id(self): + return self.epic.project_id + @property def owner_id(self): return self.epic.owner_id From 564cd4350494fd377061dcbc73ce11d785803253 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 13 Sep 2016 15:19:30 +0200 Subject: [PATCH 77/83] Excluding epics_order from diffs --- taiga/projects/history/services.py | 1 + 1 file changed, 1 insertion(+) diff --git a/taiga/projects/history/services.py b/taiga/projects/history/services.py index 019cf258..7247ccdd 100644 --- a/taiga/projects/history/services.py +++ b/taiga/projects/history/services.py @@ -80,6 +80,7 @@ _values_impl_map = {} # Not important fields for models (history entries with only # this fields are marked as hidden). _not_important_fields = { + "epics.epic": frozenset(["epics_order"]), "userstories.userstory": frozenset(["backlog_order", "sprint_order", "kanban_order"]), "tasks.task": frozenset(["us_order", "taskboard_order"]), } From 4e5013523482a4a27ebb557c0fe40c70b1b7c5b9 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 15 Sep 2016 12:20:48 +0200 Subject: [PATCH 78/83] Support for updating epic status via commit message --- taiga/hooks/event_hooks.py | 8 ++- taiga/projects/epics/models.py | 6 +- taiga/projects/history/services.py | 2 +- ...est_epics_related_userstories_resources.py | 33 +++++++--- tests/integration/test_hooks_bitbucket.py | 63 +++++++++++++++++++ tests/integration/test_hooks_github.py | 21 +++++++ tests/integration/test_hooks_gitlab.py | 25 ++++++++ tests/integration/test_hooks_gogs.py | 31 +++++++++ 8 files changed, 176 insertions(+), 13 deletions(-) diff --git a/taiga/hooks/event_hooks.py b/taiga/hooks/event_hooks.py index 85ac892f..93deb518 100644 --- a/taiga/hooks/event_hooks.py +++ b/taiga/hooks/event_hooks.py @@ -20,7 +20,8 @@ import re from django.utils.translation import ugettext as _ from django.contrib.auth import get_user_model -from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus +from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus, EpicStatus +from taiga.projects.epics.models import Epic from taiga.projects.issues.models import Issue from taiga.projects.tasks.models import Task from taiga.projects.userstories.models import UserStory @@ -189,7 +190,10 @@ class BasePushEventHook(BaseEventHook): return _simple_status_change_message.format(platform=self.platform) def get_item_classes(self, ref): - if Issue.objects.filter(project=self.project, ref=ref).exists(): + if Epic.objects.filter(project=self.project, ref=ref).exists(): + modelClass = Epic + statusClass = EpicStatus + elif Issue.objects.filter(project=self.project, ref=ref).exists(): modelClass = Issue statusClass = IssueStatus elif Task.objects.filter(project=self.project, ref=ref).exists(): diff --git a/taiga/projects/epics/models.py b/taiga/projects/epics/models.py index 09003097..c2e26d20 100644 --- a/taiga/projects/epics/models.py +++ b/taiga/projects/epics/models.py @@ -115,7 +115,11 @@ class RelatedUserStory(WatchedModelMixin, models.Model): @property def project_id(self): return self.epic.project_id - + + @property + def owner(self): + return self.epic.owner + @property def owner_id(self): return self.epic.owner_id diff --git a/taiga/projects/history/services.py b/taiga/projects/history/services.py index 7247ccdd..1bf27dee 100644 --- a/taiga/projects/history/services.py +++ b/taiga/projects/history/services.py @@ -80,7 +80,7 @@ _values_impl_map = {} # Not important fields for models (history entries with only # this fields are marked as hidden). _not_important_fields = { - "epics.epic": frozenset(["epics_order"]), + "epics.epic": frozenset(["epics_order", "user_stories"]), "userstories.userstory": frozenset(["backlog_order", "sprint_order", "kanban_order"]), "tasks.task": frozenset(["us_order", "taskboard_order"]), } diff --git a/tests/integration/resources_permissions/test_epics_related_userstories_resources.py b/tests/integration/resources_permissions/test_epics_related_userstories_resources.py index d1924ec8..04dd28c1 100644 --- a/tests/integration/resources_permissions/test_epics_related_userstories_resources.py +++ b/tests/integration/resources_permissions/test_epics_related_userstories_resources.py @@ -242,31 +242,31 @@ def test_epic_related_userstories_create(client, data): ] create_data = json.dumps({ - "user_story": data.public_us.id, + "user_story": f.UserStoryFactory(project=data.public_project).id, "epic": data.public_epic.id }) url = reverse('epics-related-userstories-list', args=[data.public_epic.pk]) results = helper_test_http_method(client, 'post', url, create_data, users) - assert results == [401, 403, 403, 201, 201] + assert results == [401, 403, 403, 201, 400] create_data = json.dumps({ - "user_story": data.private_us1.id, + "user_story": f.UserStoryFactory(project=data.private_project1).id, "epic": data.private_epic1.id }) url = reverse('epics-related-userstories-list', args=[data.private_epic1.pk]) results = helper_test_http_method(client, 'post', url, create_data, users) - assert results == [401, 403, 403, 201, 201] + assert results == [401, 403, 403, 201, 400] create_data = json.dumps({ - "user_story": data.private_us2.id, + "user_story": f.UserStoryFactory(project=data.private_project2).id, "epic": data.private_epic2.id }) url = reverse('epics-related-userstories-list', args=[data.private_epic2.pk]) results = helper_test_http_method(client, 'post', url, create_data, users) - assert results == [401, 403, 403, 201, 201] + assert results == [401, 403, 403, 201, 400] create_data = json.dumps({ - "user_story": data.blocked_us.id, + "user_story": f.UserStoryFactory(project=data.blocked_project).id, "epic": data.blocked_epic.id }) url = reverse('epics-related-userstories-list', args=[data.blocked_epic.pk]) @@ -379,14 +379,29 @@ def test_bulk_create_related_userstories(client, data): ] bulk_data = json.dumps({ - "userstories": "test1\ntest2", + "bulk_userstories": "test1\ntest2", + "project_id": data.public_project.id }) - results = helper_test_http_method(client, 'post', public_url, bulk_data, users) assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_userstories": "test1\ntest2", + "project_id": data.private_project1.id + }) results = helper_test_http_method(client, 'post', private_url1, bulk_data, users) assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_userstories": "test1\ntest2", + "project_id": data.private_project2.id + }) results = helper_test_http_method(client, 'post', private_url2, bulk_data, users) assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_userstories": "test1\ntest2", + "project_id": data.blocked_project.id + }) results = helper_test_http_method(client, 'post', blocked_url, bulk_data, users) assert results == [401, 403, 403, 451, 451] diff --git a/tests/integration/test_hooks_bitbucket.py b/tests/integration/test_hooks_bitbucket.py index 90023b38..6dbe06c3 100644 --- a/tests/integration/test_hooks_bitbucket.py +++ b/tests/integration/test_hooks_bitbucket.py @@ -31,6 +31,7 @@ from taiga.hooks.bitbucket import event_hooks from taiga.hooks.bitbucket.api import BitBucketViewSet from taiga.hooks.exceptions import ActionSyntaxException from taiga.projects import choices as project_choices +from taiga.projects.epics.models import Epic from taiga.projects.issues.models import Issue from taiga.projects.tasks.models import Task from taiga.projects.userstories.models import UserStory @@ -239,6 +240,38 @@ def test_push_event_detected(client): assert response.status_code == 204 +def test_push_event_epic_processing(client): + creation_status = f.EpicStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_epics"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.EpicStatusFactory(project=creation_status.project) + epic = f.EpicFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-%s #%s ok bye!" % (epic.ref, new_status.slug) } + ] + } + ] + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(epic.project, payload) + ev_hook.process_event() + epic = Epic.objects.get(id=epic.id) + assert epic.status.id == new_status.id + assert len(mail.outbox) == 1 + + def test_push_event_issue_processing(client): creation_status = f.IssueStatusFactory() role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) @@ -534,6 +567,36 @@ def test_push_event_task_bad_processing_non_existing_ref(client): assert len(mail.outbox) == 0 +def test_push_event_task_bad_processing_non_existing_ref(client): + issue_status = f.IssueStatusFactory() + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-6666666 #%s ok bye!" % (issue_status.slug) } + ] + } + ] + } + } + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue_status.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The referenced element doesn't exist" + assert len(mail.outbox) == 0 + + def test_push_event_us_bad_processing_non_existing_status(client): user_story = f.UserStoryFactory.create() payload = { diff --git a/tests/integration/test_hooks_github.py b/tests/integration/test_hooks_github.py index 815aba49..1bcbcdbe 100644 --- a/tests/integration/test_hooks_github.py +++ b/tests/integration/test_hooks_github.py @@ -29,6 +29,7 @@ from taiga.hooks.github import event_hooks from taiga.hooks.github.api import GitHubViewSet from taiga.hooks.exceptions import ActionSyntaxException from taiga.projects import choices as project_choices +from taiga.projects.epics.models import Epic from taiga.projects.issues.models import Issue from taiga.projects.tasks.models import Task from taiga.projects.userstories.models import UserStory @@ -111,6 +112,26 @@ def test_push_event_detected(client): assert response.status_code == 204 +def test_push_event_epic_processing(client): + creation_status = f.EpicStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_epics"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.EpicStatusFactory(project=creation_status.project) + epic = f.EpicFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s #%s ok + bye! + """ % (epic.ref, new_status.slug)}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(epic.project, payload) + ev_hook.process_event() + epic = Epic.objects.get(id=epic.id) + assert epic.status.id == new_status.id + assert len(mail.outbox) == 1 + + def test_push_event_issue_processing(client): creation_status = f.IssueStatusFactory() role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) diff --git a/tests/integration/test_hooks_gitlab.py b/tests/integration/test_hooks_gitlab.py index 5208a939..b40c94e6 100644 --- a/tests/integration/test_hooks_gitlab.py +++ b/tests/integration/test_hooks_gitlab.py @@ -30,6 +30,7 @@ from taiga.hooks.gitlab import event_hooks from taiga.hooks.gitlab.api import GitLabViewSet from taiga.hooks.exceptions import ActionSyntaxException from taiga.projects import choices as project_choices +from taiga.projects.epics.models import Epic from taiga.projects.issues.models import Issue from taiga.projects.tasks.models import Task from taiga.projects.userstories.models import UserStory @@ -446,6 +447,30 @@ def test_push_event_detected(client): assert response.status_code == 204 +def test_push_event_epic_processing(client): + creation_status = f.EpicStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_epics"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.EpicStatusFactory(project=creation_status.project) + epic = f.EpicFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test TG-%s #%s ok + bye! + """ % (epic.ref, new_status.slug), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + payload["total_commits_count"] = 1 + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(epic.project, payload) + ev_hook.process_event() + epic = Epic.objects.get(id=epic.id) + assert epic.status.id == new_status.id + assert len(mail.outbox) == 1 + + def test_push_event_issue_processing(client): creation_status = f.IssueStatusFactory() role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) diff --git a/tests/integration/test_hooks_gogs.py b/tests/integration/test_hooks_gogs.py index 290bc3f8..46c33dd9 100644 --- a/tests/integration/test_hooks_gogs.py +++ b/tests/integration/test_hooks_gogs.py @@ -29,6 +29,7 @@ from taiga.hooks.gogs import event_hooks from taiga.hooks.gogs.api import GogsViewSet from taiga.hooks.exceptions import ActionSyntaxException from taiga.projects import choices as project_choices +from taiga.projects.epics.models import Epic from taiga.projects.issues.models import Issue from taiga.projects.tasks.models import Task from taiga.projects.userstories.models import UserStory @@ -120,6 +121,36 @@ def test_push_event_detected(client): assert response.status_code == 204 +def test_push_event_epic_processing(client): + creation_status = f.EpicStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_epics"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.EpicStatusFactory(project=creation_status.project) + epic = f.EpicFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test TG-%s #%s ok + bye! + """ % (epic.ref, new_status.slug), + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(epic.project, payload) + ev_hook.process_event() + epic = Epic.objects.get(id=epic.id) + assert epic.status.id == new_status.id + assert len(mail.outbox) == 1 + + def test_push_event_issue_processing(client): creation_status = f.IssueStatusFactory() role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) From 778bd6018991c896f76d19bdb65dc61ec44388dd Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 15 Sep 2016 13:29:10 +0200 Subject: [PATCH 79/83] Epic support in webhooks --- taiga/webhooks/serializers.py | 68 +++++ taiga/webhooks/tasks.py | 10 +- tests/integration/test_webhooks_epics.py | 319 +++++++++++++++++++++++ 3 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 tests/integration/test_webhooks_epics.py diff --git a/taiga/webhooks/serializers.py b/taiga/webhooks/serializers.py index 3525c973..7f6736f4 100644 --- a/taiga/webhooks/serializers.py +++ b/taiga/webhooks/serializers.py @@ -185,6 +185,26 @@ class RolePointsSerializer(serializers.LightSerializer): return obj.points.value +class EpicStatusSerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + slug = MethodField() + color = MethodField() + is_closed = MethodField() + + def get_name(self, obj): + return obj.name + + def get_slug(self, obj): + return obj.slug + + def get_color(self, obj): + return obj.color + + def get_is_closed(self, obj): + return obj.is_closed + + class UserStoryStatusSerializer(serializers.LightSerializer): id = Field(attr="pk") name = MethodField() @@ -445,3 +465,51 @@ class WikiPageSerializer(serializers.LightSerializer): def get_permalink(self, obj): return resolve_front_url("wiki", obj.project.slug, obj.slug) + + +######################################################################## +# Epic +######################################################################## + +class EpicSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.LightSerializer): + id = Field() + ref = Field() + created_date = Field() + modified_date = Field() + subject = Field() + watchers = MethodField() + description = Field() + tags = Field() + permalink = serializers.SerializerMethodField("get_permalink") + project = ProjectSerializer() + owner = UserSerializer() + assigned_to = UserSerializer() + status = EpicStatusSerializer() + epics_order = Field() + color = Field() + client_requirement = Field() + team_requirement = Field() + client_requirement = Field() + team_requirement = Field() + + def get_permalink(self, obj): + return resolve_front_url("epic", obj.project.slug, obj.ref) + + def custom_attributes_queryset(self, project): + return project.epiccustomattributes.all() + + def get_watchers(self, obj): + return list(obj.get_watchers().values_list("id", flat=True)) + + +class EpicRelatedUserStorySerializer(serializers.LightSerializer): + id = Field() + user_story = MethodField() + epic = MethodField() + order = Field() + + def get_user_story(self, obj): + return UserStorySerializer(obj.user_story).data + + def get_epic(self, obj): + return EpicSerializer(obj.epic).data diff --git a/taiga/webhooks/tasks.py b/taiga/webhooks/tasks.py index 334cd52d..75e3caad 100644 --- a/taiga/webhooks/tasks.py +++ b/taiga/webhooks/tasks.py @@ -25,7 +25,8 @@ from taiga.base.api.renderers import UnicodeJSONRenderer from taiga.base.utils.db import get_typename_for_model_instance from taiga.celery import app -from .serializers import (UserStorySerializer, IssueSerializer, TaskSerializer, +from .serializers import (EpicSerializer, EpicRelatedUserStorySerializer, + UserStorySerializer, IssueSerializer, TaskSerializer, WikiPageSerializer, MilestoneSerializer, HistoryEntrySerializer, UserSerializer) from .models import WebhookLog @@ -33,8 +34,11 @@ from .models import WebhookLog def _serialize(obj): content_type = get_typename_for_model_instance(obj) - - if content_type == "userstories.userstory": + if content_type == "epics.epic": + return EpicSerializer(obj).data + elif content_type == "epics.relateduserstory": + return EpicRelatedUserStorySerializer(obj).data + elif content_type == "userstories.userstory": return UserStorySerializer(obj).data elif content_type == "issues.issue": return IssueSerializer(obj).data diff --git a/tests/integration/test_webhooks_epics.py b/tests/integration/test_webhooks_epics.py new file mode 100644 index 00000000..f77dc829 --- /dev/null +++ b/tests/integration/test_webhooks_epics.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# Copyright (C) 2014-2016 Anler Hernández +# 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 pytest +from unittest.mock import patch + +from .. import factories as f + +from taiga.projects.history import services + + +pytestmark = pytest.mark.django_db(transaction=True) + + +def test_webhooks_when_create_epic(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.EpicFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "create" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + + +def test_webhooks_when_update_epic(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.EpicFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + obj.subject = "test webhook update" + obj.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["data"]["subject"] == obj.subject + assert data["change"]["comment"] == "test_comment" + assert data["change"]["diff"]["subject"]["to"] == data["data"]["subject"] + assert data["change"]["diff"]["subject"]["from"] != data["data"]["subject"] + + +def test_webhooks_when_delete_epic(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.EpicFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, delete=True) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "delete" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert "data" in data + + +def test_webhooks_when_update_epic_attachments(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.EpicFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + # Create attachments + attachment1 = f.EpicAttachmentFactory(project=obj.project, content_object=obj, owner=obj.owner) + attachment2 = f.EpicAttachmentFactory(project=obj.project, content_object=obj, owner=obj.owner) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 2 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 0 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 0 + + # Update attachment + attachment1.description = "new attachment description" + attachment1.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 0 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 1 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 0 + + # Delete attachment + attachment2.delete() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 0 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 0 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 1 + + +def test_webhooks_when_update_epic_custom_attributes(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.EpicFactory.create(project=project) + + custom_attr_1 = f.EpicCustomAttributeFactory(project=obj.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.EpicCustomAttributeFactory(project=obj.project) + ct2_id = "{}".format(custom_attr_2.id) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + # Create custom attributes + obj.custom_attributes_values.attributes_values = { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + } + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 2 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 0 + + # Update custom attributes + obj.custom_attributes_values.attributes_values[ct1_id] = "test_2_updated" + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 1 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 0 + + # Delete custom attributes + del obj.custom_attributes_values.attributes_values[ct1_id] + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 1 + + +def test_webhooks_when_create_epic_related_userstory(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + epic = f.EpicFactory.create(project=project) + obj = f.RelatedUserStory.create(epic=epic) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=epic.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "create" + assert data["type"] == "relateduserstory" + assert data["by"]["id"] == epic.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + + +def test_webhooks_when_update_epic_related_userstory(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + epic = f.EpicFactory.create(project=project) + obj = f.RelatedUserStory.create(epic=epic, order=33) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=epic.owner) + assert send_request_mock.call_count == 2 + + obj.order = 66 + obj.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=epic.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "relateduserstory" + assert data["by"]["id"] == epic.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["data"]["order"] == obj.order + assert data["change"]["diff"]["order"]["to"] == 66 + assert data["change"]["diff"]["order"]["from"] == 33 + + +def test_webhooks_when_delete_epic_related_userstory(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + epic = f.EpicFactory.create(project=project) + obj = f.RelatedUserStory.create(epic=epic, order=33) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=epic.owner, delete=True) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "delete" + assert data["type"] == "relateduserstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert "data" in data From a4c57a605bf845a55b5d6024f985dbee218f120c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 15 Sep 2016 14:18:00 +0200 Subject: [PATCH 80/83] Fixing epic serialization --- taiga/projects/history/freeze_impl.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py index 6804c249..fd452257 100644 --- a/taiga/projects/history/freeze_impl.py +++ b/taiga/projects/history/freeze_impl.py @@ -190,18 +190,6 @@ def _generic_extract(obj:object, fields:list, default=None) -> dict: return result -@as_tuple -def extract_user_stories(obj) -> list: - for user_story in obj.user_stories.all(): - - yield {"id": user_story.id, - "ref": user_story.ref, - "subject": user_story.subject, - "project": { - "id": user_story.project.id, - "name": user_story.project.name, - "slug": user_story.project.slug}} - @as_tuple def extract_attachments(obj) -> list: for attach in obj.attachments.all(): @@ -313,8 +301,7 @@ def epic_freezer(epic) -> dict: "is_blocked": epic.is_blocked, "blocked_note": epic.blocked_note, "blocked_note_html": mdrender(epic.project, epic.blocked_note), - "custom_attributes": extract_epic_custom_attributes(epic), - "user_stories": extract_user_stories(epic), + "custom_attributes": extract_epic_custom_attributes(epic) } return snapshot @@ -326,7 +313,7 @@ def epic_related_userstory_freezer(related_us) -> dict: "epic": related_us.epic.id, "order": related_us.order } - + return snapshot From 3b5b658f3ca6284c87dc14ebbdf951d5e9e8c1e3 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 15 Sep 2016 14:20:40 +0200 Subject: [PATCH 81/83] Fixing gogs integration --- taiga/hooks/gogs/event_hooks.py | 2 +- tests/integration/test_hooks_gogs.py | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/taiga/hooks/gogs/event_hooks.py b/taiga/hooks/gogs/event_hooks.py index 8e68b8db..b392afe2 100644 --- a/taiga/hooks/gogs/event_hooks.py +++ b/taiga/hooks/gogs/event_hooks.py @@ -37,7 +37,7 @@ class PushEventHook(BaseGogsEventHook, BasePushEventHook): def get_data(self): result = [] commits = self.payload.get("commits", []) - project_url = self.payload.get("repository", {}).get("url", None) + project_url = self.payload.get("repository", {}).get("html_url", None) for commit in filter(None, commits): user_name = commit.get('author', {}).get('username', None) diff --git a/tests/integration/test_hooks_gogs.py b/tests/integration/test_hooks_gogs.py index 46c33dd9..11685d1a 100644 --- a/tests/integration/test_hooks_gogs.py +++ b/tests/integration/test_hooks_gogs.py @@ -105,7 +105,7 @@ def test_push_event_detected(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } @@ -140,7 +140,7 @@ def test_push_event_epic_processing(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } mail.outbox = [] @@ -170,7 +170,7 @@ def test_push_event_issue_processing(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } mail.outbox = [] @@ -200,7 +200,7 @@ def test_push_event_task_processing(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } mail.outbox = [] @@ -230,7 +230,7 @@ def test_push_event_user_story_processing(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } @@ -261,7 +261,7 @@ def test_push_event_issue_mention(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } mail.outbox = [] @@ -292,7 +292,7 @@ def test_push_event_task_mention(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } mail.outbox = [] @@ -323,7 +323,7 @@ def test_push_event_user_story_mention(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } @@ -357,7 +357,7 @@ def test_push_event_multiple_actions(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } mail.outbox = [] @@ -389,7 +389,7 @@ def test_push_event_processing_case_insensitive(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } mail.outbox = [] @@ -415,7 +415,7 @@ def test_push_event_task_bad_processing_non_existing_ref(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } mail.outbox = [] @@ -443,7 +443,7 @@ def test_push_event_us_bad_processing_non_existing_status(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } @@ -472,7 +472,7 @@ def test_push_event_bad_processing_non_existing_status(client): } ], "repository": { - "url": "http://test-url/test/project" + "html_url": "http://test-url/test/project" } } @@ -511,7 +511,7 @@ def test_api_patch_project_modules(client): data = { "gogs": { "secret": "test_secret", - "url": "test_url", + "html_url": "test_url", } } response = client.patch(url, json.dumps(data), content_type="application/json") From b0931e8f88e99e61f52c67b16d0b75fd138fdd6f Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 16 Sep 2016 12:03:12 +0200 Subject: [PATCH 82/83] Rewriting attach_user_stories_counts_to_queryset without json_build_object --- taiga/projects/epics/utils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/taiga/projects/epics/utils.py b/taiga/projects/epics/utils.py index 20f45046..49e394d8 100644 --- a/taiga/projects/epics/utils.py +++ b/taiga/projects/epics/utils.py @@ -41,9 +41,10 @@ def attach_extra_info(queryset, user=None, include_attachments=False): def attach_user_stories_counts_to_queryset(queryset, as_field="user_stories_counts"): model = queryset.model - sql = """SELECT json_build_object( - 'opened', COALESCE(SUM(CASE WHEN is_closed IS FALSE THEN 1 ELSE 0 END), 0), - 'closed', COALESCE(SUM(CASE WHEN is_closed IS TRUE THEN 1 ELSE 0 END), 0) + sql = """SELECT (SELECT row_to_json(t) + FROM (SELECT COALESCE(SUM(CASE WHEN is_closed IS FALSE THEN 1 ELSE 0 END), 0) AS "opened", + COALESCE(SUM(CASE WHEN is_closed IS TRUE THEN 1 ELSE 0 END), 0) AS "closed" + ) t ) FROM epics_relateduserstory INNER JOIN userstories_userstory ON epics_relateduserstory.user_story_id = userstories_userstory.id From 128da54eb18b8b3fa87d8c87e9ff70e56f98561d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 16 Sep 2016 12:26:59 +0200 Subject: [PATCH 83/83] Removing json_build_object postgresql calls --- taiga/projects/tasks/utils.py | 9 ++++++--- taiga/projects/userstories/utils.py | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/taiga/projects/tasks/utils.py b/taiga/projects/tasks/utils.py index d5775d19..0d8661fc 100644 --- a/taiga/projects/tasks/utils.py +++ b/taiga/projects/tasks/utils.py @@ -44,9 +44,12 @@ def attach_user_story_extra_info(queryset, as_field="user_story_extra_info"): "epics_epic"."ref" AS "ref", "epics_epic"."subject" AS "subject", "epics_epic"."color" AS "color", - json_build_object('id', "projects_project"."id", - 'name', "projects_project"."name", - 'slug', "projects_project"."slug") AS "project" + (SELECT row_to_json(p) + FROM (SELECT "projects_project"."id" AS "id", + "projects_project"."name" AS "name", + "projects_project"."slug" AS "slug" + ) p + ) AS "project" FROM "epics_relateduserstory" INNER JOIN "epics_epic" ON "epics_epic"."id" = "epics_relateduserstory"."epic_id" diff --git a/taiga/projects/userstories/utils.py b/taiga/projects/userstories/utils.py index ef5e8ba0..57e4ecd3 100644 --- a/taiga/projects/userstories/utils.py +++ b/taiga/projects/userstories/utils.py @@ -114,9 +114,12 @@ def attach_epics(queryset, as_field="epics_attr"): "epics_epic"."ref" AS "ref", "epics_epic"."subject" AS "subject", "epics_epic"."color" AS "color", - json_build_object('id', "projects_project"."id", - 'name', "projects_project"."name", - 'slug', "projects_project"."slug") AS "project" + (SELECT row_to_json(p) + FROM (SELECT "projects_project"."id" AS "id", + "projects_project"."name" AS "name", + "projects_project"."slug" AS "slug" + ) p + ) AS "project" FROM "epics_relateduserstory" INNER JOIN "epics_epic" ON "epics_epic"."id" = "epics_relateduserstory"."epic_id" INNER JOIN "projects_project" ON "projects_project"."id" = "epics_epic"."project_id"