Merge pull request #870 from taigaio/us/4662/duplicate-project
US 4662: Duplicate projectremotes/origin/github-import
commit
e91496b3c6
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
######################################################
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"))
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue