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,