diff --git a/CHANGELOG.md b/CHANGELOG.md index 344e5b8e..d91736e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ ### Features - Contact with the project: if the projects have this module enabled Taiga users can contact them. - Ability to create rich text custom fields in Epics, User Stories, Tasks and Isues. -- Full text search now use simple as tolenizer so search with non-english text are allowed. +- Full text search now use simple as tokenizer so search with non-english text are allowed. +- Duplicate project: allows creating a new project based on the structure of another (status, tags, colors, default values...) - i18n: - Add japanese (ja) translation. - Add chinese simplified (zh-Hans) translation. diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index 75644365..9d90b638 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -104,7 +104,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi 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 - (enough_slots, error_message) = users_services.has_available_slot_for_import_new_project( + (enough_slots, error_message) = users_services.has_available_slot_for_new_project( self.request.user, is_private, total_memberships @@ -344,7 +344,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi total_memberships = len([m for m in dump.get("memberships", []) if m.get("email", None) != dump["owner"]]) total_memberships = total_memberships + 1 # 1 is the owner - (enough_slots, error_message) = users_services.has_available_slot_for_import_new_project( + (enough_slots, error_message) = users_services.has_available_slot_for_new_project( user, is_private, total_memberships diff --git a/taiga/export_import/services/store.py b/taiga/export_import/services/store.py index e28353bc..80a784eb 100644 --- a/taiga/export_import/services/store.py +++ b/taiga/export_import/services/store.py @@ -700,7 +700,7 @@ def _validate_if_owner_have_enought_space_to_this_project(owner, data): 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( + (enough_slots, error_message) = users_service.has_available_slot_for_new_project( owner, is_private, total_memberships diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 7f9d0611..91d850d3 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -52,6 +52,7 @@ from taiga.projects.mixins.ordering import BulkUpdateOrderMixin from taiga.projects.tasks.models import Task from taiga.projects.tagging.api import TagsColorsResourceMixin from taiga.projects.userstories.models import UserStory, RolePoints +from taiga.users import services as users_services from . import filters as project_filters from . import models @@ -218,7 +219,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, if not template_description: raise response.BadRequest(_("Not valid template description")) - with advisory_lock("create-project-template") as acquired_key_lock: + with advisory_lock("create-project-template"): template_slug = slugify_uniquely(template_name, models.ProjectTemplate) project = self.get_object() @@ -392,6 +393,42 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, services.reject_project_transfer(project, request.user, token, reason) return response.Ok() + @detail_route(methods=["POST"]) + def duplicate(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "duplicate", project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + validator = validators.DuplicateProjectValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + + # Validate if the project can be imported + is_private = data.get('is_private', False) + total_memberships = len(data.get("users", [])) + 1 + (enough_slots, error_message) = users_services.has_available_slot_for_new_project( + self.request.user, + is_private, + total_memberships + ) + if not enough_slots: + raise exc.NotEnoughSlotsForProject(is_private, total_memberships, error_message) + + new_project = services.duplicate_project( + project=project, + owner=request.user, + name=data["name"], + description=data["description"], + is_private=data["is_private"], + users=data["users"] + ) + new_project = get_object_or_404(self.get_queryset(), id=new_project.id) + serializer = self.get_serializer(new_project) + return response.Created(serializer.data) + def _raise_if_blocked(self, project): if self.is_blocked(project): raise exc.Blocked(_("Blocked element")) @@ -632,7 +669,6 @@ class IssueStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, return super().create(request, *args, **kwargs) - ###################################################### ## Project Template ###################################################### diff --git a/taiga/projects/apps.py b/taiga/projects/apps.py index 634d56ce..2ef6ac55 100644 --- a/taiga/projects/apps.py +++ b/taiga/projects/apps.py @@ -58,6 +58,7 @@ def connect_memberships_signals(): sender=apps.get_model("projects", "Membership"), dispatch_uid='create-notify-policy') + def disconnect_memberships_signals(): signals.pre_delete.disconnect(sender=apps.get_model("projects", "Membership"), dispatch_uid='membership_pre_delete') @@ -79,7 +80,6 @@ def disconnect_us_status_signals(): dispatch_uid="try_to_close_or_open_user_stories_when_edit_us_status") - ## Tasks Statuses Signals def connect_task_status_signals(): @@ -94,7 +94,6 @@ def disconnect_task_status_signals(): dispatch_uid="try_to_close_or_open_user_stories_when_edit_task_status") - class ProjectsAppConfig(AppConfig): name = "taiga.projects" verbose_name = "Projects" diff --git a/taiga/projects/custom_attributes/api.py b/taiga/projects/custom_attributes/api.py index f8e74b00..e13a989a 100644 --- a/taiga/projects/custom_attributes/api.py +++ b/taiga/projects/custom_attributes/api.py @@ -16,14 +16,10 @@ # 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.api.mixins import BlockedByProjectMixin -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 diff --git a/taiga/projects/fixtures/initial_project_templates.json b/taiga/projects/fixtures/initial_project_templates.json index e4c92cd0..7d84b49f 100644 --- a/taiga/projects/fixtures/initial_project_templates.json +++ b/taiga/projects/fixtures/initial_project_templates.json @@ -564,7 +564,11 @@ "comment_wiki_page" ] } - ] + ], + "epic_custom_attributes": [], + "us_custom_attributes": [], + "task_custom_attributes": [], + "issue_custom_attributes": [] } }, { @@ -1132,7 +1136,11 @@ "comment_wiki_page" ] } - ] + ], + "epic_custom_attributes": [], + "us_custom_attributes": [], + "task_custom_attributes": [], + "issue_custom_attributes": [] } } ] diff --git a/taiga/projects/migrations/0058_auto_20161215_1347.py b/taiga/projects/migrations/0058_auto_20161215_1347.py new file mode 100644 index 00000000..341f2fb1 --- /dev/null +++ b/taiga/projects/migrations/0058_auto_20161215_1347.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2016-12-15 13:47 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +import django.core.serializers.json +from django.db import migrations, models +import taiga.base.db.models.fields.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0057_auto_20161129_0945'), + ] + + operations = [ + migrations.AddField( + model_name='projecttemplate', + name='epic_custom_attributes', + field=taiga.base.db.models.fields.json.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, verbose_name='epic custom attributes'), + ), + migrations.AddField( + model_name='projecttemplate', + name='is_looking_for_people', + field=models.BooleanField(default=False, verbose_name='is looking for people'), + ), + migrations.AddField( + model_name='projecttemplate', + name='issue_custom_attributes', + field=taiga.base.db.models.fields.json.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, verbose_name='issue custom attributes'), + ), + migrations.AddField( + model_name='projecttemplate', + name='looking_for_people_note', + field=models.TextField(blank=True, default='', verbose_name='loking for people note'), + ), + migrations.AddField( + model_name='projecttemplate', + name='tags', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=[], null=True, size=None, verbose_name='tags'), + ), + migrations.AddField( + model_name='projecttemplate', + name='tags_colors', + field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), size=2), blank=True, default=[], null=True, size=None, verbose_name='tags colors'), + ), + migrations.AddField( + model_name='projecttemplate', + name='task_custom_attributes', + field=taiga.base.db.models.fields.json.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, verbose_name='task custom attributes'), + ), + migrations.AddField( + model_name='projecttemplate', + name='us_custom_attributes', + field=taiga.base.db.models.fields.json.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, verbose_name='us custom attributes'), + ), + ] diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 46a8a694..b23cd151 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -32,8 +32,12 @@ from django_pglocks import advisory_lock from taiga.base.db.models.fields import JSONField from taiga.base.utils.time import timestamp_ms +from taiga.projects.custom_attributes.models import EpicCustomAttribute +from taiga.projects.custom_attributes.models import UserStoryCustomAttribute +from taiga.projects.custom_attributes.models import TaskCustomAttribute +from taiga.projects.custom_attributes.models import IssueCustomAttribute from taiga.projects.tagging.models import TaggedMixin -from taiga.projects.tagging.models import TagsColorsdMixin +from taiga.projects.tagging.models import TagsColorsMixin from taiga.base.utils.files import get_file_path from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.slug import slugify_uniquely_for_queryset @@ -82,10 +86,10 @@ class Membership(models.Model): null=True, blank=True) invitation_extra_text = models.TextField(null=True, blank=True, - verbose_name=_("invitation extra text")) + verbose_name=_("invitation extra text")) user_order = models.BigIntegerField(default=timestamp_ms, null=False, blank=False, - verbose_name=_("user order")) + verbose_name=_("user order")) class Meta: verbose_name = "membership" @@ -106,9 +110,9 @@ class Membership(models.Model): class ProjectDefaults(models.Model): default_epic_status = models.OneToOneField("projects.EpicStatus", - on_delete=models.SET_NULL, related_name="+", - null=True, blank=True, - verbose_name=_("default epic status")) + on_delete=models.SET_NULL, related_name="+", + null=True, blank=True, + verbose_name=_("default epic status")) default_us_status = models.OneToOneField("projects.UserStoryStatus", on_delete=models.SET_NULL, related_name="+", null=True, blank=True, @@ -139,7 +143,7 @@ class ProjectDefaults(models.Model): abstract = True -class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): +class Project(ProjectDefaults, TaggedMixin, TagsColorsMixin, models.Model): name = models.CharField(max_length=250, null=False, blank=False, verbose_name=_("name")) slug = models.SlugField(max_length=250, unique=True, null=False, blank=True, @@ -148,8 +152,8 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): verbose_name=_("description")) logo = models.FileField(upload_to=get_project_logo_file_path, - max_length=500, null=True, blank=True, - verbose_name=_("logo")) + max_length=500, null=True, blank=True, + verbose_name=_("logo")) created_date = models.DateTimeField(null=False, blank=False, verbose_name=_("created date"), @@ -180,7 +184,7 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): choices=choices.VIDEOCONFERENCES_CHOICES, verbose_name=_("videoconference system")) videoconferences_extra_data = models.CharField(max_length=250, null=True, blank=True, - verbose_name=_("videoconference extra data")) + verbose_name=_("videoconference extra data")) creation_template = models.ForeignKey("projects.ProjectTemplate", related_name="projects", null=True, @@ -196,10 +200,10 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): null=True, blank=True, default=[], verbose_name=_("user permissions")) is_featured = models.BooleanField(default=False, null=False, blank=True, - verbose_name=_("is featured")) + verbose_name=_("is featured")) is_looking_for_people = models.BooleanField(default=False, null=False, blank=True, - verbose_name=_("is looking for people")) + verbose_name=_("is looking for people")) looking_for_people_note = models.TextField(default="", null=False, blank=True, verbose_name=_("loking for people note")) @@ -218,36 +222,39 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): verbose_name=_("project transfer token")) blocked_code = models.CharField(null=True, blank=True, max_length=255, - choices=choices.BLOCKING_CODES + settings.EXTRA_BLOCKING_CODES, default=None, - verbose_name=_("blocked code")) - - #Totals: + choices=choices.BLOCKING_CODES + settings.EXTRA_BLOCKING_CODES, + default=None, verbose_name=_("blocked code")) + # Totals: totals_updated_datetime = models.DateTimeField(null=False, blank=False, auto_now_add=True, - verbose_name=_("updated date time"), db_index=True) + verbose_name=_("updated date time"), db_index=True) total_fans = models.PositiveIntegerField(null=False, blank=False, default=0, verbose_name=_("count"), db_index=True) total_fans_last_week = models.PositiveIntegerField(null=False, blank=False, default=0, - verbose_name=_("fans last week"), db_index=True) + verbose_name=_("fans last week"), db_index=True) total_fans_last_month = models.PositiveIntegerField(null=False, blank=False, default=0, - verbose_name=_("fans last month"), db_index=True) + verbose_name=_("fans last month"), db_index=True) total_fans_last_year = models.PositiveIntegerField(null=False, blank=False, default=0, - verbose_name=_("fans last year"), db_index=True) + verbose_name=_("fans last year"), db_index=True) total_activity = models.PositiveIntegerField(null=False, blank=False, default=0, - verbose_name=_("count"), db_index=True) + verbose_name=_("count"), + db_index=True) total_activity_last_week = models.PositiveIntegerField(null=False, blank=False, default=0, - verbose_name=_("activity last week"), db_index=True) + verbose_name=_("activity last week"), + db_index=True) total_activity_last_month = models.PositiveIntegerField(null=False, blank=False, default=0, - verbose_name=_("activity last month"), db_index=True) + verbose_name=_("activity last month"), + db_index=True) total_activity_last_year = models.PositiveIntegerField(null=False, blank=False, default=0, - verbose_name=_("activity last year"), db_index=True) + verbose_name=_("activity last year"), + db_index=True) _importing = None @@ -303,13 +310,13 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): self.total_fans = qs.count() - qs_week = qs.filter(created_date__gte=now-relativedelta(weeks=1)) + qs_week = qs.filter(created_date__gte=now - relativedelta(weeks=1)) self.total_fans_last_week = qs_week.count() - qs_month = qs.filter(created_date__gte=now-relativedelta(months=1)) + qs_month = qs.filter(created_date__gte=now - relativedelta(months=1)) self.total_fans_last_month = qs_month.count() - qs_year = qs.filter(created_date__gte=now-relativedelta(years=1)) + qs_year = qs.filter(created_date__gte=now - relativedelta(years=1)) self.total_fans_last_year = qs_year.count() tl_model = apps.get_model("timeline", "Timeline") @@ -318,13 +325,13 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): qs = tl_model.objects.filter(namespace=namespace) self.total_activity = qs.count() - qs_week = qs.filter(created__gte=now-relativedelta(weeks=1)) + qs_week = qs.filter(created__gte=now - relativedelta(weeks=1)) self.total_activity_last_week = qs_week.count() - qs_month = qs.filter(created__gte=now-relativedelta(months=1)) + qs_month = qs.filter(created__gte=now - relativedelta(months=1)) self.total_activity_last_month = qs_month.count() - qs_year = qs.filter(created__gte=now-relativedelta(years=1)) + qs_year = qs.filter(created__gte=now - relativedelta(years=1)) self.total_activity_last_year = qs_year.count() if save: @@ -358,7 +365,7 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): policy = model_cls.objects.create( project=self, user=user, - notify_level= NotifyLevel.involved) + notify_level=NotifyLevel.involved) del self.cached_notify_policies @@ -460,6 +467,8 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): # NOTE: Remember to update code in taiga.projects.admin.ProjectAdmin.delete_selected from taiga.events.apps import (connect_events_signals, disconnect_events_signals) + from taiga.projects.epics.apps import (connect_all_epics_signals, + disconnect_all_epics_signals) from taiga.projects.tasks.apps import (connect_all_tasks_signals, disconnect_all_tasks_signals) from taiga.projects.userstories.apps import (connect_all_userstories_signals, @@ -470,12 +479,14 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): disconnect_memberships_signals) disconnect_events_signals() + disconnect_all_epics_signals() disconnect_all_issues_signals() disconnect_all_tasks_signals() disconnect_all_userstories_signals() disconnect_memberships_signals() try: + self.epics.all().delete() self.tasks.all().delete() self.user_stories.all().delete() self.issues.all().delete() @@ -486,12 +497,13 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): connect_all_issues_signals() connect_all_tasks_signals() connect_all_userstories_signals() + connect_all_epics_signals() connect_memberships_signals() class ProjectModulesConfig(models.Model): project = models.OneToOneField("Project", null=False, blank=False, - related_name="modules_config", verbose_name=_("project")) + related_name="modules_config", verbose_name=_("project")) config = JSONField(null=True, blank=True, verbose_name=_("modules config")) class Meta: @@ -544,7 +556,7 @@ class UserStoryStatus(models.Model): is_closed = models.BooleanField(default=False, null=False, blank=True, verbose_name=_("is closed")) is_archived = models.BooleanField(default=False, null=False, blank=True, - verbose_name=_("is archived")) + verbose_name=_("is archived")) color = models.CharField(max_length=20, null=False, blank=False, default="#999999", verbose_name=_("color")) wip_limit = models.IntegerField(null=True, blank=True, default=None, @@ -718,7 +730,7 @@ class IssueType(models.Model): return self.name -class ProjectTemplate(models.Model): +class ProjectTemplate(TaggedMixin, TagsColorsMixin, models.Model): name = models.CharField(max_length=250, null=False, blank=False, verbose_name=_("name")) slug = models.SlugField(max_length=250, null=False, blank=True, @@ -726,7 +738,7 @@ class ProjectTemplate(models.Model): description = models.TextField(null=False, blank=False, verbose_name=_("description")) order = models.BigIntegerField(default=timestamp_ms, null=False, blank=False, - verbose_name=_("user order")) + verbose_name=_("user order")) created_date = models.DateTimeField(null=False, blank=False, verbose_name=_("created date"), default=timezone.now) @@ -747,11 +759,15 @@ class ProjectTemplate(models.Model): verbose_name=_("active wiki panel")) is_issues_activated = models.BooleanField(default=True, null=False, blank=True, verbose_name=_("active issues panel")) + is_looking_for_people = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is looking for people")) + looking_for_people_note = models.TextField(default="", null=False, blank=True, + verbose_name=_("loking for people note")) videoconferences = models.CharField(max_length=250, null=True, blank=True, choices=choices.VIDEOCONFERENCES_CHOICES, verbose_name=_("videoconference system")) videoconferences_extra_data = models.CharField(max_length=250, null=True, blank=True, - verbose_name=_("videoconference extra data")) + verbose_name=_("videoconference extra data")) default_options = JSONField(null=True, blank=True, verbose_name=_("default options")) epic_statuses = JSONField(null=True, blank=True, verbose_name=_("epic statuses")) @@ -763,6 +779,11 @@ class ProjectTemplate(models.Model): priorities = JSONField(null=True, blank=True, verbose_name=_("priorities")) severities = JSONField(null=True, blank=True, verbose_name=_("severities")) roles = JSONField(null=True, blank=True, verbose_name=_("roles")) + epic_custom_attributes = JSONField(null=True, blank=True, verbose_name=_("epic custom attributes")) + us_custom_attributes = JSONField(null=True, blank=True, verbose_name=_("us custom attributes")) + task_custom_attributes = JSONField(null=True, blank=True, verbose_name=_("task custom attributes")) + issue_custom_attributes = JSONField(null=True, blank=True, verbose_name=_("issue custom attributes")) + _importing = None class Meta: @@ -779,6 +800,8 @@ class ProjectTemplate(models.Model): def save(self, *args, **kwargs): if not self._importing or not self.modified_date: self.modified_date = timezone.now() + if not self.slug: + self.slug = slugify_uniquely(self.name, self.__class__) super().save(*args, **kwargs) def load_data_from_project(self, project): @@ -886,12 +909,53 @@ class ProjectTemplate(models.Model): "computable": role.computable }) + self.epic_custom_attributes = [] + for ca in project.epiccustomattributes.all(): + self.epic_custom_attributes.append({ + "name": ca.name, + "description": ca.description, + "type": ca.type, + "order": ca.order + }) + + self.us_custom_attributes = [] + for ca in project.userstorycustomattributes.all(): + self.us_custom_attributes.append({ + "name": ca.name, + "description": ca.description, + "type": ca.type, + "order": ca.order + }) + + self.task_custom_attributes = [] + for ca in project.taskcustomattributes.all(): + self.task_custom_attributes.append({ + "name": ca.name, + "description": ca.description, + "type": ca.type, + "order": ca.order + }) + + self.issue_custom_attributes = [] + for ca in project.issuecustomattributes.all(): + self.issue_custom_attributes.append({ + "name": ca.name, + "description": ca.description, + "type": ca.type, + "order": ca.order + }) + try: owner_membership = Membership.objects.get(project=project, user=project.owner) self.default_owner_role = owner_membership.role.slug except Membership.DoesNotExist: self.default_owner_role = self.roles[0].get("slug", None) + self.tags = project.tags + self.tags_colors = project.tags_colors + self.is_looking_for_people = project.is_looking_for_people + self.looking_for_people_note = project.looking_for_people_note + def apply_to_project(self, project): Role = apps.get_model("users", "Role") @@ -1022,4 +1086,45 @@ class ProjectTemplate(models.Model): project.default_severity = Severity.objects.get(name=self.default_options["severity"], project=project) + for ca in self.epic_custom_attributes: + EpicCustomAttribute.objects.create( + name=ca["name"], + description=ca["description"], + type=ca["type"], + order=ca["order"], + project=project + ) + + for ca in self.us_custom_attributes: + UserStoryCustomAttribute.objects.create( + name=ca["name"], + description=ca["description"], + type=ca["type"], + order=ca["order"], + project=project + ) + + for ca in self.task_custom_attributes: + TaskCustomAttribute.objects.create( + name=ca["name"], + description=ca["description"], + type=ca["type"], + order=ca["order"], + project=project + ) + + for ca in self.issue_custom_attributes: + IssueCustomAttribute.objects.create( + name=ca["name"], + description=ca["description"], + type=ca["type"], + order=ca["order"], + project=project + ) + + project.tags = self.tags + project.tags_colors = self.tags_colors + project.is_looking_for_people = self.is_looking_for_people + project.looking_for_people_note = self.looking_for_people_note + return project diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py index 7c10b5c2..cda5c17c 100644 --- a/taiga/projects/permissions.py +++ b/taiga/projects/permissions.py @@ -83,6 +83,7 @@ class ProjectPermission(TaigaResourcePermission): edit_tag_perms = IsProjectAdmin() delete_tag_perms = IsProjectAdmin() mix_tags_perms = IsProjectAdmin() + duplicate_perms = IsAuthenticated() & HasProjectPerm('view_project') class ProjectFansPermission(TaigaResourcePermission): diff --git a/taiga/projects/services/__init__.py b/taiga/projects/services/__init__.py index 3be0a9d8..b5e231c4 100644 --- a/taiga/projects/services/__init__.py +++ b/taiga/projects/services/__init__.py @@ -52,6 +52,7 @@ from .projects import check_if_project_can_be_transfered from .projects import check_if_project_is_out_of_owner_limits from .projects import orphan_project from .projects import delete_project +from .projects import duplicate_project from .stats import get_stats_for_project_issues from .stats import get_stats_for_project diff --git a/taiga/projects/services/members.py b/taiga/projects/services/members.py index 52c62eca..6164ed24 100644 --- a/taiga/projects/services/members.py +++ b/taiga/projects/services/members.py @@ -17,7 +17,7 @@ # along with this program. If not, see . from taiga.base.exceptions import ValidationError -from taiga.base.utils import db, text +from taiga.base.utils import db from taiga.users.models import User from django.conf import settings @@ -84,9 +84,9 @@ def project_has_valid_admins(project, exclude_user=None): def can_user_leave_project(user, project): membership = project.memberships.get(user=user) if not membership.is_admin: - return True + return True - #The user can't leave if is the real owner of the project + # The user can't leave if is the real owner of the project if project.owner == user: return False diff --git a/taiga/projects/services/projects.py b/taiga/projects/services/projects.py index 2bd31d94..3f97d877 100644 --- a/taiga/projects/services/projects.py +++ b/taiga/projects/services/projects.py @@ -19,7 +19,12 @@ from django.apps import apps from django.utils.translation import ugettext as _ from taiga.celery import app +from taiga.base.api.utils import get_object_or_404 +from taiga.permissions import services as permissions_services +from taiga.projects.history.services import take_snapshot + from .. import choices +from ..apps import connect_projects_signals, disconnect_projects_signals ERROR_MAX_PUBLIC_PROJECTS_MEMBERSHIPS = 'max_public_projects_memberships' ERROR_MAX_PRIVATE_PROJECTS_MEMBERSHIPS = 'max_private_projects_memberships' @@ -27,7 +32,9 @@ ERROR_MAX_PUBLIC_PROJECTS = 'max_public_projects' ERROR_MAX_PRIVATE_PROJECTS = 'max_private_projects' ERROR_PROJECT_WITHOUT_OWNER = 'project_without_owner' -def check_if_project_privacity_can_be_changed(project, + +def check_if_project_privacity_can_be_changed( + project, current_memberships=None, current_private_projects=None, current_public_projects=None): @@ -91,7 +98,7 @@ def check_if_project_can_be_created_or_updated(project): if project.is_private: current_projects = project.owner.owned_projects.filter(is_private=True).count() max_projects = project.owner.max_private_projects - error_project_exceeded = _("You can't have more private projects") + error_project_exceeded = _("You can't have more private projects") current_memberships = project.memberships.count() or 1 max_memberships = project.owner.max_memberships_private_projects @@ -131,7 +138,7 @@ def check_if_project_can_be_transfered(project, new_owner): if project.is_private: current_projects = new_owner.owned_projects.filter(is_private=True).count() max_projects = new_owner.max_private_projects - error_project_exceeded = _("You can't have more private projects") + error_project_exceeded = _("You can't have more private projects") current_memberships = project.memberships.count() max_memberships = new_owner.max_memberships_private_projects @@ -154,7 +161,8 @@ def check_if_project_can_be_transfered(project, new_owner): return (True, None) -def check_if_project_is_out_of_owner_limits(project, +def check_if_project_is_out_of_owner_limits( + project, current_memberships=None, current_private_projects=None, current_public_projects=None): @@ -219,3 +227,47 @@ def delete_project(project_id): project.delete_related_content() project.delete() + + +def duplicate_project(project, **new_project_extra_args): + owner = new_project_extra_args.get("owner") + users = new_project_extra_args.pop("users") + + disconnect_projects_signals() + Project = apps.get_model("projects", "Project") + new_project = Project.objects.create(**new_project_extra_args) + connect_projects_signals() + + permissions_services.set_base_permissions_for_project(new_project) + + # Cloning the structure from the old project using templates + Template = apps.get_model("projects", "ProjectTemplate") + template = Template() + template.load_data_from_project(project) + template.apply_to_project(new_project) + new_project.creation_template = project.creation_template + new_project.save() + + # Creating the membership for the new owner + Membership = apps.get_model("projects", "Membership") + Membership.objects.create( + user=owner, + is_admin=True, + role=new_project.roles.get(slug=template.default_owner_role), + project=new_project + ) + + # Creating the extra memberships + for user in users: + project_memberships = project.memberships.exclude(user_id=owner.id) + membership = get_object_or_404(project_memberships, user_id=user["id"]) + Membership.objects.create( + user=membership.user, + is_admin=membership.is_admin, + role=new_project.roles.get(slug=membership.role.slug), + project=new_project + ) + + # Take initial snapshot for the project + take_snapshot(new_project, user=owner) + return new_project diff --git a/taiga/projects/signals.py b/taiga/projects/signals.py index b94e5cda..6a0c016c 100644 --- a/taiga/projects/signals.py +++ b/taiga/projects/signals.py @@ -20,9 +20,6 @@ from django.apps import apps from django.conf import settings from taiga.projects.notifications.services import create_notify_policy_if_not_exists -from taiga.base.utils.db import get_typename_for_model_class - -from easy_thumbnails.files import get_thumbnailer #################################### @@ -58,6 +55,13 @@ def project_post_save(sender, instance, created, **kwargs): if template is None: ProjectTemplate = apps.get_model("projects", "ProjectTemplate") template = ProjectTemplate.objects.get(slug=settings.DEFAULT_PROJECT_TEMPLATE) + + if instance.tags: + template.tags = instance.tags + + if instance.tags_colors: + template.tags_colors = instance.tags_colors + template.apply_to_project(instance) instance.save() diff --git a/taiga/projects/tagging/models.py b/taiga/projects/tagging/models.py index 970dae40..5ad63b39 100644 --- a/taiga/projects/tagging/models.py +++ b/taiga/projects/tagging/models.py @@ -30,7 +30,7 @@ class TaggedMixin(models.Model): abstract = True -class TagsColorsdMixin(models.Model): +class TagsColorsMixin(models.Model): tags_colors = ArrayField(ArrayField(models.TextField(null=True, blank=True), size=2), null=True, blank=True, default=[], verbose_name=_("tags colors")) diff --git a/taiga/projects/utils.py b/taiga/projects/utils.py index fbec4853..12930f8a 100644 --- a/taiga/projects/utils.py +++ b/taiga/projects/utils.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . + def attach_members(queryset, as_field="members_attr"): """Attach a json members representation to each object of the queryset. diff --git a/taiga/projects/validators.py b/taiga/projects/validators.py index 32bafe35..41a1a23e 100644 --- a/taiga/projects/validators.py +++ b/taiga/projects/validators.py @@ -277,9 +277,25 @@ class ProjectTemplateValidator(validators.ModelValidator): ###################################################### -# Project order bulk serializers +# Project order bulk validators ###################################################### class UpdateProjectOrderBulkValidator(ProjectExistsValidator, validators.Validator): project_id = serializers.IntegerField() order = serializers.IntegerField() + + +###################################################### +# Project duplication validator +###################################################### + + +class DuplicateProjectMemberValidator(validators.Validator): + id = serializers.CharField() + + +class DuplicateProjectValidator(validators.Validator): + name = serializers.CharField() + description = serializers.CharField() + is_private = serializers.BooleanField() + users = DuplicateProjectMemberValidator(many=True) diff --git a/taiga/users/services.py b/taiga/users/services.py index 14a3802a..d4d520a7 100644 --- a/taiga/users/services.py +++ b/taiga/users/services.py @@ -582,7 +582,7 @@ def get_voted_list(for_user, from_user, type=None, q=None): ] -def has_available_slot_for_import_new_project(owner, is_private, total_memberships): +def has_available_slot_for_new_project(owner, is_private, total_memberships): if is_private: current_projects = owner.owned_projects.filter(is_private=True).count() max_projects = owner.max_private_projects diff --git a/tests/factories.py b/tests/factories.py index 5d3c65c6..55dafd87 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -66,6 +66,10 @@ class ProjectTemplateFactory(Factory): priorities = [] severities = [] roles = [] + epic_custom_attributes = [] + us_custom_attributes = [] + task_custom_attributes = [] + issue_custom_attributes = [] default_owner_role = "tester" diff --git a/tests/integration/resources_permissions/test_projects_resource.py b/tests/integration/resources_permissions/test_projects_resource.py index 46059004..de6a7a32 100644 --- a/tests/integration/resources_permissions/test_projects_resource.py +++ b/tests/integration/resources_permissions/test_projects_resource.py @@ -97,18 +97,22 @@ def data(): f.MembershipFactory(project=m.public_project, user=m.project_owner, + role__project=m.public_project, is_admin=True) f.MembershipFactory(project=m.private_project1, user=m.project_owner, + role__project=m.private_project1, is_admin=True) f.MembershipFactory(project=m.private_project2, user=m.project_owner, + role__project=m.private_project2, is_admin=True) f.MembershipFactory(project=m.blocked_project, user=m.project_owner, + role__project=m.blocked_project, is_admin=True) ContentType = apps.get_model("contenttypes", "ContentType") @@ -664,3 +668,33 @@ def test_project_list_with_discover_mode_enabled(client, data): projects_data = json.loads(response.content.decode('utf-8')) assert len(projects_data) == 2 assert response.status_code == 200 + + +def test_project_duplicate(client, data): + public_url = reverse('projects-duplicate', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-duplicate', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-duplicate', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-duplicate', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + + data = json.dumps({ + "name": "test", + "description": "description", + "is_private": True, + "users": [] + }) + + results = helper_test_http_method(client, 'post', public_url, data, users) + assert results == [401, 201, 201, 201] + results = helper_test_http_method(client, 'post', private1_url, data, users) + assert results == [401, 201, 201, 201] + results = helper_test_http_method(client, 'post', private2_url, data, users) + assert results == [404, 404, 201, 201] + results = helper_test_http_method(client, 'post', blocked_url, data, users) + assert results == [404, 404, 451, 451] diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index bd4fbac8..e4945150 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -33,6 +33,7 @@ from taiga.projects.tasks.models import Task from taiga.projects.issues.models import Issue from taiga.projects.epics.models import Epic from taiga.projects.choices import BLOCKED_BY_DELETING +from taiga.timeline.service import get_project_timeline from .. import factories as f from ..utils import DUMMY_BMP_DATA @@ -2095,3 +2096,182 @@ def test_color_tags_project_fired_on_element_update_respecting_color(): user_story.save() project = Project.objects.get(id=user_story.project.id) assert ["tag", "#123123"] in project.tags_colors + + +def test_duplicate_project(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create( + owner=user, + is_looking_for_people=True, + looking_for_people_note="Looking lookin", + ) + project.tags = ["tag1", "tag2"] + project.tags_colors = [["t1", "#abcbca"], ["t2", "#aaabbb"]] + + 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_issue_status = f.IssueStatusFactory.create(project=project) + project.default_points = f.PointsFactory.create(project=project) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + + f.EpicCustomAttributeFactory(project=project) + f.UserStoryCustomAttributeFactory(project=project) + f.TaskCustomAttributeFactory(project=project) + f.IssueCustomAttributeFactory(project=project) + + project.save() + + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + extra_membership = f.MembershipFactory.create(project=project, is_admin=True, role__project=project) + membership = f.MembershipFactory.create(project=project, role=role) + url = reverse("projects-duplicate", args=(project.id,)) + + data = { + "name": "test", + "description": "description", + "is_private": True, + "users": [{ + "id": extra_membership.user.id + }] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + new_project = Project.objects.get(id=response.data["id"]) + + assert new_project.owner_id == user.id + owner_membership = new_project.memberships.get(user_id=user.id) + assert owner_membership.user_id == user.id + assert owner_membership.is_admin == True + assert project.memberships.get(user_id=extra_membership.user.id).role.slug == extra_membership.role.slug + assert set(project.tags) == set(new_project.tags) + assert set(dict(project.tags_colors).keys()) == set(dict(new_project.tags_colors).keys()) + + attributes = [ + "is_epics_activated", "is_backlog_activated", "is_kanban_activated", "is_wiki_activated", + "is_issues_activated", "videoconferences", "videoconferences_extra_data", + "is_looking_for_people", "looking_for_people_note", "is_private" + ] + + for attr in attributes: + assert getattr(project, attr) == getattr(new_project, attr) + + fk_attributes = [ + "default_epic_status", "default_us_status", "default_task_status", "default_issue_status", + "default_issue_type", "default_points", "default_priority", "default_severity", + ] + + for attr in fk_attributes: + assert getattr(project, attr).name == getattr(new_project, attr).name + + related_attributes = [ + "epic_statuses", "us_statuses", "task_statuses","issue_statuses", + "issue_types", "points", "priorities", "severities", + "epiccustomattributes", "userstorycustomattributes", "taskcustomattributes", "issuecustomattributes", + "roles" + ] + for attr in related_attributes: + from_names = set(getattr(project, attr).all().values_list("name", flat=True)) + to_names = set(getattr(new_project, attr).all().values_list("name", flat=True)) + assert from_names == to_names + + timeline = list(get_project_timeline(new_project)) + assert len(timeline) == 2 + assert timeline[0].event_type == "projects.project.create" + assert timeline[1].event_type == "projects.membership.create" + + +def test_duplicate_private_project_without_enough_private_projects_slots(client): + user = f.UserFactory.create(max_private_projects=0) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(user=user, project=project, is_admin=True) + + url = reverse("projects-duplicate", args=(project.id,)) + data = { + "name": "test", + "description": "description", + "is_private": True, + "users": [] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "can't have more private projects" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "1" + assert response["Taiga-Info-Project-Is-Private"] == "True" + + +def test_duplicate_private_project_without_enough_memberships_slots(client): + user = f.UserFactory.create(max_memberships_private_projects=1) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(user=user, project=project, is_admin=True, role__project=project) + extra_membership = f.MembershipFactory.create(project=project, is_admin=True, role__project=project) + + url = reverse("projects-duplicate", args=(project.id,)) + data = { + "name": "test", + "description": "description", + "is_private": True, + "users": [{ + "id": extra_membership.user_id + }] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "current limit of memberships" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "2" + assert response["Taiga-Info-Project-Is-Private"] == "True" + + +def test_duplicate_public_project_without_enough_public_projects_slots(client): + user = f.UserFactory.create(max_public_projects=0) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(user=user, project=project, is_admin=True) + + url = reverse("projects-duplicate", args=(project.id,)) + data = { + "name": "test", + "description": "description", + "is_private": False, + "users": [] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "can't have more public projects" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "1" + assert response["Taiga-Info-Project-Is-Private"] == "False" + + +def test_duplicate_public_project_without_enough_memberships_slots(client): + user = f.UserFactory.create(max_memberships_public_projects=1) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(user=user, project=project, is_admin=True, role__project=project) + extra_membership = f.MembershipFactory.create(project=project, is_admin=True, role__project=project) + + url = reverse("projects-duplicate", args=(project.id,)) + data = { + "name": "test", + "description": "description", + "is_private": False, + "users": [{ + "id": extra_membership.user_id + }] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "current limit of memberships" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "2" + assert response["Taiga-Info-Project-Is-Private"] == "False"