diff --git a/taiga/export_import/serializers.py b/taiga/export_import/serializers.py index 2893537b..69f84b14 100644 --- a/taiga/export_import/serializers.py +++ b/taiga/export_import/serializers.py @@ -333,6 +333,93 @@ class IssueCustomAttributeExportSerializer(serializers.ModelSerializer): exclude = ('id', 'project') +class CustomAttributesValuesExportSerializerMixin: + 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.values + custom_attributes = self.custom_attribute_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: + values = JsonField(source="values", label="values", required=True) + _custom_attribute_model = None + _container_field = None + + def validate_values(self, attrs, source): + # values must be a dict + data_values = attrs.get("values", None) + if self.object: + data_values = (data_values or self.object.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, + serializers.ModelSerializer): + _custom_attribute_model = custom_attributes_models.UserStoryCustomAttribute + _container_model = "userstories.UserStory" + _container_field = "user_story" + + class Meta: + model = custom_attributes_models.UserStoryCustomAttributesValues + exclude = ("id",) + + +class TaskCustomAttributesValuesExportSerializer(BaseCustomAttributesValuesExportSerializer, + serializers.ModelSerializer): + _custom_attribute_model = custom_attributes_models.TaskCustomAttribute + _container_field = "task" + + class Meta: + model = custom_attributes_models.TaskCustomAttributesValues + exclude = ("id",) + + +class IssueCustomAttributesValuesExportSerializer(BaseCustomAttributesValuesExportSerializer, + serializers.ModelSerializer): + _custom_attribute_model = custom_attributes_models.IssueCustomAttribute + _container_field = "issue" + + class Meta: + model = custom_attributes_models.IssueCustomAttributesValues + exclude = ("id",) + + class MembershipExportSerializer(serializers.ModelSerializer): user = UserRelatedField(required=False) role = ProjectRelatedField(slug_field="name") @@ -382,8 +469,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) @@ -396,9 +483,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) @@ -412,9 +502,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) @@ -426,13 +519,16 @@ 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)] + + def custom_attributes_queryset(self, project): + return project.issuecustomattributes.all() + class WikiPageExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin, serializers.ModelSerializer): diff --git a/taiga/export_import/service.py b/taiga/export_import/service.py index 0d8aff03..55ac48c8 100644 --- a/taiga/export_import/service.py +++ b/taiga/export_import/service.py @@ -108,6 +108,30 @@ def store_custom_attributes(project, data, field, serializer): 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, + "values": data_values, + } + + 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) @@ -122,7 +146,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 @@ -164,16 +188,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: @@ -192,12 +216,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) @@ -211,8 +243,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 @@ -251,7 +283,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 @@ -261,10 +293,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 @@ -295,61 +327,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 + data_data = {key: value for key, value in data.items() if key not in ["role_points", "custom_attributes_values"]} + serialized = serializers.UserStoryExportSerializer(data=data_data, context={"project": project}) if serialized.is_valid(): serialized.object.project = project @@ -369,10 +352,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/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py index 6231d4a9..5c654394 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 @@ -256,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.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) @@ -279,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.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) @@ -536,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.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) @@ -735,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) @@ -766,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)