diff --git a/taiga/export_import/serializers/cache.py b/taiga/export_import/serializers/cache.py index c4eb5bfa..f22978f8 100644 --- a/taiga/export_import/serializers/cache.py +++ b/taiga/export_import/serializers/cache.py @@ -23,7 +23,7 @@ _cache_user_by_email = {} _custom_tasks_attributes_cache = {} _custom_issues_attributes_cache = {} _custom_userstories_attributes_cache = {} - +_custom_epics_attributes_cache = {} def cached_get_user_by_pk(pk): if pk not in _cache_user_by_pk: diff --git a/taiga/export_import/serializers/serializers.py b/taiga/export_import/serializers/serializers.py index ff7e791c..f4f46e52 100644 --- a/taiga/export_import/serializers/serializers.py +++ b/taiga/export_import/serializers/serializers.py @@ -29,6 +29,7 @@ from .mixins import (HistoryExportSerializerMixin, WatcheableObjectLightSerializerMixin) from .cache import (_custom_tasks_attributes_cache, _custom_userstories_attributes_cache, + _custom_epics_attributes_cache, _custom_issues_attributes_cache) @@ -55,6 +56,14 @@ class UserStoryStatusExportSerializer(RelatedExportSerializer): wip_limit = Field() +class EpicStatusExportSerializer(RelatedExportSerializer): + name = Field() + slug = Field() + order = Field() + is_closed = Field() + color = Field() + + class TaskStatusExportSerializer(RelatedExportSerializer): name = Field() slug = Field() @@ -97,6 +106,15 @@ class RoleExportSerializer(RelatedExportSerializer): permissions = Field() +class EpicCustomAttributesExportSerializer(RelatedExportSerializer): + name = Field() + description = Field() + type = Field() + order = Field() + created_date = DateTimeField() + modified_date = DateTimeField() + + class UserStoryCustomAttributeExportSerializer(RelatedExportSerializer): name = Field() description = Field() @@ -238,6 +256,45 @@ class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin, return _custom_userstories_attributes_cache[project.id] +class EpicRelatedUserStoryExportSerializer(RelatedExportSerializer): + user_story = SlugRelatedField(slug_field="ref") + order = Field() + + +class EpicExportSerializer(CustomAttributesValuesExportSerializerMixin, + HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, + WatcheableObjectLightSerializerMixin, + RelatedExportSerializer): + ref = Field() + owner = UserRelatedField() + status = SlugRelatedField(slug_field="name") + epics_order = Field() + created_date = DateTimeField() + modified_date = DateTimeField() + subject = Field() + description = Field() + color = Field() + assigned_to = UserRelatedField() + client_requirement = Field() + team_requirement = Field() + version = Field() + blocked_note = Field() + is_blocked = Field() + tags = Field() + related_user_stories = MethodField() + + def get_related_user_stories(self, obj): + return EpicRelatedUserStoryExportSerializer(obj.relateduserstory_set.all(), many=True).data + + def custom_attributes_queryset(self, project): + if project.id not in _custom_epics_attributes_cache: + _custom_epics_attributes_cache[project.id] = list( + project.userstorycustomattributes.all().values('id', 'name') + ) + return _custom_epics_attributes_cache[project.id] + + class IssueExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin, AttachmentExportSerializerMixin, @@ -307,6 +364,7 @@ class ProjectExportSerializer(WatcheableObjectLightSerializerMixin): logo = FileField() total_milestones = Field() total_story_points = Field() + is_epics_activated = Field() is_backlog_activated = Field() is_kanban_activated = Field() is_wiki_activated = Field() @@ -318,6 +376,7 @@ class ProjectExportSerializer(WatcheableObjectLightSerializerMixin): is_featured = Field() is_looking_for_people = Field() looking_for_people_note = Field() + epics_csv_uuid = Field() userstories_csv_uuid = Field() tasks_csv_uuid = Field() issues_csv_uuid = Field() @@ -339,6 +398,7 @@ class ProjectExportSerializer(WatcheableObjectLightSerializerMixin): owner = UserRelatedField() memberships = MembershipExportSerializer(many=True) points = PointsExportSerializer(many=True) + epic_statuses = EpicStatusExportSerializer(many=True) us_statuses = UserStoryStatusExportSerializer(many=True) task_statuses = TaskStatusExportSerializer(many=True) issue_types = IssueTypeExportSerializer(many=True) @@ -347,15 +407,18 @@ class ProjectExportSerializer(WatcheableObjectLightSerializerMixin): severities = SeverityExportSerializer(many=True) tags_colors = Field() default_points = SlugRelatedField(slug_field="name") + default_epic_status = SlugRelatedField(slug_field="name") default_us_status = SlugRelatedField(slug_field="name") default_task_status = SlugRelatedField(slug_field="name") default_priority = SlugRelatedField(slug_field="name") default_severity = SlugRelatedField(slug_field="name") default_issue_status = SlugRelatedField(slug_field="name") default_issue_type = SlugRelatedField(slug_field="name") + epiccustomattributes = EpicCustomAttributesExportSerializer(many=True) userstorycustomattributes = UserStoryCustomAttributeExportSerializer(many=True) taskcustomattributes = TaskCustomAttributeExportSerializer(many=True) issuecustomattributes = IssueCustomAttributeExportSerializer(many=True) + epics = EpicExportSerializer(many=True) user_stories = UserStoryExportSerializer(many=True) tasks = TaskExportSerializer(many=True) milestones = MilestoneExportSerializer(many=True) diff --git a/taiga/export_import/services/render.py b/taiga/export_import/services/render.py index 0b56f3f5..cb757dd0 100644 --- a/taiga/export_import/services/render.py +++ b/taiga/export_import/services/render.py @@ -45,12 +45,16 @@ def render_project(project, outfile, chunk_size=8190): # field.initialize(parent=serializer, field_name=field_name) # These four "special" fields hava attachments so we use them in a special way - if field_name in ["wiki_pages", "user_stories", "tasks", "issues"]: + if field_name in ["wiki_pages", "user_stories", "tasks", "issues", "epics"]: value = get_component(project, field_name) if field_name != "wiki_pages": - value = value.select_related('owner', 'status', 'milestone', + value = value.select_related('owner', 'status', 'project', 'assigned_to', 'custom_attributes_values') + + if field_name in ["user_stories", "tasks", "issues"]: + value = value.select_related('milestone') + if field_name == "issues": value = value.select_related('severity', 'priority', 'type') value = value.prefetch_related('history_entry', 'attachments') diff --git a/taiga/export_import/services/store.py b/taiga/export_import/services/store.py index 9739bb1e..e28353bc 100644 --- a/taiga/export_import/services/store.py +++ b/taiga/export_import/services/store.py @@ -80,11 +80,17 @@ def store_project(data): excluded_fields = [ "default_points", "default_us_status", "default_task_status", "default_priority", "default_severity", "default_issue_status", - "default_issue_type", "memberships", "points", "us_statuses", - "task_statuses", "issue_statuses", "priorities", "severities", - "issue_types", "userstorycustomattributes", "taskcustomattributes", - "issuecustomattributes", "roles", "milestones", "wiki_pages", - "wiki_links", "notify_policies", "user_stories", "issues", "tasks", + "default_issue_type", "default_epic_status", + "memberships", "points", + "epic_statuses", "us_statuses", "task_statuses", "issue_statuses", + "priorities", "severities", + "issue_types", + "epiccustomattributes", "userstorycustomattributes", + "taskcustomattributes", "issuecustomattributes", + "roles", "milestones", + "wiki_pages", "wiki_links", + "notify_policies", + "epics", "user_stories", "issues", "tasks", "is_featured" ] if key not in excluded_fields: @@ -195,7 +201,7 @@ def _store_membership(project, membership): validator.object._importing = True validator.object.token = str(uuid.uuid1()) validator.object.user = find_invited_user(validator.object.email, - default=validator.object.user) + default=validator.object.user) validator.save() return validator @@ -219,6 +225,7 @@ def _store_project_attribute_value(project, data, field, serializer): validator.object._importing = True validator.save() return validator.object + add_errors(field, validator.errors) return None @@ -239,10 +246,10 @@ def store_default_project_attributes_values(project, data): else: value = related.all().first() setattr(project, field, value) - helper(project, "default_points", project.points, data) helper(project, "default_issue_type", project.issue_types, data) helper(project, "default_issue_status", project.issue_statuses, data) + helper(project, "default_epic_status", project.epic_statuses, data) helper(project, "default_us_status", project.us_statuses, data) helper(project, "default_task_status", project.task_statuses, data) helper(project, "default_priority", project.priorities, data) @@ -317,12 +324,14 @@ def _store_role_point(project, us, role_point): add_errors("role_points", validator.errors) return None + def store_user_story(project, data): if "status" not in data and project.default_us_status: data["status"] = project.default_us_status.name us_data = {key: value for key, value in data.items() if key not in - ["role_points", "custom_attributes_values"]} + ["role_points", "custom_attributes_values"]} + validator = validators.UserStoryExportValidator(data=us_data, context={"project": project}) if validator.is_valid(): @@ -360,10 +369,13 @@ def store_user_story(project, data): custom_attributes_values = data.get("custom_attributes_values", None) if custom_attributes_values: custom_attributes = validator.object.project.userstorycustomattributes.all().values('id', 'name') - custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values( - custom_attributes, custom_attributes_values) + custom_attributes_values = \ + _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + _store_custom_attributes_values(validator.object, custom_attributes_values, - "user_story", validators.UserStoryCustomAttributesValuesExportValidator) + "user_story", + validators.UserStoryCustomAttributesValuesExportValidator) return validator @@ -379,6 +391,81 @@ def store_user_stories(project, data): return results +## EPICS + +def _store_epic_related_user_story(project, epic, related_user_story): + validator = validators.EpicRelatedUserStoryExportValidator(data=related_user_story, + context={"project": project}) + if validator.is_valid(): + validator.object.epic = epic + validator.object.save() + return validator.object + + add_errors("epic_related_user_stories", validator.errors) + return None + + +def store_epic(project, data): + if "status" not in data and project.default_epic_status: + data["status"] = project.default_epic_status.name + + validator = validators.EpicExportValidator(data=data, context={"project": project}) + if validator.is_valid(): + validator.object.project = project + if validator.object.owner is None: + validator.object.owner = validator.object.project.owner + validator.object._importing = True + validator.object._not_notify = True + + validator.save() + validator.save_watchers() + + if validator.object.ref: + sequence_name = refs.make_sequence_name(project) + if not seq.exists(sequence_name): + seq.create(sequence_name) + seq.set_max(sequence_name, validator.object.ref) + else: + validator.object.ref, _ = refs.make_reference(validator.object, project) + validator.object.save() + + for epic_attachment in data.get("attachments", []): + _store_attachment(project, validator.object, epic_attachment) + + for related_user_story in data.get("related_user_stories", []): + _store_epic_related_user_story(project, validator.object, related_user_story) + + history_entries = data.get("history", []) + for history in history_entries: + _store_history(project, validator.object, history) + + if not history_entries: + take_snapshot(validator.object, user=validator.object.owner) + + custom_attributes_values = data.get("custom_attributes_values", None) + if custom_attributes_values: + custom_attributes = validator.object.project.epiccustomattributes.all().values('id', 'name') + custom_attributes_values = \ + _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + _store_custom_attributes_values(validator.object, custom_attributes_values, + "epic", + validators.EpicCustomAttributesValuesExportValidator) + + return validator + + add_errors("epics", validator.errors) + return None + + +def store_epics(project, data): + results = [] + for epic in data.get("epics", []): + epic = store_epic(project, epic) + results.append(epic) + return results + + ## TASKS def store_task(project, data): @@ -418,10 +505,13 @@ def store_task(project, data): custom_attributes_values = data.get("custom_attributes_values", None) if custom_attributes_values: custom_attributes = validator.object.project.taskcustomattributes.all().values('id', 'name') - custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values( - custom_attributes, custom_attributes_values) + custom_attributes_values = \ + _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + _store_custom_attributes_values(validator.object, custom_attributes_values, - "task", validators.TaskCustomAttributesValuesExportValidator) + "task", + validators.TaskCustomAttributesValuesExportValidator) return validator @@ -486,10 +576,12 @@ def store_issue(project, data): custom_attributes_values = data.get("custom_attributes_values", None) if custom_attributes_values: custom_attributes = validator.object.project.issuecustomattributes.all().values('id', 'name') - custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values( - custom_attributes, custom_attributes_values) + custom_attributes_values = \ + _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) _store_custom_attributes_values(validator.object, custom_attributes_values, - "issue", validators.IssueCustomAttributesValuesExportValidator) + "issue", + validators.IssueCustomAttributesValuesExportValidator) return validator @@ -605,8 +697,9 @@ def _validate_if_owner_have_enought_space_to_this_project(owner, data): is_private = data.get("is_private", False) total_memberships = len([m for m in data.get("memberships", []) - if m.get("email", None) != data["owner"]]) - total_memberships = total_memberships + 1 # 1 is the owner + if m.get("email", None) != data["owner"]]) + + total_memberships = total_memberships + 1 # 1 is the owner (enough_slots, error_message) = users_service.has_available_slot_for_import_new_project( owner, is_private, @@ -652,9 +745,10 @@ def _populate_project_object(project, data): # Create memberships store_memberships(project, data) _create_membership_for_project_owner(project) - check_if_there_is_some_error(_("error importing memberships"), project) + check_if_there_is_some_error(_("error importing memberships"), project) # Create project attributes values + store_project_attributes_values(project, data, "epic_statuses", validators.EpicStatusExportValidator) store_project_attributes_values(project, data, "us_statuses", validators.UserStoryStatusExportValidator) store_project_attributes_values(project, data, "points", validators.PointsExportValidator) store_project_attributes_values(project, data, "task_statuses", validators.TaskStatusExportValidator) @@ -669,6 +763,8 @@ def _populate_project_object(project, data): check_if_there_is_some_error(_("error importing default project attributes values"), project) # Create custom attributes + store_custom_attributes(project, data, "epiccustomattributes", + validators.EpicCustomAttributeExportValidator) store_custom_attributes(project, data, "userstorycustomattributes", validators.UserStoryCustomAttributeExportValidator) store_custom_attributes(project, data, "taskcustomattributes", @@ -689,6 +785,10 @@ def _populate_project_object(project, data): store_user_stories(project, data) check_if_there_is_some_error(_("error importing user stories"), project) + # Creat epics + store_epics(project, data) + check_if_there_is_some_error(_("error importing epics"), project) + # Createer tasks store_tasks(project, data) check_if_there_is_some_error(_("error importing tasks"), project) diff --git a/taiga/export_import/validators/__init__.py b/taiga/export_import/validators/__init__.py index 969a8d0c..0948ade0 100644 --- a/taiga/export_import/validators/__init__.py +++ b/taiga/export_import/validators/__init__.py @@ -1,4 +1,5 @@ from .validators import PointsExportValidator +from .validators import EpicStatusExportValidator from .validators import UserStoryStatusExportValidator from .validators import TaskStatusExportValidator from .validators import IssueStatusExportValidator @@ -6,6 +7,7 @@ from .validators import PriorityExportValidator from .validators import SeverityExportValidator from .validators import IssueTypeExportValidator from .validators import RoleExportValidator +from .validators import EpicCustomAttributeExportValidator from .validators import UserStoryCustomAttributeExportValidator from .validators import TaskCustomAttributeExportValidator from .validators import IssueCustomAttributeExportValidator @@ -17,6 +19,8 @@ from .validators import MembershipExportValidator from .validators import RolePointsExportValidator from .validators import MilestoneExportValidator from .validators import TaskExportValidator +from .validators import EpicRelatedUserStoryExportValidator +from .validators import EpicExportValidator from .validators import UserStoryExportValidator from .validators import IssueExportValidator from .validators import WikiPageExportValidator diff --git a/taiga/export_import/validators/cache.py b/taiga/export_import/validators/cache.py index c4eb5bfa..d82e943d 100644 --- a/taiga/export_import/validators/cache.py +++ b/taiga/export_import/validators/cache.py @@ -22,6 +22,7 @@ _cache_user_by_pk = {} _cache_user_by_email = {} _custom_tasks_attributes_cache = {} _custom_issues_attributes_cache = {} +_custom_epics_attributes_cache = {} _custom_userstories_attributes_cache = {} diff --git a/taiga/export_import/validators/validators.py b/taiga/export_import/validators/validators.py index 818df0c3..c821b531 100644 --- a/taiga/export_import/validators/validators.py +++ b/taiga/export_import/validators/validators.py @@ -25,6 +25,7 @@ from taiga.base.exceptions import ValidationError from taiga.projects import models as projects_models from taiga.projects.custom_attributes import models as custom_attributes_models +from taiga.projects.epics import models as epics_models from taiga.projects.userstories import models as userstories_models from taiga.projects.tasks import models as tasks_models from taiga.projects.issues import models as issues_models @@ -38,6 +39,7 @@ from .fields import (FileField, UserRelatedField, TimelineDataField, ContentTypeField) from .mixins import WatcheableObjectModelValidatorMixin from .cache import (_custom_tasks_attributes_cache, + _custom_epics_attributes_cache, _custom_userstories_attributes_cache, _custom_issues_attributes_cache) @@ -48,6 +50,12 @@ class PointsExportValidator(validators.ModelValidator): exclude = ('id', 'project') +class EpicStatusExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.EpicStatus + exclude = ('id', 'project') + + class UserStoryStatusExportValidator(validators.ModelValidator): class Meta: model = projects_models.UserStoryStatus @@ -92,6 +100,14 @@ class RoleExportValidator(validators.ModelValidator): exclude = ('id', 'project') +class EpicCustomAttributeExportValidator(validators.ModelValidator): + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = custom_attributes_models.EpicCustomAttribute + exclude = ('id', 'project') + + class UserStoryCustomAttributeExportValidator(validators.ModelValidator): modified_date = serializers.DateTimeField(required=False) @@ -151,6 +167,15 @@ class BaseCustomAttributesValuesExportValidator(validators.ModelValidator): return attrs +class EpicCustomAttributesValuesExportValidator(BaseCustomAttributesValuesExportValidator): + _custom_attribute_model = custom_attributes_models.EpicCustomAttribute + _container_model = "epics.Epic" + _container_field = "epic" + + class Meta(BaseCustomAttributesValuesExportValidator.Meta): + model = custom_attributes_models.EpicCustomAttributesValues + + class UserStoryCustomAttributesValuesExportValidator(BaseCustomAttributesValuesExportValidator): _custom_attribute_model = custom_attributes_models.UserStoryCustomAttribute _container_model = "userstories.UserStory" @@ -244,6 +269,34 @@ class TaskExportValidator(WatcheableObjectModelValidatorMixin): return _custom_tasks_attributes_cache[project.id] +class EpicRelatedUserStoryExportValidator(validators.ModelValidator): + user_story = ProjectRelatedField(slug_field="ref") + order = serializers.IntegerField() + + class Meta: + model = epics_models.RelatedUserStory + exclude = ('id', 'epic') + + +class EpicExportValidator(WatcheableObjectModelValidatorMixin): + owner = UserRelatedField(required=False) + assigned_to = UserRelatedField(required=False) + status = ProjectRelatedField(slug_field="name") + modified_date = serializers.DateTimeField(required=False) + user_stories = EpicRelatedUserStoryExportValidator(many=True, required=False) + + class Meta: + model = epics_models.Epic + exclude = ('id', 'project') + + def custom_attributes_queryset(self, project): + if project.id not in _custom_epics_attributes_cache: + _custom_epics_attributes_cache[project.id] = list( + project.epiccustomattributes.all().values('id', 'name') + ) + return _custom_epics_attributes_cache[project.id] + + class UserStoryExportValidator(WatcheableObjectModelValidatorMixin): role_points = RolePointsExportValidator(many=True, required=False) owner = UserRelatedField(required=False) diff --git a/tests/unit/test_export.py b/tests/unit/test_export.py index a8ce775f..1088550f 100644 --- a/tests/unit/test_export.py +++ b/tests/unit/test_export.py @@ -42,3 +42,17 @@ def test_export_user_story_finish_date(client): project_data = json.loads(output.getvalue()) finish_date = project_data["user_stories"][0]["finish_date"] assert finish_date == "2014-10-22T00:00:00+0000" + + +def test_export_epic_with_user_stories(client): + epic = f.EpicFactory.create(subject="test epic export") + user_story = f.UserStoryFactory.create(project=epic.project) + f.RelatedUserStory.create(epic=epic, user_story=user_story) + output = io.BytesIO() + render_project(user_story.project, output) + project_data = json.loads(output.getvalue()) + assert project_data["epics"][0]["subject"] == "test epic export" + assert len(project_data["epics"]) == 1 + + assert project_data["epics"][0]["related_user_stories"][0]["user_story"] == user_story.ref + assert len(project_data["epics"][0]["related_user_stories"]) == 1 diff --git a/tests/unit/test_import.py b/tests/unit/test_import.py new file mode 100644 index 00000000..58f9b9db --- /dev/null +++ b/tests/unit/test_import.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest +import io +from .. import factories as f + +from taiga.base.utils import json +from taiga.export_import.services import render_project, store_project_from_dict + +pytestmark = pytest.mark.django_db + + +def test_import_epic_with_user_stories(client): + project = f.ProjectFactory() + project.default_points = f.PointsFactory.create(project=project) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.default_epic_status = f.EpicStatusFactory.create(project=project) + project.default_us_status = f.UserStoryStatusFactory.create(project=project) + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + + epic = f.EpicFactory.create(subject="test epic export", project=project, status=project.default_epic_status) + user_story = f.UserStoryFactory.create(project=project, status=project.default_us_status, milestone=None) + f.RelatedUserStory.create(epic=epic, user_story=user_story, order=55) + output = io.BytesIO() + render_project(user_story.project, output) + project_data = json.loads(output.getvalue()) + + epic.project.delete() + + project = store_project_from_dict(project_data) + assert project.epics.count() == 1 + assert project.epics.first().ref == epic.ref + + assert project.epics.first().user_stories.count() == 1 + related_userstory = project.epics.first().relateduserstory_set.first() + assert related_userstory.user_story.ref == user_story.ref + assert related_userstory.order == 55 + assert related_userstory.epic.ref == epic.ref