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"