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] 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()