US 4662: Duplicate project

remotes/origin/github-import
Alejandro Alonso 2016-10-25 14:58:15 +02:00 committed by Jesús Espino
parent 9bf325d5f9
commit b635055784
21 changed files with 559 additions and 63 deletions

View File

@ -6,7 +6,8 @@
### Features ### Features
- Contact with the project: if the projects have this module enabled Taiga users can contact them. - 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. - 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: - i18n:
- Add japanese (ja) translation. - Add japanese (ja) translation.
- Add chinese simplified (zh-Hans) translation. - Add chinese simplified (zh-Hans) translation.

View File

@ -104,7 +104,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
is_private = data.get('is_private', False) 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 = len([m for m in data.get("memberships", []) if m.get("email", None) != data["owner"]])
total_memberships = total_memberships + 1 # 1 is the 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, self.request.user,
is_private, is_private,
total_memberships total_memberships
@ -344,7 +344,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
total_memberships = len([m for m in dump.get("memberships", []) total_memberships = len([m for m in dump.get("memberships", [])
if m.get("email", None) != dump["owner"]]) if m.get("email", None) != dump["owner"]])
total_memberships = total_memberships + 1 # 1 is the 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, user,
is_private, is_private,
total_memberships total_memberships

View File

@ -700,7 +700,7 @@ def _validate_if_owner_have_enought_space_to_this_project(owner, data):
if m.get("email", None) != data["owner"]]) if m.get("email", None) != data["owner"]])
total_memberships = total_memberships + 1 # 1 is the 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, owner,
is_private, is_private,
total_memberships total_memberships

View File

@ -52,6 +52,7 @@ from taiga.projects.mixins.ordering import BulkUpdateOrderMixin
from taiga.projects.tasks.models import Task from taiga.projects.tasks.models import Task
from taiga.projects.tagging.api import TagsColorsResourceMixin from taiga.projects.tagging.api import TagsColorsResourceMixin
from taiga.projects.userstories.models import UserStory, RolePoints from taiga.projects.userstories.models import UserStory, RolePoints
from taiga.users import services as users_services
from . import filters as project_filters from . import filters as project_filters
from . import models from . import models
@ -218,7 +219,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
if not template_description: if not template_description:
raise response.BadRequest(_("Not valid 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) template_slug = slugify_uniquely(template_name, models.ProjectTemplate)
project = self.get_object() project = self.get_object()
@ -392,6 +393,42 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
services.reject_project_transfer(project, request.user, token, reason) services.reject_project_transfer(project, request.user, token, reason)
return response.Ok() 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): def _raise_if_blocked(self, project):
if self.is_blocked(project): if self.is_blocked(project):
raise exc.Blocked(_("Blocked element")) raise exc.Blocked(_("Blocked element"))
@ -632,7 +669,6 @@ class IssueStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
return super().create(request, *args, **kwargs) return super().create(request, *args, **kwargs)
###################################################### ######################################################
## Project Template ## Project Template
###################################################### ######################################################

View File

@ -58,6 +58,7 @@ def connect_memberships_signals():
sender=apps.get_model("projects", "Membership"), sender=apps.get_model("projects", "Membership"),
dispatch_uid='create-notify-policy') dispatch_uid='create-notify-policy')
def disconnect_memberships_signals(): def disconnect_memberships_signals():
signals.pre_delete.disconnect(sender=apps.get_model("projects", "Membership"), signals.pre_delete.disconnect(sender=apps.get_model("projects", "Membership"),
dispatch_uid='membership_pre_delete') 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") dispatch_uid="try_to_close_or_open_user_stories_when_edit_us_status")
## Tasks Statuses Signals ## Tasks Statuses Signals
def connect_task_status_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") dispatch_uid="try_to_close_or_open_user_stories_when_edit_task_status")
class ProjectsAppConfig(AppConfig): class ProjectsAppConfig(AppConfig):
name = "taiga.projects" name = "taiga.projects"
verbose_name = "Projects" verbose_name = "Projects"

View File

@ -16,14 +16,10 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.utils.translation import ugettext_lazy as _
from taiga.base.api import ModelCrudViewSet from taiga.base.api import ModelCrudViewSet
from taiga.base.api import ModelUpdateRetrieveViewSet from taiga.base.api import ModelUpdateRetrieveViewSet
from taiga.base.api.mixins import BlockedByProjectMixin from taiga.base.api.mixins import BlockedByProjectMixin
from taiga.base import exceptions as exc
from taiga.base import filters from taiga.base import filters
from taiga.base import response
from taiga.projects.mixins.ordering import BulkUpdateOrderMixin from taiga.projects.mixins.ordering import BulkUpdateOrderMixin
from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.history.mixins import HistoryResourceMixin

View File

@ -564,7 +564,11 @@
"comment_wiki_page" "comment_wiki_page"
] ]
} }
] ],
"epic_custom_attributes": [],
"us_custom_attributes": [],
"task_custom_attributes": [],
"issue_custom_attributes": []
} }
}, },
{ {
@ -1132,7 +1136,11 @@
"comment_wiki_page" "comment_wiki_page"
] ]
} }
] ],
"epic_custom_attributes": [],
"us_custom_attributes": [],
"task_custom_attributes": [],
"issue_custom_attributes": []
} }
} }
] ]

View File

@ -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'),
),
]

View File

@ -32,8 +32,12 @@ from django_pglocks import advisory_lock
from taiga.base.db.models.fields import JSONField from taiga.base.db.models.fields import JSONField
from taiga.base.utils.time import timestamp_ms 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 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.files import get_file_path
from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.slug import slugify_uniquely
from taiga.base.utils.slug import slugify_uniquely_for_queryset from taiga.base.utils.slug import slugify_uniquely_for_queryset
@ -82,10 +86,10 @@ class Membership(models.Model):
null=True, blank=True) null=True, blank=True)
invitation_extra_text = models.TextField(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, user_order = models.BigIntegerField(default=timestamp_ms, null=False, blank=False,
verbose_name=_("user order")) verbose_name=_("user order"))
class Meta: class Meta:
verbose_name = "membership" verbose_name = "membership"
@ -106,9 +110,9 @@ class Membership(models.Model):
class ProjectDefaults(models.Model): class ProjectDefaults(models.Model):
default_epic_status = models.OneToOneField("projects.EpicStatus", default_epic_status = models.OneToOneField("projects.EpicStatus",
on_delete=models.SET_NULL, related_name="+", on_delete=models.SET_NULL, related_name="+",
null=True, blank=True, null=True, blank=True,
verbose_name=_("default epic status")) verbose_name=_("default epic status"))
default_us_status = models.OneToOneField("projects.UserStoryStatus", default_us_status = models.OneToOneField("projects.UserStoryStatus",
on_delete=models.SET_NULL, related_name="+", on_delete=models.SET_NULL, related_name="+",
null=True, blank=True, null=True, blank=True,
@ -139,7 +143,7 @@ class ProjectDefaults(models.Model):
abstract = True 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, name = models.CharField(max_length=250, null=False, blank=False,
verbose_name=_("name")) verbose_name=_("name"))
slug = models.SlugField(max_length=250, unique=True, null=False, blank=True, 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")) verbose_name=_("description"))
logo = models.FileField(upload_to=get_project_logo_file_path, logo = models.FileField(upload_to=get_project_logo_file_path,
max_length=500, null=True, blank=True, max_length=500, null=True, blank=True,
verbose_name=_("logo")) verbose_name=_("logo"))
created_date = models.DateTimeField(null=False, blank=False, created_date = models.DateTimeField(null=False, blank=False,
verbose_name=_("created date"), verbose_name=_("created date"),
@ -180,7 +184,7 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model):
choices=choices.VIDEOCONFERENCES_CHOICES, choices=choices.VIDEOCONFERENCES_CHOICES,
verbose_name=_("videoconference system")) verbose_name=_("videoconference system"))
videoconferences_extra_data = models.CharField(max_length=250, null=True, blank=True, 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", creation_template = models.ForeignKey("projects.ProjectTemplate",
related_name="projects", null=True, 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")) null=True, blank=True, default=[], verbose_name=_("user permissions"))
is_featured = models.BooleanField(default=False, null=False, blank=True, 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, 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, looking_for_people_note = models.TextField(default="", null=False, blank=True,
verbose_name=_("loking for people note")) verbose_name=_("loking for people note"))
@ -218,36 +222,39 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model):
verbose_name=_("project transfer token")) verbose_name=_("project transfer token"))
blocked_code = models.CharField(null=True, blank=True, max_length=255, blocked_code = models.CharField(null=True, blank=True, max_length=255,
choices=choices.BLOCKING_CODES + settings.EXTRA_BLOCKING_CODES, default=None, choices=choices.BLOCKING_CODES + settings.EXTRA_BLOCKING_CODES,
verbose_name=_("blocked code")) default=None, verbose_name=_("blocked code"))
# Totals:
#Totals:
totals_updated_datetime = models.DateTimeField(null=False, blank=False, auto_now_add=True, 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, total_fans = models.PositiveIntegerField(null=False, blank=False, default=0,
verbose_name=_("count"), db_index=True) verbose_name=_("count"), db_index=True)
total_fans_last_week = models.PositiveIntegerField(null=False, blank=False, default=0, 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, 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, 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, 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, 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, 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, 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 _importing = None
@ -303,13 +310,13 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model):
self.total_fans = qs.count() 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() 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() 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() self.total_fans_last_year = qs_year.count()
tl_model = apps.get_model("timeline", "Timeline") 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) qs = tl_model.objects.filter(namespace=namespace)
self.total_activity = qs.count() 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() 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() 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() self.total_activity_last_year = qs_year.count()
if save: if save:
@ -358,7 +365,7 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model):
policy = model_cls.objects.create( policy = model_cls.objects.create(
project=self, project=self,
user=user, user=user,
notify_level= NotifyLevel.involved) notify_level=NotifyLevel.involved)
del self.cached_notify_policies 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 # NOTE: Remember to update code in taiga.projects.admin.ProjectAdmin.delete_selected
from taiga.events.apps import (connect_events_signals, from taiga.events.apps import (connect_events_signals,
disconnect_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, from taiga.projects.tasks.apps import (connect_all_tasks_signals,
disconnect_all_tasks_signals) disconnect_all_tasks_signals)
from taiga.projects.userstories.apps import (connect_all_userstories_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_memberships_signals)
disconnect_events_signals() disconnect_events_signals()
disconnect_all_epics_signals()
disconnect_all_issues_signals() disconnect_all_issues_signals()
disconnect_all_tasks_signals() disconnect_all_tasks_signals()
disconnect_all_userstories_signals() disconnect_all_userstories_signals()
disconnect_memberships_signals() disconnect_memberships_signals()
try: try:
self.epics.all().delete()
self.tasks.all().delete() self.tasks.all().delete()
self.user_stories.all().delete() self.user_stories.all().delete()
self.issues.all().delete() self.issues.all().delete()
@ -486,12 +497,13 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model):
connect_all_issues_signals() connect_all_issues_signals()
connect_all_tasks_signals() connect_all_tasks_signals()
connect_all_userstories_signals() connect_all_userstories_signals()
connect_all_epics_signals()
connect_memberships_signals() connect_memberships_signals()
class ProjectModulesConfig(models.Model): class ProjectModulesConfig(models.Model):
project = models.OneToOneField("Project", null=False, blank=False, 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")) config = JSONField(null=True, blank=True, verbose_name=_("modules config"))
class Meta: class Meta:
@ -544,7 +556,7 @@ class UserStoryStatus(models.Model):
is_closed = models.BooleanField(default=False, null=False, blank=True, is_closed = models.BooleanField(default=False, null=False, blank=True,
verbose_name=_("is closed")) verbose_name=_("is closed"))
is_archived = models.BooleanField(default=False, null=False, blank=True, 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", color = models.CharField(max_length=20, null=False, blank=False, default="#999999",
verbose_name=_("color")) verbose_name=_("color"))
wip_limit = models.IntegerField(null=True, blank=True, default=None, wip_limit = models.IntegerField(null=True, blank=True, default=None,
@ -718,7 +730,7 @@ class IssueType(models.Model):
return self.name return self.name
class ProjectTemplate(models.Model): class ProjectTemplate(TaggedMixin, TagsColorsMixin, models.Model):
name = models.CharField(max_length=250, null=False, blank=False, name = models.CharField(max_length=250, null=False, blank=False,
verbose_name=_("name")) verbose_name=_("name"))
slug = models.SlugField(max_length=250, null=False, blank=True, 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, description = models.TextField(null=False, blank=False,
verbose_name=_("description")) verbose_name=_("description"))
order = models.BigIntegerField(default=timestamp_ms, null=False, blank=False, 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, created_date = models.DateTimeField(null=False, blank=False,
verbose_name=_("created date"), verbose_name=_("created date"),
default=timezone.now) default=timezone.now)
@ -747,11 +759,15 @@ class ProjectTemplate(models.Model):
verbose_name=_("active wiki panel")) verbose_name=_("active wiki panel"))
is_issues_activated = models.BooleanField(default=True, null=False, blank=True, is_issues_activated = models.BooleanField(default=True, null=False, blank=True,
verbose_name=_("active issues panel")) 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, videoconferences = models.CharField(max_length=250, null=True, blank=True,
choices=choices.VIDEOCONFERENCES_CHOICES, choices=choices.VIDEOCONFERENCES_CHOICES,
verbose_name=_("videoconference system")) verbose_name=_("videoconference system"))
videoconferences_extra_data = models.CharField(max_length=250, null=True, blank=True, 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")) default_options = JSONField(null=True, blank=True, verbose_name=_("default options"))
epic_statuses = JSONField(null=True, blank=True, verbose_name=_("epic statuses")) 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")) priorities = JSONField(null=True, blank=True, verbose_name=_("priorities"))
severities = JSONField(null=True, blank=True, verbose_name=_("severities")) severities = JSONField(null=True, blank=True, verbose_name=_("severities"))
roles = JSONField(null=True, blank=True, verbose_name=_("roles")) 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 _importing = None
class Meta: class Meta:
@ -779,6 +800,8 @@ class ProjectTemplate(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self._importing or not self.modified_date: if not self._importing or not self.modified_date:
self.modified_date = timezone.now() self.modified_date = timezone.now()
if not self.slug:
self.slug = slugify_uniquely(self.name, self.__class__)
super().save(*args, **kwargs) super().save(*args, **kwargs)
def load_data_from_project(self, project): def load_data_from_project(self, project):
@ -886,12 +909,53 @@ class ProjectTemplate(models.Model):
"computable": role.computable "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: try:
owner_membership = Membership.objects.get(project=project, user=project.owner) owner_membership = Membership.objects.get(project=project, user=project.owner)
self.default_owner_role = owner_membership.role.slug self.default_owner_role = owner_membership.role.slug
except Membership.DoesNotExist: except Membership.DoesNotExist:
self.default_owner_role = self.roles[0].get("slug", None) 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): def apply_to_project(self, project):
Role = apps.get_model("users", "Role") 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.default_severity = Severity.objects.get(name=self.default_options["severity"],
project=project) 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 return project

View File

@ -83,6 +83,7 @@ class ProjectPermission(TaigaResourcePermission):
edit_tag_perms = IsProjectAdmin() edit_tag_perms = IsProjectAdmin()
delete_tag_perms = IsProjectAdmin() delete_tag_perms = IsProjectAdmin()
mix_tags_perms = IsProjectAdmin() mix_tags_perms = IsProjectAdmin()
duplicate_perms = IsAuthenticated() & HasProjectPerm('view_project')
class ProjectFansPermission(TaigaResourcePermission): class ProjectFansPermission(TaigaResourcePermission):

View File

@ -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 check_if_project_is_out_of_owner_limits
from .projects import orphan_project from .projects import orphan_project
from .projects import delete_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_issues
from .stats import get_stats_for_project from .stats import get_stats_for_project

View File

@ -17,7 +17,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from taiga.base.exceptions import ValidationError 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 taiga.users.models import User
from django.conf import settings 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): def can_user_leave_project(user, project):
membership = project.memberships.get(user=user) membership = project.memberships.get(user=user)
if not membership.is_admin: 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: if project.owner == user:
return False return False

View File

@ -19,7 +19,12 @@
from django.apps import apps from django.apps import apps
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from taiga.celery import app 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 .. import choices
from ..apps import connect_projects_signals, disconnect_projects_signals
ERROR_MAX_PUBLIC_PROJECTS_MEMBERSHIPS = 'max_public_projects_memberships' ERROR_MAX_PUBLIC_PROJECTS_MEMBERSHIPS = 'max_public_projects_memberships'
ERROR_MAX_PRIVATE_PROJECTS_MEMBERSHIPS = 'max_private_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_MAX_PRIVATE_PROJECTS = 'max_private_projects'
ERROR_PROJECT_WITHOUT_OWNER = 'project_without_owner' 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_memberships=None,
current_private_projects=None, current_private_projects=None,
current_public_projects=None): current_public_projects=None):
@ -91,7 +98,7 @@ def check_if_project_can_be_created_or_updated(project):
if project.is_private: if project.is_private:
current_projects = project.owner.owned_projects.filter(is_private=True).count() current_projects = project.owner.owned_projects.filter(is_private=True).count()
max_projects = project.owner.max_private_projects 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 current_memberships = project.memberships.count() or 1
max_memberships = project.owner.max_memberships_private_projects 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: if project.is_private:
current_projects = new_owner.owned_projects.filter(is_private=True).count() current_projects = new_owner.owned_projects.filter(is_private=True).count()
max_projects = new_owner.max_private_projects 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() current_memberships = project.memberships.count()
max_memberships = new_owner.max_memberships_private_projects 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) 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_memberships=None,
current_private_projects=None, current_private_projects=None,
current_public_projects=None): current_public_projects=None):
@ -219,3 +227,47 @@ def delete_project(project_id):
project.delete_related_content() project.delete_related_content()
project.delete() 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

View File

@ -20,9 +20,6 @@ from django.apps import apps
from django.conf import settings from django.conf import settings
from taiga.projects.notifications.services import create_notify_policy_if_not_exists 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: if template is None:
ProjectTemplate = apps.get_model("projects", "ProjectTemplate") ProjectTemplate = apps.get_model("projects", "ProjectTemplate")
template = ProjectTemplate.objects.get(slug=settings.DEFAULT_PROJECT_TEMPLATE) 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) template.apply_to_project(instance)
instance.save() instance.save()

View File

@ -30,7 +30,7 @@ class TaggedMixin(models.Model):
abstract = True abstract = True
class TagsColorsdMixin(models.Model): class TagsColorsMixin(models.Model):
tags_colors = ArrayField(ArrayField(models.TextField(null=True, blank=True), size=2), tags_colors = ArrayField(ArrayField(models.TextField(null=True, blank=True), size=2),
null=True, blank=True, default=[], verbose_name=_("tags colors")) null=True, blank=True, default=[], verbose_name=_("tags colors"))

View File

@ -17,6 +17,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
def attach_members(queryset, as_field="members_attr"): def attach_members(queryset, as_field="members_attr"):
"""Attach a json members representation to each object of the queryset. """Attach a json members representation to each object of the queryset.

View File

@ -277,9 +277,25 @@ class ProjectTemplateValidator(validators.ModelValidator):
###################################################### ######################################################
# Project order bulk serializers # Project order bulk validators
###################################################### ######################################################
class UpdateProjectOrderBulkValidator(ProjectExistsValidator, validators.Validator): class UpdateProjectOrderBulkValidator(ProjectExistsValidator, validators.Validator):
project_id = serializers.IntegerField() project_id = serializers.IntegerField()
order = 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)

View File

@ -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: if is_private:
current_projects = owner.owned_projects.filter(is_private=True).count() current_projects = owner.owned_projects.filter(is_private=True).count()
max_projects = owner.max_private_projects max_projects = owner.max_private_projects

View File

@ -66,6 +66,10 @@ class ProjectTemplateFactory(Factory):
priorities = [] priorities = []
severities = [] severities = []
roles = [] roles = []
epic_custom_attributes = []
us_custom_attributes = []
task_custom_attributes = []
issue_custom_attributes = []
default_owner_role = "tester" default_owner_role = "tester"

View File

@ -97,18 +97,22 @@ def data():
f.MembershipFactory(project=m.public_project, f.MembershipFactory(project=m.public_project,
user=m.project_owner, user=m.project_owner,
role__project=m.public_project,
is_admin=True) is_admin=True)
f.MembershipFactory(project=m.private_project1, f.MembershipFactory(project=m.private_project1,
user=m.project_owner, user=m.project_owner,
role__project=m.private_project1,
is_admin=True) is_admin=True)
f.MembershipFactory(project=m.private_project2, f.MembershipFactory(project=m.private_project2,
user=m.project_owner, user=m.project_owner,
role__project=m.private_project2,
is_admin=True) is_admin=True)
f.MembershipFactory(project=m.blocked_project, f.MembershipFactory(project=m.blocked_project,
user=m.project_owner, user=m.project_owner,
role__project=m.blocked_project,
is_admin=True) is_admin=True)
ContentType = apps.get_model("contenttypes", "ContentType") 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')) projects_data = json.loads(response.content.decode('utf-8'))
assert len(projects_data) == 2 assert len(projects_data) == 2
assert response.status_code == 200 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]

View File

@ -33,6 +33,7 @@ from taiga.projects.tasks.models import Task
from taiga.projects.issues.models import Issue from taiga.projects.issues.models import Issue
from taiga.projects.epics.models import Epic from taiga.projects.epics.models import Epic
from taiga.projects.choices import BLOCKED_BY_DELETING from taiga.projects.choices import BLOCKED_BY_DELETING
from taiga.timeline.service import get_project_timeline
from .. import factories as f from .. import factories as f
from ..utils import DUMMY_BMP_DATA from ..utils import DUMMY_BMP_DATA
@ -2095,3 +2096,182 @@ def test_color_tags_project_fired_on_element_update_respecting_color():
user_story.save() user_story.save()
project = Project.objects.get(id=user_story.project.id) project = Project.objects.get(id=user_story.project.id)
assert ["tag", "#123123"] in project.tags_colors 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"