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
- 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.

View File

@ -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

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"]])
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

View File

@ -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
######################################################

View File

@ -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"

View File

@ -16,14 +16,10 @@
# 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/>.
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

View File

@ -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": []
}
}
]

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.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
@ -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,
@ -218,10 +222,9 @@ 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)
@ -238,16 +241,20 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model):
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,6 +497,7 @@ 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()
@ -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,
@ -747,6 +759,10 @@ 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"))
@ -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

View File

@ -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):

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 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

View File

@ -17,7 +17,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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
@ -86,7 +86,7 @@ def can_user_leave_project(user, project):
if not membership.is_admin:
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

View File

@ -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):
@ -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

View File

@ -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()

View File

@ -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"))

View File

@ -17,6 +17,7 @@
# 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/>.
def attach_members(queryset, as_field="members_attr"):
"""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):
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)

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

View File

@ -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"

View File

@ -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]

View File

@ -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"