diff --git a/CHANGELOG.md b/CHANGELOG.md index 3811ae03..e6b9347d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ## 1.6.0 ??? (Unreleased) ### Features -- ... +- Added custom fields per project for user stories, tasks and issues. ### Misc - New contrib plugin for hipchat (by Δndrea Stagi) diff --git a/settings/common.py b/settings/common.py index a8ea96cd..dc8d2b9d 100644 --- a/settings/common.py +++ b/settings/common.py @@ -180,6 +180,7 @@ INSTALLED_APPS = [ "taiga.userstorage", "taiga.projects", "taiga.projects.references", + "taiga.projects.custom_attributes", "taiga.projects.history", "taiga.projects.notifications", "taiga.projects.attachments", diff --git a/taiga/base/api/__init__.py b/taiga/base/api/__init__.py index 973f17ef..845821b2 100644 --- a/taiga/base/api/__init__.py +++ b/taiga/base/api/__init__.py @@ -19,10 +19,12 @@ from .viewsets import ModelListViewSet from .viewsets import ModelCrudViewSet +from .viewsets import ModelUpdateRetrieveViewSet from .viewsets import GenericViewSet from .viewsets import ReadOnlyListViewSet __all__ = ["ModelCrudViewSet", "ModelListViewSet", + "ModelUpdateRetrieveViewSet", "GenericViewSet", "ReadOnlyListViewSet"] diff --git a/taiga/base/api/viewsets.py b/taiga/base/api/viewsets.py index b2fbdd50..cad36dcd 100644 --- a/taiga/base/api/viewsets.py +++ b/taiga/base/api/viewsets.py @@ -168,3 +168,8 @@ class ModelListViewSet(pagination.HeadersPaginationMixin, mixins.ListModelMixin, GenericViewSet): pass + +class ModelUpdateRetrieveViewSet(mixins.UpdateModelMixin, + mixins.RetrieveModelMixin, + GenericViewSet): + pass diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index f05993df..8ddb4c15 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -127,6 +127,21 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi "severities" in data): service.store_default_choices(project_serialized.object, data) + if "userstorycustomattributes" in data: + service.store_custom_attributes(project_serialized.object, data, + "userstorycustomattributes", + serializers.UserStoryCustomAttributeExportSerializer) + + if "taskcustomattributes" in data: + service.store_custom_attributes(project_serialized.object, data, + "taskcustomattributes", + serializers.TaskCustomAttributeExportSerializer) + + if "issuecustomattributes" in data: + service.store_custom_attributes(project_serialized.object, data, + "issuecustomattributes", + serializers.IssueCustomAttributeExportSerializer) + if "roles" in data: service.store_roles(project_serialized.object, data) diff --git a/taiga/export_import/dump_service.py b/taiga/export_import/dump_service.py index 09a9f0d9..2ef615a1 100644 --- a/taiga/export_import/dump_service.py +++ b/taiga/export_import/dump_service.py @@ -103,6 +103,16 @@ def dict_to_project(data, owner=None): if service.get_errors(clear=False): raise TaigaImportError('error importing default choices') + service.store_custom_attributes(proj, data, "userstorycustomattributes", + serializers.UserStoryCustomAttributeExportSerializer) + service.store_custom_attributes(proj, data, "taskcustomattributes", + serializers.TaskCustomAttributeExportSerializer) + service.store_custom_attributes(proj, data, "issuecustomattributes", + serializers.IssueCustomAttributeExportSerializer) + + if service.get_errors(clear=False): + raise TaigaImportError('error importing custom attributes') + service.store_roles(proj, data) if service.get_errors(clear=False): diff --git a/taiga/export_import/serializers.py b/taiga/export_import/serializers.py index 8f0e0534..7b27c427 100644 --- a/taiga/export_import/serializers.py +++ b/taiga/export_import/serializers.py @@ -20,11 +20,14 @@ from collections import OrderedDict from django.contrib.contenttypes.models import ContentType from django.core.files.base import ContentFile -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from taiga.projects import models as projects_models +from taiga.projects.custom_attributes import models as custom_attributes_models from taiga.projects.userstories import models as userstories_models from taiga.projects.tasks import models as tasks_models from taiga.projects.issues import models as issues_models @@ -81,14 +84,15 @@ class RelatedNoneSafeField(serializers.RelatedField): return value = self.get_default_value() + key = self.source or field_name if value in self.null_values: if self.required: raise ValidationError(self.error_messages['required']) - into[(self.source or field_name)] = None + into[key] = None elif self.many: - into[(self.source or field_name)] = [self.from_native(item) for item in value if self.from_native(item) is not None] + into[key] = [self.from_native(item) for item in value if self.from_native(item) is not None] else: - into[(self.source or field_name)] = self.from_native(value) + into[key] = self.from_native(value) class UserRelatedField(RelatedNoneSafeField): @@ -251,7 +255,8 @@ class AttachmentExportSerializerMixin(serializers.ModelSerializer): def get_attachments(self, obj): content_type = ContentType.objects.get_for_model(obj.__class__) - attachments_qs = attachments_models.Attachment.objects.filter(object_id=obj.pk, content_type=content_type) + attachments_qs = attachments_models.Attachment.objects.filter(object_id=obj.pk, + content_type=content_type) return AttachmentExportSerializer(attachments_qs, many=True).data @@ -305,6 +310,114 @@ class RoleExportSerializer(serializers.ModelSerializer): exclude = ('id', 'project') +class UserStoryCustomAttributeExportSerializer(serializers.ModelSerializer): + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = custom_attributes_models.UserStoryCustomAttribute + exclude = ('id', 'project') + + +class TaskCustomAttributeExportSerializer(serializers.ModelSerializer): + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = custom_attributes_models.TaskCustomAttribute + exclude = ('id', 'project') + + +class IssueCustomAttributeExportSerializer(serializers.ModelSerializer): + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = custom_attributes_models.IssueCustomAttribute + exclude = ('id', 'project') + + +class CustomAttributesValuesExportSerializerMixin(serializers.ModelSerializer): + custom_attributes_values = serializers.SerializerMethodField("get_custom_attributes_values") + + def custom_attributes_queryset(self, project): + raise NotImplementedError() + + def get_custom_attributes_values(self, obj): + def _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values): + ret = {} + for attr in custom_attributes: + value = values.get(str(attr["id"]), None) + if value is not None: + ret[attr["name"]] = value + + return ret + + try: + values = obj.custom_attributes_values.attributes_values + custom_attributes = self.custom_attributes_queryset(obj.project).values('id', 'name') + + return _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values) + except ObjectDoesNotExist: + return None + + +class BaseCustomAttributesValuesExportSerializer(serializers.ModelSerializer): + attributes_values = JsonField(source="attributes_values",required=True) + _custom_attribute_model = None + _container_field = None + + class Meta: + exclude = ("id",) + + def validate_attributes_values(self, attrs, source): + # values must be a dict + data_values = attrs.get("attributes_values", None) + if self.object: + data_values = (data_values or self.object.attributes_values) + + if type(data_values) is not dict: + raise ValidationError(_("Invalid content. It must be {\"key\": \"value\",...}")) + + # Values keys must be in the container object project + data_container = attrs.get(self._container_field, None) + if data_container: + project_id = data_container.project_id + elif self.object: + project_id = getattr(self.object, self._container_field).project_id + else: + project_id = None + + values_ids = list(data_values.keys()) + qs = self._custom_attribute_model.objects.filter(project=project_id, + id__in=values_ids) + if qs.count() != len(values_ids): + raise ValidationError(_("It contain invalid custom fields.")) + + return attrs + +class UserStoryCustomAttributesValuesExportSerializer(BaseCustomAttributesValuesExportSerializer): + _custom_attribute_model = custom_attributes_models.UserStoryCustomAttribute + _container_model = "userstories.UserStory" + _container_field = "user_story" + + class Meta(BaseCustomAttributesValuesExportSerializer.Meta): + model = custom_attributes_models.UserStoryCustomAttributesValues + + +class TaskCustomAttributesValuesExportSerializer(BaseCustomAttributesValuesExportSerializer): + _custom_attribute_model = custom_attributes_models.TaskCustomAttribute + _container_field = "task" + + class Meta(BaseCustomAttributesValuesExportSerializer.Meta): + model = custom_attributes_models.TaskCustomAttributesValues + + +class IssueCustomAttributesValuesExportSerializer(BaseCustomAttributesValuesExportSerializer): + _custom_attribute_model = custom_attributes_models.IssueCustomAttribute + _container_field = "issue" + + class Meta(BaseCustomAttributesValuesExportSerializer.Meta): + model = custom_attributes_models.IssueCustomAttributesValues + + class MembershipExportSerializer(serializers.ModelSerializer): user = UserRelatedField(required=False) role = ProjectRelatedField(slug_field="name") @@ -354,7 +467,8 @@ class MilestoneExportSerializer(serializers.ModelSerializer): exclude = ('id', 'project') -class TaskExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin, serializers.ModelSerializer): +class TaskExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, serializers.ModelSerializer): owner = UserRelatedField(required=False) status = ProjectRelatedField(slug_field="name") user_story = ProjectRelatedField(slug_field="ref", required=False) @@ -367,8 +481,12 @@ class TaskExportSerializer(HistoryExportSerializerMixin, AttachmentExportSeriali model = tasks_models.Task exclude = ('id', 'project') + def custom_attributes_queryset(self, project): + return project.taskcustomattributes.all() -class UserStoryExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin, serializers.ModelSerializer): + +class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, serializers.ModelSerializer): role_points = RolePointsExportSerializer(many=True, required=False) owner = UserRelatedField(required=False) assigned_to = UserRelatedField(required=False) @@ -382,8 +500,12 @@ class UserStoryExportSerializer(HistoryExportSerializerMixin, AttachmentExportSe model = userstories_models.UserStory exclude = ('id', 'project', 'points', 'tasks') + def custom_attributes_queryset(self, project): + return project.userstorycustomattributes.all() -class IssueExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin, serializers.ModelSerializer): + +class IssueExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, serializers.ModelSerializer): owner = UserRelatedField(required=False) status = ProjectRelatedField(slug_field="name") assigned_to = UserRelatedField(required=False) @@ -395,15 +517,19 @@ class IssueExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerial votes = serializers.SerializerMethodField("get_votes") modified_date = serializers.DateTimeField(required=False) - def get_votes(self, obj): - return [x.email for x in votes_service.get_voters(obj)] - class Meta: model = issues_models.Issue exclude = ('id', 'project') + def get_votes(self, obj): + return [x.email for x in votes_service.get_voters(obj)] -class WikiPageExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin, serializers.ModelSerializer): + def custom_attributes_queryset(self, project): + return project.issuecustomattributes.all() + + +class WikiPageExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin, + serializers.ModelSerializer): owner = UserRelatedField(required=False) last_modifier = UserRelatedField(required=False) watchers = UserRelatedField(many=True, required=False) @@ -437,6 +563,9 @@ class ProjectExportSerializer(serializers.ModelSerializer): priorities = PriorityExportSerializer(many=True, required=False) severities = SeverityExportSerializer(many=True, required=False) issue_types = IssueTypeExportSerializer(many=True, required=False) + userstorycustomattributes = UserStoryCustomAttributeExportSerializer(many=True, required=False) + taskcustomattributes = TaskCustomAttributeExportSerializer(many=True, required=False) + issuecustomattributes = IssueCustomAttributeExportSerializer(many=True, required=False) roles = RoleExportSerializer(many=True, required=False) milestones = MilestoneExportSerializer(many=True, required=False) wiki_pages = WikiPageExportSerializer(many=True, required=False) diff --git a/taiga/export_import/service.py b/taiga/export_import/service.py index 797ae81f..8eb8cd42 100644 --- a/taiga/export_import/service.py +++ b/taiga/export_import/service.py @@ -20,6 +20,7 @@ from unidecode import unidecode from django.template.defaultfilters import slugify from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from taiga.projects.history.services import make_key_from_model_object from taiga.projects.references import sequences as seq @@ -57,7 +58,8 @@ def store_project(data): "default_priority", "default_severity", "default_issue_status", "default_issue_type", "memberships", "points", "us_statuses", "task_statuses", "issue_statuses", "priorities", "severities", - "issue_types", "roles", "milestones", "wiki_pages", + "issue_types", "userstorycustomattributes", "taskcustomattributes", + "issuecustomattributes", "roles", "milestones", "wiki_pages", "wiki_links", "notify_policies", "user_stories", "issues", "tasks", ] if key not in excluded_fields: @@ -72,7 +74,7 @@ def store_project(data): return None -def store_choice(project, data, field, serializer): +def _store_choice(project, data, field, serializer): serialized = serializer(data=data) if serialized.is_valid(): serialized.object.project = project @@ -86,10 +88,58 @@ def store_choice(project, data, field, serializer): def store_choices(project, data, field, serializer): result = [] for choice_data in data.get(field, []): - result.append(store_choice(project, choice_data, field, serializer)) + result.append(_store_choice(project, choice_data, field, serializer)) return result +def _store_custom_attribute(project, data, field, serializer): + serialized = serializer(data=data) + if serialized.is_valid(): + serialized.object.project = project + serialized.object._importing = True + serialized.save() + return serialized.object + add_errors(field, serialized.errors) + return None + + +def store_custom_attributes(project, data, field, serializer): + result = [] + for custom_attribute_data in data.get(field, []): + result.append(_store_custom_attribute(project, custom_attribute_data, field, serializer)) + return result + + +def store_custom_attributes_values(obj, data_values, obj_field, serializer_class): + data = { + obj_field: obj.id, + "attributes_values": data_values, + } + + try: + custom_attributes_values = obj.custom_attributes_values + serializer = serializer_class(custom_attributes_values, data=data) + except ObjectDoesNotExist: + serializer = serializer_class(data=data) + + if serializer.is_valid(): + serializer.save() + return serializer + + add_errors("custom_attributes_values", serializer.errors) + return None + + +def _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, values): + ret = {} + for attr in custom_attributes: + value = values.get(attr["name"], None) + if value is not None: + ret[str(attr["id"])] = value + + return ret + + def store_role(project, role): serialized = serializers.RoleExportSerializer(data=role) if serialized.is_valid(): @@ -103,7 +153,7 @@ def store_role(project, role): def store_roles(project, data): results = [] - for role in data.get('roles', []): + for role in data.get("roles", []): results.append(store_role(project, role)) return results @@ -145,16 +195,16 @@ def store_membership(project, membership): def store_memberships(project, data): results = [] - for membership in data.get('memberships', []): + for membership in data.get("memberships", []): results.append(store_membership(project, membership)) return results -def store_task(project, task): - if 'status' not in task and project.default_task_status: - task['status'] = project.default_task_status.name +def store_task(project, data): + if "status" not in data and project.default_task_status: + data["status"] = project.default_task_status.name - serialized = serializers.TaskExportSerializer(data=task, context={"project": project}) + serialized = serializers.TaskExportSerializer(data=data, context={"project": project}) if serialized.is_valid(): serialized.object.project = project if serialized.object.owner is None: @@ -173,12 +223,20 @@ def store_task(project, task): serialized.object.ref, _ = refs.make_reference(serialized.object, project) serialized.object.save() - for task_attachment in task.get('attachments', []): + for task_attachment in data.get("attachments", []): store_attachment(project, serialized.object, task_attachment) - for history in task.get('history', []): + for history in data.get("history", []): store_history(project, serialized.object, history) + custom_attributes_values = data.get("custom_attributes_values", None) + if custom_attributes_values: + custom_attributes = serialized.object.project.taskcustomattributes.all().values('id', 'name') + custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + store_custom_attributes_values(serialized.object, custom_attributes_values, + "task", serializers.TaskCustomAttributesValuesExportSerializer) + return serialized add_errors("tasks", serialized.errors) @@ -192,8 +250,8 @@ def store_milestone(project, milestone): serialized.object._importing = True serialized.save() - for task_without_us in milestone.get('tasks_without_us', []): - task_without_us['user_story'] = None + for task_without_us in milestone.get("tasks_without_us", []): + task_without_us["user_story"] = None store_task(project, task_without_us) return serialized @@ -232,7 +290,7 @@ def store_history(project, obj, history): def store_wiki_page(project, wiki_page): - wiki_page['slug'] = slugify(unidecode(wiki_page.get('slug', ''))) + wiki_page["slug"] = slugify(unidecode(wiki_page.get("slug", ""))) serialized = serializers.WikiPageExportSerializer(data=wiki_page) if serialized.is_valid(): serialized.object.project = project @@ -242,10 +300,10 @@ def store_wiki_page(project, wiki_page): serialized.object._not_notify = True serialized.save() - for attachment in wiki_page.get('attachments', []): + for attachment in wiki_page.get("attachments", []): store_attachment(project, serialized.object, attachment) - for history in wiki_page.get('history', []): + for history in wiki_page.get("history", []): store_history(project, serialized.object, history) return serialized @@ -276,61 +334,12 @@ def store_role_point(project, us, role_point): return None -def store_user_story(project, userstory): - if 'status' not in userstory and project.default_us_status: - userstory['status'] = project.default_us_status.name +def store_user_story(project, data): + if "status" not in data and project.default_us_status: + data["status"] = project.default_us_status.name - userstory_data = {} - for key, value in userstory.items(): - if key != 'role_points': - userstory_data[key] = value - serialized_us = serializers.UserStoryExportSerializer(data=userstory_data, context={"project": project}) - if serialized_us.is_valid(): - serialized_us.object.project = project - if serialized_us.object.owner is None: - serialized_us.object.owner = serialized_us.object.project.owner - serialized_us.object._importing = True - serialized_us.object._not_notify = True - - serialized_us.save() - - if serialized_us.object.ref: - sequence_name = refs.make_sequence_name(project) - if not seq.exists(sequence_name): - seq.create(sequence_name) - seq.set_max(sequence_name, serialized_us.object.ref) - else: - serialized_us.object.ref, _ = refs.make_reference(serialized_us.object, project) - serialized_us.object.save() - - for us_attachment in userstory.get('attachments', []): - store_attachment(project, serialized_us.object, us_attachment) - - for role_point in userstory.get('role_points', []): - store_role_point(project, serialized_us.object, role_point) - - for history in userstory.get('history', []): - store_history(project, serialized_us.object, history) - - return serialized_us - add_errors("user_stories", serialized_us.errors) - return None - - -def store_issue(project, data): - serialized = serializers.IssueExportSerializer(data=data, context={"project": project}) - - if 'type' not in data and project.default_issue_type: - data['type'] = project.default_issue_type.name - - if 'status' not in data and project.default_issue_status: - data['status'] = project.default_issue_status.name - - if 'priority' not in data and project.default_priority: - data['priority'] = project.default_priority.name - - if 'severity' not in data and project.default_severity: - data['severity'] = project.default_severity.name + us_data = {key: value for key, value in data.items() if key not in ["role_points", "custom_attributes_values"]} + serialized = serializers.UserStoryExportSerializer(data=us_data, context={"project": project}) if serialized.is_valid(): serialized.object.project = project @@ -350,10 +359,77 @@ def store_issue(project, data): serialized.object.ref, _ = refs.make_reference(serialized.object, project) serialized.object.save() - for attachment in data.get('attachments', []): - store_attachment(project, serialized.object, attachment) - for history in data.get('history', []): + for us_attachment in data.get("attachments", []): + store_attachment(project, serialized.object, us_attachment) + + for role_point in data.get("role_points", []): + store_role_point(project, serialized.object, role_point) + + for history in data.get("history", []): store_history(project, serialized.object, history) + + custom_attributes_values = data.get("custom_attributes_values", None) + if custom_attributes_values: + custom_attributes = serialized.object.project.userstorycustomattributes.all().values('id', 'name') + custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + store_custom_attributes_values(serialized.object, custom_attributes_values, + "user_story", serializers.UserStoryCustomAttributesValuesExportSerializer) + return serialized + + add_errors("user_stories", serialized.errors) + return None + + +def store_issue(project, data): + serialized = serializers.IssueExportSerializer(data=data, context={"project": project}) + + if "type" not in data and project.default_issue_type: + data["type"] = project.default_issue_type.name + + if "status" not in data and project.default_issue_status: + data["status"] = project.default_issue_status.name + + if "priority" not in data and project.default_priority: + data["priority"] = project.default_priority.name + + if "severity" not in data and project.default_severity: + data["severity"] = project.default_severity.name + + if serialized.is_valid(): + serialized.object.project = project + if serialized.object.owner is None: + serialized.object.owner = serialized.object.project.owner + serialized.object._importing = True + serialized.object._not_notify = True + + serialized.save() + + if serialized.object.ref: + sequence_name = refs.make_sequence_name(project) + if not seq.exists(sequence_name): + seq.create(sequence_name) + seq.set_max(sequence_name, serialized.object.ref) + else: + serialized.object.ref, _ = refs.make_reference(serialized.object, project) + serialized.object.save() + + for attachment in data.get("attachments", []): + store_attachment(project, serialized.object, attachment) + + for history in data.get("history", []): + store_history(project, serialized.object, history) + + custom_attributes_values = data.get("custom_attributes_values", None) + if custom_attributes_values: + custom_attributes = serialized.object.project.issuecustomattributes.all().values('id', 'name') + custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + store_custom_attributes_values(serialized.object, custom_attributes_values, + "issue", serializers.IssueCustomAttributesValuesExportSerializer) + + return serialized + add_errors("issues", serialized.errors) return None diff --git a/taiga/projects/custom_attributes/__init__.py b/taiga/projects/custom_attributes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/projects/custom_attributes/admin.py b/taiga/projects/custom_attributes/admin.py new file mode 100644 index 00000000..201a31f0 --- /dev/null +++ b/taiga/projects/custom_attributes/admin.py @@ -0,0 +1,71 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.contrib import admin + +from . import models + + + +@admin.register(models.UserStoryCustomAttribute) +class UserStoryCustomAttributeAdmin(admin.ModelAdmin): + list_display = ["id", "name", "project", "order"] + list_display_links = ["id", "name"] + fieldsets = ( + (None, { + "fields": ("name", "description", ("project", "order")) + }), + ("Advanced options", { + "classes": ("collapse",), + "fields": (("created_date", "modified_date"),) + }) + ) + readonly_fields = ("created_date", "modified_date") + search_fields = ["id", "name", "project__name", "project__slug"] + + +@admin.register(models.TaskCustomAttribute) +class TaskCustomAttributeAdmin(admin.ModelAdmin): + list_display = ["id", "name", "project", "order"] + list_display_links = ["id", "name"] + fieldsets = ( + (None, { + "fields": ("name", "description", ("project", "order")) + }), + ("Advanced options", { + "classes": ("collapse",), + "fields": (("created_date", "modified_date"),) + }) + ) + readonly_fields = ("created_date", "modified_date") + search_fields = ["id", "name", "project__name", "project__slug"] + + +@admin.register(models.IssueCustomAttribute) +class IssueCustomAttributeAdmin(admin.ModelAdmin): + list_display = ["id", "name", "project", "order"] + list_display_links = ["id", "name"] + fieldsets = ( + (None, { + "fields": ("name", "description", ("project", "order")) + }), + ("Advanced options", { + "classes": ("collapse",), + "fields": (("created_date", "modified_date"),) + }) + ) + readonly_fields = ("created_date", "modified_date") + search_fields = ["id", "name", "project__name", "project__slug"] diff --git a/taiga/projects/custom_attributes/api.py b/taiga/projects/custom_attributes/api.py new file mode 100644 index 00000000..c93bb790 --- /dev/null +++ b/taiga/projects/custom_attributes/api.py @@ -0,0 +1,119 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.utils.translation import ugettext_lazy as _ + +from taiga.base.api import ModelCrudViewSet +from taiga.base.api import ModelUpdateRetrieveViewSet +from taiga.base import exceptions as exc +from taiga.base import filters +from taiga.base import response + +from taiga.projects.mixins.ordering import BulkUpdateOrderMixin +from taiga.projects.history.mixins import HistoryResourceMixin +from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.occ.mixins import OCCResourceMixin + +from . import models +from . import serializers +from . import permissions +from . import services + + +###################################################### +# Custom Attribute ViewSets +####################################################### + +class UserStoryCustomAttributeViewSet(BulkUpdateOrderMixin, ModelCrudViewSet): + model = models.UserStoryCustomAttribute + serializer_class = serializers.UserStoryCustomAttributeSerializer + permission_classes = (permissions.UserStoryCustomAttributePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_userstory_custom_attributes" + bulk_update_perm = "change_userstory_custom_attributes" + bulk_update_order_action = services.bulk_update_userstory_custom_attribute_order + + +class TaskCustomAttributeViewSet(BulkUpdateOrderMixin, ModelCrudViewSet): + model = models.TaskCustomAttribute + serializer_class = serializers.TaskCustomAttributeSerializer + permission_classes = (permissions.TaskCustomAttributePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_task_custom_attributes" + bulk_update_perm = "change_task_custom_attributes" + bulk_update_order_action = services.bulk_update_task_custom_attribute_order + + +class IssueCustomAttributeViewSet(BulkUpdateOrderMixin, ModelCrudViewSet): + model = models.IssueCustomAttribute + serializer_class = serializers.IssueCustomAttributeSerializer + permission_classes = (permissions.IssueCustomAttributePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_issue_custom_attributes" + bulk_update_perm = "change_issue_custom_attributes" + bulk_update_order_action = services.bulk_update_issue_custom_attribute_order + + +###################################################### +# Custom Attributes Values ViewSets +####################################################### + +class BaseCustomAttributesValuesViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, + ModelUpdateRetrieveViewSet): + def get_object_for_snapshot(self, obj): + return getattr(obj, self.content_object) + + +class UserStoryCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): + model = models.UserStoryCustomAttributesValues + serializer_class = serializers.UserStoryCustomAttributesValuesSerializer + permission_classes = (permissions.UserStoryCustomAttributesValuesPermission,) + lookup_field = "user_story_id" + content_object = "user_story" + + def get_queryset(self): + qs = self.model.objects.all() + qs = qs.select_related("user_story", "user_story__project") + return qs + + +class TaskCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): + model = models.TaskCustomAttributesValues + serializer_class = serializers.TaskCustomAttributesValuesSerializer + permission_classes = (permissions.TaskCustomAttributesValuesPermission,) + lookup_field = "task_id" + content_object = "task" + + def get_queryset(self): + qs = self.model.objects.all() + qs = qs.select_related("task", "task__project") + return qs + + +class IssueCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): + model = models.IssueCustomAttributesValues + serializer_class = serializers.IssueCustomAttributesValuesSerializer + permission_classes = (permissions.IssueCustomAttributesValuesPermission,) + lookup_field = "issue_id" + content_object = "issue" + + def get_queryset(self): + qs = self.model.objects.all() + qs = qs.select_related("issue", "issue__project") + return qs diff --git a/taiga/projects/custom_attributes/migrations/0001_initial.py b/taiga/projects/custom_attributes/migrations/0001_initial.py new file mode 100644 index 00000000..5814507d --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0001_initial.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0015_auto_20141230_1212'), + ] + + operations = [ + migrations.CreateModel( + name='IssueCustomAttribute', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('name', models.CharField(verbose_name='name', max_length=64)), + ('description', models.TextField(blank=True, verbose_name='description')), + ('order', models.IntegerField(verbose_name='order', default=10000)), + ('created_date', models.DateTimeField(verbose_name='created date', default=django.utils.timezone.now)), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('project', models.ForeignKey(to='projects.Project', verbose_name='project', related_name='issuecustomattributes')), + ], + options={ + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'issue custom attribute', + 'verbose_name_plural': 'issue custom attributes', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='TaskCustomAttribute', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('name', models.CharField(verbose_name='name', max_length=64)), + ('description', models.TextField(blank=True, verbose_name='description')), + ('order', models.IntegerField(verbose_name='order', default=10000)), + ('created_date', models.DateTimeField(verbose_name='created date', default=django.utils.timezone.now)), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('project', models.ForeignKey(to='projects.Project', verbose_name='project', related_name='taskcustomattributes')), + ], + options={ + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'task custom attribute', + 'verbose_name_plural': 'task custom attributes', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='UserStoryCustomAttribute', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('name', models.CharField(verbose_name='name', max_length=64)), + ('description', models.TextField(blank=True, verbose_name='description')), + ('order', models.IntegerField(verbose_name='order', default=10000)), + ('created_date', models.DateTimeField(verbose_name='created date', default=django.utils.timezone.now)), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('project', models.ForeignKey(to='projects.Project', verbose_name='project', related_name='userstorycustomattributes')), + ], + options={ + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'user story custom attribute', + 'verbose_name_plural': 'user story custom attributes', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='userstorycustomattribute', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='taskcustomattribute', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='issuecustomattribute', + unique_together=set([('project', 'name')]), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0002_issuecustomattributesvalues_taskcustomattributesvalues_userstorycustomattributesvalues.py b/taiga/projects/custom_attributes/migrations/0002_issuecustomattributesvalues_taskcustomattributesvalues_userstorycustomattributesvalues.py new file mode 100644 index 00000000..8c1848db --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0002_issuecustomattributesvalues_taskcustomattributesvalues_userstorycustomattributesvalues.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import django_pgjson.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0005_auto_20150114_0954'), + ('issues', '0004_auto_20150114_0954'), + ('userstories', '0009_remove_userstory_is_archived'), + ('custom_attributes', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='IssueCustomAttributesValues', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('attributes_values', django_pgjson.fields.JsonField(default={}, verbose_name='attributes_values')), + ('issue', models.OneToOneField(verbose_name='issue', to='issues.Issue', related_name='custom_attributes_values')), + ], + options={ + 'verbose_name_plural': 'issue custom attributes values', + 'ordering': ['id'], + 'verbose_name': 'issue ustom attributes values', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='TaskCustomAttributesValues', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('attributes_values', django_pgjson.fields.JsonField(default={}, verbose_name='attributes_values')), + ('task', models.OneToOneField(verbose_name='task', to='tasks.Task', related_name='custom_attributes_values')), + ], + options={ + 'verbose_name_plural': 'task custom attributes values', + 'ordering': ['id'], + 'verbose_name': 'task ustom attributes values', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='UserStoryCustomAttributesValues', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('attributes_values', django_pgjson.fields.JsonField(default={}, verbose_name='attributes_values')), + ('user_story', models.OneToOneField(verbose_name='user story', to='userstories.UserStory', related_name='custom_attributes_values')), + ], + options={ + 'verbose_name_plural': 'user story custom attributes values', + 'ordering': ['id'], + 'verbose_name': 'user story ustom attributes values', + 'abstract': False, + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0003_triggers_on_delete_customattribute.py b/taiga/projects/custom_attributes/migrations/0003_triggers_on_delete_customattribute.py new file mode 100644 index 00000000..43285e38 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0003_triggers_on_delete_customattribute.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0002_issuecustomattributesvalues_taskcustomattributesvalues_userstorycustomattributesvalues'), + ] + + operations = [ + # Function: Remove a key in a json field + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) + RETURNS json + LANGUAGE sql + IMMUTABLE + STRICT + AS $function$ + SELECT COALESCE ((SELECT ('{' || string_agg(to_json("key") || ':' || "value", ',') || '}') + FROM json_each("json") + WHERE "key" <> ALL ("keys_to_delete")), + '{}')::json $function$; + """, + reverse_sql="""DROP FUNCTION IF EXISTS "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) + CASCADE;""" + ), + + # Function: Romeve a key in the json field of *_custom_attributes_values.values + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION "clean_key_in_custom_attributes_values"() + RETURNS trigger + AS $clean_key_in_custom_attributes_values$ + DECLARE + key text; + tablename text; + BEGIN + key := OLD.id::text; + tablename := TG_ARGV[0]::text; + + EXECUTE 'UPDATE ' || quote_ident(tablename) || ' + SET attributes_values = json_object_delete_keys(attributes_values, ' || + quote_literal(key) || ')'; + + RETURN NULL; + END; $clean_key_in_custom_attributes_values$ + LANGUAGE plpgsql; + + """, + reverse_sql="""DROP FUNCTION IF EXISTS "clean_key_in_custom_attributes_values"() + CASCADE;""" + ), + + # Trigger: Clean userstorycustomattributes values before remove a userstorycustomattribute + migrations.RunSQL( + """ + CREATE TRIGGER "update_userstorycustomvalues_after_remove_userstorycustomattribute" + AFTER DELETE ON custom_attributes_userstorycustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('custom_attributes_userstorycustomattributesvalues'); + """, + reverse_sql="""DROP TRIGGER IF EXISTS "update_userstorycustomvalues_after_remove_userstorycustomattribute" + ON custom_attributes_userstorycustomattribute + CASCADE;""" + ), + + # Trigger: Clean taskcustomattributes values before remove a taskcustomattribute + migrations.RunSQL( + """ + CREATE TRIGGER "update_taskcustomvalues_after_remove_taskcustomattribute" + AFTER DELETE ON custom_attributes_taskcustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('custom_attributes_taskcustomattributesvalues'); + """, + reverse_sql="""DROP TRIGGER IF EXISTS "update_taskcustomvalues_after_remove_taskcustomattribute" + ON custom_attributes_taskcustomattribute + CASCADE;""" + ), + + # Trigger: Clean issuecustomattributes values before remove a issuecustomattribute + migrations.RunSQL( + """ + CREATE TRIGGER "update_issuecustomvalues_after_remove_issuecustomattribute" + AFTER DELETE ON custom_attributes_issuecustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('custom_attributes_issuecustomattributesvalues'); + """, + reverse_sql="""DROP TRIGGER IF EXISTS "update_issuecustomvalues_after_remove_issuecustomattribute" + ON custom_attributes_issuecustomattribute + CASCADE;""" + ) + ] diff --git a/taiga/projects/custom_attributes/migrations/0004_create_empty_customattributesvalues_for_existen_object.py b/taiga/projects/custom_attributes/migrations/0004_create_empty_customattributesvalues_for_existen_object.py new file mode 100644 index 00000000..9d807ad4 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0004_create_empty_customattributesvalues_for_existen_object.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +def create_empty_user_story_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "UserStoryCustomAttributesValues") + obj_model = apps.get_model("userstories", "UserStory") + db_alias = schema_editor.connection.alias + + data = [] + for user_story in obj_model.objects.using(db_alias).all().select_related("custom_attributes_values"): + if not hasattr(user_story, "custom_attributes_values"): + data.append(cav_model(user_story=user_story,attributes_values={})) + + cav_model.objects.using(db_alias).bulk_create(data) + + +def delete_empty_user_story_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "UserStoryCustomAttributesValues") + db_alias = schema_editor.connection.alias + + cav_model.objects.using(db_alias).extra(where=["attributes_values::text <> '{}'::text"]).delete() + + +def create_empty_task_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "TaskCustomAttributesValues") + obj_model = apps.get_model("tasks", "Task") + db_alias = schema_editor.connection.alias + + data = [] + for task in obj_model.objects.using(db_alias).all().select_related("custom_attributes_values"): + if not hasattr(task, "custom_attributes_values"): + data.append(cav_model(task=task,attributes_values={})) + + cav_model.objects.using(db_alias).bulk_create(data) + + +def delete_empty_task_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "TaskCustomAttributesValues") + db_alias = schema_editor.connection.alias + + cav_model.objects.using(db_alias).extra(where=["attributes_values::text <> '{}'::text"]).delete() + + +def create_empty_issues_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "IssueCustomAttributesValues") + obj_model = apps.get_model("issues", "Issue") + db_alias = schema_editor.connection.alias + + data = [] + for issue in obj_model.objects.using(db_alias).all().select_related("custom_attributes_values"): + if not hasattr(issue, "custom_attributes_values"): + data.append(cav_model(issue=issue,attributes_values={})) + + cav_model.objects.using(db_alias).bulk_create(data) + + +def delete_empty_issue_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "IssueCustomAttributesValues") + db_alias = schema_editor.connection.alias + + cav_model.objects.using(db_alias).extra(where=["attributes_values::text <> '{}'::text"]).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0003_triggers_on_delete_customattribute'), + ] + + operations = [ + migrations.RunPython(create_empty_user_story_custom_attrributes_values, + reverse_code=delete_empty_user_story_custom_attrributes_values, + atomic=True), + migrations.RunPython(create_empty_task_custom_attrributes_values, + reverse_code=delete_empty_task_custom_attrributes_values, + atomic=True), + migrations.RunPython(create_empty_issues_custom_attrributes_values, + reverse_code=delete_empty_issue_custom_attrributes_values, + atomic=True), + ] diff --git a/taiga/projects/custom_attributes/migrations/__init__.py b/taiga/projects/custom_attributes/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/projects/custom_attributes/models.py b/taiga/projects/custom_attributes/models.py new file mode 100644 index 00000000..2fc0cf5c --- /dev/null +++ b/taiga/projects/custom_attributes/models.py @@ -0,0 +1,130 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone + +from django_pgjson.fields import JsonField + +from taiga.projects.occ.mixins import OCCModelMixin + + +###################################################### +# 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")) + order = models.IntegerField(null=False, blank=False, default=10000, verbose_name=_("order")) + project = models.ForeignKey("projects.Project", null=False, blank=False, related_name="%(class)ss", + verbose_name=_("project")) + + created_date = models.DateTimeField(null=False, blank=False, default=timezone.now, + verbose_name=_("created date")) + modified_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("modified date")) + _importing = None + + class Meta: + abstract = True + ordering = ["project", "order", "name"] + unique_together = ("project", "name") + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + + return super().save(*args, **kwargs) + + +class UserStoryCustomAttribute(AbstractCustomAttribute): + class Meta(AbstractCustomAttribute.Meta): + verbose_name = "user story custom attribute" + verbose_name_plural = "user story custom attributes" + + +class TaskCustomAttribute(AbstractCustomAttribute): + class Meta(AbstractCustomAttribute.Meta): + verbose_name = "task custom attribute" + verbose_name_plural = "task custom attributes" + + +class IssueCustomAttribute(AbstractCustomAttribute): + class Meta(AbstractCustomAttribute.Meta): + verbose_name = "issue custom attribute" + verbose_name_plural = "issue custom attributes" + + +###################################################### +# Custom Attributes Values Models +####################################################### + +class AbstractCustomAttributesValues(OCCModelMixin, models.Model): + attributes_values = JsonField(null=False, blank=False, default={}, verbose_name=_("attributes_values")) + + class Meta: + abstract = True + ordering = ["id"] + + +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_plural = "user story custom attributes values" + + @property + def project(self): + # NOTE: This property simplifies checking permissions + return self.user_story.project + + +class TaskCustomAttributesValues(AbstractCustomAttributesValues): + task = models.OneToOneField("tasks.Task", + null=False, blank=False, related_name="custom_attributes_values", + verbose_name=_("task")) + + class Meta(AbstractCustomAttributesValues.Meta): + verbose_name = "task ustom attributes values" + verbose_name_plural = "task custom attributes values" + + @property + def project(self): + # NOTE: This property simplifies checking permissions + return self.task.project + + +class IssueCustomAttributesValues(AbstractCustomAttributesValues): + issue = models.OneToOneField("issues.Issue", + null=False, blank=False, related_name="custom_attributes_values", + verbose_name=_("issue")) + + class Meta(AbstractCustomAttributesValues.Meta): + verbose_name = "issue ustom attributes values" + verbose_name_plural = "issue custom attributes values" + + @property + def project(self): + # NOTE: This property simplifies checking permissions + return self.issue.project diff --git a/taiga/projects/custom_attributes/permissions.py b/taiga/projects/custom_attributes/permissions.py new file mode 100644 index 00000000..160d340e --- /dev/null +++ b/taiga/projects/custom_attributes/permissions.py @@ -0,0 +1,83 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base.api.permissions import TaigaResourcePermission +from taiga.base.api.permissions import HasProjectPerm +from taiga.base.api.permissions import IsProjectOwner +from taiga.base.api.permissions import AllowAny +from taiga.base.api.permissions import IsSuperUser + + +###################################################### +# Custom Attribute Permissions +####################################################### + +class UserStoryCustomAttributePermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectOwner() + update_perms = IsProjectOwner() + destroy_perms = IsProjectOwner() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectOwner() + + +class TaskCustomAttributePermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectOwner() + update_perms = IsProjectOwner() + destroy_perms = IsProjectOwner() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectOwner() + + +class IssueCustomAttributePermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectOwner() + update_perms = IsProjectOwner() + destroy_perms = IsProjectOwner() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectOwner() + + +###################################################### +# Custom Attributes Values Permissions +####################################################### + +class UserStoryCustomAttributesValuesPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_us') + update_perms = HasProjectPerm('modify_us') + + +class TaskCustomAttributesValuesPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_tasks') + update_perms = HasProjectPerm('modify_task') + + +class IssueCustomAttributesValuesPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_issues') + update_perms = HasProjectPerm('modify_issue') diff --git a/taiga/projects/custom_attributes/serializers.py b/taiga/projects/custom_attributes/serializers.py new file mode 100644 index 00000000..dbc367f4 --- /dev/null +++ b/taiga/projects/custom_attributes/serializers.py @@ -0,0 +1,146 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# 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 apps +from django.utils.translation import ugettext_lazy as _ + +from rest_framework.serializers import ValidationError + +from taiga.base.serializers import ModelSerializer +from taiga.base.serializers import JsonField + +from . import models + + +###################################################### +# Custom Attribute Serializer +####################################################### + +class BaseCustomAttributeSerializer(ModelSerializer): + class Meta: + read_only_fields = ('id',) + exclude = ('created_date', 'modified_date') + + def _validate_integrity_between_project_and_name(self, attrs, source): + """ + Check the name is not duplicated in the project. Check when: + - create a new one + - update the name + - update the project (move to another project) + """ + data_id = attrs.get("id", None) + data_name = attrs.get("name", None) + data_project = attrs.get("project", None) + + if self.object: + data_id = data_id or self.object.id + data_name = data_name or self.object.name + data_project = data_project or self.object.project + + model = self.Meta.model + qs = (model.objects.filter(project=data_project, name=data_name) + .exclude(id=data_id)) + if qs.exists(): + raise ValidationError(_("Already exists one with the same name.")) + + return attrs + + def validate_name(self, attrs, source): + return self._validate_integrity_between_project_and_name(attrs, source) + + def validate_project(self, attrs, source): + return self._validate_integrity_between_project_and_name(attrs, source) + + +class UserStoryCustomAttributeSerializer(BaseCustomAttributeSerializer): + class Meta(BaseCustomAttributeSerializer.Meta): + model = models.UserStoryCustomAttribute + + +class TaskCustomAttributeSerializer(BaseCustomAttributeSerializer): + class Meta(BaseCustomAttributeSerializer.Meta): + model = models.TaskCustomAttribute + + +class IssueCustomAttributeSerializer(BaseCustomAttributeSerializer): + class Meta(BaseCustomAttributeSerializer.Meta): + model = models.IssueCustomAttribute + + +###################################################### +# Custom Attribute Serializer +####################################################### + + +class BaseCustomAttributesValuesSerializer(ModelSerializer): + attributes_values = JsonField(source="attributes_values", label="attributes values") + _custom_attribute_model = None + _container_field = None + + class Meta: + exclude = ("id",) + + def validate_attributes_values(self, attrs, source): + # values must be a dict + data_values = attrs.get("attributes_values", None) + if self.object: + data_values = (data_values or self.object.attributes_values) + + if type(data_values) is not dict: + raise ValidationError(_("Invalid content. It must be {\"key\": \"value\",...}")) + + # Values keys must be in the container object project + data_container = attrs.get(self._container_field, None) + if data_container: + project_id = data_container.project_id + elif self.object: + project_id = getattr(self.object, self._container_field).project_id + else: + project_id = None + + values_ids = list(data_values.keys()) + qs = self._custom_attribute_model.objects.filter(project=project_id, + id__in=values_ids) + if qs.count() != len(values_ids): + raise ValidationError(_("It contain invalid custom fields.")) + + return attrs + + +class UserStoryCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer): + _custom_attribute_model = models.UserStoryCustomAttribute + _container_model = "userstories.UserStory" + _container_field = "user_story" + + class Meta(BaseCustomAttributesValuesSerializer.Meta): + model = models.UserStoryCustomAttributesValues + + +class TaskCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer, ModelSerializer): + _custom_attribute_model = models.TaskCustomAttribute + _container_field = "task" + + class Meta(BaseCustomAttributesValuesSerializer.Meta): + model = models.TaskCustomAttributesValues + + +class IssueCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer, ModelSerializer): + _custom_attribute_model = models.IssueCustomAttribute + _container_field = "issue" + + class Meta(BaseCustomAttributesValuesSerializer.Meta): + model = models.IssueCustomAttributesValues diff --git a/taiga/projects/custom_attributes/services.py b/taiga/projects/custom_attributes/services.py new file mode 100644 index 00000000..7cbea6c4 --- /dev/null +++ b/taiga/projects/custom_attributes/services.py @@ -0,0 +1,69 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# 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 transaction +from django.db import connection + + +@transaction.atomic +def bulk_update_userstory_custom_attribute_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update custom_attributes_userstorycustomattribute set "order" = $1 + where custom_attributes_userstorycustomattribute.id = $2 and + custom_attributes_userstorycustomattribute.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_task_custom_attribute_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update custom_attributes_taskcustomattribute set "order" = $1 + where custom_attributes_taskcustomattribute.id = $2 and + custom_attributes_taskcustomattribute.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_issue_custom_attribute_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update custom_attributes_issuecustomattribute set "order" = $1 + where custom_attributes_issuecustomattribute.id = $2 and + custom_attributes_issuecustomattribute.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() diff --git a/taiga/projects/custom_attributes/signals.py b/taiga/projects/custom_attributes/signals.py new file mode 100644 index 00000000..fa90bb10 --- /dev/null +++ b/taiga/projects/custom_attributes/signals.py @@ -0,0 +1,35 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# 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 . import models + + +def create_custom_attribute_value_when_create_user_story(sender, instance, created, **kwargs): + if created: + models.UserStoryCustomAttributesValues.objects.get_or_create(user_story=instance, + defaults={"attributes_values":{}}) + + +def create_custom_attribute_value_when_create_task(sender, instance, created, **kwargs): + if created: + models.TaskCustomAttributesValues.objects.get_or_create(task=instance, + defaults={"attributes_values":{}}) + + +def create_custom_attribute_value_when_create_issue(sender, instance, created, **kwargs): + if created: + models.IssueCustomAttributesValues.objects.get_or_create(issue=instance, + defaults={"attributes_values":{}}) diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py index c3bc3a4f..54c9eefb 100644 --- a/taiga/projects/history/freeze_impl.py +++ b/taiga/projects/history/freeze_impl.py @@ -14,9 +14,13 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from contextlib import suppress + from functools import partial from django.apps import apps from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist + from taiga.base.utils.iterators import as_tuple from taiga.base.utils.iterators import as_dict from taiga.mdrender.service import render as mdrender @@ -181,6 +185,42 @@ def extract_attachments(obj) -> list: "order": attach.order} +@as_tuple +def extract_user_story_custom_attributes(obj) -> list: + with suppress(ObjectDoesNotExist): + custom_attributes_values = obj.custom_attributes_values.attributes_values + for attr in obj.project.userstorycustomattributes.all(): + with suppress(KeyError): + value = custom_attributes_values[str(attr.id)] + yield {"id": attr.id, + "name": attr.name, + "value": value} + + +@as_tuple +def extract_task_custom_attributes(obj) -> list: + with suppress(ObjectDoesNotExist): + custom_attributes_values = obj.custom_attributes_values.attributes_values + for attr in obj.project.taskcustomattributes.all(): + with suppress(KeyError): + value = custom_attributes_values[str(attr.id)] + yield {"id": attr.id, + "name": attr.name, + "value": value} + + +@as_tuple +def extract_issue_custom_attributes(obj) -> list: + with suppress(ObjectDoesNotExist): + custom_attributes_values = obj.custom_attributes_values.attributes_values + for attr in obj.project.issuecustomattributes.all(): + with suppress(KeyError): + value = custom_attributes_values[str(attr.id)] + yield {"id": attr.id, + "name": attr.name, + "value": value} + + def project_freezer(project) -> dict: fields = ("name", "slug", @@ -243,6 +283,7 @@ def userstory_freezer(us) -> dict: "is_blocked": us.is_blocked, "blocked_note": us.blocked_note, "blocked_note_html": mdrender(us.project, us.blocked_note), + "custom_attributes": extract_user_story_custom_attributes(us), } return snapshot @@ -267,6 +308,7 @@ def issue_freezer(issue) -> dict: "is_blocked": issue.is_blocked, "blocked_note": issue.blocked_note, "blocked_note_html": mdrender(issue.project, issue.blocked_note), + "custom_attributes": extract_issue_custom_attributes(issue), } return snapshot @@ -292,6 +334,7 @@ def task_freezer(task) -> dict: "is_blocked": task.is_blocked, "blocked_note": task.blocked_note, "blocked_note_html": mdrender(task.project, task.blocked_note), + "custom_attributes": extract_task_custom_attributes(task), } return snapshot diff --git a/taiga/projects/history/models.py b/taiga/projects/history/models.py index 462e3d45..468eb439 100644 --- a/taiga/projects/history/models.py +++ b/taiga/projects/history/models.py @@ -197,6 +197,35 @@ class HistoryEntry(models.Model): if attachments["new"] or attachments["changed"] or attachments["deleted"]: value = attachments + elif key == "custom_attributes": + custom_attributes = { + "new": [], + "changed": [], + "deleted": [], + } + + oldcustattrs = {x["id"]:x for x in self.diff["custom_attributes"][0] or []} + newcustattrs = {x["id"]:x for x in self.diff["custom_attributes"][1] or []} + + for aid in set(tuple(oldcustattrs.keys()) + tuple(newcustattrs.keys())): + if aid in oldcustattrs and aid in newcustattrs: + changes = make_diff_from_dicts(oldcustattrs[aid], newcustattrs[aid], + excluded_keys=("name")) + + if changes: + change = { + "name": newcustattrs.get(aid, {}).get("name", ""), + "changes": changes + } + custom_attributes["changed"].append(change) + elif aid in oldcustattrs and aid not in newcustattrs: + custom_attributes["deleted"].append(oldcustattrs[aid]) + elif aid not in oldcustattrs and aid in newcustattrs: + custom_attributes["new"].append(newcustattrs[aid]) + + if custom_attributes["new"] or custom_attributes["changed"] or custom_attributes["deleted"]: + value = custom_attributes + elif key in self.values: value = [resolve_value(key, x) for x in self.diff[key]] else: diff --git a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja index 4b537805..84a2d462 100644 --- a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja +++ b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja @@ -6,7 +6,8 @@ "backlog_order", "kanban_order", "taskboard_order", - "us_order" + "us_order", + "custom_attributes" ] %} {% for field_name, values in changed_fields.items() %} @@ -80,9 +81,7 @@

{{ _("Deleted attachment") }}

- {% if att.changes.description %}

{{ att.filename|linebreaksbr }}

- {% endif %} {% endfor %} @@ -155,7 +154,6 @@ {# * #} {% else %} -

{{ verbose_name(obj_class, field_name) }}

@@ -172,5 +170,52 @@ {% endif %} + + {% elif field_name == "custom_attributes" %} + {# CUSTOM ATTRIBUTES #} + {% if values.new %} + {% for attr in values['new']%} + + +

{{ attr.name }}

+ + + + + {{ _("to") }}
+ {{ attr.value|linebreaksbr }} + + + {% endfor %} + {% endif %} + {% if values.changed %} + {% for attr in values['changed'] %} + + +

{{ attr.name }}

+ + + {{ _("from") }}
+ {{ attr.changes.value.0|linebreaksbr }} + + + + + {{ _("to") }}
+ {{ attr.changes.value.1|linebreaksbr }} + + + {% endfor %} + {% endif %} + {% if values.deleted %} + {% for attr in values['deleted']%} + + +

{{ attr.name }}

+

{{ _("-deleted-") }}

+ + + {% endfor %} + {% endif %} {% endif %} {% endfor %} diff --git a/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja b/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja index 206e237c..5ecbf496 100644 --- a/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja +++ b/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja @@ -8,7 +8,8 @@ "taskboard_order", "us_order", "blocked_note_diff", - "blocked_note_html" + "blocked_note_html", + "custom_attributes" ] %} {% for field_name, values in changed_fields.items() %} {% if field_name not in excluded_fields %} @@ -18,6 +19,7 @@ {% for role, points in values.items() %} * {{ role }} {{ _("to:") }} {{ points.1 }} {{ _("from:") }} {{ points.0 }} {% endfor %} + {# ATTACHMENTS #} {% elif field_name == "attachments" %} {% if values.new %} @@ -40,6 +42,7 @@ - {{ att.filename }} {% endfor %} {% endif %} + {# TAGS AND WATCHERS #} {% elif field_name in ["tags", "watchers"] %} {% set values_from = values.0 or [] %} @@ -53,6 +56,36 @@ {% if values_removed %} * {{ _("removed:") }} {{ ', '.join(values_removed) }} {% endif %} + + {# * #} + {% else %} + * {{ _("From:") }} {{ values.0 }} + * {{ _("To:") }} {{ values.1 }} {% endif %} + + {% elif field_name == "custom_attributes" %} + {# CUSTOM ATTRIBUTES #} + {% elif field_name == "attachments" %} + {% if values.new %} + {% for attr in values['new']%} + - {{ attr.name }}: + * {{ attr.value }} + {% endfor %} + {% endif %} + + {% if values.changed %} + {% for attr in values['changed'] %} + - {{ attr.name }}: + * {{ _("From:") }} {{ attr.changes.value.0 }} + * {{ _("To:") }} {{ attr.changes.value.1 }} + {% endfor %} + {% endif %} + + {% if values.deleted %} + {% for attr in values['deleted']%} + - {{ attr.name }}: {{ _("-deleted-") }} + * {{ attr.value }} + {% endfor %} + {% endif %} {% endif %} {% endfor %} diff --git a/taiga/projects/issues/apps.py b/taiga/projects/issues/apps.py index 4b8714ff..3972ef48 100644 --- a/taiga/projects/issues/apps.py +++ b/taiga/projects/issues/apps.py @@ -19,6 +19,7 @@ from django.apps import apps from django.db.models import signals from taiga.projects import signals as generic_handlers +from taiga.projects.custom_attributes import signals as custom_attributes_handlers from . import signals as handlers @@ -39,3 +40,8 @@ class IssuesAppConfig(AppConfig): sender=apps.get_model("issues", "Issue")) signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item, sender=apps.get_model("issues", "Issue")) + + # Custom Attributes + signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_issue, + sender=apps.get_model("issues", "Issue"), + dispatch_uid="create_custom_attribute_value_when_create_issue") diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py index 711cefd9..a951c369 100644 --- a/taiga/projects/issues/serializers.py +++ b/taiga/projects/issues/serializers.py @@ -16,7 +16,7 @@ from rest_framework import serializers -from taiga.base.serializers import (Serializer, TagsField, NeighborsSerializerMixin, +from taiga.base.serializers import (Serializer, TagsField, NeighborsSerializerMixin, PgArrayField, ModelSerializer) from taiga.mdrender.service import render as mdrender diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index f88c2ff0..b8d86b75 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -34,6 +34,7 @@ from taiga.projects.tasks.models import * from taiga.projects.issues.models import * from taiga.projects.wiki.models import * from taiga.projects.attachments.models import * +from taiga.projects.custom_attributes.models import * from taiga.projects.history.services import take_snapshot from taiga.events.apps import disconnect_events_signals @@ -150,6 +151,27 @@ class Command(BaseCommand): if role.computable: computable_project_roles.add(role) + # added custom attributes + if self.sd.boolean: + for i in range(1, 4): + UserStoryCustomAttribute.objects.create(name=self.sd.words(1, 3), + description=self.sd.words(3, 12), + project=project, + order=i) + if self.sd.boolean: + for i in range(1, 4): + TaskCustomAttribute.objects.create(name=self.sd.words(1, 3), + description=self.sd.words(3, 12), + project=project, + order=i) + if self.sd.boolean: + for i in range(1, 4): + IssueCustomAttribute.objects.create(name=self.sd.words(1, 3), + description=self.sd.words(3, 12), + project=project, + order=i) + + if x < NUM_PROJECTS: start_date = now() - datetime.timedelta(55) @@ -248,6 +270,14 @@ class Command(BaseCommand): project=project)), tags=self.sd.words(1, 10).split(" ")) + bug.save() + + custom_attributes_values = {str(ca.id): self.sd.words(1, 12) for ca in project.issuecustomattributes.all() + if self.sd.boolean()} + if custom_attributes_values: + bug.custom_attributes_values.attributes_values = custom_attributes_values + bug.custom_attributes_values.save() + for i in range(self.sd.int(*NUM_ATTACHMENTS)): attachment = self.create_attachment(bug, i+1) @@ -291,6 +321,12 @@ class Command(BaseCommand): task.save() + custom_attributes_values = {str(ca.id): self.sd.words(1, 12) for ca in project.taskcustomattributes.all() + if self.sd.boolean()} + if custom_attributes_values: + task.custom_attributes_values.attributes_values = custom_attributes_values + task.custom_attributes_values.save() + for i in range(self.sd.int(*NUM_ATTACHMENTS)): attachment = self.create_attachment(task, i+1) @@ -328,6 +364,15 @@ class Command(BaseCommand): role_points.save() + us.save() + + custom_attributes_values = {str(ca.id): self.sd.words(1, 12) for ca in project.userstorycustomattributes.all() + if self.sd.boolean()} + if custom_attributes_values: + us.custom_attributes_values.attributes_values = custom_attributes_values + us.custom_attributes_values.save() + + for i in range(self.sd.int(*NUM_ATTACHMENTS)): attachment = self.create_attachment(us, i+1) @@ -345,7 +390,7 @@ class Command(BaseCommand): take_snapshot(us, comment=self.sd.paragraph(), user=us.owner) - + return us def create_milestone(self, project, start_date, end_date): @@ -375,9 +420,9 @@ class Command(BaseCommand): def create_user(self, counter=None, username=None, full_name=None, email=None): counter = counter or self.sd.int() - username = username or 'user{0}'.format(counter) + username = username or "user{0}".format(counter) full_name = full_name or "{} {}".format(self.sd.name('es'), self.sd.surname('es', number=1)) - email = email or self.sd.email() + email = email or "user{0}@taigaio.demo".format(counter) user = User.objects.create(username=username, full_name=full_name, diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index ad0e99a7..b0332be9 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -34,7 +34,10 @@ from taiga.permissions.service import is_project_owner from . import models from . import services -from . validators import ProjectExistsValidator +from .validators import ProjectExistsValidator +from .custom_attributes.serializers import UserStoryCustomAttributeSerializer +from .custom_attributes.serializers import TaskCustomAttributeSerializer +from .custom_attributes.serializers import IssueCustomAttributeSerializer ###################################################### @@ -298,7 +301,6 @@ class ProjectSerializer(ModelSerializer): raise serializers.ValidationError("Total milestones must be major or equal to zero") return attrs - class ProjectDetailSerializer(ProjectSerializer): roles = serializers.SerializerMethodField("get_roles") memberships = serializers.SerializerMethodField("get_memberships") @@ -309,6 +311,12 @@ class ProjectDetailSerializer(ProjectSerializer): issue_types = IssueTypeSerializer(many=True, required=False) priorities = PrioritySerializer(many=True, required=False) # Issues severities = SeveritySerializer(many=True, required=False) + userstory_custom_attributes = UserStoryCustomAttributeSerializer(source="userstorycustomattributes", + many=True, required=False) + task_custom_attributes = TaskCustomAttributeSerializer(source="taskcustomattributes", + many=True, required=False) + issue_custom_attributes = IssueCustomAttributeSerializer(source="issuecustomattributes", + many=True, required=False) def get_memberships(self, obj): qs = obj.memberships.filter(user__isnull=False) diff --git a/taiga/projects/tasks/apps.py b/taiga/projects/tasks/apps.py index 752560de..a6597339 100644 --- a/taiga/projects/tasks/apps.py +++ b/taiga/projects/tasks/apps.py @@ -19,6 +19,7 @@ from django.apps import apps from django.db.models import signals from taiga.projects import signals as generic_handlers +from taiga.projects.custom_attributes import signals as custom_attributes_handlers from . import signals as handlers @@ -44,3 +45,8 @@ class TasksAppConfig(AppConfig): sender=apps.get_model("tasks", "Task")) signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item, sender=apps.get_model("tasks", "Task")) + + # Custom Attributes + signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_task, + sender=apps.get_model("tasks", "Task"), + dispatch_uid="create_custom_attribute_value_when_create_task") diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py index 9e2d2f4a..7ee80fa3 100644 --- a/taiga/projects/tasks/serializers.py +++ b/taiga/projects/tasks/serializers.py @@ -18,7 +18,7 @@ from rest_framework import serializers from taiga.base.serializers import (Serializer, TagsField, NeighborsSerializerMixin, PgArrayField, ModelSerializer) - + from taiga.mdrender.service import render as mdrender from taiga.projects.validators import ProjectExistsValidator from taiga.projects.milestones.validators import SprintExistsValidator diff --git a/taiga/projects/userstories/apps.py b/taiga/projects/userstories/apps.py index 299f1cfc..f6b1bb77 100644 --- a/taiga/projects/userstories/apps.py +++ b/taiga/projects/userstories/apps.py @@ -19,6 +19,7 @@ from django.apps import apps from django.db.models import signals from taiga.projects import signals as generic_handlers +from taiga.projects.custom_attributes import signals as custom_attributes_handlers from . import signals as handlers @@ -52,3 +53,8 @@ class UserStoriesAppConfig(AppConfig): sender=apps.get_model("userstories", "UserStory")) signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item, sender=apps.get_model("userstories", "UserStory")) + + # Custom Attributes + signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_user_story, + sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="create_custom_attribute_value_when_create_user_story") diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py index 52772ca6..c0abc9b4 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -22,10 +22,10 @@ from django.utils import timezone from djorm_pgarray.fields import TextArrayField +from taiga.base.tags import TaggedMixin from taiga.projects.occ import OCCModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.mixins.blocked import BlockedMixin -from taiga.base.tags import TaggedMixin class RolePoints(models.Model): diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index f90970f3..ae81cf20 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -14,15 +14,19 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json from django.apps import apps from rest_framework import serializers -from taiga.base.serializers import (Serializer, TagsField, NeighborsSerializerMixin, - PgArrayField, ModelSerializer) +from taiga.base.serializers import Serializer +from taiga.base.serializers import TagsField +from taiga.base.serializers import NeighborsSerializerMixin +from taiga.base.serializers import PgArrayField +from taiga.base.serializers import ModelSerializer +from taiga.base.utils import json from taiga.mdrender.service import render as mdrender -from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator +from taiga.projects.validators import ProjectExistsValidator +from taiga.projects.validators import UserStoryStatusExistsValidator from taiga.projects.userstories.validators import UserStoryExistsValidator from taiga.projects.notifications.validators import WatchersValidator @@ -92,7 +96,6 @@ class UserStorySerializer(WatchersValidator, ModelSerializer): class UserStoryNeighborsSerializer(NeighborsSerializerMixin, UserStorySerializer): - def serialize_neighbor(self, neighbor): return NeighborUserStorySerializer(neighbor).data @@ -104,8 +107,7 @@ class NeighborUserStorySerializer(ModelSerializer): depth = 0 -class UserStoriesBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, - Serializer): +class UserStoriesBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, Serializer): project_id = serializers.IntegerField() status_id = serializers.IntegerField(required=False) bulk_stories = serializers.CharField() @@ -118,8 +120,6 @@ class _UserStoryOrderBulkSerializer(UserStoryExistsValidator, Serializer): order = serializers.IntegerField() -class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator, - UserStoryStatusExistsValidator, - Serializer): +class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, Serializer): project_id = serializers.IntegerField() bulk_stories = _UserStoryOrderBulkSerializer(many=True) diff --git a/taiga/routers.py b/taiga/routers.py index ad39b94c..8a09b80e 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -35,23 +35,10 @@ from taiga.userstorage.api import StorageEntriesViewSet router.register(r"user-storage", StorageEntriesViewSet, base_name="user-storage") -# Resolver -from taiga.projects.references.api import ResolverViewSet +# Notify policies +from taiga.projects.notifications.api import NotifyPolicyViewSet -router.register(r"resolver", ResolverViewSet, base_name="resolver") - - -# Search -from taiga.searches.api import SearchViewSet - -router.register(r"search", SearchViewSet, base_name="search") - - -# Importer -from taiga.export_import.api import ProjectImporterViewSet, ProjectExporterViewSet - -router.register(r"importer", ProjectImporterViewSet, base_name="importer") -router.register(r"exporter", ProjectExporterViewSet, base_name="exporter") +router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications") # Projects & Selectors @@ -80,6 +67,41 @@ router.register(r"priorities", PriorityViewSet, base_name="priorities") router.register(r"severities",SeverityViewSet , base_name="severities") +# Custom Attributes +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 UserStoryCustomAttributesValuesViewSet +from taiga.projects.custom_attributes.api import TaskCustomAttributesValuesViewSet +from taiga.projects.custom_attributes.api import IssueCustomAttributesValuesViewSet + +router.register(r"userstory-custom-attributes", UserStoryCustomAttributeViewSet, + base_name="userstory-custom-attributes") +router.register(r"task-custom-attributes", TaskCustomAttributeViewSet, + base_name="task-custom-attributes") +router.register(r"issue-custom-attributes", IssueCustomAttributeViewSet, + base_name="issue-custom-attributes") + +router.register(r"userstories/custom-attributes-values", UserStoryCustomAttributesValuesViewSet, + base_name="userstory-custom-attributes-values") +router.register(r"tasks/custom-attributes-values", TaskCustomAttributesValuesViewSet, + base_name="task-custom-attributes-values") +router.register(r"issues/custom-attributes-values", IssueCustomAttributesValuesViewSet, + base_name="issue-custom-attributes-values") + + +# Search +from taiga.searches.api import SearchViewSet + +router.register(r"search", SearchViewSet, base_name="search") + + +# Resolver +from taiga.projects.references.api import ResolverViewSet + +router.register(r"resolver", ResolverViewSet, base_name="resolver") + + # Attachments from taiga.projects.attachments.api import UserStoryAttachmentViewSet from taiga.projects.attachments.api import IssueAttachmentViewSet @@ -93,11 +115,21 @@ router.register(r"issues/attachments", IssueAttachmentViewSet, base_name="issue- router.register(r"wiki/attachments", WikiAttachmentViewSet, base_name="wiki-attachments") -# Webhooks -from taiga.webhooks.api import WebhookViewSet, WebhookLogViewSet +# Project components +from taiga.projects.milestones.api import MilestoneViewSet +from taiga.projects.userstories.api import UserStoryViewSet +from taiga.projects.tasks.api import TaskViewSet +from taiga.projects.issues.api import IssueViewSet +from taiga.projects.issues.api import VotersViewSet +from taiga.projects.wiki.api import WikiViewSet, WikiLinkViewSet -router.register(r"webhooks", WebhookViewSet, base_name="webhooks") -router.register(r"webhooklogs", WebhookLogViewSet, base_name="webhooklogs") +router.register(r"milestones", MilestoneViewSet, base_name="milestones") +router.register(r"userstories", UserStoryViewSet, base_name="userstories") +router.register(r"tasks", TaskViewSet, base_name="tasks") +router.register(r"issues", IssueViewSet, base_name="issues") +router.register(r"issues/(?P\d+)/voters", VotersViewSet, base_name="issue-voters") +router.register(r"wiki", WikiViewSet, base_name="wiki") +router.register(r"wiki-links", WikiLinkViewSet, base_name="wiki-links") # History & Components @@ -120,27 +152,12 @@ router.register(r"timeline/user", UserTimeline, base_name="user-timeline") router.register(r"timeline/project", ProjectTimeline, base_name="project-timeline") -# Project components -from taiga.projects.milestones.api import MilestoneViewSet -from taiga.projects.userstories.api import UserStoryViewSet -from taiga.projects.tasks.api import TaskViewSet -from taiga.projects.issues.api import IssueViewSet -from taiga.projects.issues.api import VotersViewSet -from taiga.projects.wiki.api import WikiViewSet, WikiLinkViewSet +# Webhooks +from taiga.webhooks.api import WebhookViewSet +from taiga.webhooks.api import WebhookLogViewSet -router.register(r"milestones", MilestoneViewSet, base_name="milestones") -router.register(r"userstories", UserStoryViewSet, base_name="userstories") -router.register(r"tasks", TaskViewSet, base_name="tasks") -router.register(r"issues", IssueViewSet, base_name="issues") -router.register(r"issues/(?P\d+)/voters", VotersViewSet, base_name="issue-voters") -router.register(r"wiki", WikiViewSet, base_name="wiki") -router.register(r"wiki-links", WikiLinkViewSet, base_name="wiki-links") - - -# Notify policies -from taiga.projects.notifications.api import NotifyPolicyViewSet - -router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications") +router.register(r"webhooks", WebhookViewSet, base_name="webhooks") +router.register(r"webhooklogs", WebhookLogViewSet, base_name="webhooklogs") # GitHub webhooks @@ -161,5 +178,12 @@ from taiga.hooks.bitbucket.api import BitBucketViewSet router.register(r"bitbucket-hook", BitBucketViewSet, base_name="bitbucket-hook") +# Importer +from taiga.export_import.api import ProjectImporterViewSet, ProjectExporterViewSet + +router.register(r"importer", ProjectImporterViewSet, base_name="importer") +router.register(r"exporter", ProjectExporterViewSet, base_name="exporter") + + # feedback # - see taiga.feedback.routers and taiga.feedback.apps diff --git a/taiga/webhooks/serializers.py b/taiga/webhooks/serializers.py index 613e832a..17712cc3 100644 --- a/taiga/webhooks/serializers.py +++ b/taiga/webhooks/serializers.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from django.core.exceptions import ObjectDoesNotExist + from rest_framework import serializers from taiga.base.serializers import TagsField, PgArrayField, JsonField @@ -63,6 +65,30 @@ class UserSerializer(serializers.Serializer): return obj.full_name +class CustomAttributesValuesWebhookSerializerMixin(serializers.ModelSerializer): + custom_attributes_values = serializers.SerializerMethodField("get_custom_attributes_values") + + def custom_attributes_queryset(self, project): + raise NotImplementedError() + + def get_custom_attributes_values(self, obj): + def _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values): + ret = {} + for attr in custom_attributes: + value = values.get(str(attr["id"]), None) + if value is not None: + ret[attr["name"]] = value + + return ret + + try: + values = obj.custom_attributes_values.attributes_values + custom_attributes = self.custom_attributes_queryset(obj.project).values('id', 'name') + + return _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values) + except ObjectDoesNotExist: + return None + class PointSerializer(serializers.Serializer): id = serializers.SerializerMethodField("get_pk") name = serializers.SerializerMethodField("get_name") @@ -78,7 +104,7 @@ class PointSerializer(serializers.Serializer): return obj.value -class UserStorySerializer(serializers.ModelSerializer): +class UserStorySerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.ModelSerializer): tags = TagsField(default=[], required=False) external_reference = PgArrayField(required=False) owner = UserSerializer() @@ -90,8 +116,11 @@ class UserStorySerializer(serializers.ModelSerializer): model = us_models.UserStory exclude = ("backlog_order", "sprint_order", "kanban_order", "version") + def custom_attributes_queryset(self, project): + return project.userstorycustomattributes.all() -class TaskSerializer(serializers.ModelSerializer): + +class TaskSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.ModelSerializer): tags = TagsField(default=[], required=False) owner = UserSerializer() assigned_to = UserSerializer() @@ -100,8 +129,11 @@ class TaskSerializer(serializers.ModelSerializer): class Meta: model = task_models.Task + def custom_attributes_queryset(self, project): + return project.taskcustomattributes.all() -class IssueSerializer(serializers.ModelSerializer): + +class IssueSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.ModelSerializer): tags = TagsField(default=[], required=False) owner = UserSerializer() assigned_to = UserSerializer() @@ -110,6 +142,9 @@ class IssueSerializer(serializers.ModelSerializer): class Meta: model = issue_models.Issue + def custom_attributes_queryset(self, project): + return project.issuecustomattributes.all() + class WikiPageSerializer(serializers.ModelSerializer): owner = UserSerializer() diff --git a/tests/factories.py b/tests/factories.py index 96f6ccf4..4e9b9d0c 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -354,6 +354,63 @@ class IssueTypeFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") +class UserStoryCustomAttributeFactory(Factory): + class Meta: + model = "custom_attributes.UserStoryCustomAttribute" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "UserStory Custom Attribute {}".format(n)) + description = factory.Sequence(lambda n: "Description for UserStory Custom Attribute {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class TaskCustomAttributeFactory(Factory): + class Meta: + model = "custom_attributes.TaskCustomAttribute" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Task Custom Attribute {}".format(n)) + description = factory.Sequence(lambda n: "Description for Task Custom Attribute {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class IssueCustomAttributeFactory(Factory): + class Meta: + model = "custom_attributes.IssueCustomAttribute" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Issue Custom Attribute {}".format(n)) + description = factory.Sequence(lambda n: "Description for Issue Custom Attribute {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class UserStoryCustomAttributesValuesFactory(Factory): + class Meta: + model = "custom_attributes.UserStoryCustomAttributesValues" + strategy = factory.CREATE_STRATEGY + + attributes_values = {} + user_story = factory.SubFactory("tests.factories.UserStoryFactory") + + +class TaskCustomAttributesValuesFactory(Factory): + class Meta: + model = "custom_attributes.TaskCustomAttributesValues" + strategy = factory.CREATE_STRATEGY + + attributes_values = {} + task = factory.SubFactory("tests.factories.TaskFactory") + + +class IssueCustomAttributesValuesFactory(Factory): + class Meta: + model = "custom_attributes.IssueCustomAttributesValues" + strategy = factory.CREATE_STRATEGY + + attributes_values = {} + issue = factory.SubFactory("tests.factories.IssueFactory") + + # class FanFactory(Factory): # project = factory.SubFactory("tests.factories.ProjectFactory") # user = factory.SubFactory("tests.factories.UserFactory") diff --git a/tests/integration/resources_permissions/test_issues_custom_attributes_resource.py b/tests/integration/resources_permissions/test_issues_custom_attributes_resource.py new file mode 100644 index 00000000..e8e87048 --- /dev/null +++ b/tests/integration/resources_permissions/test_issues_custom_attributes_resource.py @@ -0,0 +1,398 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from django.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.projects.custom_attributes import serializers +from taiga.permissions.permissions import (MEMBERS_PERMISSIONS, + ANON_PERMISSIONS, USER_PERMISSIONS) + +from tests import factories as f +from tests.utils import helper_test_http_method + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project1, + role__permissions=[]) + + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project2, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + + m.public_issue_ca = f.IssueCustomAttributeFactory(project=m.public_project) + m.private_issue_ca1 = f.IssueCustomAttributeFactory(project=m.private_project1) + m.private_issue_ca2 = f.IssueCustomAttributeFactory(project=m.private_project2) + + m.public_issue = f.IssueFactory(project=m.public_project, + status__project=m.public_project, + severity__project=m.public_project, + priority__project=m.public_project, + type__project=m.public_project, + milestone__project=m.public_project) + m.private_issue1 = f.IssueFactory(project=m.private_project1, + status__project=m.private_project1, + severity__project=m.private_project1, + priority__project=m.private_project1, + type__project=m.private_project1, + milestone__project=m.private_project1) + m.private_issue2 = f.IssueFactory(project=m.private_project2, + status__project=m.private_project2, + severity__project=m.private_project2, + priority__project=m.private_project2, + type__project=m.private_project2, + milestone__project=m.private_project2) + + m.public_issue_cav = m.public_issue.custom_attributes_values + m.private_issue_cav1 = m.private_issue1.custom_attributes_values + m.private_issue_cav2 = m.private_issue2.custom_attributes_values + + return m + + +######################################################### +# Issue Custom Attribute +######################################################### + +def test_issue_custom_attribute_retrieve(client, data): + public_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.public_issue_ca.pk}) + private1_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca1.pk}) + private2_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca2.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] + + +def test_issue_custom_attribute_create(client, data): + public_url = reverse('issue-custom-attributes-list') + private1_url = reverse('issue-custom-attributes-list') + private2_url = reverse('issue-custom-attributes-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + issue_ca_data = {"name": "test-new", "project": data.public_project.id} + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'post', public_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + issue_ca_data = {"name": "test-new", "project": data.private_project1.id} + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'post', private1_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + issue_ca_data = {"name": "test-new", "project": data.private_project2.id} + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'post', private2_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + +def test_issue_custom_attribute_update(client, data): + public_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.public_issue_ca.pk}) + private1_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca1.pk}) + private2_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + issue_ca_data = serializers.IssueCustomAttributeSerializer(data.public_issue_ca).data + issue_ca_data["name"] = "test" + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'put', public_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_ca_data = serializers.IssueCustomAttributeSerializer(data.private_issue_ca1).data + issue_ca_data["name"] = "test" + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'put', private1_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_ca_data = serializers.IssueCustomAttributeSerializer(data.private_issue_ca2).data + issue_ca_data["name"] = "test" + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'put', private2_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + +def test_issue_custom_attribute_delete(client, data): + public_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.public_issue_ca.pk}) + private1_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca1.pk}) + private2_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca2.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] + + +def test_issue_custom_attribute_list(client, data): + url = reverse('issue-custom-attributes-list') + + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.json.get(url) + assert len(response.data) == 3 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.json.get(url) + assert len(response.data) == 3 + assert response.status_code == 200 + + +def test_issue_custom_attribute_patch(client, data): + public_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.public_issue_ca.pk}) + private1_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca1.pk}) + private2_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca2.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] + + +def test_issue_custom_attribute_action_bulk_update_order(client, data): + url = reverse('issue-custom-attributes-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_issue_custom_attributes": [(1,2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_custom_attributes": [(1,2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_custom_attributes": [(1,2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + +######################################################### +# Issue Custom Attribute +######################################################### + + +def test_issue_custom_attributes_values_retrieve(client, data): + public_url = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.public_issue.pk}) + private_url1 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue1.pk}) + private_url2 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_custom_attributes_values_update(client, data): + public_url = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.public_issue.pk}) + private_url1 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue1.pk}) + private_url2 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + issue_data = serializers.IssueCustomAttributesValuesSerializer(data.public_issue_cav).data + issue_data["attributes_values"] = {str(data.public_issue_ca.pk): "test"} + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', public_url, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = serializers.IssueCustomAttributesValuesSerializer(data.private_issue_cav1).data + issue_data["attributes_values"] = {str(data.private_issue_ca1.pk): "test"} + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', private_url1, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = serializers.IssueCustomAttributesValuesSerializer(data.private_issue_cav2).data + issue_data["attributes_values"] = {str(data.private_issue_ca2.pk): "test"} + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', private_url2, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_custom_attributes_values_patch(client, data): + public_url = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.public_issue.pk}) + private_url1 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue1.pk}) + private_url2 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"attributes_values": {str(data.public_issue_ca.pk): "test"}, + "version": data.public_issue.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_issue_ca1.pk): "test"}, + "version": data.private_issue1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_issue_ca2.pk): "test"}, + "version": data.private_issue2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/resources_permissions/test_tasks_custom_attributes_resource.py b/tests/integration/resources_permissions/test_tasks_custom_attributes_resource.py new file mode 100644 index 00000000..773c44cb --- /dev/null +++ b/tests/integration/resources_permissions/test_tasks_custom_attributes_resource.py @@ -0,0 +1,392 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from django.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.projects.custom_attributes import serializers +from taiga.permissions.permissions import (MEMBERS_PERMISSIONS, + ANON_PERMISSIONS, USER_PERMISSIONS) + +from tests import factories as f +from tests.utils import helper_test_http_method + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project1, + role__permissions=[]) + + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project2, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + + m.public_task_ca = f.TaskCustomAttributeFactory(project=m.public_project) + m.private_task_ca1 = f.TaskCustomAttributeFactory(project=m.private_project1) + m.private_task_ca2 = f.TaskCustomAttributeFactory(project=m.private_project2) + + m.public_task = f.TaskFactory(project=m.public_project, + status__project=m.public_project, + milestone__project=m.public_project, + user_story__project=m.public_project) + m.private_task1 = f.TaskFactory(project=m.private_project1, + status__project=m.private_project1, + milestone__project=m.private_project1, + user_story__project=m.private_project1) + m.private_task2 = f.TaskFactory(project=m.private_project2, + status__project=m.private_project2, + milestone__project=m.private_project2, + user_story__project=m.private_project2) + + m.public_task_cav = m.public_task.custom_attributes_values + m.private_task_cav1 = m.private_task1.custom_attributes_values + m.private_task_cav2 = m.private_task2.custom_attributes_values + + return m + + +######################################################### +# Task Custom Attribute +######################################################### + +def test_task_custom_attribute_retrieve(client, data): + public_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.public_task_ca.pk}) + private1_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca1.pk}) + private2_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca2.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] + + +def test_task_custom_attribute_create(client, data): + public_url = reverse('task-custom-attributes-list') + private1_url = reverse('task-custom-attributes-list') + private2_url = reverse('task-custom-attributes-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + task_ca_data = {"name": "test-new", "project": data.public_project.id} + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'post', public_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + task_ca_data = {"name": "test-new", "project": data.private_project1.id} + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'post', private1_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + task_ca_data = {"name": "test-new", "project": data.private_project2.id} + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'post', private2_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + +def test_task_custom_attribute_update(client, data): + public_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.public_task_ca.pk}) + private1_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca1.pk}) + private2_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + task_ca_data = serializers.TaskCustomAttributeSerializer(data.public_task_ca).data + task_ca_data["name"] = "test" + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'put', public_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + task_ca_data = serializers.TaskCustomAttributeSerializer(data.private_task_ca1).data + task_ca_data["name"] = "test" + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'put', private1_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + task_ca_data = serializers.TaskCustomAttributeSerializer(data.private_task_ca2).data + task_ca_data["name"] = "test" + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'put', private2_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + +def test_task_custom_attribute_delete(client, data): + public_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.public_task_ca.pk}) + private1_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca1.pk}) + private2_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca2.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] + + +def test_task_custom_attribute_list(client, data): + url = reverse('task-custom-attributes-list') + + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.json.get(url) + assert len(response.data) == 3 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.json.get(url) + assert len(response.data) == 3 + assert response.status_code == 200 + + +def test_task_custom_attribute_patch(client, data): + public_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.public_task_ca.pk}) + private1_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca1.pk}) + private2_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca2.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] + + +def test_task_custom_attribute_action_bulk_update_order(client, data): + url = reverse('task-custom-attributes-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_task_custom_attributes": [(1,2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_task_custom_attributes": [(1,2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_task_custom_attributes": [(1,2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + +######################################################### +# Task Custom Attribute +######################################################### + + +def test_task_custom_attributes_values_retrieve(client, data): + public_url = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.public_task.pk}) + private_url1 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task1.pk}) + private_url2 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_custom_attributes_values_update(client, data): + public_url = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.public_task.pk}) + private_url1 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task1.pk}) + private_url2 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + task_data = serializers.TaskCustomAttributesValuesSerializer(data.public_task_cav).data + task_data["attributes_values"] = {str(data.public_task_ca.pk): "test"} + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', public_url, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = serializers.TaskCustomAttributesValuesSerializer(data.private_task_cav1).data + task_data["attributes_values"] = {str(data.private_task_ca1.pk): "test"} + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', private_url1, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = serializers.TaskCustomAttributesValuesSerializer(data.private_task_cav2).data + task_data["attributes_values"] = {str(data.private_task_ca2.pk): "test"} + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', private_url2, task_data, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_custom_attributes_values_patch(client, data): + public_url = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.public_task.pk}) + private_url1 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task1.pk}) + private_url2 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"attributes_values": {str(data.public_task_ca.pk): "test"}, + "version": data.public_task.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_task_ca1.pk): "test"}, + "version": data.private_task1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_task_ca2.pk): "test"}, + "version": data.private_task2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/resources_permissions/test_userstories_custom_attributes_resource.py b/tests/integration/resources_permissions/test_userstories_custom_attributes_resource.py new file mode 100644 index 00000000..17a30bc8 --- /dev/null +++ b/tests/integration/resources_permissions/test_userstories_custom_attributes_resource.py @@ -0,0 +1,398 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from django.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.projects.custom_attributes import serializers +from taiga.permissions.permissions import (MEMBERS_PERMISSIONS, + ANON_PERMISSIONS, USER_PERMISSIONS) + + +from tests import factories as f +from tests.utils import helper_test_http_method + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project1, + role__permissions=[]) + + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project2, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + + m.public_userstory_ca = f.UserStoryCustomAttributeFactory(project=m.public_project) + m.private_userstory_ca1 = f.UserStoryCustomAttributeFactory(project=m.private_project1) + m.private_userstory_ca2 = f.UserStoryCustomAttributeFactory(project=m.private_project2) + + + m.public_user_story = f.UserStoryFactory(project=m.public_project, + status__project=m.public_project) + m.private_user_story1 = f.UserStoryFactory(project=m.private_project1, + status__project=m.private_project1) + m.private_user_story2 = f.UserStoryFactory(project=m.private_project2, + status__project=m.private_project2) + + m.public_user_story_cav = m.public_user_story.custom_attributes_values + m.private_user_story_cav1 = m.private_user_story1.custom_attributes_values + m.private_user_story_cav2 = m.private_user_story2.custom_attributes_values + + return m + + +######################################################### +# User Story Custom Attribute +######################################################### + +def test_userstory_custom_attribute_retrieve(client, data): + public_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.public_userstory_ca.pk}) + private1_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca1.pk}) + private2_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca2.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] + + +def test_userstory_custom_attribute_create(client, data): + public_url = reverse('userstory-custom-attributes-list') + private1_url = reverse('userstory-custom-attributes-list') + private2_url = reverse('userstory-custom-attributes-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + userstory_ca_data = {"name": "test-new", "project": data.public_project.id} + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'post', public_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + userstory_ca_data = {"name": "test-new", "project": data.private_project1.id} + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'post', private1_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + userstory_ca_data = {"name": "test-new", "project": data.private_project2.id} + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'post', private2_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + +def test_userstory_custom_attribute_update(client, data): + public_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.public_userstory_ca.pk}) + private1_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca1.pk}) + private2_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + userstory_ca_data = serializers.UserStoryCustomAttributeSerializer(data.public_userstory_ca).data + userstory_ca_data["name"] = "test" + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'put', public_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + userstory_ca_data = serializers.UserStoryCustomAttributeSerializer(data.private_userstory_ca1).data + userstory_ca_data["name"] = "test" + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'put', private1_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + userstory_ca_data = serializers.UserStoryCustomAttributeSerializer(data.private_userstory_ca2).data + userstory_ca_data["name"] = "test" + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'put', private2_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + +def test_userstory_custom_attribute_delete(client, data): + public_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.public_userstory_ca.pk}) + private1_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca1.pk}) + private2_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca2.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] + + +def test_userstory_custom_attribute_list(client, data): + url = reverse('userstory-custom-attributes-list') + + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.json.get(url) + assert len(response.data) == 3 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.json.get(url) + assert len(response.data) == 3 + assert response.status_code == 200 + + +def test_userstory_custom_attribute_patch(client, data): + public_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.public_userstory_ca.pk}) + private1_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca1.pk}) + private2_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca2.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] + + +def test_userstory_custom_attribute_action_bulk_update_order(client, data): + url = reverse('userstory-custom-attributes-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_userstory_custom_attributes": [(1,2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_userstory_custom_attributes": [(1,2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_userstory_custom_attributes": [(1,2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + + +######################################################### +# UserStory Custom Attribute +######################################################### + + +def test_userstory_custom_attributes_values_retrieve(client, data): + public_url = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_userstory_custom_attributes_values_update(client, data): + public_url = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + user_story_data = serializers.UserStoryCustomAttributesValuesSerializer(data.public_user_story_cav).data + user_story_data["attributes_values"] = {str(data.public_userstory_ca.pk): "test"} + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', public_url, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = serializers.UserStoryCustomAttributesValuesSerializer(data.private_user_story_cav1).data + user_story_data["attributes_values"] = {str(data.private_userstory_ca1.pk): "test"} + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', private_url1, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = serializers.UserStoryCustomAttributesValuesSerializer(data.private_user_story_cav2).data + user_story_data["attributes_values"] = {str(data.private_userstory_ca2.pk): "test"} + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', private_url2, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + +def test_userstory_custom_attributes_values_patch(client, data): + public_url = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"attributes_values": {str(data.public_userstory_ca.pk): "test"}, + "version": data.public_user_story.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_userstory_ca1.pk): "test"}, + "version": data.private_user_story1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_userstory_ca2.pk): "test"}, + "version": data.private_user_story2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/test_custom_attributes_issues.py b/tests/integration/test_custom_attributes_issues.py new file mode 100644 index 00000000..65152aa5 --- /dev/null +++ b/tests/integration/test_custom_attributes_issues.py @@ -0,0 +1,200 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# 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 + + +######################################################### +# Issue Custom Attributes +######################################################### + +def test_issue_custom_attribute_duplicate_name_error_on_create(client): + custom_attr_1 = f.IssueCustomAttributeFactory() + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + + + url = reverse("issue-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_issue_custom_attribute_duplicate_name_error_on_update(client): + custom_attr_1 = f.IssueCustomAttributeFactory() + custom_attr_2 = f.IssueCustomAttributeFactory(project=custom_attr_1.project) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + + + url = reverse("issue-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_issue_custom_attribute_duplicate_name_error_on_move_between_projects(client): + custom_attr_1 = f.IssueCustomAttributeFactory() + custom_attr_2 = f.IssueCustomAttributeFactory(name=custom_attr_1.name) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_2.project, + is_owner=True) + + + url = reverse("issue-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 + + +######################################################### +# Issue Custom Attributes Values +######################################################### + +def test_issue_custom_attributes_values_when_create_us(client): + issue = f.IssueFactory() + assert issue.custom_attributes_values.attributes_values == {} + + +def test_issue_custom_attributes_values_update(client): + issue = f.IssueFactory() + member = f.MembershipFactory(user=issue.project.owner, + project=issue.project, + is_owner=True) + + custom_attr_1 = f.IssueCustomAttributeFactory(project=issue.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=issue.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = issue.custom_attributes_values + + url = reverse("issue-custom-attributes-values-detail", args=[issue.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + }, + "version": custom_attrs_val.version + } + + + assert issue.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"] + issue = issue.__class__.objects.get(id=issue.id) + assert issue.custom_attributes_values.attributes_values == data["attributes_values"] + + +def test_issue_custom_attributes_values_update_with_error_invalid_key(client): + issue = f.IssueFactory() + member = f.MembershipFactory(user=issue.project.owner, + project=issue.project, + is_owner=True) + + custom_attr_1 = f.IssueCustomAttributeFactory(project=issue.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=issue.project) + + custom_attrs_val = issue.custom_attributes_values + + url = reverse("issue-custom-attributes-values-detail", args=[issue.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_issue_custom_attributes_values_delete_issue(client): + issue = f.IssueFactory() + member = f.MembershipFactory(user=issue.project.owner, + project=issue.project, + is_owner=True) + + custom_attr_1 = f.IssueCustomAttributeFactory(project=issue.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=issue.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = issue.custom_attributes_values + + url = reverse("issues-detail", args=[issue.id]) + + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + assert not issue.__class__.objects.filter(id=issue.id).exists() + assert not custom_attrs_val.__class__.objects.filter(id=custom_attrs_val.id).exists() + + +######################################################### +# Test tristres triggers :-P +######################################################### + +def test_trigger_update_issuecustomvalues_afeter_remove_issuecustomattribute(client): + issue = f.IssueFactory() + member = f.MembershipFactory(user=issue.project.owner, + project=issue.project, + is_owner=True) + custom_attr_1 = f.IssueCustomAttributeFactory(project=issue.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=issue.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = issue.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("issue-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() diff --git a/tests/integration/test_custom_attributes_tasks.py b/tests/integration/test_custom_attributes_tasks.py new file mode 100644 index 00000000..fee38830 --- /dev/null +++ b/tests/integration/test_custom_attributes_tasks.py @@ -0,0 +1,202 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from django.core.urlresolvers import reverse +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +######################################################### +# Task Custom Attributes +######################################################### + +def test_task_custom_attribute_duplicate_name_error_on_create(client): + custom_attr_1 = f.TaskCustomAttributeFactory() + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + + + url = reverse("task-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_task_custom_attribute_duplicate_name_error_on_update(client): + custom_attr_1 = f.TaskCustomAttributeFactory() + custom_attr_2 = f.TaskCustomAttributeFactory(project=custom_attr_1.project) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + + + url = reverse("task-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_task_custom_attribute_duplicate_name_error_on_move_between_projects(client): + custom_attr_1 = f.TaskCustomAttributeFactory() + custom_attr_2 = f.TaskCustomAttributeFactory(name=custom_attr_1.name) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_2.project, + is_owner=True) + + + url = reverse("task-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 + + +######################################################### +# Task Custom Attributes Values +######################################################### + +def test_task_custom_attributes_values_when_create_us(client): + task = f.TaskFactory() + assert task.custom_attributes_values.attributes_values == {} + + +def test_task_custom_attributes_values_update(client): + task = f.TaskFactory() + member = f.MembershipFactory(user=task.project.owner, + project=task.project, + is_owner=True) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=task.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=task.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = task.custom_attributes_values + + url = reverse("task-custom-attributes-values-detail", args=[task.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + }, + "version": custom_attrs_val.version + } + + assert task.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"] + task = task.__class__.objects.get(id=task.id) + assert task.custom_attributes_values.attributes_values == data["attributes_values"] + + +def test_task_custom_attributes_values_update_with_error_invalid_key(client): + task = f.TaskFactory() + member = f.MembershipFactory(user=task.project.owner, + project=task.project, + is_owner=True) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=task.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=task.project) + + custom_attrs_val = task.custom_attributes_values + + url = reverse("task-custom-attributes-values-detail", args=[task.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + "123456": "test_2_updated" + }, + "version": custom_attrs_val.version + } + + assert task.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_task_custom_attributes_values_delete_task(client): + task = f.TaskFactory() + member = f.MembershipFactory(user=task.project.owner, + project=task.project, + is_owner=True) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=task.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=task.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = task.custom_attributes_values + + url = reverse("tasks-detail", args=[task.id]) + + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + assert not task.__class__.objects.filter(id=task.id).exists() + assert not custom_attrs_val.__class__.objects.filter(id=custom_attrs_val.id).exists() + + +######################################################### +# Test tristres triggers :-P +######################################################### + +def test_trigger_update_taskcustomvalues_afeter_remove_taskcustomattribute(client): + task = f.TaskFactory() + member = f.MembershipFactory(user=task.project.owner, + project=task.project, + is_owner=True) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=task.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=task.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = task.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("task-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() diff --git a/tests/integration/test_custom_attributes_user_stories.py b/tests/integration/test_custom_attributes_user_stories.py new file mode 100644 index 00000000..6e602269 --- /dev/null +++ b/tests/integration/test_custom_attributes_user_stories.py @@ -0,0 +1,179 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from django.core.urlresolvers import reverse +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +######################################################### +# User Story Custom Attributes +######################################################### + +def test_userstory_custom_attribute_duplicate_name_error_on_create(client): + custom_attr_1 = f.UserStoryCustomAttributeFactory() + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + + + url = reverse("userstory-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_userstory_custom_attribute_duplicate_name_error_on_update(client): + custom_attr_1 = f.UserStoryCustomAttributeFactory() + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=custom_attr_1.project) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + + + url = reverse("userstory-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_userstory_custom_attribute_duplicate_name_error_on_move_between_projects(client): + custom_attr_1 = f.UserStoryCustomAttributeFactory() + custom_attr_2 = f.UserStoryCustomAttributeFactory(name=custom_attr_1.name) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_2.project, + is_owner=True) + + + url = reverse("userstory-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 + + +######################################################### +# User Story Custom Attributes Values +######################################################### + +def test_userstory_custom_attributes_values_when_create_us(client): + user_story = f.UserStoryFactory() + assert user_story.custom_attributes_values.attributes_values == {} + + +def test_userstory_custom_attributes_values_update(client): + user_story = f.UserStoryFactory() + member = f.MembershipFactory(user=user_story.project.owner, + project=user_story.project, + is_owner=True) + + custom_attr_1 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = user_story.custom_attributes_values + + url = reverse("userstory-custom-attributes-values-detail", args=[user_story.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + }, + "version": custom_attrs_val.version + } + + assert user_story.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"] + user_story = user_story.__class__.objects.get(id=user_story.id) + assert user_story.custom_attributes_values.attributes_values == data["attributes_values"] + + +def test_userstory_custom_attributes_values_update_with_error_invalid_key(client): + user_story = f.UserStoryFactory() + member = f.MembershipFactory(user=user_story.project.owner, + project=user_story.project, + is_owner=True) + + custom_attr_1 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=user_story.project) + + custom_attrs_val = user_story.custom_attributes_values + + url = reverse("userstory-custom-attributes-values-detail", args=[user_story.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + "123456": "test_2_updated" + }, + "version": custom_attrs_val.version + } + + assert user_story.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +######################################################### +# Test tristres triggers :-P +######################################################### + +def test_trigger_update_userstorycustomvalues_afeter_remove_userstorycustomattribute(client): + user_story = f.UserStoryFactory() + member = f.MembershipFactory(user=user_story.project.owner, + project=user_story.project, + is_owner=True) + + custom_attr_1 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = user_story.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("userstory-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 ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id not in custom_attrs_val.attributes_values.keys() diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py index 6236f80f..9cbb64c0 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -22,6 +22,8 @@ from django.core.files.base import ContentFile from .. import factories as f +from django.apps import apps + from taiga.base.utils import json from taiga.projects.models import Project from taiga.projects.issues.models import Issue @@ -167,6 +169,61 @@ def test_invalid_project_import_with_extra_data(client): assert Project.objects.filter(slug="imported-project").count() == 0 +def test_valid_project_import_with_custom_attributes(client): + user = f.UserFactory.create() + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + "userstorycustomattributes": [{ + "name": "custom attribute example 1", + "description": "short description 1", + "order": 1 + }], + "taskcustomattributes": [{ + "name": "custom attribute example 1", + "description": "short description 1", + "order": 1 + }], + "issuecustomattributes": [{ + "name": "custom attribute example 1", + "description": "short description 1", + "order": 1 + }] + } + + must_empty_children = ["issues", "user_stories", "wiki_pages", "milestones", "wiki_links"] + must_one_instance_children = ["userstorycustomattributes", "taskcustomattributes", "issuecustomattributes"] + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert all(map(lambda x: len(response.data[x]) == 0, must_empty_children)) + # Allwais is created at least the owner membership + assert all(map(lambda x: len(response.data[x]) == 1, must_one_instance_children)) + assert response.data["owner"] == user.email + + +def test_invalid_project_import_with_custom_attributes(client): + user = f.UserFactory.create() + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + "userstorycustomattributes": [{ }], + "taskcustomattributes": [{ }], + "issuecustomattributes": [{ }] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 3 + assert Project.objects.filter(slug="imported-project").count() == 0 + + def test_invalid_issue_import(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) @@ -201,6 +258,30 @@ def test_valid_user_story_import(client): assert response_data["finish_date"] == "2014-10-24T00:00:00+0000" +def test_valid_user_story_import_with_custom_attributes_values(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) + project.default_us_status = f.UserStoryStatusFactory.create(project=project) + project.save() + custom_attr = f.UserStoryCustomAttributeFactory(project=project) + + url = reverse("importer-us", args=[project.pk]) + data = { + "subject": "Test Custom Attrs Values User Story", + "custom_attributes_values": { + custom_attr.name: "test_value" + } + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + custom_attributes_values = apps.get_model("custom_attributes.UserStoryCustomAttributesValues").objects.get( + user_story__subject=response.data["subject"]) + assert custom_attributes_values.attributes_values == {str(custom_attr.id): "test_value"} + + def test_valid_issue_import_without_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) @@ -224,6 +305,33 @@ def test_valid_issue_import_without_extra_data(client): assert response_data["ref"] is not None +def test_valid_issue_import_with_custom_attributes_values(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.save() + custom_attr = f.IssueCustomAttributeFactory(project=project) + + url = reverse("importer-issue", args=[project.pk]) + data = { + "subject": "Test Custom Attrs Values Issues", + "custom_attributes_values": { + custom_attr.name: "test_value" + } + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + custom_attributes_values = apps.get_model("custom_attributes.IssueCustomAttributesValues").objects.get( + issue__subject=response.data["subject"]) + assert custom_attributes_values.attributes_values == {str(custom_attr.id): "test_value"} + + def test_valid_issue_import_with_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) @@ -481,6 +589,30 @@ def test_valid_task_import_without_extra_data(client): assert response_data["ref"] is not None +def test_valid_task_import_with_custom_attributes_values(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() + custom_attr = f.TaskCustomAttributeFactory(project=project) + + url = reverse("importer-task", args=[project.pk]) + data = { + "subject": "Test Custom Attrs Values Tasks", + "custom_attributes_values": { + custom_attr.name: "test_value" + } + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + custom_attributes_values = apps.get_model("custom_attributes.TaskCustomAttributesValues").objects.get( + task__subject=response.data["subject"]) + assert custom_attributes_values.attributes_values == {str(custom_attr.id): "test_value"} + + def test_valid_task_import_with_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) @@ -680,6 +812,7 @@ def test_valid_wiki_link_import(client): json.loads(response.content.decode("utf-8")) + def test_invalid_milestone_import(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) @@ -711,6 +844,7 @@ def test_valid_milestone_import(client): json.loads(response.content.decode("utf-8")) + def test_milestone_import_duplicated_milestone(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user)