Merge pull request #770 from taigaio/us/1563/epics
US #1563: Epicsremotes/origin/issue/4795/notification_even_they_are_disabled
commit
b8befbc28f
|
@ -3,6 +3,7 @@
|
|||
## 3.0.0 ??? (unreleased)
|
||||
|
||||
### Features
|
||||
- Add Epics.
|
||||
- Include created, modified and finished dates for tasks in CSV reports.
|
||||
- Add gravatar url to Users API endpoint.
|
||||
- ProjectTemplates now are sorted by the attribute 'order'.
|
||||
|
|
|
@ -300,6 +300,7 @@ INSTALLED_APPS = [
|
|||
"taiga.projects.likes",
|
||||
"taiga.projects.votes",
|
||||
"taiga.projects.milestones",
|
||||
"taiga.projects.epics",
|
||||
"taiga.projects.userstories",
|
||||
"taiga.projects.tasks",
|
||||
"taiga.projects.issues",
|
||||
|
|
|
@ -211,14 +211,14 @@ class UpdateModelMixin:
|
|||
Set any attributes on the object that are implicit in the request.
|
||||
"""
|
||||
# pk and/or slug attributes are implicit in the URL.
|
||||
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
||||
lookup = self.kwargs.get(lookup_url_kwarg, None)
|
||||
##lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
||||
##lookup = self.kwargs.get(lookup_url_kwarg, None)
|
||||
pk = self.kwargs.get(self.pk_url_kwarg, None)
|
||||
slug = self.kwargs.get(self.slug_url_kwarg, None)
|
||||
slug_field = slug and self.slug_field or None
|
||||
|
||||
if lookup:
|
||||
setattr(obj, self.lookup_field, lookup)
|
||||
##if lookup:
|
||||
## setattr(obj, self.lookup_field, lookup)
|
||||
|
||||
if pk:
|
||||
setattr(obj, 'pk', pk)
|
||||
|
@ -253,6 +253,27 @@ class DestroyModelMixin:
|
|||
return response.NoContent()
|
||||
|
||||
|
||||
class NestedViewSetMixin(object):
|
||||
def get_queryset(self):
|
||||
return self._filter_queryset_by_parents_lookups(super().get_queryset())
|
||||
|
||||
def _filter_queryset_by_parents_lookups(self, queryset):
|
||||
parents_query_dict = self._get_parents_query_dict()
|
||||
if parents_query_dict:
|
||||
return queryset.filter(**parents_query_dict)
|
||||
else:
|
||||
return queryset
|
||||
|
||||
def _get_parents_query_dict(self):
|
||||
result = {}
|
||||
for kwarg_name in self.kwargs:
|
||||
query_value = self.kwargs.get(kwarg_name)
|
||||
result[kwarg_name] = query_value
|
||||
return result
|
||||
|
||||
|
||||
## TODO: Move blocked mixind out of the base module because is related to project
|
||||
|
||||
class BlockeableModelMixin:
|
||||
def is_blocked(self, obj):
|
||||
raise NotImplementedError("is_blocked must be overridden")
|
||||
|
|
|
@ -134,6 +134,25 @@ class ViewSetMixin(object):
|
|||
return super().check_permissions(request, action=action, obj=obj)
|
||||
|
||||
|
||||
class NestedViewSetMixin(object):
|
||||
def get_queryset(self):
|
||||
return self._filter_queryset_by_parents_lookups(super().get_queryset())
|
||||
|
||||
def _filter_queryset_by_parents_lookups(self, queryset):
|
||||
parents_query_dict = self._get_parents_query_dict()
|
||||
if parents_query_dict:
|
||||
return queryset.filter(**parents_query_dict)
|
||||
else:
|
||||
return queryset
|
||||
|
||||
def _get_parents_query_dict(self):
|
||||
result = {}
|
||||
for kwarg_name in self.kwargs:
|
||||
query_value = self.kwargs.get(kwarg_name)
|
||||
result[kwarg_name] = query_value
|
||||
return result
|
||||
|
||||
|
||||
class ViewSet(ViewSetMixin, views.APIView):
|
||||
"""
|
||||
The base ViewSet class does not provide any actions by default.
|
||||
|
|
|
@ -160,6 +160,10 @@ class CanViewProjectFilterBackend(PermissionBasedFilterBackend):
|
|||
permission = "view_project"
|
||||
|
||||
|
||||
class CanViewEpicsFilterBackend(PermissionBasedFilterBackend):
|
||||
permission = "view_epics"
|
||||
|
||||
|
||||
class CanViewUsFilterBackend(PermissionBasedFilterBackend):
|
||||
permission = "view_us"
|
||||
|
||||
|
@ -198,6 +202,10 @@ class PermissionBasedAttachmentFilterBackend(PermissionBasedFilterBackend):
|
|||
return qs.filter(content_type=ct)
|
||||
|
||||
|
||||
class CanViewEpicAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend):
|
||||
permission = "view_epics"
|
||||
|
||||
|
||||
class CanViewUserStoryAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend):
|
||||
permission = "view_us"
|
||||
|
||||
|
@ -329,10 +337,16 @@ class IsProjectAdminFromWebhookLogFilterBackend(FilterBackend, BaseIsProjectAdmi
|
|||
#####################################################################
|
||||
|
||||
class BaseRelatedFieldsFilter(FilterBackend):
|
||||
def __init__(self, filter_name=None):
|
||||
filter_name = None
|
||||
param_name = None
|
||||
|
||||
def __init__(self, filter_name=None, param_name=None):
|
||||
if filter_name:
|
||||
self.filter_name = filter_name
|
||||
|
||||
if param_name:
|
||||
self.param_name = param_name
|
||||
|
||||
def _prepare_filter_data(self, query_param_value):
|
||||
def _transform_value(value):
|
||||
try:
|
||||
|
@ -347,7 +361,8 @@ class BaseRelatedFieldsFilter(FilterBackend):
|
|||
return list(values)
|
||||
|
||||
def _get_queryparams(self, params):
|
||||
raw_value = params.get(self.filter_name, None)
|
||||
param_name = self.param_name or self.filter_name
|
||||
raw_value = params.get(param_name, None)
|
||||
|
||||
if raw_value:
|
||||
value = self._prepare_filter_data(raw_value)
|
||||
|
|
|
@ -318,7 +318,58 @@ class DRFDefaultRouter(SimpleRouter):
|
|||
return urls
|
||||
|
||||
|
||||
class DefaultRouter(DRFDefaultRouter):
|
||||
class NestedRegistryItem(object):
|
||||
def __init__(self, router, parent_prefix, parent_item=None):
|
||||
self.router = router
|
||||
self.parent_prefix = parent_prefix
|
||||
self.parent_item = parent_item
|
||||
|
||||
def register(self, prefix, viewset, base_name, parents_query_lookups):
|
||||
self.router._register(
|
||||
prefix=self.get_prefix(current_prefix=prefix, parents_query_lookups=parents_query_lookups),
|
||||
viewset=viewset,
|
||||
base_name=base_name,
|
||||
)
|
||||
return NestedRegistryItem(
|
||||
router=self.router,
|
||||
parent_prefix=prefix,
|
||||
parent_item=self
|
||||
)
|
||||
|
||||
def get_prefix(self, current_prefix, parents_query_lookups):
|
||||
return "{0}/{1}".format(
|
||||
self.get_parent_prefix(parents_query_lookups),
|
||||
current_prefix
|
||||
)
|
||||
|
||||
def get_parent_prefix(self, parents_query_lookups):
|
||||
prefix = "/"
|
||||
current_item = self
|
||||
i = len(parents_query_lookups) - 1
|
||||
while current_item:
|
||||
prefix = "{parent_prefix}/(?P<{parent_pk_kwarg_name}>[^/.]+)/{prefix}".format(
|
||||
parent_prefix=current_item.parent_prefix,
|
||||
parent_pk_kwarg_name=parents_query_lookups[i],
|
||||
prefix=prefix
|
||||
)
|
||||
i -= 1
|
||||
current_item = current_item.parent_item
|
||||
return prefix.strip("/")
|
||||
|
||||
|
||||
class NestedRouterMixin:
|
||||
def _register(self, *args, **kwargs):
|
||||
return super().register(*args, **kwargs)
|
||||
|
||||
def register(self, *args, **kwargs):
|
||||
self._register(*args, **kwargs)
|
||||
return NestedRegistryItem(
|
||||
router=self,
|
||||
parent_prefix=self.registry[-1][0]
|
||||
)
|
||||
|
||||
|
||||
class DefaultRouter(NestedRouterMixin, DRFDefaultRouter):
|
||||
pass
|
||||
|
||||
__all__ = ["DefaultRouter"]
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
import random
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
DEFAULT_PREDEFINED_COLORS = (
|
||||
"#fce94f",
|
||||
"#edd400",
|
||||
"#c4a000",
|
||||
"#8ae234",
|
||||
"#73d216",
|
||||
"#4e9a06",
|
||||
"#d3d7cf",
|
||||
"#fcaf3e",
|
||||
"#f57900",
|
||||
"#ce5c00",
|
||||
"#729fcf",
|
||||
"#3465a4",
|
||||
"#204a87",
|
||||
"#888a85",
|
||||
"#ad7fa8",
|
||||
"#75507b",
|
||||
"#5c3566",
|
||||
"#ef2929",
|
||||
"#cc0000",
|
||||
"#a40000"
|
||||
)
|
||||
|
||||
PREDEFINED_COLORS = getattr(settings, "PREDEFINED_COLORS", DEFAULT_PREDEFINED_COLORS)
|
||||
|
||||
|
||||
def generate_random_hex_color():
|
||||
return "#{:06x}".format(random.randint(0,0xFFFFFF))
|
||||
|
||||
|
||||
def generate_random_predefined_hex_color():
|
||||
return random.choice(PREDEFINED_COLORS)
|
||||
|
|
@ -83,6 +83,7 @@ def save_in_bulk(instances, callback=None, precall=None, **save_options):
|
|||
:params callback: Callback to call after each save.
|
||||
:params save_options: Additional options to use when saving each instance.
|
||||
"""
|
||||
ret = []
|
||||
if callback is None:
|
||||
callback = functions.noop
|
||||
|
||||
|
@ -98,6 +99,7 @@ def save_in_bulk(instances, callback=None, precall=None, **save_options):
|
|||
instance.save(**save_options)
|
||||
callback(instance, created=created)
|
||||
|
||||
return ret
|
||||
|
||||
@transaction.atomic
|
||||
def update_in_bulk(instances, list_of_new_values, callback=None, precall=None):
|
||||
|
@ -130,13 +132,13 @@ def update_attr_in_bulk_for_ids(values, attr, model):
|
|||
"""
|
||||
values = [str((id, order)) for id, order in values.items()]
|
||||
sql = """
|
||||
UPDATE {tbl}
|
||||
SET {attr}=update_values.column2
|
||||
UPDATE "{tbl}"
|
||||
SET "{attr}"=update_values.column2
|
||||
FROM (
|
||||
VALUES
|
||||
{values}
|
||||
) AS update_values
|
||||
WHERE {tbl}.id=update_values.column1;
|
||||
WHERE "{tbl}"."id"=update_values.column1;
|
||||
""".format(tbl=model._meta.db_table,
|
||||
values=', '.join(values),
|
||||
attr=attr)
|
||||
|
|
|
@ -23,7 +23,7 @@ _cache_user_by_email = {}
|
|||
_custom_tasks_attributes_cache = {}
|
||||
_custom_issues_attributes_cache = {}
|
||||
_custom_userstories_attributes_cache = {}
|
||||
|
||||
_custom_epics_attributes_cache = {}
|
||||
|
||||
def cached_get_user_by_pk(pk):
|
||||
if pk not in _cache_user_by_pk:
|
||||
|
|
|
@ -29,6 +29,7 @@ from .mixins import (HistoryExportSerializerMixin,
|
|||
WatcheableObjectLightSerializerMixin)
|
||||
from .cache import (_custom_tasks_attributes_cache,
|
||||
_custom_userstories_attributes_cache,
|
||||
_custom_epics_attributes_cache,
|
||||
_custom_issues_attributes_cache)
|
||||
|
||||
|
||||
|
@ -55,6 +56,14 @@ class UserStoryStatusExportSerializer(RelatedExportSerializer):
|
|||
wip_limit = Field()
|
||||
|
||||
|
||||
class EpicStatusExportSerializer(RelatedExportSerializer):
|
||||
name = Field()
|
||||
slug = Field()
|
||||
order = Field()
|
||||
is_closed = Field()
|
||||
color = Field()
|
||||
|
||||
|
||||
class TaskStatusExportSerializer(RelatedExportSerializer):
|
||||
name = Field()
|
||||
slug = Field()
|
||||
|
@ -97,6 +106,15 @@ class RoleExportSerializer(RelatedExportSerializer):
|
|||
permissions = Field()
|
||||
|
||||
|
||||
class EpicCustomAttributesExportSerializer(RelatedExportSerializer):
|
||||
name = Field()
|
||||
description = Field()
|
||||
type = Field()
|
||||
order = Field()
|
||||
created_date = DateTimeField()
|
||||
modified_date = DateTimeField()
|
||||
|
||||
|
||||
class UserStoryCustomAttributeExportSerializer(RelatedExportSerializer):
|
||||
name = Field()
|
||||
description = Field()
|
||||
|
@ -238,6 +256,45 @@ class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin,
|
|||
return _custom_userstories_attributes_cache[project.id]
|
||||
|
||||
|
||||
class EpicRelatedUserStoryExportSerializer(RelatedExportSerializer):
|
||||
user_story = SlugRelatedField(slug_field="ref")
|
||||
order = Field()
|
||||
|
||||
|
||||
class EpicExportSerializer(CustomAttributesValuesExportSerializerMixin,
|
||||
HistoryExportSerializerMixin,
|
||||
AttachmentExportSerializerMixin,
|
||||
WatcheableObjectLightSerializerMixin,
|
||||
RelatedExportSerializer):
|
||||
ref = Field()
|
||||
owner = UserRelatedField()
|
||||
status = SlugRelatedField(slug_field="name")
|
||||
epics_order = Field()
|
||||
created_date = DateTimeField()
|
||||
modified_date = DateTimeField()
|
||||
subject = Field()
|
||||
description = Field()
|
||||
color = Field()
|
||||
assigned_to = UserRelatedField()
|
||||
client_requirement = Field()
|
||||
team_requirement = Field()
|
||||
version = Field()
|
||||
blocked_note = Field()
|
||||
is_blocked = Field()
|
||||
tags = Field()
|
||||
related_user_stories = MethodField()
|
||||
|
||||
def get_related_user_stories(self, obj):
|
||||
return EpicRelatedUserStoryExportSerializer(obj.relateduserstory_set.all(), many=True).data
|
||||
|
||||
def custom_attributes_queryset(self, project):
|
||||
if project.id not in _custom_epics_attributes_cache:
|
||||
_custom_epics_attributes_cache[project.id] = list(
|
||||
project.userstorycustomattributes.all().values('id', 'name')
|
||||
)
|
||||
return _custom_epics_attributes_cache[project.id]
|
||||
|
||||
|
||||
class IssueExportSerializer(CustomAttributesValuesExportSerializerMixin,
|
||||
HistoryExportSerializerMixin,
|
||||
AttachmentExportSerializerMixin,
|
||||
|
@ -307,6 +364,7 @@ class ProjectExportSerializer(WatcheableObjectLightSerializerMixin):
|
|||
logo = FileField()
|
||||
total_milestones = Field()
|
||||
total_story_points = Field()
|
||||
is_epics_activated = Field()
|
||||
is_backlog_activated = Field()
|
||||
is_kanban_activated = Field()
|
||||
is_wiki_activated = Field()
|
||||
|
@ -318,6 +376,7 @@ class ProjectExportSerializer(WatcheableObjectLightSerializerMixin):
|
|||
is_featured = Field()
|
||||
is_looking_for_people = Field()
|
||||
looking_for_people_note = Field()
|
||||
epics_csv_uuid = Field()
|
||||
userstories_csv_uuid = Field()
|
||||
tasks_csv_uuid = Field()
|
||||
issues_csv_uuid = Field()
|
||||
|
@ -339,6 +398,7 @@ class ProjectExportSerializer(WatcheableObjectLightSerializerMixin):
|
|||
owner = UserRelatedField()
|
||||
memberships = MembershipExportSerializer(many=True)
|
||||
points = PointsExportSerializer(many=True)
|
||||
epic_statuses = EpicStatusExportSerializer(many=True)
|
||||
us_statuses = UserStoryStatusExportSerializer(many=True)
|
||||
task_statuses = TaskStatusExportSerializer(many=True)
|
||||
issue_types = IssueTypeExportSerializer(many=True)
|
||||
|
@ -347,15 +407,18 @@ class ProjectExportSerializer(WatcheableObjectLightSerializerMixin):
|
|||
severities = SeverityExportSerializer(many=True)
|
||||
tags_colors = Field()
|
||||
default_points = SlugRelatedField(slug_field="name")
|
||||
default_epic_status = SlugRelatedField(slug_field="name")
|
||||
default_us_status = SlugRelatedField(slug_field="name")
|
||||
default_task_status = SlugRelatedField(slug_field="name")
|
||||
default_priority = SlugRelatedField(slug_field="name")
|
||||
default_severity = SlugRelatedField(slug_field="name")
|
||||
default_issue_status = SlugRelatedField(slug_field="name")
|
||||
default_issue_type = SlugRelatedField(slug_field="name")
|
||||
epiccustomattributes = EpicCustomAttributesExportSerializer(many=True)
|
||||
userstorycustomattributes = UserStoryCustomAttributeExportSerializer(many=True)
|
||||
taskcustomattributes = TaskCustomAttributeExportSerializer(many=True)
|
||||
issuecustomattributes = IssueCustomAttributeExportSerializer(many=True)
|
||||
epics = EpicExportSerializer(many=True)
|
||||
user_stories = UserStoryExportSerializer(many=True)
|
||||
tasks = TaskExportSerializer(many=True)
|
||||
milestones = MilestoneExportSerializer(many=True)
|
||||
|
|
|
@ -45,12 +45,16 @@ def render_project(project, outfile, chunk_size=8190):
|
|||
# field.initialize(parent=serializer, field_name=field_name)
|
||||
|
||||
# These four "special" fields hava attachments so we use them in a special way
|
||||
if field_name in ["wiki_pages", "user_stories", "tasks", "issues"]:
|
||||
if field_name in ["wiki_pages", "user_stories", "tasks", "issues", "epics"]:
|
||||
value = get_component(project, field_name)
|
||||
if field_name != "wiki_pages":
|
||||
value = value.select_related('owner', 'status', 'milestone',
|
||||
value = value.select_related('owner', 'status',
|
||||
'project', 'assigned_to',
|
||||
'custom_attributes_values')
|
||||
|
||||
if field_name in ["user_stories", "tasks", "issues"]:
|
||||
value = value.select_related('milestone')
|
||||
|
||||
if field_name == "issues":
|
||||
value = value.select_related('severity', 'priority', 'type')
|
||||
value = value.prefetch_related('history_entry', 'attachments')
|
||||
|
|
|
@ -80,11 +80,17 @@ def store_project(data):
|
|||
excluded_fields = [
|
||||
"default_points", "default_us_status", "default_task_status",
|
||||
"default_priority", "default_severity", "default_issue_status",
|
||||
"default_issue_type", "memberships", "points", "us_statuses",
|
||||
"task_statuses", "issue_statuses", "priorities", "severities",
|
||||
"issue_types", "userstorycustomattributes", "taskcustomattributes",
|
||||
"issuecustomattributes", "roles", "milestones", "wiki_pages",
|
||||
"wiki_links", "notify_policies", "user_stories", "issues", "tasks",
|
||||
"default_issue_type", "default_epic_status",
|
||||
"memberships", "points",
|
||||
"epic_statuses", "us_statuses", "task_statuses", "issue_statuses",
|
||||
"priorities", "severities",
|
||||
"issue_types",
|
||||
"epiccustomattributes", "userstorycustomattributes",
|
||||
"taskcustomattributes", "issuecustomattributes",
|
||||
"roles", "milestones",
|
||||
"wiki_pages", "wiki_links",
|
||||
"notify_policies",
|
||||
"epics", "user_stories", "issues", "tasks",
|
||||
"is_featured"
|
||||
]
|
||||
if key not in excluded_fields:
|
||||
|
@ -195,7 +201,7 @@ def _store_membership(project, membership):
|
|||
validator.object._importing = True
|
||||
validator.object.token = str(uuid.uuid1())
|
||||
validator.object.user = find_invited_user(validator.object.email,
|
||||
default=validator.object.user)
|
||||
default=validator.object.user)
|
||||
validator.save()
|
||||
return validator
|
||||
|
||||
|
@ -219,6 +225,7 @@ def _store_project_attribute_value(project, data, field, serializer):
|
|||
validator.object._importing = True
|
||||
validator.save()
|
||||
return validator.object
|
||||
|
||||
add_errors(field, validator.errors)
|
||||
return None
|
||||
|
||||
|
@ -239,10 +246,10 @@ def store_default_project_attributes_values(project, data):
|
|||
else:
|
||||
value = related.all().first()
|
||||
setattr(project, field, value)
|
||||
|
||||
helper(project, "default_points", project.points, data)
|
||||
helper(project, "default_issue_type", project.issue_types, data)
|
||||
helper(project, "default_issue_status", project.issue_statuses, data)
|
||||
helper(project, "default_epic_status", project.epic_statuses, data)
|
||||
helper(project, "default_us_status", project.us_statuses, data)
|
||||
helper(project, "default_task_status", project.task_statuses, data)
|
||||
helper(project, "default_priority", project.priorities, data)
|
||||
|
@ -317,12 +324,14 @@ def _store_role_point(project, us, role_point):
|
|||
add_errors("role_points", validator.errors)
|
||||
return None
|
||||
|
||||
|
||||
def store_user_story(project, data):
|
||||
if "status" not in data and project.default_us_status:
|
||||
data["status"] = project.default_us_status.name
|
||||
|
||||
us_data = {key: value for key, value in data.items() if key not in
|
||||
["role_points", "custom_attributes_values"]}
|
||||
["role_points", "custom_attributes_values"]}
|
||||
|
||||
validator = validators.UserStoryExportValidator(data=us_data, context={"project": project})
|
||||
|
||||
if validator.is_valid():
|
||||
|
@ -360,10 +369,13 @@ def store_user_story(project, data):
|
|||
custom_attributes_values = data.get("custom_attributes_values", None)
|
||||
if custom_attributes_values:
|
||||
custom_attributes = validator.object.project.userstorycustomattributes.all().values('id', 'name')
|
||||
custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values(
|
||||
custom_attributes, custom_attributes_values)
|
||||
custom_attributes_values = \
|
||||
_use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes,
|
||||
custom_attributes_values)
|
||||
|
||||
_store_custom_attributes_values(validator.object, custom_attributes_values,
|
||||
"user_story", validators.UserStoryCustomAttributesValuesExportValidator)
|
||||
"user_story",
|
||||
validators.UserStoryCustomAttributesValuesExportValidator)
|
||||
|
||||
return validator
|
||||
|
||||
|
@ -379,6 +391,81 @@ def store_user_stories(project, data):
|
|||
return results
|
||||
|
||||
|
||||
## EPICS
|
||||
|
||||
def _store_epic_related_user_story(project, epic, related_user_story):
|
||||
validator = validators.EpicRelatedUserStoryExportValidator(data=related_user_story,
|
||||
context={"project": project})
|
||||
if validator.is_valid():
|
||||
validator.object.epic = epic
|
||||
validator.object.save()
|
||||
return validator.object
|
||||
|
||||
add_errors("epic_related_user_stories", validator.errors)
|
||||
return None
|
||||
|
||||
|
||||
def store_epic(project, data):
|
||||
if "status" not in data and project.default_epic_status:
|
||||
data["status"] = project.default_epic_status.name
|
||||
|
||||
validator = validators.EpicExportValidator(data=data, context={"project": project})
|
||||
if validator.is_valid():
|
||||
validator.object.project = project
|
||||
if validator.object.owner is None:
|
||||
validator.object.owner = validator.object.project.owner
|
||||
validator.object._importing = True
|
||||
validator.object._not_notify = True
|
||||
|
||||
validator.save()
|
||||
validator.save_watchers()
|
||||
|
||||
if validator.object.ref:
|
||||
sequence_name = refs.make_sequence_name(project)
|
||||
if not seq.exists(sequence_name):
|
||||
seq.create(sequence_name)
|
||||
seq.set_max(sequence_name, validator.object.ref)
|
||||
else:
|
||||
validator.object.ref, _ = refs.make_reference(validator.object, project)
|
||||
validator.object.save()
|
||||
|
||||
for epic_attachment in data.get("attachments", []):
|
||||
_store_attachment(project, validator.object, epic_attachment)
|
||||
|
||||
for related_user_story in data.get("related_user_stories", []):
|
||||
_store_epic_related_user_story(project, validator.object, related_user_story)
|
||||
|
||||
history_entries = data.get("history", [])
|
||||
for history in history_entries:
|
||||
_store_history(project, validator.object, history)
|
||||
|
||||
if not history_entries:
|
||||
take_snapshot(validator.object, user=validator.object.owner)
|
||||
|
||||
custom_attributes_values = data.get("custom_attributes_values", None)
|
||||
if custom_attributes_values:
|
||||
custom_attributes = validator.object.project.epiccustomattributes.all().values('id', 'name')
|
||||
custom_attributes_values = \
|
||||
_use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes,
|
||||
custom_attributes_values)
|
||||
_store_custom_attributes_values(validator.object, custom_attributes_values,
|
||||
"epic",
|
||||
validators.EpicCustomAttributesValuesExportValidator)
|
||||
|
||||
return validator
|
||||
|
||||
add_errors("epics", validator.errors)
|
||||
return None
|
||||
|
||||
|
||||
def store_epics(project, data):
|
||||
results = []
|
||||
for epic in data.get("epics", []):
|
||||
epic = store_epic(project, epic)
|
||||
results.append(epic)
|
||||
return results
|
||||
|
||||
|
||||
## TASKS
|
||||
|
||||
def store_task(project, data):
|
||||
|
@ -418,10 +505,13 @@ def store_task(project, data):
|
|||
custom_attributes_values = data.get("custom_attributes_values", None)
|
||||
if custom_attributes_values:
|
||||
custom_attributes = validator.object.project.taskcustomattributes.all().values('id', 'name')
|
||||
custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values(
|
||||
custom_attributes, custom_attributes_values)
|
||||
custom_attributes_values = \
|
||||
_use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes,
|
||||
custom_attributes_values)
|
||||
|
||||
_store_custom_attributes_values(validator.object, custom_attributes_values,
|
||||
"task", validators.TaskCustomAttributesValuesExportValidator)
|
||||
"task",
|
||||
validators.TaskCustomAttributesValuesExportValidator)
|
||||
|
||||
return validator
|
||||
|
||||
|
@ -486,10 +576,12 @@ def store_issue(project, data):
|
|||
custom_attributes_values = data.get("custom_attributes_values", None)
|
||||
if custom_attributes_values:
|
||||
custom_attributes = validator.object.project.issuecustomattributes.all().values('id', 'name')
|
||||
custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values(
|
||||
custom_attributes, custom_attributes_values)
|
||||
custom_attributes_values = \
|
||||
_use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes,
|
||||
custom_attributes_values)
|
||||
_store_custom_attributes_values(validator.object, custom_attributes_values,
|
||||
"issue", validators.IssueCustomAttributesValuesExportValidator)
|
||||
"issue",
|
||||
validators.IssueCustomAttributesValuesExportValidator)
|
||||
|
||||
return validator
|
||||
|
||||
|
@ -605,8 +697,9 @@ def _validate_if_owner_have_enought_space_to_this_project(owner, data):
|
|||
|
||||
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
|
||||
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(
|
||||
owner,
|
||||
is_private,
|
||||
|
@ -652,9 +745,10 @@ def _populate_project_object(project, data):
|
|||
# Create memberships
|
||||
store_memberships(project, data)
|
||||
_create_membership_for_project_owner(project)
|
||||
check_if_there_is_some_error(_("error importing memberships"), project)
|
||||
check_if_there_is_some_error(_("error importing memberships"), project)
|
||||
|
||||
# Create project attributes values
|
||||
store_project_attributes_values(project, data, "epic_statuses", validators.EpicStatusExportValidator)
|
||||
store_project_attributes_values(project, data, "us_statuses", validators.UserStoryStatusExportValidator)
|
||||
store_project_attributes_values(project, data, "points", validators.PointsExportValidator)
|
||||
store_project_attributes_values(project, data, "task_statuses", validators.TaskStatusExportValidator)
|
||||
|
@ -669,6 +763,8 @@ def _populate_project_object(project, data):
|
|||
check_if_there_is_some_error(_("error importing default project attributes values"), project)
|
||||
|
||||
# Create custom attributes
|
||||
store_custom_attributes(project, data, "epiccustomattributes",
|
||||
validators.EpicCustomAttributeExportValidator)
|
||||
store_custom_attributes(project, data, "userstorycustomattributes",
|
||||
validators.UserStoryCustomAttributeExportValidator)
|
||||
store_custom_attributes(project, data, "taskcustomattributes",
|
||||
|
@ -689,6 +785,10 @@ def _populate_project_object(project, data):
|
|||
store_user_stories(project, data)
|
||||
check_if_there_is_some_error(_("error importing user stories"), project)
|
||||
|
||||
# Creat epics
|
||||
store_epics(project, data)
|
||||
check_if_there_is_some_error(_("error importing epics"), project)
|
||||
|
||||
# Createer tasks
|
||||
store_tasks(project, data)
|
||||
check_if_there_is_some_error(_("error importing tasks"), project)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from .validators import PointsExportValidator
|
||||
from .validators import EpicStatusExportValidator
|
||||
from .validators import UserStoryStatusExportValidator
|
||||
from .validators import TaskStatusExportValidator
|
||||
from .validators import IssueStatusExportValidator
|
||||
|
@ -6,6 +7,7 @@ from .validators import PriorityExportValidator
|
|||
from .validators import SeverityExportValidator
|
||||
from .validators import IssueTypeExportValidator
|
||||
from .validators import RoleExportValidator
|
||||
from .validators import EpicCustomAttributeExportValidator
|
||||
from .validators import UserStoryCustomAttributeExportValidator
|
||||
from .validators import TaskCustomAttributeExportValidator
|
||||
from .validators import IssueCustomAttributeExportValidator
|
||||
|
@ -17,6 +19,8 @@ from .validators import MembershipExportValidator
|
|||
from .validators import RolePointsExportValidator
|
||||
from .validators import MilestoneExportValidator
|
||||
from .validators import TaskExportValidator
|
||||
from .validators import EpicRelatedUserStoryExportValidator
|
||||
from .validators import EpicExportValidator
|
||||
from .validators import UserStoryExportValidator
|
||||
from .validators import IssueExportValidator
|
||||
from .validators import WikiPageExportValidator
|
||||
|
|
|
@ -22,6 +22,7 @@ _cache_user_by_pk = {}
|
|||
_cache_user_by_email = {}
|
||||
_custom_tasks_attributes_cache = {}
|
||||
_custom_issues_attributes_cache = {}
|
||||
_custom_epics_attributes_cache = {}
|
||||
_custom_userstories_attributes_cache = {}
|
||||
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ from taiga.base.exceptions import ValidationError
|
|||
|
||||
from taiga.projects import models as projects_models
|
||||
from taiga.projects.custom_attributes import models as custom_attributes_models
|
||||
from taiga.projects.epics import models as epics_models
|
||||
from taiga.projects.userstories import models as userstories_models
|
||||
from taiga.projects.tasks import models as tasks_models
|
||||
from taiga.projects.issues import models as issues_models
|
||||
|
@ -38,6 +39,7 @@ from .fields import (FileField, UserRelatedField,
|
|||
TimelineDataField, ContentTypeField)
|
||||
from .mixins import WatcheableObjectModelValidatorMixin
|
||||
from .cache import (_custom_tasks_attributes_cache,
|
||||
_custom_epics_attributes_cache,
|
||||
_custom_userstories_attributes_cache,
|
||||
_custom_issues_attributes_cache)
|
||||
|
||||
|
@ -48,6 +50,12 @@ class PointsExportValidator(validators.ModelValidator):
|
|||
exclude = ('id', 'project')
|
||||
|
||||
|
||||
class EpicStatusExportValidator(validators.ModelValidator):
|
||||
class Meta:
|
||||
model = projects_models.EpicStatus
|
||||
exclude = ('id', 'project')
|
||||
|
||||
|
||||
class UserStoryStatusExportValidator(validators.ModelValidator):
|
||||
class Meta:
|
||||
model = projects_models.UserStoryStatus
|
||||
|
@ -92,6 +100,14 @@ class RoleExportValidator(validators.ModelValidator):
|
|||
exclude = ('id', 'project')
|
||||
|
||||
|
||||
class EpicCustomAttributeExportValidator(validators.ModelValidator):
|
||||
modified_date = serializers.DateTimeField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = custom_attributes_models.EpicCustomAttribute
|
||||
exclude = ('id', 'project')
|
||||
|
||||
|
||||
class UserStoryCustomAttributeExportValidator(validators.ModelValidator):
|
||||
modified_date = serializers.DateTimeField(required=False)
|
||||
|
||||
|
@ -151,6 +167,15 @@ class BaseCustomAttributesValuesExportValidator(validators.ModelValidator):
|
|||
return attrs
|
||||
|
||||
|
||||
class EpicCustomAttributesValuesExportValidator(BaseCustomAttributesValuesExportValidator):
|
||||
_custom_attribute_model = custom_attributes_models.EpicCustomAttribute
|
||||
_container_model = "epics.Epic"
|
||||
_container_field = "epic"
|
||||
|
||||
class Meta(BaseCustomAttributesValuesExportValidator.Meta):
|
||||
model = custom_attributes_models.EpicCustomAttributesValues
|
||||
|
||||
|
||||
class UserStoryCustomAttributesValuesExportValidator(BaseCustomAttributesValuesExportValidator):
|
||||
_custom_attribute_model = custom_attributes_models.UserStoryCustomAttribute
|
||||
_container_model = "userstories.UserStory"
|
||||
|
@ -244,6 +269,34 @@ class TaskExportValidator(WatcheableObjectModelValidatorMixin):
|
|||
return _custom_tasks_attributes_cache[project.id]
|
||||
|
||||
|
||||
class EpicRelatedUserStoryExportValidator(validators.ModelValidator):
|
||||
user_story = ProjectRelatedField(slug_field="ref")
|
||||
order = serializers.IntegerField()
|
||||
|
||||
class Meta:
|
||||
model = epics_models.RelatedUserStory
|
||||
exclude = ('id', 'epic')
|
||||
|
||||
|
||||
class EpicExportValidator(WatcheableObjectModelValidatorMixin):
|
||||
owner = UserRelatedField(required=False)
|
||||
assigned_to = UserRelatedField(required=False)
|
||||
status = ProjectRelatedField(slug_field="name")
|
||||
modified_date = serializers.DateTimeField(required=False)
|
||||
user_stories = EpicRelatedUserStoryExportValidator(many=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = epics_models.Epic
|
||||
exclude = ('id', 'project')
|
||||
|
||||
def custom_attributes_queryset(self, project):
|
||||
if project.id not in _custom_epics_attributes_cache:
|
||||
_custom_epics_attributes_cache[project.id] = list(
|
||||
project.epiccustomattributes.all().values('id', 'name')
|
||||
)
|
||||
return _custom_epics_attributes_cache[project.id]
|
||||
|
||||
|
||||
class UserStoryExportValidator(WatcheableObjectModelValidatorMixin):
|
||||
role_points = RolePointsExportValidator(many=True, required=False)
|
||||
owner = UserRelatedField(required=False)
|
||||
|
|
|
@ -21,11 +21,14 @@ from collections import OrderedDict
|
|||
from .generics import GenericSitemap
|
||||
|
||||
from .projects import ProjectsSitemap
|
||||
from .projects import ProjectEpicsSitemap
|
||||
from .projects import ProjectBacklogsSitemap
|
||||
from .projects import ProjectKanbansSitemap
|
||||
from .projects import ProjectIssuesSitemap
|
||||
from .projects import ProjectTeamsSitemap
|
||||
|
||||
from .epics import EpicsSitemap
|
||||
|
||||
from .milestones import MilestonesSitemap
|
||||
|
||||
from .userstories import UserStoriesSitemap
|
||||
|
@ -43,11 +46,14 @@ sitemaps = OrderedDict([
|
|||
("generics", GenericSitemap),
|
||||
|
||||
("projects", ProjectsSitemap),
|
||||
("project-epics-list", ProjectEpicsSitemap),
|
||||
("project-backlogs", ProjectBacklogsSitemap),
|
||||
("project-kanbans", ProjectKanbansSitemap),
|
||||
("project-issues-list", ProjectIssuesSitemap),
|
||||
("project-teams", ProjectTeamsSitemap),
|
||||
|
||||
("epics", EpicsSitemap),
|
||||
|
||||
("milestones", MilestonesSitemap),
|
||||
|
||||
("userstories", UserStoriesSitemap),
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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.db.models import Q
|
||||
from django.apps import apps
|
||||
|
||||
from taiga.front.templatetags.functions import resolve
|
||||
|
||||
from .base import Sitemap
|
||||
|
||||
|
||||
class EpicsSitemap(Sitemap):
|
||||
def items(self):
|
||||
epic_model = apps.get_model("epics", "Epic")
|
||||
|
||||
# Get epics of public projects OR private projects if anon user can view them
|
||||
queryset = epic_model.objects.filter(Q(project__is_private=False) |
|
||||
Q(project__is_private=True,
|
||||
project__anon_permissions__contains=["view_epics"]))
|
||||
|
||||
# Exclude blocked projects
|
||||
queryset = queryset.filter(project__blocked_code__isnull=True)
|
||||
|
||||
# Project data is needed
|
||||
queryset = queryset.select_related("project")
|
||||
|
||||
return queryset
|
||||
|
||||
def location(self, obj):
|
||||
return resolve("epic", obj.project.slug, obj.ref)
|
||||
|
||||
def lastmod(self, obj):
|
||||
return obj.modified_date
|
||||
|
||||
def changefreq(self, obj):
|
||||
return "daily"
|
||||
|
||||
def priority(self, obj):
|
||||
return 0.4
|
|
@ -51,6 +51,34 @@ class ProjectsSitemap(Sitemap):
|
|||
return 0.9
|
||||
|
||||
|
||||
class ProjectEpicsSitemap(Sitemap):
|
||||
def items(self):
|
||||
project_model = apps.get_model("projects", "Project")
|
||||
|
||||
# Get public projects OR private projects if anon user can view them and epics
|
||||
queryset = project_model.objects.filter(Q(is_private=False) |
|
||||
Q(is_private=True,
|
||||
anon_permissions__contains=["view_project",
|
||||
"view_epics"]))
|
||||
|
||||
# Exclude projects without epics enabled
|
||||
queryset = queryset.exclude(is_epics_activated=False)
|
||||
|
||||
return queryset
|
||||
|
||||
def location(self, obj):
|
||||
return resolve("epics", obj.slug)
|
||||
|
||||
def lastmod(self, obj):
|
||||
return obj.modified_date
|
||||
|
||||
def changefreq(self, obj):
|
||||
return "daily"
|
||||
|
||||
def priority(self, obj):
|
||||
return 0.6
|
||||
|
||||
|
||||
class ProjectBacklogsSitemap(Sitemap):
|
||||
def items(self):
|
||||
project_model = apps.get_model("projects", "Project")
|
||||
|
|
|
@ -33,6 +33,9 @@ urls = {
|
|||
|
||||
"project": "/project/{0}", # project.slug
|
||||
|
||||
"epics": "/project/{0}/epics/", # project.slug
|
||||
"epic": "/project/{0}/epic/{1}", # project.slug, epic.ref
|
||||
|
||||
"backlog": "/project/{0}/backlog/", # project.slug
|
||||
"taskboard": "/project/{0}/taskboard/{1}", # project.slug, milestone.slug
|
||||
"kanban": "/project/{0}/kanban/", # project.slug
|
||||
|
|
|
@ -20,7 +20,8 @@ import re
|
|||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.contrib.auth import get_user_model
|
||||
from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus
|
||||
from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus, EpicStatus
|
||||
from taiga.projects.epics.models import Epic
|
||||
from taiga.projects.issues.models import Issue
|
||||
from taiga.projects.tasks.models import Task
|
||||
from taiga.projects.userstories.models import UserStory
|
||||
|
@ -189,7 +190,10 @@ class BasePushEventHook(BaseEventHook):
|
|||
return _simple_status_change_message.format(platform=self.platform)
|
||||
|
||||
def get_item_classes(self, ref):
|
||||
if Issue.objects.filter(project=self.project, ref=ref).exists():
|
||||
if Epic.objects.filter(project=self.project, ref=ref).exists():
|
||||
modelClass = Epic
|
||||
statusClass = EpicStatus
|
||||
elif Issue.objects.filter(project=self.project, ref=ref).exists():
|
||||
modelClass = Issue
|
||||
statusClass = IssueStatus
|
||||
elif Task.objects.filter(project=self.project, ref=ref).exists():
|
||||
|
|
|
@ -37,7 +37,7 @@ class PushEventHook(BaseGogsEventHook, BasePushEventHook):
|
|||
def get_data(self):
|
||||
result = []
|
||||
commits = self.payload.get("commits", [])
|
||||
project_url = self.payload.get("repository", {}).get("url", None)
|
||||
project_url = self.payload.get("repository", {}).get("html_url", None)
|
||||
|
||||
for commit in filter(None, commits):
|
||||
user_name = commit.get('author', {}).get('username', None)
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -22,12 +22,14 @@ from django.utils.translation import ugettext_lazy as _
|
|||
ANON_PERMISSIONS = [
|
||||
('view_project', _('View project')),
|
||||
('view_milestones', _('View milestones')),
|
||||
('view_epics', _('View epic')),
|
||||
('view_us', _('View user stories')),
|
||||
('view_tasks', _('View tasks')),
|
||||
('view_issues', _('View issues')),
|
||||
('view_wiki_pages', _('View wiki pages')),
|
||||
('view_wiki_links', _('View wiki links')),
|
||||
]
|
||||
|
||||
MEMBERS_PERMISSIONS = [
|
||||
('view_project', _('View project')),
|
||||
# Milestone permissions
|
||||
|
@ -35,6 +37,12 @@ MEMBERS_PERMISSIONS = [
|
|||
('add_milestone', _('Add milestone')),
|
||||
('modify_milestone', _('Modify milestone')),
|
||||
('delete_milestone', _('Delete milestone')),
|
||||
# Epic permissions
|
||||
('view_epics', _('View epic')),
|
||||
('add_epic', _('Add epic')),
|
||||
('modify_epic', _('Modify epic')),
|
||||
('comment_epic', _('Comment epic')),
|
||||
('delete_epic', _('Delete epic')),
|
||||
# US permissions
|
||||
('view_us', _('View user story')),
|
||||
('add_us', _('Add user story')),
|
||||
|
|
|
@ -78,7 +78,6 @@ def user_has_perm(user, perm, obj=None, cache="user"):
|
|||
in cache
|
||||
"""
|
||||
project = _get_object_project(obj)
|
||||
|
||||
if not project:
|
||||
return False
|
||||
|
||||
|
|
|
@ -40,6 +40,8 @@ from taiga.base.decorators import detail_route
|
|||
from taiga.base.utils.slug import slugify_uniquely
|
||||
|
||||
from taiga.permissions import services as permissions_services
|
||||
|
||||
from taiga.projects.epics.models import Epic
|
||||
from taiga.projects.history.mixins import HistoryResourceMixin
|
||||
from taiga.projects.issues.models import Issue
|
||||
from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin
|
||||
|
@ -49,7 +51,6 @@ from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin
|
|||
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 . import filters as project_filters
|
||||
|
@ -110,17 +111,17 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
|
|||
now = timezone.now()
|
||||
order_by_field_name = self._get_order_by_field_name()
|
||||
if order_by_field_name == "total_fans_last_week":
|
||||
qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(weeks=1))
|
||||
qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(weeks=1))
|
||||
elif order_by_field_name == "total_fans_last_month":
|
||||
qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(months=1))
|
||||
qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(months=1))
|
||||
elif order_by_field_name == "total_fans_last_year":
|
||||
qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(years=1))
|
||||
qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(years=1))
|
||||
elif order_by_field_name == "total_activity_last_week":
|
||||
qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(weeks=1))
|
||||
qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(weeks=1))
|
||||
elif order_by_field_name == "total_activity_last_month":
|
||||
qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(months=1))
|
||||
qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(months=1))
|
||||
elif order_by_field_name == "total_activity_last_year":
|
||||
qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(years=1))
|
||||
qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(years=1))
|
||||
|
||||
return qs
|
||||
|
||||
|
@ -242,6 +243,20 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
|
|||
services.remove_user_from_project(request.user, project)
|
||||
return response.Ok()
|
||||
|
||||
def _regenerate_csv_uuid(self, project, field):
|
||||
uuid_value = uuid.uuid4().hex
|
||||
setattr(project, field, uuid_value)
|
||||
project.save()
|
||||
return uuid_value
|
||||
|
||||
@detail_route(methods=["POST"])
|
||||
def regenerate_epics_csv_uuid(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
self.check_permissions(request, "regenerate_epics_csv_uuid", project)
|
||||
self.pre_conditions_on_save(project)
|
||||
data = {"uuid": self._regenerate_csv_uuid(project, "epics_csv_uuid")}
|
||||
return response.Ok(data)
|
||||
|
||||
@detail_route(methods=["POST"])
|
||||
def regenerate_userstories_csv_uuid(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
|
@ -250,14 +265,6 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
|
|||
data = {"uuid": self._regenerate_csv_uuid(project, "userstories_csv_uuid")}
|
||||
return response.Ok(data)
|
||||
|
||||
@detail_route(methods=["POST"])
|
||||
def regenerate_issues_csv_uuid(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
self.check_permissions(request, "regenerate_issues_csv_uuid", project)
|
||||
self.pre_conditions_on_save(project)
|
||||
data = {"uuid": self._regenerate_csv_uuid(project, "issues_csv_uuid")}
|
||||
return response.Ok(data)
|
||||
|
||||
@detail_route(methods=["POST"])
|
||||
def regenerate_tasks_csv_uuid(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
|
@ -266,6 +273,14 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
|
|||
data = {"uuid": self._regenerate_csv_uuid(project, "tasks_csv_uuid")}
|
||||
return response.Ok(data)
|
||||
|
||||
@detail_route(methods=["POST"])
|
||||
def regenerate_issues_csv_uuid(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
self.check_permissions(request, "regenerate_issues_csv_uuid", project)
|
||||
self.pre_conditions_on_save(project)
|
||||
data = {"uuid": self._regenerate_csv_uuid(project, "issues_csv_uuid")}
|
||||
return response.Ok(data)
|
||||
|
||||
@list_route(methods=["GET"])
|
||||
def by_slug(self, request, *args, **kwargs):
|
||||
slug = request.QUERY_PARAMS.get("slug", None)
|
||||
|
@ -292,12 +307,6 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
|
|||
self.check_permissions(request, "stats", project)
|
||||
return response.Ok(services.get_stats_for_project(project))
|
||||
|
||||
def _regenerate_csv_uuid(self, project, field):
|
||||
uuid_value = uuid.uuid4().hex
|
||||
setattr(project, field, uuid_value)
|
||||
project.save()
|
||||
return uuid_value
|
||||
|
||||
@detail_route(methods=["GET"])
|
||||
def member_stats(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
|
@ -449,21 +458,21 @@ class ProjectWatchersViewSet(WatchersViewSetMixin, ModelListViewSet):
|
|||
## Custom values for selectors
|
||||
######################################################
|
||||
|
||||
class PointsViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
|
||||
ModelCrudViewSet, BulkUpdateOrderMixin):
|
||||
class EpicStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
|
||||
ModelCrudViewSet, BulkUpdateOrderMixin):
|
||||
|
||||
model = models.Points
|
||||
serializer_class = serializers.PointsSerializer
|
||||
validator_class = validators.PointsValidator
|
||||
permission_classes = (permissions.PointsPermission,)
|
||||
model = models.EpicStatus
|
||||
serializer_class = serializers.EpicStatusSerializer
|
||||
validator_class = validators.EpicStatusValidator
|
||||
permission_classes = (permissions.EpicStatusPermission,)
|
||||
filter_backends = (filters.CanViewProjectFilterBackend,)
|
||||
filter_fields = ('project',)
|
||||
bulk_update_param = "bulk_points"
|
||||
bulk_update_perm = "change_points"
|
||||
bulk_update_order_action = services.bulk_update_points_order
|
||||
move_on_destroy_related_class = RolePoints
|
||||
move_on_destroy_related_field = "points"
|
||||
move_on_destroy_project_default_field = "default_points"
|
||||
bulk_update_param = "bulk_epic_statuses"
|
||||
bulk_update_perm = "change_epicstatus"
|
||||
bulk_update_order_action = services.bulk_update_epic_status_order
|
||||
move_on_destroy_related_class = Epic
|
||||
move_on_destroy_related_field = "status"
|
||||
move_on_destroy_project_default_field = "default_epic_status"
|
||||
|
||||
|
||||
class UserStoryStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
|
||||
|
@ -483,6 +492,23 @@ class UserStoryStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
|
|||
move_on_destroy_project_default_field = "default_us_status"
|
||||
|
||||
|
||||
class PointsViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
|
||||
ModelCrudViewSet, BulkUpdateOrderMixin):
|
||||
|
||||
model = models.Points
|
||||
serializer_class = serializers.PointsSerializer
|
||||
validator_class = validators.PointsValidator
|
||||
permission_classes = (permissions.PointsPermission,)
|
||||
filter_backends = (filters.CanViewProjectFilterBackend,)
|
||||
filter_fields = ('project',)
|
||||
bulk_update_param = "bulk_points"
|
||||
bulk_update_perm = "change_points"
|
||||
bulk_update_order_action = services.bulk_update_points_order
|
||||
move_on_destroy_related_class = RolePoints
|
||||
move_on_destroy_related_field = "points"
|
||||
move_on_destroy_project_default_field = "default_points"
|
||||
|
||||
|
||||
class TaskStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
|
||||
ModelCrudViewSet, BulkUpdateOrderMixin):
|
||||
|
||||
|
|
|
@ -83,6 +83,12 @@ class BaseAttachmentViewSet(HistoryResourceMixin, WatchedResourceMixin,
|
|||
return obj.content_object
|
||||
|
||||
|
||||
class EpicAttachmentViewSet(BaseAttachmentViewSet):
|
||||
permission_classes = (permissions.EpicAttachmentPermission,)
|
||||
filter_backends = (filters.CanViewEpicAttachmentFilterBackend,)
|
||||
content_type = "epics.epic"
|
||||
|
||||
|
||||
class UserStoryAttachmentViewSet(BaseAttachmentViewSet):
|
||||
permission_classes = (permissions.UserStoryAttachmentPermission,)
|
||||
filter_backends = (filters.CanViewUserStoryAttachmentFilterBackend,)
|
||||
|
|
|
@ -28,6 +28,15 @@ class IsAttachmentOwnerPerm(PermissionComponent):
|
|||
return False
|
||||
|
||||
|
||||
class EpicAttachmentPermission(TaigaResourcePermission):
|
||||
retrieve_perms = HasProjectPerm('view_epics') | IsAttachmentOwnerPerm()
|
||||
create_perms = HasProjectPerm('modify_epic')
|
||||
update_perms = HasProjectPerm('modify_epic') | IsAttachmentOwnerPerm()
|
||||
partial_update_perms = HasProjectPerm('modify_epic') | IsAttachmentOwnerPerm()
|
||||
destroy_perms = HasProjectPerm('modify_epic') | IsAttachmentOwnerPerm()
|
||||
list_perms = AllowAny()
|
||||
|
||||
|
||||
class UserStoryAttachmentPermission(TaigaResourcePermission):
|
||||
retrieve_perms = HasProjectPerm('view_us') | IsAttachmentOwnerPerm()
|
||||
create_perms = HasProjectPerm('modify_us')
|
||||
|
@ -67,7 +76,9 @@ class WikiAttachmentPermission(TaigaResourcePermission):
|
|||
class RawAttachmentPerm(PermissionComponent):
|
||||
def check_permissions(self, request, view, obj=None):
|
||||
is_owner = IsAttachmentOwnerPerm().check_permissions(request, view, obj)
|
||||
if obj.content_type.app_label == "userstories" and obj.content_type.model == "userstory":
|
||||
if obj.content_type.app_label == "epics" and obj.content_type.model == "epic":
|
||||
return EpicAttachmentPermission(request, view).check_permissions('retrieve', obj) or is_owner
|
||||
elif obj.content_type.app_label == "userstories" and obj.content_type.model == "userstory":
|
||||
return UserStoryAttachmentPermission(request, view).check_permissions('retrieve', obj) or is_owner
|
||||
elif obj.content_type.app_label == "tasks" and obj.content_type.model == "task":
|
||||
return TaskAttachmentPermission(request, view).check_permissions('retrieve', obj) or is_owner
|
||||
|
|
|
@ -38,6 +38,11 @@ class BaseCustomAttributeAdmin:
|
|||
raw_id_fields = ["project"]
|
||||
|
||||
|
||||
@admin.register(models.EpicCustomAttribute)
|
||||
class EpicCustomAttributeAdmin(BaseCustomAttributeAdmin, admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(models.UserStoryCustomAttribute)
|
||||
class UserStoryCustomAttributeAdmin(BaseCustomAttributeAdmin, admin.ModelAdmin):
|
||||
pass
|
||||
|
|
|
@ -41,6 +41,18 @@ from . import services
|
|||
# Custom Attribute ViewSets
|
||||
#######################################################
|
||||
|
||||
class EpicCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet):
|
||||
model = models.EpicCustomAttribute
|
||||
serializer_class = serializers.EpicCustomAttributeSerializer
|
||||
validator_class = validators.EpicCustomAttributeValidator
|
||||
permission_classes = (permissions.EpicCustomAttributePermission,)
|
||||
filter_backends = (filters.CanViewProjectFilterBackend,)
|
||||
filter_fields = ("project",)
|
||||
bulk_update_param = "bulk_epic_custom_attributes"
|
||||
bulk_update_perm = "change_epic_custom_attributes"
|
||||
bulk_update_order_action = services.bulk_update_epic_custom_attribute_order
|
||||
|
||||
|
||||
class UserStoryCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet):
|
||||
model = models.UserStoryCustomAttribute
|
||||
serializer_class = serializers.UserStoryCustomAttributeSerializer
|
||||
|
@ -87,6 +99,20 @@ class BaseCustomAttributesValuesViewSet(OCCResourceMixin, HistoryResourceMixin,
|
|||
return getattr(obj, self.content_object)
|
||||
|
||||
|
||||
class EpicCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet):
|
||||
model = models.EpicCustomAttributesValues
|
||||
serializer_class = serializers.EpicCustomAttributesValuesSerializer
|
||||
validator_class = validators.EpicCustomAttributesValuesValidator
|
||||
permission_classes = (permissions.EpicCustomAttributesValuesPermission,)
|
||||
lookup_field = "epic_id"
|
||||
content_object = "epic"
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.model.objects.all()
|
||||
qs = qs.select_related("epic", "epic__project")
|
||||
return qs
|
||||
|
||||
|
||||
class UserStoryCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet):
|
||||
model = models.UserStoryCustomAttributesValues
|
||||
serializer_class = serializers.UserStoryCustomAttributesValuesSerializer
|
||||
|
|
|
@ -15,50 +15,50 @@ class Migration(migrations.Migration):
|
|||
# Function: Remove a key in a json field
|
||||
migrations.RunSQL(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[])
|
||||
RETURNS json
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
STRICT
|
||||
AS $function$
|
||||
SELECT COALESCE ((SELECT ('{' || string_agg(to_json("key") || ':' || "value", ',') || '}')
|
||||
FROM json_each("json")
|
||||
WHERE "key" <> ALL ("keys_to_delete")),
|
||||
'{}')::json $function$;
|
||||
CREATE OR REPLACE FUNCTION "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[])
|
||||
RETURNS json
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
STRICT
|
||||
AS $function$
|
||||
SELECT COALESCE ((SELECT ('{' || string_agg(to_json("key") || ':' || "value", ',') || '}')
|
||||
FROM json_each("json")
|
||||
WHERE "key" <> ALL ("keys_to_delete")),
|
||||
'{}')::json $function$;
|
||||
""",
|
||||
reverse_sql="""DROP FUNCTION IF EXISTS "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[])
|
||||
CASCADE;"""
|
||||
reverse_sql="""
|
||||
DROP FUNCTION IF EXISTS "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[])
|
||||
CASCADE;"""
|
||||
),
|
||||
|
||||
# Function: Romeve a key in the json field of *_custom_attributes_values.values
|
||||
migrations.RunSQL(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION "clean_key_in_custom_attributes_values"()
|
||||
RETURNS trigger
|
||||
AS $clean_key_in_custom_attributes_values$
|
||||
DECLARE
|
||||
key text;
|
||||
project_id int;
|
||||
object_id int;
|
||||
attribute text;
|
||||
tablename text;
|
||||
custom_attributes_tablename text;
|
||||
BEGIN
|
||||
key := OLD.id::text;
|
||||
project_id := OLD.project_id;
|
||||
attribute := TG_ARGV[0]::text;
|
||||
tablename := TG_ARGV[1]::text;
|
||||
custom_attributes_tablename := TG_ARGV[2]::text;
|
||||
|
||||
EXECUTE 'UPDATE ' || quote_ident(custom_attributes_tablename) || '
|
||||
SET attributes_values = json_object_delete_keys(attributes_values, ' || quote_literal(key) || ')
|
||||
FROM ' || quote_ident(tablename) || '
|
||||
WHERE ' || quote_ident(tablename) || '.project_id = ' || project_id || '
|
||||
AND ' || quote_ident(custom_attributes_tablename) || '.' || quote_ident(attribute) || ' = ' || quote_ident(tablename) || '.id';
|
||||
RETURN NULL;
|
||||
END; $clean_key_in_custom_attributes_values$
|
||||
LANGUAGE plpgsql;
|
||||
CREATE OR REPLACE FUNCTION "clean_key_in_custom_attributes_values"()
|
||||
RETURNS trigger
|
||||
AS $clean_key_in_custom_attributes_values$
|
||||
DECLARE
|
||||
key text;
|
||||
project_id int;
|
||||
object_id int;
|
||||
attribute text;
|
||||
tablename text;
|
||||
custom_attributes_tablename text;
|
||||
BEGIN
|
||||
key := OLD.id::text;
|
||||
project_id := OLD.project_id;
|
||||
attribute := TG_ARGV[0]::text;
|
||||
tablename := TG_ARGV[1]::text;
|
||||
custom_attributes_tablename := TG_ARGV[2]::text;
|
||||
|
||||
EXECUTE 'UPDATE ' || quote_ident(custom_attributes_tablename) || '
|
||||
SET attributes_values = json_object_delete_keys(attributes_values, ' || quote_literal(key) || ')
|
||||
FROM ' || quote_ident(tablename) || '
|
||||
WHERE ' || quote_ident(tablename) || '.project_id = ' || project_id || '
|
||||
AND ' || quote_ident(custom_attributes_tablename) || '.' || quote_ident(attribute) || ' = ' || quote_ident(tablename) || '.id';
|
||||
RETURN NULL;
|
||||
END; $clean_key_in_custom_attributes_values$
|
||||
LANGUAGE plpgsql;
|
||||
"""
|
||||
),
|
||||
|
||||
|
@ -66,13 +66,14 @@ class Migration(migrations.Migration):
|
|||
migrations.RunSQL(
|
||||
"""
|
||||
DROP TRIGGER IF EXISTS "update_userstorycustomvalues_after_remove_userstorycustomattribute"
|
||||
ON custom_attributes_userstorycustomattribute
|
||||
CASCADE;
|
||||
ON custom_attributes_userstorycustomattribute
|
||||
CASCADE;
|
||||
|
||||
CREATE TRIGGER "update_userstorycustomvalues_after_remove_userstorycustomattribute"
|
||||
AFTER DELETE ON custom_attributes_userstorycustomattribute
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE clean_key_in_custom_attributes_values('user_story_id', 'userstories_userstory', 'custom_attributes_userstorycustomattributesvalues');
|
||||
EXECUTE PROCEDURE clean_key_in_custom_attributes_values('user_story_id', 'userstories_userstory',
|
||||
'custom_attributes_userstorycustomattributesvalues');
|
||||
"""
|
||||
),
|
||||
|
||||
|
@ -80,13 +81,14 @@ class Migration(migrations.Migration):
|
|||
migrations.RunSQL(
|
||||
"""
|
||||
DROP TRIGGER IF EXISTS "update_taskcustomvalues_after_remove_taskcustomattribute"
|
||||
ON custom_attributes_taskcustomattribute
|
||||
CASCADE;
|
||||
ON custom_attributes_taskcustomattribute
|
||||
CASCADE;
|
||||
|
||||
CREATE TRIGGER "update_taskcustomvalues_after_remove_taskcustomattribute"
|
||||
AFTER DELETE ON custom_attributes_taskcustomattribute
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE clean_key_in_custom_attributes_values('task_id', 'tasks_task', 'custom_attributes_taskcustomattributesvalues');
|
||||
EXECUTE PROCEDURE clean_key_in_custom_attributes_values('task_id', 'tasks_task',
|
||||
'custom_attributes_taskcustomattributesvalues');
|
||||
"""
|
||||
),
|
||||
|
||||
|
@ -94,13 +96,14 @@ class Migration(migrations.Migration):
|
|||
migrations.RunSQL(
|
||||
"""
|
||||
DROP TRIGGER IF EXISTS "update_issuecustomvalues_after_remove_issuecustomattribute"
|
||||
ON custom_attributes_issuecustomattribute
|
||||
CASCADE;
|
||||
ON custom_attributes_issuecustomattribute
|
||||
CASCADE;
|
||||
|
||||
CREATE TRIGGER "update_issuecustomvalues_after_remove_issuecustomattribute"
|
||||
AFTER DELETE ON custom_attributes_issuecustomattribute
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE clean_key_in_custom_attributes_values('issue_id', 'issues_issue', 'custom_attributes_issuecustomattributesvalues');
|
||||
EXECUTE PROCEDURE clean_key_in_custom_attributes_values('issue_id', 'issues_issue',
|
||||
'custom_attributes_issuecustomattributesvalues');
|
||||
"""
|
||||
),
|
||||
migrations.AlterIndexTogether(
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-07-28 10:02
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import django_pgjson.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epics', '0002_epic_color'),
|
||||
('projects', '0050_project_epics_csv_uuid'),
|
||||
('custom_attributes', '0008_auto_20160728_0540'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Change some verbose names
|
||||
migrations.AlterModelOptions(
|
||||
name='issuecustomattributesvalues',
|
||||
options={'ordering': ['id'], 'verbose_name': 'issue custom attributes values', 'verbose_name_plural': 'issue custom attributes values'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='taskcustomattributesvalues',
|
||||
options={'ordering': ['id'], 'verbose_name': 'task custom attributes values', 'verbose_name_plural': 'task custom attributes values'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='userstorycustomattributesvalues',
|
||||
options={'ordering': ['id'], 'verbose_name': 'user story custom attributes values', 'verbose_name_plural': 'user story custom attributes values'},
|
||||
),
|
||||
# Custom attributes for epics
|
||||
migrations.CreateModel(
|
||||
name='EpicCustomAttribute',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=64, verbose_name='name')),
|
||||
('description', models.TextField(blank=True, verbose_name='description')),
|
||||
('type', models.CharField(choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('date', 'Date'), ('url', 'Url')], default='text', max_length=16, verbose_name='type')),
|
||||
('order', models.IntegerField(default=10000, verbose_name='order')),
|
||||
('created_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created date')),
|
||||
('modified_date', models.DateTimeField(verbose_name='modified date')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='epiccustomattributes', to='projects.Project', verbose_name='project')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'epic custom attribute',
|
||||
'abstract': False,
|
||||
'ordering': ['project', 'order', 'name'],
|
||||
'verbose_name_plural': 'epic custom attributes',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EpicCustomAttributesValues',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('version', models.IntegerField(default=1, verbose_name='version')),
|
||||
('attributes_values', django_pgjson.fields.JsonField(default={}, verbose_name='values')),
|
||||
('epic', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='custom_attributes_values', to='epics.Epic', verbose_name='epic')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'verbose_name': 'epic custom attributes values',
|
||||
'ordering': ['id'],
|
||||
'verbose_name_plural': 'epic custom attributes values',
|
||||
},
|
||||
),
|
||||
migrations.AlterIndexTogether(
|
||||
name='epiccustomattributesvalues',
|
||||
index_together=set([('epic',)]),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='epiccustomattribute',
|
||||
unique_together=set([('project', 'name')]),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"""
|
||||
CREATE TRIGGER "update_epiccustomvalues_after_remove_epiccustomattribute"
|
||||
AFTER DELETE ON custom_attributes_epiccustomattribute
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE clean_key_in_custom_attributes_values('epic_id', 'epics_epic',
|
||||
'custom_attributes_epiccustomattributesvalues');
|
||||
""",
|
||||
reverse_sql="""DROP TRIGGER IF EXISTS "update_epiccustomvalues_after_remove_epiccustomattribute"
|
||||
ON custom_attributes_epiccustomattribute
|
||||
CASCADE;"""
|
||||
),
|
||||
]
|
|
@ -31,7 +31,6 @@ from . import choices
|
|||
# Custom Attribute Models
|
||||
#######################################################
|
||||
|
||||
|
||||
class AbstractCustomAttribute(models.Model):
|
||||
name = models.CharField(null=False, blank=False, max_length=64, verbose_name=_("name"))
|
||||
description = models.TextField(null=False, blank=True, verbose_name=_("description"))
|
||||
|
@ -63,6 +62,12 @@ class AbstractCustomAttribute(models.Model):
|
|||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class EpicCustomAttribute(AbstractCustomAttribute):
|
||||
class Meta(AbstractCustomAttribute.Meta):
|
||||
verbose_name = "epic custom attribute"
|
||||
verbose_name_plural = "epic custom attributes"
|
||||
|
||||
|
||||
class UserStoryCustomAttribute(AbstractCustomAttribute):
|
||||
class Meta(AbstractCustomAttribute.Meta):
|
||||
verbose_name = "user story custom attribute"
|
||||
|
@ -93,13 +98,29 @@ class AbstractCustomAttributesValues(OCCModelMixin, models.Model):
|
|||
ordering = ["id"]
|
||||
|
||||
|
||||
class EpicCustomAttributesValues(AbstractCustomAttributesValues):
|
||||
epic = models.OneToOneField("epics.Epic",
|
||||
null=False, blank=False, related_name="custom_attributes_values",
|
||||
verbose_name=_("epic"))
|
||||
|
||||
class Meta(AbstractCustomAttributesValues.Meta):
|
||||
verbose_name = "epic custom attributes values"
|
||||
verbose_name_plural = "epic custom attributes values"
|
||||
index_together = [("epic",)]
|
||||
|
||||
@property
|
||||
def project(self):
|
||||
# NOTE: This property simplifies checking permissions
|
||||
return self.epic.project
|
||||
|
||||
|
||||
class UserStoryCustomAttributesValues(AbstractCustomAttributesValues):
|
||||
user_story = models.OneToOneField("userstories.UserStory",
|
||||
null=False, blank=False, related_name="custom_attributes_values",
|
||||
verbose_name=_("user story"))
|
||||
|
||||
class Meta(AbstractCustomAttributesValues.Meta):
|
||||
verbose_name = "user story ustom attributes values"
|
||||
verbose_name = "user story custom attributes values"
|
||||
verbose_name_plural = "user story custom attributes values"
|
||||
index_together = [("user_story",)]
|
||||
|
||||
|
@ -115,7 +136,7 @@ class TaskCustomAttributesValues(AbstractCustomAttributesValues):
|
|||
verbose_name=_("task"))
|
||||
|
||||
class Meta(AbstractCustomAttributesValues.Meta):
|
||||
verbose_name = "task ustom attributes values"
|
||||
verbose_name = "task custom attributes values"
|
||||
verbose_name_plural = "task custom attributes values"
|
||||
index_together = [("task",)]
|
||||
|
||||
|
@ -131,7 +152,7 @@ class IssueCustomAttributesValues(AbstractCustomAttributesValues):
|
|||
verbose_name=_("issue"))
|
||||
|
||||
class Meta(AbstractCustomAttributesValues.Meta):
|
||||
verbose_name = "issue ustom attributes values"
|
||||
verbose_name = "issue custom attributes values"
|
||||
verbose_name_plural = "issue custom attributes values"
|
||||
index_together = [("issue",)]
|
||||
|
||||
|
|
|
@ -27,6 +27,18 @@ from taiga.base.api.permissions import IsSuperUser
|
|||
# Custom Attribute Permissions
|
||||
#######################################################
|
||||
|
||||
class EpicCustomAttributePermission(TaigaResourcePermission):
|
||||
enought_perms = IsProjectAdmin() | IsSuperUser()
|
||||
global_perms = None
|
||||
retrieve_perms = HasProjectPerm('view_project')
|
||||
create_perms = IsProjectAdmin()
|
||||
update_perms = IsProjectAdmin()
|
||||
partial_update_perms = IsProjectAdmin()
|
||||
destroy_perms = IsProjectAdmin()
|
||||
list_perms = AllowAny()
|
||||
bulk_update_order_perms = IsProjectAdmin()
|
||||
|
||||
|
||||
class UserStoryCustomAttributePermission(TaigaResourcePermission):
|
||||
enought_perms = IsProjectAdmin() | IsSuperUser()
|
||||
global_perms = None
|
||||
|
@ -67,6 +79,14 @@ class IssueCustomAttributePermission(TaigaResourcePermission):
|
|||
# Custom Attributes Values Permissions
|
||||
#######################################################
|
||||
|
||||
class EpicCustomAttributesValuesPermission(TaigaResourcePermission):
|
||||
enought_perms = IsProjectAdmin() | IsSuperUser()
|
||||
global_perms = None
|
||||
retrieve_perms = HasProjectPerm('view_us')
|
||||
update_perms = HasProjectPerm('modify_us')
|
||||
partial_update_perms = HasProjectPerm('modify_us')
|
||||
|
||||
|
||||
class UserStoryCustomAttributesValuesPermission(TaigaResourcePermission):
|
||||
enought_perms = IsProjectAdmin() | IsSuperUser()
|
||||
global_perms = None
|
||||
|
|
|
@ -36,6 +36,10 @@ class BaseCustomAttributeSerializer(serializers.LightSerializer):
|
|||
modified_date = Field()
|
||||
|
||||
|
||||
class EpicCustomAttributeSerializer(BaseCustomAttributeSerializer):
|
||||
pass
|
||||
|
||||
|
||||
class UserStoryCustomAttributeSerializer(BaseCustomAttributeSerializer):
|
||||
pass
|
||||
|
||||
|
@ -56,6 +60,10 @@ class BaseCustomAttributesValuesSerializer(serializers.LightSerializer):
|
|||
version = Field()
|
||||
|
||||
|
||||
class EpicCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer):
|
||||
epic = Field(attr="epic.id")
|
||||
|
||||
|
||||
class UserStoryCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer):
|
||||
user_story = Field(attr="user_story.id")
|
||||
|
||||
|
|
|
@ -20,6 +20,23 @@ from django.db import transaction
|
|||
from django.db import connection
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def bulk_update_epic_custom_attribute_order(project, user, data):
|
||||
cursor = connection.cursor()
|
||||
|
||||
sql = """
|
||||
prepare bulk_update_order as update custom_attributes_epiccustomattribute set "order" = $1
|
||||
where custom_attributes_epiccustomattribute.id = $2 and
|
||||
custom_attributes_epiccustomattribute.project_id = $3;
|
||||
"""
|
||||
cursor.execute(sql)
|
||||
for id, order in data:
|
||||
cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);",
|
||||
(order, id, project.id))
|
||||
cursor.execute("DEALLOCATE bulk_update_order")
|
||||
cursor.close()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def bulk_update_userstory_custom_attribute_order(project, user, data):
|
||||
cursor = connection.cursor()
|
||||
|
|
|
@ -19,6 +19,12 @@
|
|||
from . import models
|
||||
|
||||
|
||||
def create_custom_attribute_value_when_create_epic(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
models.EpicCustomAttributesValues.objects.get_or_create(epic=instance,
|
||||
defaults={"attributes_values":{}})
|
||||
|
||||
|
||||
def create_custom_attribute_value_when_create_user_story(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
models.UserStoryCustomAttributesValues.objects.get_or_create(user_story=instance,
|
||||
|
|
|
@ -66,6 +66,11 @@ class BaseCustomAttributeValidator(ModelValidator):
|
|||
return self._validate_integrity_between_project_and_name(attrs, source)
|
||||
|
||||
|
||||
class EpicCustomAttributeValidator(BaseCustomAttributeValidator):
|
||||
class Meta(BaseCustomAttributeValidator.Meta):
|
||||
model = models.EpicCustomAttribute
|
||||
|
||||
|
||||
class UserStoryCustomAttributeValidator(BaseCustomAttributeValidator):
|
||||
class Meta(BaseCustomAttributeValidator.Meta):
|
||||
model = models.UserStoryCustomAttribute
|
||||
|
@ -121,6 +126,15 @@ class BaseCustomAttributesValuesValidator(ModelValidator):
|
|||
return attrs
|
||||
|
||||
|
||||
class EpicCustomAttributesValuesValidator(BaseCustomAttributesValuesValidator):
|
||||
_custom_attribute_model = models.EpicCustomAttribute
|
||||
_container_model = "epics.Epic"
|
||||
_container_field = "epic"
|
||||
|
||||
class Meta(BaseCustomAttributesValuesValidator.Meta):
|
||||
model = models.EpicCustomAttributesValues
|
||||
|
||||
|
||||
class UserStoryCustomAttributesValuesValidator(BaseCustomAttributesValuesValidator):
|
||||
_custom_attribute_model = models.UserStoryCustomAttribute
|
||||
_container_model = "userstories.UserStory"
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
default_app_config = "taiga.projects.epics.apps.EpicsAppConfig"
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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.contrib import admin
|
||||
|
||||
from taiga.projects.notifications.admin import WatchedInline
|
||||
from taiga.projects.votes.admin import VoteInline
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class RelatedUserStoriesInline(admin.TabularInline):
|
||||
model = models.RelatedUserStory
|
||||
sortable_field_name = "order"
|
||||
raw_id_fields = ["user_story", ]
|
||||
extra = 0
|
||||
|
||||
|
||||
class EpicAdmin(admin.ModelAdmin):
|
||||
list_display = ["project", "ref", "subject"]
|
||||
list_display_links = ["ref", "subject"]
|
||||
inlines = [WatchedInline, VoteInline, RelatedUserStoriesInline]
|
||||
raw_id_fields = ["project"]
|
||||
search_fields = ["subject", "description", "id", "ref"]
|
||||
|
||||
def get_object(self, *args, **kwargs):
|
||||
self.obj = super().get_object(*args, **kwargs)
|
||||
return self.obj
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
if (db_field.name in ["status"] and getattr(self, 'obj', None)):
|
||||
kwargs["queryset"] = db_field.related.model.objects.filter(project=self.obj.project)
|
||||
|
||||
elif (db_field.name in ["owner", "assigned_to"] and getattr(self, 'obj', None)):
|
||||
kwargs["queryset"] = db_field.related.model.objects.filter(memberships__project=self.obj.project)
|
||||
|
||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
|
||||
def formfield_for_manytomany(self, db_field, request, **kwargs):
|
||||
if (db_field.name in ["watchers"] and getattr(self, 'obj', None)):
|
||||
kwargs["queryset"] = db_field.related.parent_model.objects.filter(memberships__project=self.obj.project)
|
||||
return super().formfield_for_manytomany(db_field, request, **kwargs)
|
||||
|
||||
|
||||
admin.site.register(models.Epic, EpicAdmin)
|
|
@ -0,0 +1,315 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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.http import HttpResponse
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from taiga.base.api.utils import get_object_or_404
|
||||
from taiga.base import filters, response
|
||||
from taiga.base import exceptions as exc
|
||||
from taiga.base.decorators import list_route
|
||||
from taiga.base.api import ModelCrudViewSet, ModelListViewSet
|
||||
from taiga.base.api.mixins import BlockedByProjectMixin
|
||||
from taiga.base.api.viewsets import NestedViewSetMixin
|
||||
from taiga.base.utils import json
|
||||
|
||||
from taiga.projects.history.mixins import HistoryResourceMixin
|
||||
from taiga.projects.models import Project, EpicStatus
|
||||
from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
|
||||
from taiga.projects.occ import OCCResourceMixin
|
||||
from taiga.projects.tagging.api import TaggedResourceMixin
|
||||
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
|
||||
|
||||
from . import models
|
||||
from . import permissions
|
||||
from . import serializers
|
||||
from . import services
|
||||
from . import validators
|
||||
from . import utils as epics_utils
|
||||
|
||||
|
||||
class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin,
|
||||
WatchedResourceMixin, TaggedResourceMixin, BlockedByProjectMixin,
|
||||
ModelCrudViewSet):
|
||||
validator_class = validators.EpicValidator
|
||||
queryset = models.Epic.objects.all()
|
||||
permission_classes = (permissions.EpicPermission,)
|
||||
filter_backends = (filters.CanViewEpicsFilterBackend,
|
||||
filters.OwnersFilter,
|
||||
filters.AssignedToFilter,
|
||||
filters.StatusesFilter,
|
||||
filters.TagsFilter,
|
||||
filters.WatchersFilter,
|
||||
filters.QFilter,
|
||||
filters.CreatedDateFilter,
|
||||
filters.ModifiedDateFilter)
|
||||
filter_fields = ["project",
|
||||
"project__slug",
|
||||
"assigned_to",
|
||||
"status__is_closed"]
|
||||
|
||||
def get_serializer_class(self, *args, **kwargs):
|
||||
if self.action in ["retrieve", "by_ref"]:
|
||||
return serializers.EpicNeighborsSerializer
|
||||
|
||||
if self.action == "list":
|
||||
return serializers.EpicListSerializer
|
||||
|
||||
return serializers.EpicSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
qs = qs.select_related("project",
|
||||
"status",
|
||||
"owner",
|
||||
"assigned_to")
|
||||
|
||||
include_attachments = "include_attachments" in self.request.QUERY_PARAMS
|
||||
qs = epics_utils.attach_extra_info(qs, user=self.request.user,
|
||||
include_attachments=include_attachments)
|
||||
|
||||
return qs
|
||||
|
||||
def pre_conditions_on_save(self, obj):
|
||||
super().pre_conditions_on_save(obj)
|
||||
|
||||
if obj.status and obj.status.project != obj.project:
|
||||
raise exc.WrongArguments(_("You don't have permissions to set this status to this epic."))
|
||||
|
||||
"""
|
||||
Updating the epic order attribute can affect the ordering of another epics
|
||||
This method generate a key for the epic and can be used to be compared before and after
|
||||
saving
|
||||
If there is any difference it means an extra ordering update must be done
|
||||
"""
|
||||
def _epics_order_key(self, obj):
|
||||
return "{}-{}".format(obj.project_id, obj.epics_order)
|
||||
|
||||
def pre_save(self, obj):
|
||||
if not obj.id:
|
||||
obj.owner = self.request.user
|
||||
else:
|
||||
self._old_epics_order_key = self._epics_order_key(self.get_object())
|
||||
|
||||
super().pre_save(obj)
|
||||
|
||||
def _reorder_if_needed(self, obj, old_order_key, order_key):
|
||||
# Executes the extra ordering if there is a difference in the ordering keys
|
||||
if old_order_key == order_key:
|
||||
return {}
|
||||
|
||||
extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}"))
|
||||
data = [{"epic_id": obj.id, "order": getattr(obj, "epics_order")}]
|
||||
for id, order in extra_orders.items():
|
||||
data.append({"epic_id": int(id), "order": order})
|
||||
|
||||
return services.update_epics_order_in_bulk(data, "epics_order", project=obj.project)
|
||||
|
||||
def post_save(self, obj, created=False):
|
||||
if not created:
|
||||
# Let's reorder the related stuff after edit the element
|
||||
orders_updated = self._reorder_if_needed(obj,
|
||||
self._old_epics_order_key,
|
||||
self._epics_order_key(obj))
|
||||
self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated)
|
||||
|
||||
super().post_save(obj, created)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
self.object = self.get_object_or_none()
|
||||
project_id = request.DATA.get('project', None)
|
||||
if project_id and self.object and self.object.project.id != project_id:
|
||||
try:
|
||||
new_project = Project.objects.get(pk=project_id)
|
||||
self.check_permissions(request, "destroy", self.object)
|
||||
self.check_permissions(request, "create", new_project)
|
||||
|
||||
status_id = request.DATA.get('status', None)
|
||||
if status_id is not None:
|
||||
try:
|
||||
old_status = self.object.project.epic_statuses.get(pk=status_id)
|
||||
new_status = new_project.epic_statuses.get(slug=old_status.slug)
|
||||
request.DATA['status'] = new_status.id
|
||||
except EpicStatus.DoesNotExist:
|
||||
request.DATA['status'] = new_project.default_epic_status.id
|
||||
|
||||
except Project.DoesNotExist:
|
||||
return response.BadRequest(_("The project doesn't exist"))
|
||||
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
@list_route(methods=["GET"])
|
||||
def filters_data(self, request, *args, **kwargs):
|
||||
project_id = request.QUERY_PARAMS.get("project", None)
|
||||
project = get_object_or_404(Project, id=project_id)
|
||||
|
||||
filter_backends = self.get_filter_backends()
|
||||
statuses_filter_backends = (f for f in filter_backends if f != filters.StatusesFilter)
|
||||
assigned_to_filter_backends = (f for f in filter_backends if f != filters.AssignedToFilter)
|
||||
owners_filter_backends = (f for f in filter_backends if f != filters.OwnersFilter)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
querysets = {
|
||||
"statuses": self.filter_queryset(queryset, filter_backends=statuses_filter_backends),
|
||||
"assigned_to": self.filter_queryset(queryset, filter_backends=assigned_to_filter_backends),
|
||||
"owners": self.filter_queryset(queryset, filter_backends=owners_filter_backends),
|
||||
"tags": self.filter_queryset(queryset)
|
||||
}
|
||||
return response.Ok(services.get_epics_filters_data(project, querysets))
|
||||
|
||||
@list_route(methods=["GET"])
|
||||
def by_ref(self, request):
|
||||
retrieve_kwargs = {
|
||||
"ref": request.QUERY_PARAMS.get("ref", None)
|
||||
}
|
||||
project_id = request.QUERY_PARAMS.get("project", None)
|
||||
if project_id is not None:
|
||||
retrieve_kwargs["project_id"] = project_id
|
||||
|
||||
project_slug = request.QUERY_PARAMS.get("project__slug", None)
|
||||
if project_slug is not None:
|
||||
retrieve_kwargs["project__slug"] = project_slug
|
||||
|
||||
return self.retrieve(request, **retrieve_kwargs)
|
||||
|
||||
@list_route(methods=["GET"])
|
||||
def csv(self, request):
|
||||
uuid = request.QUERY_PARAMS.get("uuid", None)
|
||||
if uuid is None:
|
||||
return response.NotFound()
|
||||
|
||||
project = get_object_or_404(Project, epics_csv_uuid=uuid)
|
||||
queryset = project.epics.all().order_by('ref')
|
||||
data = services.epics_to_csv(project, queryset)
|
||||
csv_response = HttpResponse(data.getvalue(), content_type='application/csv; charset=utf-8')
|
||||
csv_response['Content-Disposition'] = 'attachment; filename="epics.csv"'
|
||||
return csv_response
|
||||
|
||||
@list_route(methods=["POST"])
|
||||
def bulk_create(self, request, **kwargs):
|
||||
validator = validators.EpicsBulkValidator(data=request.DATA)
|
||||
if not validator.is_valid():
|
||||
return response.BadRequest(validator.errors)
|
||||
|
||||
data = validator.data
|
||||
project = Project.objects.get(id=data["project_id"])
|
||||
self.check_permissions(request, "bulk_create", project)
|
||||
if project.blocked_code is not None:
|
||||
raise exc.Blocked(_("Blocked element"))
|
||||
|
||||
epics = services.create_epics_in_bulk(
|
||||
data["bulk_epics"],
|
||||
status_id=data.get("status_id") or project.default_epic_status_id,
|
||||
project=project,
|
||||
owner=request.user,
|
||||
callback=self.post_save, precall=self.pre_save)
|
||||
|
||||
epics = self.get_queryset().filter(id__in=[i.id for i in epics])
|
||||
for epic in epics:
|
||||
self.persist_history_snapshot(obj=epic)
|
||||
|
||||
epics_serialized = self.get_serializer_class()(epics, many=True)
|
||||
|
||||
return response.Ok(epics_serialized.data)
|
||||
|
||||
|
||||
class EpicRelatedUserStoryViewSet(NestedViewSetMixin, HistoryResourceMixin,
|
||||
BlockedByProjectMixin, ModelCrudViewSet):
|
||||
queryset = models.RelatedUserStory.objects.all()
|
||||
serializer_class = serializers.EpicRelatedUserStorySerializer
|
||||
validator_class = validators.EpicRelatedUserStoryValidator
|
||||
model = models.RelatedUserStory
|
||||
permission_classes = (permissions.EpicRelatedUserStoryPermission,)
|
||||
lookup_field = "user_story"
|
||||
|
||||
"""
|
||||
Updating the order attribute can affect the ordering of another userstories in the epic
|
||||
This method generate a key for the userstory and can be used to be compared before and after
|
||||
saving
|
||||
If there is any difference it means an extra ordering update must be done
|
||||
"""
|
||||
def _order_key(self, obj):
|
||||
return "{}-{}".format(obj.user_story.project_id, obj.order)
|
||||
|
||||
def pre_save(self, obj):
|
||||
if not obj.id:
|
||||
obj.epic_id = self.kwargs["epic"]
|
||||
else:
|
||||
self._old_order_key = self._order_key(self.get_object())
|
||||
|
||||
super().pre_save(obj)
|
||||
|
||||
def _reorder_if_needed(self, obj, old_order_key, order_key):
|
||||
# Executes the extra ordering if there is a difference in the ordering keys
|
||||
if old_order_key == order_key:
|
||||
return {}
|
||||
|
||||
extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}"))
|
||||
data = [{"us_id": obj.user_story.id, "order": getattr(obj, "order")}]
|
||||
for id, order in extra_orders.items():
|
||||
data.append({"us_id": int(id), "order": order})
|
||||
|
||||
return services.update_epic_related_userstories_order_in_bulk(data, epic=obj.epic)
|
||||
|
||||
def post_save(self, obj, created=False):
|
||||
if not created:
|
||||
# Let's reorder the related stuff after edit the element
|
||||
orders_updated = self._reorder_if_needed(obj,
|
||||
self._old_order_key,
|
||||
self._order_key(obj))
|
||||
self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated)
|
||||
|
||||
super().post_save(obj, created)
|
||||
|
||||
@list_route(methods=["POST"])
|
||||
def bulk_create(self, request, **kwargs):
|
||||
validator = validators.CreateRelatedUserStoriesBulkValidator(data=request.DATA)
|
||||
if not validator.is_valid():
|
||||
return response.BadRequest(validator.errors)
|
||||
|
||||
data = validator.data
|
||||
|
||||
epic = get_object_or_404(models.Epic, id=kwargs["epic"])
|
||||
project = Project.objects.get(pk=data.get('project_id'))
|
||||
|
||||
self.check_permissions(request, 'bulk_create', project)
|
||||
if project.blocked_code is not None:
|
||||
raise exc.Blocked(_("Blocked element"))
|
||||
|
||||
related_userstories = services.create_related_userstories_in_bulk(
|
||||
data["bulk_userstories"],
|
||||
epic,
|
||||
project=project,
|
||||
owner=request.user
|
||||
)
|
||||
|
||||
for related_userstory in related_userstories:
|
||||
self.persist_history_snapshot(obj=related_userstory)
|
||||
|
||||
related_uss_serialized = self.get_serializer_class()(epic.relateduserstory_set.all(), many=True)
|
||||
return response.Ok(related_uss_serialized.data)
|
||||
|
||||
|
||||
class EpicVotersViewSet(VotersViewSetMixin, ModelListViewSet):
|
||||
permission_classes = (permissions.EpicVotersPermission,)
|
||||
resource_model = models.Epic
|
||||
|
||||
|
||||
class EpicWatchersViewSet(WatchersViewSetMixin, ModelListViewSet):
|
||||
permission_classes = (permissions.EpicWatchersPermission,)
|
||||
resource_model = models.Epic
|
|
@ -0,0 +1,65 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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.apps import AppConfig
|
||||
from django.apps import apps
|
||||
from django.db.models import signals
|
||||
|
||||
|
||||
def connect_epics_signals():
|
||||
from taiga.projects.tagging import signals as tagging_handlers
|
||||
|
||||
# Tags
|
||||
signals.pre_save.connect(tagging_handlers.tags_normalization,
|
||||
sender=apps.get_model("epics", "Epic"),
|
||||
dispatch_uid="tags_normalization_epic")
|
||||
|
||||
|
||||
def connect_epics_custom_attributes_signals():
|
||||
from taiga.projects.custom_attributes import signals as custom_attributes_handlers
|
||||
signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_epic,
|
||||
sender=apps.get_model("epics", "Epic"),
|
||||
dispatch_uid="create_custom_attribute_value_when_create_epic")
|
||||
|
||||
|
||||
def connect_all_epics_signals():
|
||||
connect_epics_signals()
|
||||
connect_epics_custom_attributes_signals()
|
||||
|
||||
|
||||
def disconnect_epics_signals():
|
||||
signals.pre_save.disconnect(sender=apps.get_model("epics", "Epic"),
|
||||
dispatch_uid="tags_normalization")
|
||||
|
||||
|
||||
def disconnect_epics_custom_attributes_signals():
|
||||
signals.post_save.disconnect(sender=apps.get_model("epics", "Epic"),
|
||||
dispatch_uid="create_custom_attribute_value_when_create_epic")
|
||||
|
||||
|
||||
def disconnect_all_epics_signals():
|
||||
disconnect_epics_signals()
|
||||
disconnect_epics_custom_attributes_signals()
|
||||
|
||||
|
||||
class EpicsAppConfig(AppConfig):
|
||||
name = "taiga.projects.epics"
|
||||
verbose_name = "Epics"
|
||||
|
||||
def ready(self):
|
||||
connect_all_epics_signals()
|
|
@ -0,0 +1,89 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-07-05 11:12
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import taiga.projects.notifications.mixins
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('userstories', '0012_auto_20160614_1201'),
|
||||
('projects', '0049_auto_20160629_1443'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Epic',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('tags', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=[], null=True, size=None, verbose_name='tags')),
|
||||
('version', models.IntegerField(default=1, verbose_name='version')),
|
||||
('is_blocked', models.BooleanField(default=False, verbose_name='is blocked')),
|
||||
('blocked_note', models.TextField(blank=True, default='', verbose_name='blocked note')),
|
||||
('ref', models.BigIntegerField(blank=True, db_index=True, default=None, null=True, verbose_name='ref')),
|
||||
('epics_order', models.IntegerField(default=10000, verbose_name='epics order')),
|
||||
('created_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created date')),
|
||||
('modified_date', models.DateTimeField(verbose_name='modified date')),
|
||||
('subject', models.TextField(verbose_name='subject')),
|
||||
('description', models.TextField(blank=True, verbose_name='description')),
|
||||
('client_requirement', models.BooleanField(default=False, verbose_name='is client requirement')),
|
||||
('team_requirement', models.BooleanField(default=False, verbose_name='is team requirement')),
|
||||
('assigned_to', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='epics_assigned_to_me', to=settings.AUTH_USER_MODEL, verbose_name='assigned to')),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_epics', to=settings.AUTH_USER_MODEL, verbose_name='owner')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='epics', to='projects.Project', verbose_name='project')),
|
||||
('status', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='epics', to='projects.EpicStatus', verbose_name='status')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['project', 'epics_order', 'ref'],
|
||||
'verbose_name_plural': 'epics',
|
||||
'verbose_name': 'epic',
|
||||
},
|
||||
bases=(taiga.projects.notifications.mixins.WatchedModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RelatedUserStory',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order', models.IntegerField(default=10000, verbose_name='order')),
|
||||
('epic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epics.Epic')),
|
||||
('user_story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='userstories.UserStory')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['user_story', 'order', 'id'],
|
||||
'verbose_name_plural': 'related user stories',
|
||||
'verbose_name': 'related user story',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='epic',
|
||||
name='user_stories',
|
||||
field=models.ManyToManyField(related_name='epics', through='epics.RelatedUserStory', to='userstories.UserStory', verbose_name='user stories'),
|
||||
),
|
||||
# Execute trigger after epic update
|
||||
migrations.RunSQL(
|
||||
"""
|
||||
DROP TRIGGER IF EXISTS update_project_tags_colors_on_epic_update ON epics_epic;
|
||||
CREATE TRIGGER update_project_tags_colors_on_epic_update
|
||||
AFTER UPDATE ON epics_epic
|
||||
FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors();
|
||||
"""
|
||||
),
|
||||
# Execute trigger after epic insert
|
||||
migrations.RunSQL(
|
||||
"""
|
||||
DROP TRIGGER IF EXISTS update_project_tags_colors_on_epic_insert ON epics_epic;
|
||||
CREATE TRIGGER update_project_tags_colors_on_epic_insert
|
||||
AFTER INSERT ON epics_epic
|
||||
FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors();
|
||||
"""
|
||||
),
|
||||
]
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-07-27 09:37
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import taiga.base.utils.colors
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epics', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='epic',
|
||||
name='color',
|
||||
field=models.CharField(blank=True, default=taiga.base.utils.colors.generate_random_predefined_hex_color, max_length=32, verbose_name='color'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-09-01 10:21
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epics', '0002_epic_color'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='relateduserstory',
|
||||
unique_together=set([('user_story', 'epic')]),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,129 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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.db import models
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils import timezone
|
||||
|
||||
from taiga.base.utils.colors import generate_random_predefined_hex_color
|
||||
from taiga.projects.tagging.models import TaggedMixin
|
||||
from taiga.projects.occ import OCCModelMixin
|
||||
from taiga.projects.notifications.mixins import WatchedModelMixin
|
||||
from taiga.projects.mixins.blocked import BlockedMixin
|
||||
|
||||
|
||||
class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.Model):
|
||||
ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None,
|
||||
verbose_name=_("ref"))
|
||||
project = models.ForeignKey("projects.Project", null=False, blank=False,
|
||||
related_name="epics", verbose_name=_("project"))
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
related_name="owned_epics", verbose_name=_("owner"),
|
||||
on_delete=models.SET_NULL)
|
||||
status = models.ForeignKey("projects.EpicStatus", null=True, blank=True,
|
||||
related_name="epics", verbose_name=_("status"),
|
||||
on_delete=models.SET_NULL)
|
||||
epics_order = models.IntegerField(null=False, blank=False, default=10000,
|
||||
verbose_name=_("epics order"))
|
||||
|
||||
created_date = models.DateTimeField(null=False, blank=False,
|
||||
verbose_name=_("created date"),
|
||||
default=timezone.now)
|
||||
modified_date = models.DateTimeField(null=False, blank=False,
|
||||
verbose_name=_("modified date"))
|
||||
|
||||
subject = models.TextField(null=False, blank=False,
|
||||
verbose_name=_("subject"))
|
||||
description = models.TextField(null=False, blank=True, verbose_name=_("description"))
|
||||
color = models.CharField(max_length=32, null=False, blank=True,
|
||||
default=generate_random_predefined_hex_color,
|
||||
verbose_name=_("color"))
|
||||
assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True,
|
||||
default=None, related_name="epics_assigned_to_me",
|
||||
verbose_name=_("assigned to"))
|
||||
client_requirement = models.BooleanField(default=False, null=False, blank=True,
|
||||
verbose_name=_("is client requirement"))
|
||||
team_requirement = models.BooleanField(default=False, null=False, blank=True,
|
||||
verbose_name=_("is team requirement"))
|
||||
|
||||
user_stories = models.ManyToManyField("userstories.UserStory", related_name="epics",
|
||||
through='RelatedUserStory',
|
||||
verbose_name=_("user stories"))
|
||||
|
||||
attachments = GenericRelation("attachments.Attachment")
|
||||
|
||||
_importing = None
|
||||
|
||||
class Meta:
|
||||
verbose_name = "epic"
|
||||
verbose_name_plural = "epics"
|
||||
ordering = ["project", "epics_order", "ref"]
|
||||
|
||||
def __str__(self):
|
||||
return "#{0} {1}".format(self.ref, self.subject)
|
||||
|
||||
def __repr__(self):
|
||||
return "<Epic %s>" % (self.id)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self._importing or not self.modified_date:
|
||||
self.modified_date = timezone.now()
|
||||
|
||||
if not self.status:
|
||||
self.status = self.project.default_epic_status
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class RelatedUserStory(WatchedModelMixin, models.Model):
|
||||
user_story = models.ForeignKey("userstories.UserStory", on_delete=models.CASCADE)
|
||||
epic = models.ForeignKey("epics.Epic", on_delete=models.CASCADE)
|
||||
|
||||
order = models.IntegerField(null=False, blank=False, default=10000,
|
||||
verbose_name=_("order"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = "related user story"
|
||||
verbose_name_plural = "related user stories"
|
||||
ordering = ["user_story", "order", "id"]
|
||||
unique_together = (("user_story", "epic"), )
|
||||
|
||||
def __str__(self):
|
||||
return "{0} - {1}".format(self.epic_id, self.user_story_id)
|
||||
|
||||
@property
|
||||
def project(self):
|
||||
return self.epic.project
|
||||
|
||||
@property
|
||||
def project_id(self):
|
||||
return self.epic.project_id
|
||||
|
||||
@property
|
||||
def owner(self):
|
||||
return self.epic.owner
|
||||
|
||||
@property
|
||||
def owner_id(self):
|
||||
return self.epic.owner_id
|
||||
|
||||
@property
|
||||
def assigned_to_id(self):
|
||||
return self.epic.assigned_to_id
|
|
@ -0,0 +1,66 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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 taiga.base.api.permissions import TaigaResourcePermission, AllowAny, IsAuthenticated
|
||||
from taiga.base.api.permissions import IsSuperUser, HasProjectPerm, IsProjectAdmin
|
||||
|
||||
from taiga.permissions.permissions import CommentAndOrUpdatePerm
|
||||
|
||||
|
||||
class EpicPermission(TaigaResourcePermission):
|
||||
enought_perms = IsProjectAdmin() | IsSuperUser()
|
||||
global_perms = None
|
||||
retrieve_perms = HasProjectPerm('view_epics')
|
||||
create_perms = HasProjectPerm('add_epic')
|
||||
update_perms = CommentAndOrUpdatePerm('modify_epic', 'comment_epic')
|
||||
partial_update_perms = CommentAndOrUpdatePerm('modify_epic', 'comment_epic')
|
||||
destroy_perms = HasProjectPerm('delete_epic')
|
||||
list_perms = AllowAny()
|
||||
filters_data_perms = AllowAny()
|
||||
csv_perms = AllowAny()
|
||||
bulk_create_perms = HasProjectPerm('add_epic')
|
||||
upvote_perms = IsAuthenticated() & HasProjectPerm('view_epics')
|
||||
downvote_perms = IsAuthenticated() & HasProjectPerm('view_epics')
|
||||
watch_perms = IsAuthenticated() & HasProjectPerm('view_epics')
|
||||
unwatch_perms = IsAuthenticated() & HasProjectPerm('view_epics')
|
||||
|
||||
|
||||
class EpicRelatedUserStoryPermission(TaigaResourcePermission):
|
||||
enought_perms = IsProjectAdmin() | IsSuperUser()
|
||||
global_perms = None
|
||||
retrieve_perms = HasProjectPerm('view_epics')
|
||||
create_perms = HasProjectPerm('modify_epic')
|
||||
update_perms = HasProjectPerm('modify_epic')
|
||||
partial_update_perms = HasProjectPerm('modify_epic')
|
||||
destroy_perms = HasProjectPerm('modify_epic')
|
||||
list_perms = AllowAny()
|
||||
bulk_create_perms = HasProjectPerm('modify_epic')
|
||||
|
||||
|
||||
class EpicVotersPermission(TaigaResourcePermission):
|
||||
enought_perms = IsProjectAdmin() | IsSuperUser()
|
||||
global_perms = None
|
||||
retrieve_perms = HasProjectPerm('view_epics')
|
||||
list_perms = HasProjectPerm('view_epics')
|
||||
|
||||
|
||||
class EpicWatchersPermission(TaigaResourcePermission):
|
||||
enought_perms = IsProjectAdmin() | IsSuperUser()
|
||||
global_perms = None
|
||||
retrieve_perms = HasProjectPerm('view_epics')
|
||||
list_perms = HasProjectPerm('view_epics')
|
|
@ -0,0 +1,86 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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 taiga.base.api import serializers
|
||||
from taiga.base.fields import Field, MethodField
|
||||
from taiga.base.neighbors import NeighborsSerializerMixin
|
||||
|
||||
from taiga.mdrender.service import render as mdrender
|
||||
from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin
|
||||
from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin
|
||||
from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin
|
||||
from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin
|
||||
from taiga.projects.notifications.mixins import WatchedResourceSerializer
|
||||
from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer
|
||||
from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin
|
||||
|
||||
|
||||
class EpicListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer,
|
||||
OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin,
|
||||
StatusExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin,
|
||||
TaggedInProjectResourceSerializer, serializers.LightSerializer):
|
||||
|
||||
id = Field()
|
||||
ref = Field()
|
||||
project = Field(attr="project_id")
|
||||
created_date = Field()
|
||||
modified_date = Field()
|
||||
subject = Field()
|
||||
color = Field()
|
||||
epics_order = Field()
|
||||
client_requirement = Field()
|
||||
team_requirement = Field()
|
||||
version = Field()
|
||||
watchers = Field()
|
||||
is_blocked = Field()
|
||||
blocked_note = Field()
|
||||
is_closed = MethodField()
|
||||
user_stories_counts = MethodField()
|
||||
|
||||
def get_is_closed(self, obj):
|
||||
return obj.status is not None and obj.status.is_closed
|
||||
|
||||
def get_user_stories_counts(self, obj):
|
||||
assert hasattr(obj, "user_stories_counts"), "instance must have a user_stories_counts attribute"
|
||||
return obj.user_stories_counts
|
||||
|
||||
|
||||
class EpicSerializer(EpicListSerializer):
|
||||
comment = MethodField()
|
||||
blocked_note_html = MethodField()
|
||||
description = Field()
|
||||
description_html = MethodField()
|
||||
|
||||
def get_comment(self, obj):
|
||||
return ""
|
||||
|
||||
def get_blocked_note_html(self, obj):
|
||||
return mdrender(obj.project, obj.blocked_note)
|
||||
|
||||
def get_description_html(self, obj):
|
||||
return mdrender(obj.project, obj.description)
|
||||
|
||||
|
||||
class EpicNeighborsSerializer(NeighborsSerializerMixin, EpicSerializer):
|
||||
pass
|
||||
|
||||
|
||||
class EpicRelatedUserStorySerializer(serializers.LightSerializer):
|
||||
epic = Field(attr="epic_id")
|
||||
user_story = Field(attr="user_story_id")
|
||||
order = Field()
|
|
@ -0,0 +1,423 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
import csv
|
||||
import io
|
||||
from collections import OrderedDict
|
||||
from operator import itemgetter
|
||||
from contextlib import closing
|
||||
|
||||
from django.db import connection
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from taiga.base.utils import db, text
|
||||
from taiga.projects.epics.apps import connect_epics_signals
|
||||
from taiga.projects.epics.apps import disconnect_epics_signals
|
||||
from taiga.projects.services import apply_order_updates
|
||||
from taiga.projects.userstories.apps import connect_userstories_signals
|
||||
from taiga.projects.userstories.apps import disconnect_userstories_signals
|
||||
from taiga.projects.userstories.services import get_userstories_from_bulk
|
||||
from taiga.events import events
|
||||
from taiga.projects.votes.utils import attach_total_voters_to_queryset
|
||||
from taiga.projects.notifications.utils import attach_watchers_to_queryset
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
#####################################################
|
||||
# Bulk actions
|
||||
#####################################################
|
||||
|
||||
def get_epics_from_bulk(bulk_data, **additional_fields):
|
||||
"""Convert `bulk_data` into a list of epics.
|
||||
|
||||
:param bulk_data: List of epics in bulk format.
|
||||
:param additional_fields: Additional fields when instantiating each epic.
|
||||
|
||||
:return: List of `Epic` instances.
|
||||
"""
|
||||
return [models.Epic(subject=line, **additional_fields)
|
||||
for line in text.split_in_lines(bulk_data)]
|
||||
|
||||
|
||||
def create_epics_in_bulk(bulk_data, callback=None, precall=None, **additional_fields):
|
||||
"""Create epics from `bulk_data`.
|
||||
|
||||
:param bulk_data: List of epics in bulk format.
|
||||
:param callback: Callback to execute after each epic save.
|
||||
:param additional_fields: Additional fields when instantiating each epic.
|
||||
|
||||
:return: List of created `Epic` instances.
|
||||
"""
|
||||
epics = get_epics_from_bulk(bulk_data, **additional_fields)
|
||||
|
||||
disconnect_epics_signals()
|
||||
|
||||
try:
|
||||
db.save_in_bulk(epics, callback, precall)
|
||||
finally:
|
||||
connect_epics_signals()
|
||||
|
||||
return epics
|
||||
|
||||
|
||||
def update_epics_order_in_bulk(bulk_data: list, field: str, project: object):
|
||||
"""
|
||||
Update the order of some epics.
|
||||
`bulk_data` should be a list of tuples with the following format:
|
||||
|
||||
[{'epic_id': <value>, 'order': <value>}, ...]
|
||||
"""
|
||||
epics = project.epics.all()
|
||||
|
||||
epic_orders = {e.id: getattr(e, field) for e in epics}
|
||||
new_epic_orders = {d["epic_id"]: d["order"] for d in bulk_data}
|
||||
apply_order_updates(epic_orders, new_epic_orders)
|
||||
|
||||
epic_ids = epic_orders.keys()
|
||||
events.emit_event_for_ids(ids=epic_ids,
|
||||
content_type="epics.epic",
|
||||
projectid=project.pk)
|
||||
|
||||
db.update_attr_in_bulk_for_ids(epic_orders, field, models.Epic)
|
||||
return epic_orders
|
||||
|
||||
|
||||
def create_related_userstories_in_bulk(bulk_data, epic, **additional_fields):
|
||||
"""Create user stories from `bulk_data`.
|
||||
|
||||
:param epic: Element where all the user stories will be contained
|
||||
:param bulk_data: List of user stories in bulk format.
|
||||
:param additional_fields: Additional fields when instantiating each user story.
|
||||
|
||||
:return: List of created `Task` instances.
|
||||
"""
|
||||
userstories = get_userstories_from_bulk(bulk_data, **additional_fields)
|
||||
disconnect_userstories_signals()
|
||||
|
||||
try:
|
||||
db.save_in_bulk(userstories)
|
||||
related_userstories = []
|
||||
for userstory in userstories:
|
||||
related_userstories.append(
|
||||
models.RelatedUserStory(
|
||||
user_story=userstory,
|
||||
epic=epic
|
||||
)
|
||||
)
|
||||
db.save_in_bulk(related_userstories)
|
||||
finally:
|
||||
connect_userstories_signals()
|
||||
|
||||
return related_userstories
|
||||
|
||||
|
||||
def update_epic_related_userstories_order_in_bulk(bulk_data: list, epic: object):
|
||||
"""
|
||||
Updates the order of the related userstories of an specific epic.
|
||||
`bulk_data` should be a list of dicts with the following format:
|
||||
`epic` is the epic with related stories.
|
||||
|
||||
[{'us_id': <value>, 'order': <value>}, ...]
|
||||
"""
|
||||
related_user_stories = epic.relateduserstory_set.all()
|
||||
# select_related
|
||||
rus_orders = {rus.id: rus.order for rus in related_user_stories}
|
||||
|
||||
rus_conversion = {rus.user_story_id: rus.id for rus in related_user_stories}
|
||||
new_rus_orders = {rus_conversion[e["us_id"]]: e["order"] for e in bulk_data
|
||||
if e["us_id"] in rus_conversion}
|
||||
|
||||
apply_order_updates(rus_orders, new_rus_orders)
|
||||
|
||||
if rus_orders:
|
||||
related_user_story_ids = rus_orders.keys()
|
||||
events.emit_event_for_ids(ids=related_user_story_ids,
|
||||
content_type="epics.relateduserstory",
|
||||
projectid=epic.project_id)
|
||||
|
||||
db.update_attr_in_bulk_for_ids(rus_orders, "order", models.RelatedUserStory)
|
||||
|
||||
return rus_orders
|
||||
|
||||
|
||||
#####################################################
|
||||
# CSV
|
||||
#####################################################
|
||||
|
||||
def epics_to_csv(project, queryset):
|
||||
csv_data = io.StringIO()
|
||||
fieldnames = ["ref", "subject", "description", "owner", "owner_full_name", "assigned_to",
|
||||
"assigned_to_full_name", "status", "epics_order", "client_requirement",
|
||||
"team_requirement", "attachments", "tags", "watchers", "voters",
|
||||
"created_date", "modified_date"]
|
||||
|
||||
custom_attrs = project.epiccustomattributes.all()
|
||||
for custom_attr in custom_attrs:
|
||||
fieldnames.append(custom_attr.name)
|
||||
|
||||
queryset = queryset.prefetch_related("attachments",
|
||||
"custom_attributes_values")
|
||||
queryset = queryset.select_related("owner",
|
||||
"assigned_to",
|
||||
"status",
|
||||
"project")
|
||||
|
||||
queryset = attach_total_voters_to_queryset(queryset)
|
||||
queryset = attach_watchers_to_queryset(queryset)
|
||||
|
||||
writer = csv.DictWriter(csv_data, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for epic in queryset:
|
||||
epic_data = {
|
||||
"ref": epic.ref,
|
||||
"subject": epic.subject,
|
||||
"description": epic.description,
|
||||
"owner": epic.owner.username if epic.owner else None,
|
||||
"owner_full_name": epic.owner.get_full_name() if epic.owner else None,
|
||||
"assigned_to": epic.assigned_to.username if epic.assigned_to else None,
|
||||
"assigned_to_full_name": epic.assigned_to.get_full_name() if epic.assigned_to else None,
|
||||
"status": epic.status.name if epic.status else None,
|
||||
"epics_order": epic.epics_order,
|
||||
"client_requirement": epic.client_requirement,
|
||||
"team_requirement": epic.team_requirement,
|
||||
"attachments": epic.attachments.count(),
|
||||
"tags": ",".join(epic.tags or []),
|
||||
"watchers": epic.watchers,
|
||||
"voters": epic.total_voters,
|
||||
"created_date": epic.created_date,
|
||||
"modified_date": epic.modified_date,
|
||||
}
|
||||
for custom_attr in custom_attrs:
|
||||
value = epic.custom_attributes_values.attributes_values.get(str(custom_attr.id), None)
|
||||
epic_data[custom_attr.name] = value
|
||||
|
||||
writer.writerow(epic_data)
|
||||
|
||||
return csv_data
|
||||
|
||||
|
||||
#####################################################
|
||||
# Api filter data
|
||||
#####################################################
|
||||
|
||||
def _get_epics_statuses(project, queryset):
|
||||
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
|
||||
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
|
||||
where = queryset_where_tuple[0]
|
||||
where_params = queryset_where_tuple[1]
|
||||
|
||||
extra_sql = """
|
||||
SELECT "projects_epicstatus"."id",
|
||||
"projects_epicstatus"."name",
|
||||
"projects_epicstatus"."color",
|
||||
"projects_epicstatus"."order",
|
||||
(SELECT count(*)
|
||||
FROM "epics_epic"
|
||||
INNER JOIN "projects_project" ON
|
||||
("epics_epic"."project_id" = "projects_project"."id")
|
||||
WHERE {where} AND "epics_epic"."status_id" = "projects_epicstatus"."id")
|
||||
FROM "projects_epicstatus"
|
||||
WHERE "projects_epicstatus"."project_id" = %s
|
||||
ORDER BY "projects_epicstatus"."order";
|
||||
""".format(where=where)
|
||||
|
||||
with closing(connection.cursor()) as cursor:
|
||||
cursor.execute(extra_sql, where_params + [project.id])
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result = []
|
||||
for id, name, color, order, count in rows:
|
||||
result.append({
|
||||
"id": id,
|
||||
"name": _(name),
|
||||
"color": color,
|
||||
"order": order,
|
||||
"count": count,
|
||||
})
|
||||
return sorted(result, key=itemgetter("order"))
|
||||
|
||||
|
||||
def _get_epics_assigned_to(project, queryset):
|
||||
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
|
||||
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
|
||||
where = queryset_where_tuple[0]
|
||||
where_params = queryset_where_tuple[1]
|
||||
|
||||
extra_sql = """
|
||||
WITH counters AS (
|
||||
SELECT assigned_to_id, count(assigned_to_id) count
|
||||
FROM "epics_epic"
|
||||
INNER JOIN "projects_project" ON ("epics_epic"."project_id" = "projects_project"."id")
|
||||
WHERE {where} AND "epics_epic"."assigned_to_id" IS NOT NULL
|
||||
GROUP BY assigned_to_id
|
||||
)
|
||||
|
||||
SELECT "projects_membership"."user_id" user_id,
|
||||
"users_user"."full_name",
|
||||
"users_user"."username",
|
||||
COALESCE("counters".count, 0) count
|
||||
FROM projects_membership
|
||||
LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."assigned_to_id")
|
||||
INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id")
|
||||
WHERE "projects_membership"."project_id" = %s
|
||||
AND "projects_membership"."user_id" IS NOT NULL
|
||||
|
||||
-- unassigned epics
|
||||
UNION
|
||||
|
||||
SELECT NULL user_id, NULL, NULL, count(coalesce(assigned_to_id, -1)) count
|
||||
FROM "epics_epic"
|
||||
INNER JOIN "projects_project" ON ("epics_epic"."project_id" = "projects_project"."id")
|
||||
WHERE {where} AND "epics_epic"."assigned_to_id" IS NULL
|
||||
GROUP BY assigned_to_id
|
||||
""".format(where=where)
|
||||
|
||||
with closing(connection.cursor()) as cursor:
|
||||
cursor.execute(extra_sql, where_params + [project.id] + where_params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result = []
|
||||
none_valued_added = False
|
||||
for id, full_name, username, count in rows:
|
||||
result.append({
|
||||
"id": id,
|
||||
"full_name": full_name or username or "",
|
||||
"count": count,
|
||||
})
|
||||
|
||||
if id is None:
|
||||
none_valued_added = True
|
||||
|
||||
# If there was no epic with null assigned_to we manually add it
|
||||
if not none_valued_added:
|
||||
result.append({
|
||||
"id": None,
|
||||
"full_name": "",
|
||||
"count": 0,
|
||||
})
|
||||
|
||||
return sorted(result, key=itemgetter("full_name"))
|
||||
|
||||
|
||||
def _get_epics_owners(project, queryset):
|
||||
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
|
||||
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
|
||||
where = queryset_where_tuple[0]
|
||||
where_params = queryset_where_tuple[1]
|
||||
|
||||
extra_sql = """
|
||||
WITH counters AS (
|
||||
SELECT "epics_epic"."owner_id" owner_id,
|
||||
count(coalesce("epics_epic"."owner_id", -1)) count
|
||||
FROM "epics_epic"
|
||||
INNER JOIN "projects_project" ON ("epics_epic"."project_id" = "projects_project"."id")
|
||||
WHERE {where}
|
||||
GROUP BY "epics_epic"."owner_id"
|
||||
)
|
||||
|
||||
SELECT "projects_membership"."user_id" id,
|
||||
"users_user"."full_name",
|
||||
"users_user"."username",
|
||||
COALESCE("counters".count, 0) count
|
||||
FROM projects_membership
|
||||
LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id")
|
||||
INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id")
|
||||
WHERE "projects_membership"."project_id" = %s
|
||||
AND "projects_membership"."user_id" IS NOT NULL
|
||||
|
||||
-- System users
|
||||
UNION
|
||||
|
||||
SELECT "users_user"."id" user_id,
|
||||
"users_user"."full_name" full_name,
|
||||
"users_user"."username" username,
|
||||
COALESCE("counters".count, 0) count
|
||||
FROM users_user
|
||||
LEFT OUTER JOIN counters ON ("users_user"."id" = "counters"."owner_id")
|
||||
WHERE ("users_user"."is_system" IS TRUE)
|
||||
""".format(where=where)
|
||||
|
||||
with closing(connection.cursor()) as cursor:
|
||||
cursor.execute(extra_sql, where_params + [project.id])
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result = []
|
||||
for id, full_name, username, count in rows:
|
||||
if count > 0:
|
||||
result.append({
|
||||
"id": id,
|
||||
"full_name": full_name or username or "",
|
||||
"count": count,
|
||||
})
|
||||
return sorted(result, key=itemgetter("full_name"))
|
||||
|
||||
|
||||
def _get_epics_tags(project, queryset):
|
||||
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
|
||||
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
|
||||
where = queryset_where_tuple[0]
|
||||
where_params = queryset_where_tuple[1]
|
||||
|
||||
extra_sql = """
|
||||
WITH epics_tags AS (
|
||||
SELECT tag,
|
||||
COUNT(tag) counter FROM (
|
||||
SELECT UNNEST(epics_epic.tags) tag
|
||||
FROM epics_epic
|
||||
INNER JOIN projects_project
|
||||
ON (epics_epic.project_id = projects_project.id)
|
||||
WHERE {where}) tags
|
||||
GROUP BY tag),
|
||||
project_tags AS (
|
||||
SELECT reduce_dim(tags_colors) tag_color
|
||||
FROM projects_project
|
||||
WHERE id=%s)
|
||||
|
||||
SELECT tag_color[1] tag, COALESCE(epics_tags.counter, 0) counter
|
||||
FROM project_tags
|
||||
LEFT JOIN epics_tags ON project_tags.tag_color[1] = epics_tags.tag
|
||||
ORDER BY tag
|
||||
""".format(where=where)
|
||||
|
||||
with closing(connection.cursor()) as cursor:
|
||||
cursor.execute(extra_sql, where_params + [project.id])
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result = []
|
||||
for name, count in rows:
|
||||
result.append({
|
||||
"name": name,
|
||||
"count": count,
|
||||
})
|
||||
return sorted(result, key=itemgetter("name"))
|
||||
|
||||
|
||||
def get_epics_filters_data(project, querysets):
|
||||
"""
|
||||
Given a project and an epics queryset, return a simple data structure
|
||||
of all possible filters for the epics in the queryset.
|
||||
"""
|
||||
data = OrderedDict([
|
||||
("statuses", _get_epics_statuses(project, querysets["statuses"])),
|
||||
("assigned_to", _get_epics_assigned_to(project, querysets["assigned_to"])),
|
||||
("owners", _get_epics_owners(project, querysets["owners"])),
|
||||
("tags", _get_epics_tags(project, querysets["tags"])),
|
||||
])
|
||||
|
||||
return data
|
|
@ -0,0 +1,55 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# Copyright (C) 2014-2016 Anler Hernández <hello@anler.me>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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 taiga.projects.attachments.utils import attach_basic_attachments
|
||||
from taiga.projects.notifications.utils import attach_watchers_to_queryset
|
||||
from taiga.projects.notifications.utils import attach_total_watchers_to_queryset
|
||||
from taiga.projects.notifications.utils import attach_is_watcher_to_queryset
|
||||
from taiga.projects.votes.utils import attach_total_voters_to_queryset
|
||||
from taiga.projects.votes.utils import attach_is_voter_to_queryset
|
||||
|
||||
|
||||
def attach_extra_info(queryset, user=None, include_attachments=False):
|
||||
if include_attachments:
|
||||
queryset = attach_basic_attachments(queryset)
|
||||
queryset = queryset.extra(select={"include_attachments": "True"})
|
||||
|
||||
queryset = attach_user_stories_counts_to_queryset(queryset)
|
||||
queryset = attach_total_voters_to_queryset(queryset)
|
||||
queryset = attach_watchers_to_queryset(queryset)
|
||||
queryset = attach_total_watchers_to_queryset(queryset)
|
||||
queryset = attach_is_voter_to_queryset(queryset, user)
|
||||
queryset = attach_is_watcher_to_queryset(queryset, user)
|
||||
return queryset
|
||||
|
||||
|
||||
def attach_user_stories_counts_to_queryset(queryset, as_field="user_stories_counts"):
|
||||
model = queryset.model
|
||||
sql = """SELECT (SELECT row_to_json(t)
|
||||
FROM (SELECT COALESCE(SUM(CASE WHEN is_closed IS FALSE THEN 1 ELSE 0 END), 0) AS "opened",
|
||||
COALESCE(SUM(CASE WHEN is_closed IS TRUE THEN 1 ELSE 0 END), 0) AS "closed"
|
||||
) t
|
||||
)
|
||||
FROM epics_relateduserstory
|
||||
INNER JOIN userstories_userstory ON epics_relateduserstory.user_story_id = userstories_userstory.id
|
||||
WHERE epics_relateduserstory.epic_id = {tbl}.id"""
|
||||
|
||||
sql = sql.format(tbl=model._meta.db_table)
|
||||
queryset = queryset.extra(select={as_field: sql})
|
||||
return queryset
|
|
@ -0,0 +1,68 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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 as _
|
||||
|
||||
from taiga.base.api import serializers
|
||||
from taiga.base.api import validators
|
||||
from taiga.base.exceptions import ValidationError
|
||||
from taiga.base.fields import PgArrayField
|
||||
from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer
|
||||
from taiga.projects.notifications.validators import WatchersValidator
|
||||
from taiga.projects.tagging.fields import TagsAndTagsColorsField
|
||||
from taiga.projects.userstories.validators import UserStoryExistsValidator
|
||||
from taiga.projects.validators import ProjectExistsValidator
|
||||
from . import models
|
||||
|
||||
|
||||
class EpicExistsValidator:
|
||||
def validate_epic_id(self, attrs, source):
|
||||
value = attrs[source]
|
||||
if not models.Epic.objects.filter(pk=value).exists():
|
||||
msg = _("There's no epic with that id")
|
||||
raise ValidationError(msg)
|
||||
return attrs
|
||||
|
||||
|
||||
class EpicValidator(WatchersValidator, EditableWatchedResourceSerializer, validators.ModelValidator):
|
||||
tags = TagsAndTagsColorsField(default=[], required=False)
|
||||
external_reference = PgArrayField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = models.Epic
|
||||
read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner')
|
||||
|
||||
|
||||
class EpicsBulkValidator(ProjectExistsValidator, EpicExistsValidator,
|
||||
validators.Validator):
|
||||
project_id = serializers.IntegerField()
|
||||
status_id = serializers.IntegerField(required=False)
|
||||
bulk_epics = serializers.CharField()
|
||||
|
||||
|
||||
class CreateRelatedUserStoriesBulkValidator(ProjectExistsValidator, EpicExistsValidator,
|
||||
validators.Validator):
|
||||
project_id = serializers.IntegerField()
|
||||
bulk_userstories = serializers.CharField()
|
||||
|
||||
|
||||
|
||||
class EpicRelatedUserStoryValidator(validators.ModelValidator):
|
||||
class Meta:
|
||||
model = models.RelatedUserStory
|
||||
read_only_fields = ('id',)
|
|
@ -5,26 +5,28 @@
|
|||
"fields": {
|
||||
"name": "Scrum",
|
||||
"slug": "scrum",
|
||||
"order": 1,
|
||||
"description": "The agile product backlog in Scrum is a prioritized features list, containing short descriptions of all functionality desired in the product. When applying Scrum, it's not necessary to start a project with a lengthy, upfront effort to document all requirements. The Scrum product backlog is then allowed to grow and change as more is learned about the product and its customers",
|
||||
"order": 1,
|
||||
"created_date": "2014-04-22T14:48:43.596Z",
|
||||
"modified_date": "2014-07-25T10:02:46.479Z",
|
||||
"modified_date": "2016-08-24T16:26:40.845Z",
|
||||
"default_owner_role": "product-owner",
|
||||
"is_epics_activated": false,
|
||||
"is_backlog_activated": true,
|
||||
"is_kanban_activated": false,
|
||||
"is_wiki_activated": true,
|
||||
"is_issues_activated": true,
|
||||
"videoconferences": null,
|
||||
"videoconferences_extra_data": "",
|
||||
"default_options": "{\"severity\": \"Normal\", \"priority\": \"Normal\", \"task_status\": \"New\", \"points\": \"?\", \"us_status\": \"New\", \"issue_type\": \"Bug\", \"issue_status\": \"New\"}",
|
||||
"us_statuses": "[{\"is_archived\": false, \"slug\": \"new\", \"is_closed\": false, \"wip_limit\": null, \"order\": 1, \"name\": \"New\", \"color\": \"#999999\"}, {\"is_archived\": false, \"slug\": \"ready\", \"is_closed\": false, \"wip_limit\": null, \"order\": 2, \"name\": \"Ready\", \"color\": \"#ff8a84\"}, {\"is_archived\": false, \"slug\": \"in-progress\", \"is_closed\": false, \"wip_limit\": null, \"order\": 3, \"name\": \"In progress\", \"color\": \"#ff9900\"}, {\"is_archived\": false, \"slug\": \"ready-for-test\", \"is_closed\": false, \"wip_limit\": null, \"order\": 4, \"name\": \"Ready for test\", \"color\": \"#fcc000\"}, {\"is_archived\": false, \"slug\": \"done\", \"is_closed\": true, \"wip_limit\": null, \"order\": 5, \"name\": \"Done\", \"color\": \"#669900\"}, {\"is_archived\": true, \"slug\": \"archived\", \"is_closed\": true, \"wip_limit\": null, \"order\": 6, \"name\": \"Archived\", \"color\": \"#5c3566\"}]",
|
||||
"points": "[{\"order\": 1, \"name\": \"?\", \"value\": null}, {\"order\": 2, \"name\": \"0\", \"value\": 0.0}, {\"order\": 3, \"name\": \"1/2\", \"value\": 0.5}, {\"order\": 4, \"name\": \"1\", \"value\": 1.0}, {\"order\": 5, \"name\": \"2\", \"value\": 2.0}, {\"order\": 6, \"name\": \"3\", \"value\": 3.0}, {\"order\": 7, \"name\": \"5\", \"value\": 5.0}, {\"order\": 8, \"name\": \"8\", \"value\": 8.0}, {\"order\": 9, \"name\": \"10\", \"value\": 10.0}, {\"order\": 10, \"name\": \"13\", \"value\": 13.0}, {\"order\": 11, \"name\": \"20\", \"value\": 20.0}, {\"order\": 12, \"name\": \"40\", \"value\": 40.0}]",
|
||||
"task_statuses": "[{\"order\": 1, \"name\": \"New\", \"slug\": \"new\", \"color\": \"#999999\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"slug\": \"in-progress\", \"color\": \"#ff9900\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"color\": \"#ffcc00\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"slug\": \"closed\", \"color\": \"#669900\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"slug\": \"needs-info\", \"color\": \"#999999\", \"is_closed\": false}]",
|
||||
"issue_statuses": "[{\"order\": 1, \"name\": \"New\", \"slug\": \"new\", \"color\": \"#8C2318\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"slug\": \"in-progress\", \"color\": \"#5E8C6A\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"color\": \"#88A65E\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"slug\": \"closed\", \"color\": \"#BFB35A\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"slug\": \"needs-info\", \"color\": \"#89BAB4\", \"is_closed\": false}, {\"order\": 6, \"name\": \"Rejected\", \"slug\": \"rejected\", \"color\": \"#CC0000\", \"is_closed\": true}, {\"order\": 7, \"name\": \"Postponed\", \"slug\": \"posponed\", \"color\": \"#666666\", \"is_closed\": false}]",
|
||||
"issue_types": "[{\"order\": 1, \"name\": \"Bug\", \"color\": \"#89BAB4\"}, {\"order\": 2, \"name\": \"Question\", \"color\": \"#ba89a8\"}, {\"order\": 3, \"name\": \"Enhancement\", \"color\": \"#89a8ba\"}]",
|
||||
"priorities": "[{\"order\": 1, \"name\": \"Low\", \"color\": \"#666666\"}, {\"order\": 3, \"name\": \"Normal\", \"color\": \"#669933\"}, {\"order\": 5, \"name\": \"High\", \"color\": \"#CC0000\"}]",
|
||||
"severities": "[{\"order\": 1, \"name\": \"Wishlist\", \"color\": \"#666666\"}, {\"order\": 2, \"name\": \"Minor\", \"color\": \"#669933\"}, {\"order\": 3, \"name\": \"Normal\", \"color\": \"#0000FF\"}, {\"order\": 4, \"name\": \"Important\", \"color\": \"#FFA500\"}, {\"order\": 5, \"name\": \"Critical\", \"color\": \"#CC0000\"}]",
|
||||
"roles": "[{\"order\": 10, \"name\": \"UX\", \"slug\": \"ux\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 20, \"name\": \"Design\", \"slug\": \"design\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 30, \"name\": \"Front\", \"slug\": \"front\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 40, \"name\": \"Back\", \"slug\": \"back\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 50, \"name\": \"Product Owner\", \"slug\": \"product-owner\", \"computable\": false, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 60, \"name\": \"Stakeholder\", \"slug\": \"stakeholder\", \"computable\": false, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}]"
|
||||
"default_options": "{\"epic_status\": \"New\", \"issue_status\": \"New\", \"task_status\": \"New\", \"points\": \"?\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"priority\": \"Normal\", \"us_status\": \"New\"}",
|
||||
"epic_statuses": "[{\"is_closed\": false, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"order\": 1}, {\"is_closed\": false, \"name\": \"Ready\", \"color\": \"#ff8a84\", \"slug\": \"ready\", \"order\": 2}, {\"is_closed\": false, \"name\": \"In progress\", \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"order\": 3}, {\"is_closed\": false, \"name\": \"Ready for test\", \"color\": \"#fcc000\", \"slug\": \"ready-for-test\", \"order\": 4}, {\"is_closed\": true, \"name\": \"Done\", \"color\": \"#669900\", \"slug\": \"done\", \"order\": 5}]",
|
||||
"us_statuses": "[{\"is_archived\": false, \"name\": \"New\", \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"Ready\", \"slug\": \"ready\", \"order\": 2, \"color\": \"#ff8a84\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"In progress\", \"slug\": \"in-progress\", \"order\": 3, \"color\": \"#ff9900\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"order\": 4, \"color\": \"#fcc000\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"Done\", \"slug\": \"done\", \"order\": 5, \"color\": \"#669900\", \"wip_limit\": null, \"is_closed\": true}, {\"is_archived\": true, \"name\": \"Archived\", \"slug\": \"archived\", \"order\": 6, \"color\": \"#5c3566\", \"wip_limit\": null, \"is_closed\": true}]",
|
||||
"points": "[{\"value\": null, \"name\": \"?\", \"order\": 1}, {\"value\": 0.0, \"name\": \"0\", \"order\": 2}, {\"value\": 0.5, \"name\": \"1/2\", \"order\": 3}, {\"value\": 1.0, \"name\": \"1\", \"order\": 4}, {\"value\": 2.0, \"name\": \"2\", \"order\": 5}, {\"value\": 3.0, \"name\": \"3\", \"order\": 6}, {\"value\": 5.0, \"name\": \"5\", \"order\": 7}, {\"value\": 8.0, \"name\": \"8\", \"order\": 8}, {\"value\": 10.0, \"name\": \"10\", \"order\": 9}, {\"value\": 13.0, \"name\": \"13\", \"order\": 10}, {\"value\": 20.0, \"name\": \"20\", \"order\": 11}, {\"value\": 40.0, \"name\": \"40\", \"order\": 12}]",
|
||||
"task_statuses": "[{\"is_closed\": false, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"order\": 1}, {\"is_closed\": false, \"name\": \"In progress\", \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"order\": 2}, {\"is_closed\": true, \"name\": \"Ready for test\", \"color\": \"#ffcc00\", \"slug\": \"ready-for-test\", \"order\": 3}, {\"is_closed\": true, \"name\": \"Closed\", \"color\": \"#669900\", \"slug\": \"closed\", \"order\": 4}, {\"is_closed\": false, \"name\": \"Needs Info\", \"color\": \"#999999\", \"slug\": \"needs-info\", \"order\": 5}]",
|
||||
"issue_statuses": "[{\"is_closed\": false, \"name\": \"New\", \"color\": \"#8C2318\", \"slug\": \"new\", \"order\": 1}, {\"is_closed\": false, \"name\": \"In progress\", \"color\": \"#5E8C6A\", \"slug\": \"in-progress\", \"order\": 2}, {\"is_closed\": true, \"name\": \"Ready for test\", \"color\": \"#88A65E\", \"slug\": \"ready-for-test\", \"order\": 3}, {\"is_closed\": true, \"name\": \"Closed\", \"color\": \"#BFB35A\", \"slug\": \"closed\", \"order\": 4}, {\"is_closed\": false, \"name\": \"Needs Info\", \"color\": \"#89BAB4\", \"slug\": \"needs-info\", \"order\": 5}, {\"is_closed\": true, \"name\": \"Rejected\", \"color\": \"#CC0000\", \"slug\": \"rejected\", \"order\": 6}, {\"is_closed\": false, \"name\": \"Postponed\", \"color\": \"#666666\", \"slug\": \"posponed\", \"order\": 7}]",
|
||||
"issue_types": "[{\"name\": \"Bug\", \"color\": \"#89BAB4\", \"order\": 1}, {\"name\": \"Question\", \"color\": \"#ba89a8\", \"order\": 2}, {\"name\": \"Enhancement\", \"color\": \"#89a8ba\", \"order\": 3}]",
|
||||
"priorities": "[{\"name\": \"Low\", \"color\": \"#666666\", \"order\": 1}, {\"name\": \"Normal\", \"color\": \"#669933\", \"order\": 3}, {\"name\": \"High\", \"color\": \"#CC0000\", \"order\": 5}]",
|
||||
"severities": "[{\"name\": \"Wishlist\", \"color\": \"#666666\", \"order\": 1}, {\"name\": \"Minor\", \"color\": \"#669933\", \"order\": 2}, {\"name\": \"Normal\", \"color\": \"#0000FF\", \"order\": 3}, {\"name\": \"Important\", \"color\": \"#FFA500\", \"order\": 4}, {\"name\": \"Critical\", \"color\": \"#CC0000\", \"order\": 5}]",
|
||||
"roles": "[{\"name\": \"UX\", \"computable\": true, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 10}, {\"name\": \"Design\", \"computable\": true, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 20}, {\"name\": \"Front\", \"computable\": true, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 30}, {\"name\": \"Back\", \"computable\": true, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 40}, {\"name\": \"Product Owner\", \"computable\": false, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 50}, {\"name\": \"Stakeholder\", \"computable\": false, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 60}]"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -33,26 +35,28 @@
|
|||
"fields": {
|
||||
"name": "Kanban",
|
||||
"slug": "kanban",
|
||||
"order": 2,
|
||||
"description": "Kanban is a method for managing knowledge work with an emphasis on just-in-time delivery while not overloading the team members. In this approach, the process, from definition of a task to its delivery to the customer, is displayed for participants to see and team members pull work from a queue.",
|
||||
"order": 2,
|
||||
"created_date": "2014-04-22T14:50:19.738Z",
|
||||
"modified_date": "2014-07-25T13:11:42.754Z",
|
||||
"modified_date": "2016-08-24T16:26:45.365Z",
|
||||
"default_owner_role": "product-owner",
|
||||
"is_epics_activated": false,
|
||||
"is_backlog_activated": false,
|
||||
"is_kanban_activated": true,
|
||||
"is_wiki_activated": false,
|
||||
"is_issues_activated": false,
|
||||
"videoconferences": null,
|
||||
"videoconferences_extra_data": "",
|
||||
"default_options": "{\"severity\": \"Normal\", \"priority\": \"Normal\", \"task_status\": \"New\", \"points\": \"?\", \"us_status\": \"New\", \"issue_type\": \"Bug\", \"issue_status\": \"New\"}",
|
||||
"us_statuses": "[{\"is_archived\": false, \"slug\": \"new\", \"is_closed\": false, \"wip_limit\": null, \"order\": 1, \"name\": \"New\", \"color\": \"#999999\"}, {\"is_archived\": false, \"slug\": \"ready\", \"is_closed\": false, \"wip_limit\": null, \"order\": 2, \"name\": \"Ready\", \"color\": \"#f57900\"}, {\"is_archived\": false, \"slug\": \"in-progress\", \"is_closed\": false, \"wip_limit\": null, \"order\": 3, \"name\": \"In progress\", \"color\": \"#729fcf\"}, {\"is_archived\": false, \"slug\": \"ready-for-test\", \"is_closed\": false, \"wip_limit\": null, \"order\": 4, \"name\": \"Ready for test\", \"color\": \"#4e9a06\"}, {\"is_archived\": false, \"slug\": \"done\", \"is_closed\": true, \"wip_limit\": null, \"order\": 5, \"name\": \"Done\", \"color\": \"#cc0000\"}, {\"is_archived\": true, \"slug\": \"archived\", \"is_closed\": true, \"wip_limit\": null, \"order\": 6, \"name\": \"Archived\", \"color\": \"#5c3566\"}]",
|
||||
"points": "[{\"order\": 1, \"name\": \"?\", \"value\": null}, {\"order\": 2, \"name\": \"0\", \"value\": 0.0}, {\"order\": 3, \"name\": \"1/2\", \"value\": 0.5}, {\"order\": 4, \"name\": \"1\", \"value\": 1.0}, {\"order\": 5, \"name\": \"2\", \"value\": 2.0}, {\"order\": 6, \"name\": \"3\", \"value\": 3.0}, {\"order\": 7, \"name\": \"5\", \"value\": 5.0}, {\"order\": 8, \"name\": \"8\", \"value\": 8.0}, {\"order\": 9, \"name\": \"10\", \"value\": 10.0}, {\"order\": 10, \"name\": \"13\", \"value\": 13.0}, {\"order\": 11, \"name\": \"20\", \"value\": 20.0}, {\"order\": 12, \"name\": \"40\", \"value\": 40.0}]",
|
||||
"task_statuses": "[{\"order\": 1, \"name\": \"New\", \"slug\": \"new\", \"color\": \"#999999\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"slug\": \"in-progress\", \"color\": \"#729fcf\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"color\": \"#f57900\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"slug\": \"closed\", \"color\": \"#4e9a06\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"slug\": \"needs-info\", \"color\": \"#cc0000\", \"is_closed\": false}]",
|
||||
"issue_statuses": "[{\"order\": 1, \"name\": \"New\", \"slug\": \"new\", \"color\": \"#999999\", \"is_closed\": false}, {\"order\": 2, \"name\": \"In progress\", \"slug\": \"in-progress\", \"color\": \"#729fcf\", \"is_closed\": false}, {\"order\": 3, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"color\": \"#f57900\", \"is_closed\": true}, {\"order\": 4, \"name\": \"Closed\", \"slug\": \"closed\", \"color\": \"#4e9a06\", \"is_closed\": true}, {\"order\": 5, \"name\": \"Needs Info\", \"slug\": \"needs-info\", \"color\": \"#cc0000\", \"is_closed\": false}, {\"order\": 6, \"name\": \"Rejected\", \"slug\": \"rejected\", \"color\": \"#d3d7cf\", \"is_closed\": true}, {\"order\": 7, \"name\": \"Postponed\", \"slug\": \"posponed\", \"color\": \"#75507b\", \"is_closed\": false}]",
|
||||
"issue_types": "[{\"order\": 1, \"name\": \"Bug\", \"color\": \"#cc0000\"}, {\"order\": 2, \"name\": \"Question\", \"color\": \"#729fcf\"}, {\"order\": 3, \"name\": \"Enhancement\", \"color\": \"#4e9a06\"}]",
|
||||
"priorities": "[{\"order\": 1, \"name\": \"Low\", \"color\": \"#999999\"}, {\"order\": 3, \"name\": \"Normal\", \"color\": \"#4e9a06\"}, {\"order\": 5, \"name\": \"High\", \"color\": \"#CC0000\"}]",
|
||||
"severities": "[{\"order\": 1, \"name\": \"Wishlist\", \"color\": \"#999999\"}, {\"order\": 2, \"name\": \"Minor\", \"color\": \"#729fcf\"}, {\"order\": 3, \"name\": \"Normal\", \"color\": \"#4e9a06\"}, {\"order\": 4, \"name\": \"Important\", \"color\": \"#f57900\"}, {\"order\": 5, \"name\": \"Critical\", \"color\": \"#CC0000\"}]",
|
||||
"roles": "[{\"order\": 10, \"name\": \"UX\", \"slug\": \"ux\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 20, \"name\": \"Design\", \"slug\": \"design\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 30, \"name\": \"Front\", \"slug\": \"front\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 40, \"name\": \"Back\", \"slug\": \"back\", \"computable\": true, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 50, \"name\": \"Product Owner\", \"slug\": \"product-owner\", \"computable\": false, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}, {\"order\": 60, \"name\": \"Stakeholder\", \"slug\": \"stakeholder\", \"computable\": false, \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"]}]"
|
||||
"default_options": "{\"epic_status\": \"New\", \"issue_status\": \"New\", \"task_status\": \"New\", \"points\": \"?\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"priority\": \"Normal\", \"us_status\": \"New\"}",
|
||||
"epic_statuses": "[{\"is_closed\": false, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"order\": 1}, {\"is_closed\": false, \"name\": \"Ready\", \"color\": \"#ff8a84\", \"slug\": \"ready\", \"order\": 2}, {\"is_closed\": false, \"name\": \"In progress\", \"color\": \"#ff9900\", \"slug\": \"in-progress\", \"order\": 3}, {\"is_closed\": false, \"name\": \"Ready for test\", \"color\": \"#fcc000\", \"slug\": \"ready-for-test\", \"order\": 4}, {\"is_closed\": true, \"name\": \"Done\", \"color\": \"#669900\", \"slug\": \"done\", \"order\": 5}]",
|
||||
"us_statuses": "[{\"is_archived\": false, \"name\": \"New\", \"slug\": \"new\", \"order\": 1, \"color\": \"#999999\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"Ready\", \"slug\": \"ready\", \"order\": 2, \"color\": \"#f57900\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"In progress\", \"slug\": \"in-progress\", \"order\": 3, \"color\": \"#729fcf\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\", \"order\": 4, \"color\": \"#4e9a06\", \"wip_limit\": null, \"is_closed\": false}, {\"is_archived\": false, \"name\": \"Done\", \"slug\": \"done\", \"order\": 5, \"color\": \"#cc0000\", \"wip_limit\": null, \"is_closed\": true}, {\"is_archived\": true, \"name\": \"Archived\", \"slug\": \"archived\", \"order\": 6, \"color\": \"#5c3566\", \"wip_limit\": null, \"is_closed\": true}]",
|
||||
"points": "[{\"value\": null, \"name\": \"?\", \"order\": 1}, {\"value\": 0.0, \"name\": \"0\", \"order\": 2}, {\"value\": 0.5, \"name\": \"1/2\", \"order\": 3}, {\"value\": 1.0, \"name\": \"1\", \"order\": 4}, {\"value\": 2.0, \"name\": \"2\", \"order\": 5}, {\"value\": 3.0, \"name\": \"3\", \"order\": 6}, {\"value\": 5.0, \"name\": \"5\", \"order\": 7}, {\"value\": 8.0, \"name\": \"8\", \"order\": 8}, {\"value\": 10.0, \"name\": \"10\", \"order\": 9}, {\"value\": 13.0, \"name\": \"13\", \"order\": 10}, {\"value\": 20.0, \"name\": \"20\", \"order\": 11}, {\"value\": 40.0, \"name\": \"40\", \"order\": 12}]",
|
||||
"task_statuses": "[{\"is_closed\": false, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"order\": 1}, {\"is_closed\": false, \"name\": \"In progress\", \"color\": \"#729fcf\", \"slug\": \"in-progress\", \"order\": 2}, {\"is_closed\": true, \"name\": \"Ready for test\", \"color\": \"#f57900\", \"slug\": \"ready-for-test\", \"order\": 3}, {\"is_closed\": true, \"name\": \"Closed\", \"color\": \"#4e9a06\", \"slug\": \"closed\", \"order\": 4}, {\"is_closed\": false, \"name\": \"Needs Info\", \"color\": \"#cc0000\", \"slug\": \"needs-info\", \"order\": 5}]",
|
||||
"issue_statuses": "[{\"is_closed\": false, \"name\": \"New\", \"color\": \"#999999\", \"slug\": \"new\", \"order\": 1}, {\"is_closed\": false, \"name\": \"In progress\", \"color\": \"#729fcf\", \"slug\": \"in-progress\", \"order\": 2}, {\"is_closed\": true, \"name\": \"Ready for test\", \"color\": \"#f57900\", \"slug\": \"ready-for-test\", \"order\": 3}, {\"is_closed\": true, \"name\": \"Closed\", \"color\": \"#4e9a06\", \"slug\": \"closed\", \"order\": 4}, {\"is_closed\": false, \"name\": \"Needs Info\", \"color\": \"#cc0000\", \"slug\": \"needs-info\", \"order\": 5}, {\"is_closed\": true, \"name\": \"Rejected\", \"color\": \"#d3d7cf\", \"slug\": \"rejected\", \"order\": 6}, {\"is_closed\": false, \"name\": \"Postponed\", \"color\": \"#75507b\", \"slug\": \"posponed\", \"order\": 7}]",
|
||||
"issue_types": "[{\"name\": \"Bug\", \"color\": \"#cc0000\", \"order\": 1}, {\"name\": \"Question\", \"color\": \"#729fcf\", \"order\": 2}, {\"name\": \"Enhancement\", \"color\": \"#4e9a06\", \"order\": 3}]",
|
||||
"priorities": "[{\"name\": \"Low\", \"color\": \"#999999\", \"order\": 1}, {\"name\": \"Normal\", \"color\": \"#4e9a06\", \"order\": 3}, {\"name\": \"High\", \"color\": \"#CC0000\", \"order\": 5}]",
|
||||
"severities": "[{\"name\": \"Wishlist\", \"color\": \"#999999\", \"order\": 1}, {\"name\": \"Minor\", \"color\": \"#729fcf\", \"order\": 2}, {\"name\": \"Normal\", \"color\": \"#4e9a06\", \"order\": 3}, {\"name\": \"Important\", \"color\": \"#f57900\", \"order\": 4}, {\"name\": \"Critical\", \"color\": \"#CC0000\", \"order\": 5}]",
|
||||
"roles": "[{\"name\": \"UX\", \"computable\": true, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 10}, {\"name\": \"Design\", \"computable\": true, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 20}, {\"name\": \"Front\", \"computable\": true, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 30}, {\"name\": \"Back\", \"computable\": true, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 40}, {\"name\": \"Product Owner\", \"computable\": false, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"add_epic\", \"modify_epic\", \"delete_epic\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 50}, {\"name\": \"Stakeholder\", \"computable\": false, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\", \"view_epics\", \"comment_epic\", \"comment_us\", \"comment_task\", \"comment_issue\", \"comment_wiki_page\"], \"order\": 60}]"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -168,6 +168,11 @@ class HistoryViewSet(ReadOnlyListViewSet):
|
|||
return self.response_for_queryset(qs)
|
||||
|
||||
|
||||
class EpicHistory(HistoryViewSet):
|
||||
content_type = "epics.epic"
|
||||
permission_classes = (permissions.EpicHistoryPermission,)
|
||||
|
||||
|
||||
class UserStoryHistory(HistoryViewSet):
|
||||
content_type = "userstories.userstory"
|
||||
permission_classes = (permissions.UserStoryHistoryPermission,)
|
||||
|
|
|
@ -106,6 +106,20 @@ def milestone_values(diff):
|
|||
return values
|
||||
|
||||
|
||||
def epic_values(diff):
|
||||
values = _common_users_values(diff)
|
||||
|
||||
if "status" in diff:
|
||||
values["status"] = _get_us_status_values(diff["status"])
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def epic_related_userstory_values(diff):
|
||||
values = _common_users_values(diff)
|
||||
return values
|
||||
|
||||
|
||||
def userstory_values(diff):
|
||||
values = _common_users_values(diff)
|
||||
|
||||
|
@ -190,6 +204,18 @@ def extract_attachments(obj) -> list:
|
|||
"order": attach.order}
|
||||
|
||||
|
||||
@as_tuple
|
||||
def extract_epic_custom_attributes(obj) -> list:
|
||||
with suppress(ObjectDoesNotExist):
|
||||
custom_attributes_values = obj.custom_attributes_values.attributes_values
|
||||
for attr in obj.project.epiccustomattributes.all():
|
||||
with suppress(KeyError):
|
||||
value = custom_attributes_values[str(attr.id)]
|
||||
yield {"id": attr.id,
|
||||
"name": attr.name,
|
||||
"value": value}
|
||||
|
||||
|
||||
@as_tuple
|
||||
def extract_user_story_custom_attributes(obj) -> list:
|
||||
with suppress(ObjectDoesNotExist):
|
||||
|
@ -235,6 +261,7 @@ def project_freezer(project) -> dict:
|
|||
"total_milestones",
|
||||
"total_story_points",
|
||||
"tags",
|
||||
"is_epics_activated",
|
||||
"is_backlog_activated",
|
||||
"is_kanban_activated",
|
||||
"is_wiki_activated",
|
||||
|
@ -256,6 +283,40 @@ def milestone_freezer(milestone) -> dict:
|
|||
return snapshot
|
||||
|
||||
|
||||
def epic_freezer(epic) -> dict:
|
||||
snapshot = {
|
||||
"ref": epic.ref,
|
||||
"color": epic.color,
|
||||
"owner": epic.owner_id,
|
||||
"status": epic.status.id if epic.status else None,
|
||||
"epics_order": epic.epics_order,
|
||||
"subject": epic.subject,
|
||||
"description": epic.description,
|
||||
"description_html": mdrender(epic.project, epic.description),
|
||||
"assigned_to": epic.assigned_to_id,
|
||||
"client_requirement": epic.client_requirement,
|
||||
"team_requirement": epic.team_requirement,
|
||||
"attachments": extract_attachments(epic),
|
||||
"tags": epic.tags,
|
||||
"is_blocked": epic.is_blocked,
|
||||
"blocked_note": epic.blocked_note,
|
||||
"blocked_note_html": mdrender(epic.project, epic.blocked_note),
|
||||
"custom_attributes": extract_epic_custom_attributes(epic)
|
||||
}
|
||||
|
||||
return snapshot
|
||||
|
||||
|
||||
def epic_related_userstory_freezer(related_us) -> dict:
|
||||
snapshot = {
|
||||
"user_story": related_us.user_story.id,
|
||||
"epic": related_us.epic.id,
|
||||
"order": related_us.order
|
||||
}
|
||||
|
||||
return snapshot
|
||||
|
||||
|
||||
def userstory_freezer(us) -> dict:
|
||||
rp_cls = apps.get_model("userstories", "RolePoints")
|
||||
rpqsd = rp_cls.objects.filter(user_story=us)
|
||||
|
|
|
@ -258,6 +258,24 @@ class HistoryEntry(models.Model):
|
|||
if custom_attributes["new"] or custom_attributes["changed"] or custom_attributes["deleted"]:
|
||||
value = custom_attributes
|
||||
|
||||
elif key == "user_stories":
|
||||
user_stories = {
|
||||
"new": [],
|
||||
"deleted": [],
|
||||
}
|
||||
|
||||
olduss = {x["id"]: x for x in self.diff["user_stories"][0]}
|
||||
newuss = {x["id"]: x for x in self.diff["user_stories"][1]}
|
||||
|
||||
for usid in set(tuple(olduss.keys()) + tuple(newuss.keys())):
|
||||
if usid in olduss and usid not in newuss:
|
||||
user_stories["deleted"].append(olduss[usid])
|
||||
elif usid not in olduss and usid in newuss:
|
||||
user_stories["new"].append(newuss[usid])
|
||||
|
||||
if user_stories["new"] or user_stories["deleted"]:
|
||||
value = user_stories
|
||||
|
||||
elif key in self.values:
|
||||
value = [resolve_value(key, x) for x in self.diff[key]]
|
||||
else:
|
||||
|
|
|
@ -42,6 +42,14 @@ class IsCommentProjectAdmin(PermissionComponent):
|
|||
return is_project_admin(request.user, project)
|
||||
|
||||
|
||||
class EpicHistoryPermission(TaigaResourcePermission):
|
||||
retrieve_perms = HasProjectPerm('view_project')
|
||||
edit_comment_perms = IsCommentProjectAdmin() | IsCommentOwner()
|
||||
delete_comment_perms = IsCommentProjectAdmin() | IsCommentOwner()
|
||||
undelete_comment_perms = IsCommentProjectAdmin() | IsCommentDeleter()
|
||||
comment_versions_perms = IsCommentProjectAdmin() | IsCommentOwner()
|
||||
|
||||
|
||||
class UserStoryHistoryPermission(TaigaResourcePermission):
|
||||
retrieve_perms = HasProjectPerm('view_project')
|
||||
edit_comment_perms = IsCommentProjectAdmin() | IsCommentOwner()
|
||||
|
|
|
@ -50,6 +50,8 @@ from .models import HistoryType
|
|||
# Freeze implementatitions
|
||||
from .freeze_impl import project_freezer
|
||||
from .freeze_impl import milestone_freezer
|
||||
from .freeze_impl import epic_freezer
|
||||
from .freeze_impl import epic_related_userstory_freezer
|
||||
from .freeze_impl import userstory_freezer
|
||||
from .freeze_impl import issue_freezer
|
||||
from .freeze_impl import task_freezer
|
||||
|
@ -58,6 +60,8 @@ from .freeze_impl import wikipage_freezer
|
|||
|
||||
from .freeze_impl import project_values
|
||||
from .freeze_impl import milestone_values
|
||||
from .freeze_impl import epic_values
|
||||
from .freeze_impl import epic_related_userstory_values
|
||||
from .freeze_impl import userstory_values
|
||||
from .freeze_impl import issue_values
|
||||
from .freeze_impl import task_values
|
||||
|
@ -76,6 +80,7 @@ _values_impl_map = {}
|
|||
# Not important fields for models (history entries with only
|
||||
# this fields are marked as hidden).
|
||||
_not_important_fields = {
|
||||
"epics.epic": frozenset(["epics_order", "user_stories"]),
|
||||
"userstories.userstory": frozenset(["backlog_order", "sprint_order", "kanban_order"]),
|
||||
"tasks.task": frozenset(["us_order", "taskboard_order"]),
|
||||
}
|
||||
|
@ -337,10 +342,7 @@ def take_snapshot(obj: object, *, comment: str="", user=None, delete: bool=False
|
|||
|
||||
# If diff and comment are empty, do
|
||||
# not create empty history entry
|
||||
if (not fdiff.diff and not comment
|
||||
and old_fobj is not None
|
||||
and entry_type != HistoryType.delete):
|
||||
|
||||
if (not fdiff.diff and not comment and old_fobj is not None and entry_type != HistoryType.delete):
|
||||
return None
|
||||
|
||||
fvals = make_diff_values(typename, fdiff)
|
||||
|
@ -394,8 +396,11 @@ def prefetch_owners_in_history_queryset(qs):
|
|||
return qs
|
||||
|
||||
|
||||
# Freeze & value register
|
||||
register_freeze_implementation("projects.project", project_freezer)
|
||||
register_freeze_implementation("milestones.milestone", milestone_freezer,)
|
||||
register_freeze_implementation("epics.epic", epic_freezer)
|
||||
register_freeze_implementation("epics.relateduserstory", epic_related_userstory_freezer)
|
||||
register_freeze_implementation("userstories.userstory", userstory_freezer)
|
||||
register_freeze_implementation("issues.issue", issue_freezer)
|
||||
register_freeze_implementation("tasks.task", task_freezer)
|
||||
|
@ -403,6 +408,8 @@ register_freeze_implementation("wiki.wikipage", wikipage_freezer)
|
|||
|
||||
register_values_implementation("projects.project", project_values)
|
||||
register_values_implementation("milestones.milestone", milestone_values)
|
||||
register_values_implementation("epics.epic", epic_values)
|
||||
register_values_implementation("epics.relateduserstory", epic_related_userstory_values)
|
||||
register_values_implementation("userstories.userstory", userstory_values)
|
||||
register_values_implementation("issues.issue", issue_values)
|
||||
register_values_implementation("tasks.task", task_values)
|
||||
|
|
|
@ -58,23 +58,13 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W
|
|||
filters.TagsFilter,
|
||||
filters.WatchersFilter,
|
||||
filters.QFilter,
|
||||
filters.OrderByFilterMixin,
|
||||
filters.CreatedDateFilter,
|
||||
filters.ModifiedDateFilter,
|
||||
filters.FinishedDateFilter)
|
||||
retrieve_exclude_filters = (filters.OwnersFilter,
|
||||
filters.AssignedToFilter,
|
||||
filters.StatusesFilter,
|
||||
filters.IssueTypesFilter,
|
||||
filters.SeveritiesFilter,
|
||||
filters.PrioritiesFilter,
|
||||
filters.TagsFilter,
|
||||
filters.WatchersFilter,)
|
||||
|
||||
filters.FinishedDateFilter,
|
||||
filters.OrderByFilterMixin)
|
||||
filter_fields = ("project",
|
||||
"project__slug",
|
||||
"status__is_closed")
|
||||
|
||||
order_by_fields = ("type",
|
||||
"status",
|
||||
"severity",
|
||||
|
|
|
@ -34,6 +34,7 @@ from taiga.permissions.choices import ANON_PERMISSIONS
|
|||
from taiga.projects.choices import BLOCKED_BY_STAFF
|
||||
from taiga.external_apps.models import Application, ApplicationToken
|
||||
from taiga.projects.models import *
|
||||
from taiga.projects.epics.models import *
|
||||
from taiga.projects.milestones.models import *
|
||||
from taiga.projects.notifications.choices import NotifyLevel
|
||||
from taiga.projects.services.stats import get_stats_for_project
|
||||
|
@ -109,6 +110,8 @@ NUM_PROJECTS =getattr(settings, "SAMPLE_DATA_NUM_PROJECTS", 4)
|
|||
NUM_EMPTY_PROJECTS = getattr(settings, "SAMPLE_DATA_NUM_EMPTY_PROJECTS", 2)
|
||||
NUM_BLOCKED_PROJECTS = getattr(settings, "SAMPLE_DATA_NUM_BLOCKED_PROJECTS", 1)
|
||||
NUM_MILESTONES = getattr(settings, "SAMPLE_DATA_NUM_MILESTONES", (1, 5))
|
||||
NUM_EPICS = getattr(settings, "SAMPLE_DATA_NUM_EPICS", (4, 8))
|
||||
NUM_USS_EPICS = getattr(settings, "SAMPLE_DATA_NUM_USS_EPICS", (2, 12))
|
||||
NUM_USS = getattr(settings, "SAMPLE_DATA_NUM_USS", (3, 7))
|
||||
NUM_TASKS_FINISHED = getattr(settings, "SAMPLE_DATA_NUM_TASKS_FINISHED", (1, 5))
|
||||
NUM_TASKS = getattr(settings, "SAMPLE_DATA_NUM_TASKS", (0, 4))
|
||||
|
@ -128,7 +131,7 @@ LOOKING_FOR_PEOPLE_PROJECTS_POSITIONS = [0, 1, 2]
|
|||
class Command(BaseCommand):
|
||||
sd = SampleDataHelper(seed=12345678901)
|
||||
|
||||
@transaction.atomic
|
||||
#@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
# Prevent events emission when sample data is running
|
||||
disconnect_events_signals()
|
||||
|
@ -190,6 +193,13 @@ class Command(BaseCommand):
|
|||
|
||||
# added custom attributes
|
||||
names = set([self.sd.words(1, 3) for i in range(1, 6)])
|
||||
for name in names:
|
||||
EpicCustomAttribute.objects.create(name=name,
|
||||
description=self.sd.words(3, 12),
|
||||
type=self.sd.choice(TYPES_CHOICES)[0],
|
||||
project=project,
|
||||
order=i)
|
||||
names = set([self.sd.words(1, 3) for i in range(1, 6)])
|
||||
for name in names:
|
||||
UserStoryCustomAttribute.objects.create(name=name,
|
||||
description=self.sd.words(3, 12),
|
||||
|
@ -255,6 +265,11 @@ class Command(BaseCommand):
|
|||
if self.sd.boolean():
|
||||
self.create_wiki_page(project, wiki_link.href)
|
||||
|
||||
# create epics
|
||||
for y in range(self.sd.int(*NUM_EPICS)):
|
||||
epic = self.create_epic(project)
|
||||
|
||||
|
||||
|
||||
project.refresh_from_db()
|
||||
|
||||
|
@ -494,6 +509,63 @@ class Command(BaseCommand):
|
|||
|
||||
return milestone
|
||||
|
||||
def create_epic(self, project):
|
||||
epic = Epic.objects.create(subject=self.sd.choice(SUBJECT_CHOICES),
|
||||
project=project,
|
||||
owner=self.sd.db_object_from_queryset(
|
||||
project.memberships.filter(user__isnull=False)).user,
|
||||
description=self.sd.paragraph(),
|
||||
status=self.sd.db_object_from_queryset(project.epic_statuses.filter(
|
||||
is_closed=False)),
|
||||
tags=self.sd.words(1, 3).split(" "))
|
||||
epic.save()
|
||||
|
||||
custom_attributes_values = {str(ca.id): self.get_custom_attributes_value(ca.type) for ca
|
||||
in project.epiccustomattributes.all().order_by("id") if self.sd.boolean()}
|
||||
if custom_attributes_values:
|
||||
epic.custom_attributes_values.attributes_values = custom_attributes_values
|
||||
epic.custom_attributes_values.save()
|
||||
|
||||
for i in range(self.sd.int(*NUM_ATTACHMENTS)):
|
||||
attachment = self.create_attachment(epic, i+1)
|
||||
|
||||
if self.sd.choice([True, True, False, True, True]):
|
||||
epic.assigned_to = self.sd.db_object_from_queryset(project.memberships.filter(
|
||||
user__isnull=False)).user
|
||||
epic.save()
|
||||
|
||||
take_snapshot(epic,
|
||||
comment=self.sd.paragraph(),
|
||||
user=epic.owner)
|
||||
|
||||
# Add history entry
|
||||
epic.status=self.sd.db_object_from_queryset(project.epic_statuses.filter(is_closed=False))
|
||||
epic.save()
|
||||
take_snapshot(epic,
|
||||
comment=self.sd.paragraph(),
|
||||
user=epic.owner)
|
||||
|
||||
self.create_votes(epic)
|
||||
self.create_watchers(epic)
|
||||
|
||||
if self.sd.choice([True, True, False, True, True]):
|
||||
filters = {}
|
||||
if self.sd.choice([True, True, False, True, True]):
|
||||
filters = {"project": epic.project}
|
||||
n = self.sd.choice(list(range(self.sd.int(*NUM_USS_EPICS))))
|
||||
user_stories = UserStory.objects.filter(**filters).order_by("?")[:n]
|
||||
for idx, us in enumerate(list(user_stories)):
|
||||
RelatedUserStory.objects.create(epic=epic,
|
||||
user_story=us,
|
||||
order=idx+1)
|
||||
|
||||
# Add history entry
|
||||
take_snapshot(epic,
|
||||
comment=self.sd.paragraph(),
|
||||
user=epic.owner)
|
||||
|
||||
return epic
|
||||
|
||||
def create_project(self, counter, is_private=None, blocked_code=None):
|
||||
if is_private is None:
|
||||
is_private=self.sd.boolean()
|
||||
|
|
|
@ -110,6 +110,7 @@ class Migration(migrations.Migration):
|
|||
|
||||
dependencies = [
|
||||
('projects', '0029_project_is_looking_for_people'),
|
||||
('likes', '0001_initial'),
|
||||
('timeline', '0004_auto_20150603_1312'),
|
||||
('likes', '0001_initial'),
|
||||
]
|
||||
|
|
|
@ -9,6 +9,9 @@ class Migration(migrations.Migration):
|
|||
|
||||
dependencies = [
|
||||
('projects', '0045_merge'),
|
||||
('userstories', '0011_userstory_tribe_gig'),
|
||||
('tasks', '0009_auto_20151104_1131'),
|
||||
('issues', '0006_remove_issue_watchers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-06-29 14:43
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_pgjson.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0048_auto_20160615_1508'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EpicStatus',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('slug', models.SlugField(blank=True, max_length=255, verbose_name='slug')),
|
||||
('order', models.IntegerField(default=10, verbose_name='order')),
|
||||
('is_closed', models.BooleanField(default=False, verbose_name='is closed')),
|
||||
('color', models.CharField(default='#999999', max_length=20, verbose_name='color')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'epic statuses',
|
||||
'ordering': ['project', 'order', 'name'],
|
||||
'verbose_name': 'epic status',
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='issuestatus',
|
||||
options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'issue status', 'verbose_name_plural': 'issue statuses'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='issuetype',
|
||||
options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'issue type', 'verbose_name_plural': 'issue types'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='membership',
|
||||
options={'ordering': ['project', 'user__full_name', 'user__username', 'user__email', 'email'], 'verbose_name': 'membership', 'verbose_name_plural': 'memberships'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='points',
|
||||
options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'points', 'verbose_name_plural': 'points'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='priority',
|
||||
options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'priority', 'verbose_name_plural': 'priorities'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='severity',
|
||||
options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'severity', 'verbose_name_plural': 'severities'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='taskstatus',
|
||||
options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'task status', 'verbose_name_plural': 'task statuses'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='userstorystatus',
|
||||
options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'user story status', 'verbose_name_plural': 'user story statuses'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='is_epics_activated',
|
||||
field=models.BooleanField(default=False, verbose_name='active epics panel'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projecttemplate',
|
||||
name='epic_statuses',
|
||||
field=django_pgjson.fields.JsonField(blank=True, null=True, verbose_name='epic statuses'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projecttemplate',
|
||||
name='is_epics_activated',
|
||||
field=models.BooleanField(default=False, verbose_name='active epics panel'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='project',
|
||||
name='anon_permissions',
|
||||
field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('view_epics', 'View epic'), ('view_us', 'View user stories'), ('view_tasks', 'View tasks'), ('view_issues', 'View issues'), ('view_wiki_pages', 'View wiki pages'), ('view_wiki_links', 'View wiki links')]), blank=True, default=[], null=True, size=None, verbose_name='anonymous permissions'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='project',
|
||||
name='public_permissions',
|
||||
field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_epics', 'View epic'), ('add_epic', 'Add epic'), ('modify_epic', 'Modify epic'), ('comment_epic', 'Comment epic'), ('delete_epic', 'Delete epic'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=[], null=True, size=None, verbose_name='user permissions'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='epicstatus',
|
||||
name='project',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='epic_statuses', to='projects.Project', verbose_name='project'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='default_epic_status',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='projects.EpicStatus', verbose_name='default epic status'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='epicstatus',
|
||||
unique_together=set([('project', 'slug'), ('project', 'name')]),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-07-20 17:57
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0049_auto_20160629_1443'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='epics_csv_uuid',
|
||||
field=models.CharField(blank=True, db_index=True, default=None, editable=False, max_length=32, null=True),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-07-29 08:02
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0050_project_epics_csv_uuid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='project',
|
||||
options={'ordering': ['name', 'id'], 'verbose_name': 'project', 'verbose_name_plural': 'projects'},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,55 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-08-25 10:19
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import connection, migrations, models
|
||||
|
||||
def update_epic_status(apps, schema_editor):
|
||||
Project = apps.get_model("projects", "Project")
|
||||
project_ids = Project.objects.filter(default_epic_status__isnull=True).values_list("id", flat=True)
|
||||
if not project_ids:
|
||||
return
|
||||
|
||||
values_sql = []
|
||||
for project_id in project_ids:
|
||||
values_sql.append("('New', 'new', 1, false, '#999999', {project_id})".format(project_id=project_id))
|
||||
values_sql.append("('Ready', 'ready', 2, false, '#ff8a84', {project_id})".format(project_id=project_id))
|
||||
values_sql.append("('In progress', 'in-progress', 3, false, '#ff9900', {project_id})".format(project_id=project_id))
|
||||
values_sql.append("('Ready for test', 'ready-for-test', 4, false, '#fcc000', {project_id})".format(project_id=project_id))
|
||||
values_sql.append("('Done', 'done', 5, true, '#669900', {project_id})".format(project_id=project_id))
|
||||
|
||||
sql = """
|
||||
INSERT INTO projects_epicstatus (name, slug, "order", is_closed, color, project_id)
|
||||
VALUES
|
||||
{values};
|
||||
""".format(values=','.join(values_sql))
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(sql)
|
||||
|
||||
|
||||
def update_default_epic_status(apps, schema_editor):
|
||||
sql = """
|
||||
UPDATE projects_project
|
||||
SET default_epic_status_id = projects_epicstatus.id
|
||||
FROM projects_epicstatus
|
||||
WHERE
|
||||
projects_project.default_epic_status_id IS NULL
|
||||
AND
|
||||
projects_epicstatus.order = 1
|
||||
AND
|
||||
projects_epicstatus.project_id = projects_project.id;
|
||||
"""
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(sql)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0051_auto_20160729_0802'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_epic_status),
|
||||
migrations.RunPython(update_default_epic_status)
|
||||
]
|
|
@ -80,6 +80,8 @@ def attach_extra_info(queryset, user=None):
|
|||
|
||||
us_queryset = userstories_utils.attach_total_points(us_queryset)
|
||||
us_queryset = userstories_utils.attach_role_points(us_queryset)
|
||||
us_queryset = userstories_utils.attach_epics(us_queryset)
|
||||
|
||||
us_queryset = attach_total_voters_to_queryset(us_queryset)
|
||||
us_queryset = attach_watchers_to_queryset(us_queryset)
|
||||
us_queryset = attach_total_watchers_to_queryset(us_queryset)
|
||||
|
@ -87,6 +89,7 @@ def attach_extra_info(queryset, user=None):
|
|||
us_queryset = attach_is_watcher_to_queryset(us_queryset, user)
|
||||
|
||||
queryset = queryset.prefetch_related(Prefetch("user_stories", queryset=us_queryset))
|
||||
|
||||
queryset = attach_total_points(queryset)
|
||||
queryset = attach_closed_points(queryset)
|
||||
|
||||
|
@ -95,4 +98,5 @@ def attach_extra_info(queryset, user=None):
|
|||
queryset = attach_total_watchers_to_queryset(queryset)
|
||||
queryset = attach_is_voter_to_queryset(queryset, user)
|
||||
queryset = attach_is_watcher_to_queryset(queryset, user)
|
||||
|
||||
return queryset
|
||||
|
|
|
@ -16,12 +16,13 @@
|
|||
# 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 as _
|
||||
|
||||
from taiga.base.api import serializers
|
||||
from taiga.base.fields import Field, MethodField
|
||||
from taiga.projects import services
|
||||
from taiga.users.serializers import UserBasicInfoSerializer
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
|
||||
class CachedUsersSerializerMixin(serializers.LightSerializer):
|
||||
def to_value(self, instance):
|
||||
|
@ -72,8 +73,34 @@ class StatusExtraInfoSerializerMixin(serializers.LightSerializer):
|
|||
if serialized_status is None:
|
||||
serialized_status = {
|
||||
"name": _(obj.status.name),
|
||||
"color": obj.status.color
|
||||
"color": obj.status.color,
|
||||
"is_closed": obj.status.is_closed
|
||||
}
|
||||
self._serialized_status[obj.status_id] = serialized_status
|
||||
|
||||
return serialized_status
|
||||
|
||||
|
||||
class ProjectExtraInfoSerializerMixin(serializers.LightSerializer):
|
||||
project = Field(attr="project_id")
|
||||
project_extra_info = MethodField()
|
||||
|
||||
def to_value(self, instance):
|
||||
self._serialized_project = {}
|
||||
return super().to_value(instance)
|
||||
|
||||
def get_project_extra_info(self, obj):
|
||||
if obj.project_id is None:
|
||||
return None
|
||||
|
||||
serialized_project = self._serialized_project.get(obj.project_id, None)
|
||||
if serialized_project is None:
|
||||
serialized_project = {
|
||||
"name": obj.project.name,
|
||||
"slug": obj.project.slug,
|
||||
"logo_small_url": services.get_logo_small_thumbnail_url(obj.project),
|
||||
"id": obj.project_id
|
||||
}
|
||||
self._serialized_project[obj.project_id] = serialized_project
|
||||
|
||||
return serialized_project
|
||||
|
|
|
@ -87,6 +87,12 @@ class Membership(models.Model):
|
|||
user_order = models.IntegerField(default=10000, null=False, blank=False,
|
||||
verbose_name=_("user order"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = "membership"
|
||||
verbose_name_plural = "memberships"
|
||||
unique_together = ("user", "project",)
|
||||
ordering = ["project", "user__full_name", "user__username", "user__email", "email"]
|
||||
|
||||
def get_related_people(self):
|
||||
related_people = get_user_model().objects.filter(id=self.user.id)
|
||||
return related_people
|
||||
|
@ -97,24 +103,19 @@ class Membership(models.Model):
|
|||
if self.user and memberships.count() > 0 and memberships[0].id != self.id:
|
||||
raise ValidationError(_('The user is already member of the project'))
|
||||
|
||||
class Meta:
|
||||
verbose_name = "membership"
|
||||
verbose_name_plural = "memberships"
|
||||
unique_together = ("user", "project",)
|
||||
ordering = ["project", "user__full_name", "user__username", "user__email", "email"]
|
||||
permissions = (
|
||||
("view_membership", "Can view membership"),
|
||||
)
|
||||
|
||||
|
||||
class ProjectDefaults(models.Model):
|
||||
default_points = models.OneToOneField("projects.Points", on_delete=models.SET_NULL,
|
||||
related_name="+", null=True, blank=True,
|
||||
verbose_name=_("default points"))
|
||||
default_epic_status = models.OneToOneField("projects.EpicStatus",
|
||||
on_delete=models.SET_NULL, related_name="+",
|
||||
null=True, blank=True,
|
||||
verbose_name=_("default epic status"))
|
||||
default_us_status = models.OneToOneField("projects.UserStoryStatus",
|
||||
on_delete=models.SET_NULL, related_name="+",
|
||||
null=True, blank=True,
|
||||
verbose_name=_("default US status"))
|
||||
default_points = models.OneToOneField("projects.Points", on_delete=models.SET_NULL,
|
||||
related_name="+", null=True, blank=True,
|
||||
verbose_name=_("default points"))
|
||||
default_task_status = models.OneToOneField("projects.TaskStatus",
|
||||
on_delete=models.SET_NULL, related_name="+",
|
||||
null=True, blank=True,
|
||||
|
@ -164,6 +165,8 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model):
|
|||
verbose_name=_("total of milestones"))
|
||||
total_story_points = models.FloatField(null=True, blank=True, verbose_name=_("total story points"))
|
||||
|
||||
is_epics_activated = models.BooleanField(default=False, null=False, blank=True,
|
||||
verbose_name=_("active epics panel"))
|
||||
is_backlog_activated = models.BooleanField(default=True, null=False, blank=True,
|
||||
verbose_name=_("active backlog panel"))
|
||||
is_kanban_activated = models.BooleanField(default=False, null=False, blank=True,
|
||||
|
@ -199,6 +202,8 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model):
|
|||
looking_for_people_note = models.TextField(default="", null=False, blank=True,
|
||||
verbose_name=_("loking for people note"))
|
||||
|
||||
epics_csv_uuid = models.CharField(max_length=32, editable=False, null=True,
|
||||
blank=True, default=None, db_index=True)
|
||||
userstories_csv_uuid = models.CharField(max_length=32, editable=False,
|
||||
null=True, blank=True,
|
||||
default=None, db_index=True)
|
||||
|
@ -253,10 +258,6 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model):
|
|||
["name", "id"],
|
||||
]
|
||||
|
||||
permissions = (
|
||||
("view_project", "Can view project"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
@ -504,6 +505,39 @@ class ProjectModulesConfig(models.Model):
|
|||
ordering = ["project"]
|
||||
|
||||
|
||||
# Epic common Models
|
||||
class EpicStatus(models.Model):
|
||||
name = models.CharField(max_length=255, null=False, blank=False,
|
||||
verbose_name=_("name"))
|
||||
slug = models.SlugField(max_length=255, null=False, blank=True,
|
||||
verbose_name=_("slug"))
|
||||
order = models.IntegerField(default=10, null=False, blank=False,
|
||||
verbose_name=_("order"))
|
||||
is_closed = models.BooleanField(default=False, null=False, blank=True,
|
||||
verbose_name=_("is closed"))
|
||||
color = models.CharField(max_length=20, null=False, blank=False, default="#999999",
|
||||
verbose_name=_("color"))
|
||||
project = models.ForeignKey("Project", null=False, blank=False,
|
||||
related_name="epic_statuses", verbose_name=_("project"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = "epic status"
|
||||
verbose_name_plural = "epic statuses"
|
||||
ordering = ["project", "order", "name"]
|
||||
unique_together = (("project", "name"), ("project", "slug"))
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
qs = self.project.epic_statuses
|
||||
if self.id:
|
||||
qs = qs.exclude(id=self.id)
|
||||
|
||||
self.slug = slugify_uniquely_for_queryset(self.name, qs)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
# User Stories common Models
|
||||
class UserStoryStatus(models.Model):
|
||||
name = models.CharField(max_length=255, null=False, blank=False,
|
||||
|
@ -528,9 +562,6 @@ class UserStoryStatus(models.Model):
|
|||
verbose_name_plural = "user story statuses"
|
||||
ordering = ["project", "order", "name"]
|
||||
unique_together = (("project", "name"), ("project", "slug"))
|
||||
permissions = (
|
||||
("view_userstorystatus", "Can view user story status"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -559,9 +590,6 @@ class Points(models.Model):
|
|||
verbose_name_plural = "points"
|
||||
ordering = ["project", "order", "name"]
|
||||
unique_together = ("project", "name")
|
||||
permissions = (
|
||||
("view_points", "Can view points"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -588,9 +616,6 @@ class TaskStatus(models.Model):
|
|||
verbose_name_plural = "task statuses"
|
||||
ordering = ["project", "order", "name"]
|
||||
unique_together = (("project", "name"), ("project", "slug"))
|
||||
permissions = (
|
||||
("view_taskstatus", "Can view task status"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -621,9 +646,6 @@ class Priority(models.Model):
|
|||
verbose_name_plural = "priorities"
|
||||
ordering = ["project", "order", "name"]
|
||||
unique_together = ("project", "name")
|
||||
permissions = (
|
||||
("view_priority", "Can view priority"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -644,9 +666,6 @@ class Severity(models.Model):
|
|||
verbose_name_plural = "severities"
|
||||
ordering = ["project", "order", "name"]
|
||||
unique_together = ("project", "name")
|
||||
permissions = (
|
||||
("view_severity", "Can view severity"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -671,9 +690,6 @@ class IssueStatus(models.Model):
|
|||
verbose_name_plural = "issue statuses"
|
||||
ordering = ["project", "order", "name"]
|
||||
unique_together = (("project", "name"), ("project", "slug"))
|
||||
permissions = (
|
||||
("view_issuestatus", "Can view issue status"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -702,9 +718,6 @@ class IssueType(models.Model):
|
|||
verbose_name_plural = "issue types"
|
||||
ordering = ["project", "order", "name"]
|
||||
unique_together = ("project", "name")
|
||||
permissions = (
|
||||
("view_issuetype", "Can view issue type"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -728,6 +741,8 @@ class ProjectTemplate(models.Model):
|
|||
blank=False,
|
||||
verbose_name=_("default owner's role"))
|
||||
|
||||
is_epics_activated = models.BooleanField(default=False, null=False, blank=True,
|
||||
verbose_name=_("active epics panel"))
|
||||
is_backlog_activated = models.BooleanField(default=True, null=False, blank=True,
|
||||
verbose_name=_("active backlog panel"))
|
||||
is_kanban_activated = models.BooleanField(default=False, null=False, blank=True,
|
||||
|
@ -743,6 +758,7 @@ class ProjectTemplate(models.Model):
|
|||
verbose_name=_("videoconference extra data"))
|
||||
|
||||
default_options = JsonField(null=True, blank=True, verbose_name=_("default options"))
|
||||
epic_statuses = JsonField(null=True, blank=True, verbose_name=_("epic statuses"))
|
||||
us_statuses = JsonField(null=True, blank=True, verbose_name=_("us statuses"))
|
||||
points = JsonField(null=True, blank=True, verbose_name=_("points"))
|
||||
task_statuses = JsonField(null=True, blank=True, verbose_name=_("task statuses"))
|
||||
|
@ -770,6 +786,7 @@ class ProjectTemplate(models.Model):
|
|||
super().save(*args, **kwargs)
|
||||
|
||||
def load_data_from_project(self, project):
|
||||
self.is_epics_activated = project.is_epics_activated
|
||||
self.is_backlog_activated = project.is_backlog_activated
|
||||
self.is_kanban_activated = project.is_kanban_activated
|
||||
self.is_wiki_activated = project.is_wiki_activated
|
||||
|
@ -779,6 +796,7 @@ class ProjectTemplate(models.Model):
|
|||
|
||||
self.default_options = {
|
||||
"points": getattr(project.default_points, "name", None),
|
||||
"epic_status": getattr(project.default_epic_status, "name", None),
|
||||
"us_status": getattr(project.default_us_status, "name", None),
|
||||
"task_status": getattr(project.default_task_status, "name", None),
|
||||
"issue_status": getattr(project.default_issue_status, "name", None),
|
||||
|
@ -787,6 +805,16 @@ class ProjectTemplate(models.Model):
|
|||
"severity": getattr(project.default_severity, "name", None)
|
||||
}
|
||||
|
||||
self.epic_statuses = []
|
||||
for epic_status in project.epic_statuses.all():
|
||||
self.epic_statuses.append({
|
||||
"name": epic_status.name,
|
||||
"slug": epic_status.slug,
|
||||
"is_closed": epic_status.is_closed,
|
||||
"color": epic_status.color,
|
||||
"order": epic_status.order,
|
||||
})
|
||||
|
||||
self.us_statuses = []
|
||||
for us_status in project.us_statuses.all():
|
||||
self.us_statuses.append({
|
||||
|
@ -874,6 +902,7 @@ class ProjectTemplate(models.Model):
|
|||
raise Exception("Project need an id (must be a saved project)")
|
||||
|
||||
project.creation_template = self
|
||||
project.is_epics_activated = self.is_epics_activated
|
||||
project.is_backlog_activated = self.is_backlog_activated
|
||||
project.is_kanban_activated = self.is_kanban_activated
|
||||
project.is_wiki_activated = self.is_wiki_activated
|
||||
|
@ -881,6 +910,16 @@ class ProjectTemplate(models.Model):
|
|||
project.videoconferences = self.videoconferences
|
||||
project.videoconferences_extra_data = self.videoconferences_extra_data
|
||||
|
||||
for epic_status in self.epic_statuses:
|
||||
EpicStatus.objects.create(
|
||||
name=epic_status["name"],
|
||||
slug=epic_status["slug"],
|
||||
is_closed=epic_status["is_closed"],
|
||||
color=epic_status["color"],
|
||||
order=epic_status["order"],
|
||||
project=project
|
||||
)
|
||||
|
||||
for us_status in self.us_statuses:
|
||||
UserStoryStatus.objects.create(
|
||||
name=us_status["name"],
|
||||
|
@ -955,12 +994,16 @@ class ProjectTemplate(models.Model):
|
|||
permissions=role['permissions']
|
||||
)
|
||||
|
||||
if self.points:
|
||||
project.default_points = Points.objects.get(name=self.default_options["points"],
|
||||
project=project)
|
||||
if self.epic_statuses:
|
||||
project.default_epic_status = EpicStatus.objects.get(name=self.default_options["epic_status"],
|
||||
project=project)
|
||||
|
||||
if self.us_statuses:
|
||||
project.default_us_status = UserStoryStatus.objects.get(name=self.default_options["us_status"],
|
||||
project=project)
|
||||
if self.points:
|
||||
project.default_points = Points.objects.get(name=self.default_options["points"],
|
||||
project=project)
|
||||
|
||||
if self.task_statuses:
|
||||
project.default_task_status = TaskStatus.objects.get(name=self.default_options["task_status"],
|
||||
|
|
|
@ -112,6 +112,7 @@ def _filter_by_permissions(obj, user):
|
|||
UserStory = apps.get_model("userstories", "UserStory")
|
||||
Issue = apps.get_model("issues", "Issue")
|
||||
Task = apps.get_model("tasks", "Task")
|
||||
Epic = apps.get_model("epics", "Epic")
|
||||
WikiPage = apps.get_model("wiki", "WikiPage")
|
||||
|
||||
if isinstance(obj, UserStory):
|
||||
|
@ -120,6 +121,8 @@ def _filter_by_permissions(obj, user):
|
|||
return user_has_perm(user, "view_issues", obj, cache="project")
|
||||
elif isinstance(obj, Task):
|
||||
return user_has_perm(user, "view_tasks", obj, cache="project")
|
||||
elif isinstance(obj, Epic):
|
||||
return user_has_perm(user, "view_epics", obj, cache="project")
|
||||
elif isinstance(obj, WikiPage):
|
||||
return user_has_perm(user, "view_wiki_pages", obj, cache="project")
|
||||
return False
|
||||
|
@ -333,6 +336,7 @@ def get_related_people(obj):
|
|||
related_people = related_people.exclude(is_active=False)
|
||||
related_people = related_people.exclude(is_system=True)
|
||||
related_people = related_people.distinct()
|
||||
|
||||
return related_people
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
{% extends "emails/updates-body-html.jinja" %}
|
||||
|
||||
{% block head %}
|
||||
{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("epic", project.slug, snapshot.ref) %}
|
||||
<h1>Epic updated</h1>
|
||||
<p>Hello {{ user }}, <br> {{ changer }} has updated a epic on {{ project }}</p>
|
||||
<p>Epic #{{ ref }} {{ subject }}</p>
|
||||
<a class="button" href="{{ url }}" title="See Epic #{{ ref }}: {{ subject }} in Taiga">See epic</a>
|
||||
{% endtrans %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,8 @@
|
|||
{% extends "emails/updates-body-text.jinja" %}
|
||||
{% block head %}
|
||||
{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("epic", project.slug, snapshot.ref) %}
|
||||
Epic updated
|
||||
Hello {{ user }}, {{ changer }} has updated a epic on {{ project }}
|
||||
See epic #{{ ref }} {{ subject }} at {{ url }}
|
||||
{% endtrans %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,3 @@
|
|||
{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %}
|
||||
[{{ project }}] Updated the epic #{{ ref }} "{{ subject }}"
|
||||
{% endtrans %}
|
|
@ -0,0 +1,11 @@
|
|||
{% extends "emails/base-body-html.jinja" %}
|
||||
|
||||
{% block body %}
|
||||
{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("epic", project.slug, snapshot.ref) %}
|
||||
<h1>New epic created</h1>
|
||||
<p>Hello {{ user }},<br />{{ changer }} has created a new epic on {{ project }}</p>
|
||||
<p>Epic #{{ ref }} {{ subject }}</p>
|
||||
<a class="button" href="{{ url }}" title="See Epic #{{ ref }} {{ subject }}">See epic</a>
|
||||
<p><small>The Taiga Team</small></p>
|
||||
{% endtrans %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,8 @@
|
|||
{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("epic", project.slug, snapshot.ref) %}
|
||||
New epic created
|
||||
Hello {{ user }}, {{ changer }} has created a new epic on {{ project }}
|
||||
See epic #{{ ref }} {{ subject }} at {{ url }}
|
||||
|
||||
---
|
||||
The Taiga Team
|
||||
{% endtrans %}
|
|
@ -0,0 +1,3 @@
|
|||
{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %}
|
||||
[{{ project }}] Created the epic #{{ ref }} "{{ subject }}"
|
||||
{% endtrans %}
|
|
@ -0,0 +1,11 @@
|
|||
{% extends "emails/base-body-html.jinja" %}
|
||||
|
||||
{% block body %}
|
||||
{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject %}
|
||||
<h1>Epic deleted</h1>
|
||||
<p>Hello {{ user }},<br />{{ changer }} has deleted a epic on {{ project }}</p>
|
||||
<p>Epic #{{ ref }} {{ subject }}</p>
|
||||
<p><small>The Taiga Team</small></p>
|
||||
{% endtrans %}
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject %}
|
||||
Epic deleted
|
||||
Hello {{ user }}, {{ changer }} has deleted a epic on {{ project }}
|
||||
Epic #{{ ref }} {{ subject }}
|
||||
|
||||
---
|
||||
The Taiga Team
|
||||
{% endtrans %}
|
|
@ -0,0 +1,3 @@
|
|||
{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %}
|
||||
[{{ project }}] Deleted the epic #{{ ref }} "{{ subject }}"
|
||||
{% endtrans %}
|
|
@ -62,6 +62,7 @@ class ProjectPermission(TaigaResourcePermission):
|
|||
stats_perms = HasProjectPerm('view_project')
|
||||
member_stats_perms = HasProjectPerm('view_project')
|
||||
issues_stats_perms = HasProjectPerm('view_project')
|
||||
regenerate_epics_csv_uuid_perms = IsProjectAdmin()
|
||||
regenerate_userstories_csv_uuid_perms = IsProjectAdmin()
|
||||
regenerate_issues_csv_uuid_perms = IsProjectAdmin()
|
||||
regenerate_tasks_csv_uuid_perms = IsProjectAdmin()
|
||||
|
@ -109,6 +110,18 @@ class MembershipPermission(TaigaResourcePermission):
|
|||
resend_invitation_perms = IsProjectAdmin()
|
||||
|
||||
|
||||
# Epics
|
||||
|
||||
class EpicStatusPermission(TaigaResourcePermission):
|
||||
retrieve_perms = HasProjectPerm('view_project')
|
||||
create_perms = IsProjectAdmin()
|
||||
update_perms = IsProjectAdmin()
|
||||
partial_update_perms = IsProjectAdmin()
|
||||
destroy_perms = IsProjectAdmin()
|
||||
list_perms = AllowAny()
|
||||
bulk_update_order_perms = IsProjectAdmin()
|
||||
|
||||
|
||||
# User Stories
|
||||
|
||||
class PointsPermission(TaigaResourcePermission):
|
||||
|
|
|
@ -45,6 +45,9 @@ class ResolverViewSet(viewsets.ViewSet):
|
|||
|
||||
result = {"project": project.pk}
|
||||
|
||||
if data["epic"] and user_has_perm(request.user, "view_epics", project):
|
||||
result["epic"] = get_object_or_404(project.epics.all(),
|
||||
ref=data["epic"]).pk
|
||||
if data["us"] and user_has_perm(request.user, "view_us", project):
|
||||
result["us"] = get_object_or_404(project.user_stories.all(),
|
||||
ref=data["us"]).pk
|
||||
|
@ -63,6 +66,11 @@ class ResolverViewSet(viewsets.ViewSet):
|
|||
|
||||
if data["ref"]:
|
||||
ref_found = False # No need to continue once one ref is found
|
||||
if ref_found is False and user_has_perm(request.user, "view_epics", project):
|
||||
epic = project.epics.filter(ref=data["ref"]).first()
|
||||
if epic:
|
||||
result["epic"] = epic.pk
|
||||
ref_found = True
|
||||
if user_has_perm(request.user, "view_us", project):
|
||||
us = project.user_stories.filter(ref=data["ref"]).first()
|
||||
if us:
|
||||
|
|
|
@ -21,10 +21,11 @@ from django.utils import timezone
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
|
||||
from taiga.projects.models import Project
|
||||
from taiga.projects.epics.models import Epic
|
||||
from taiga.projects.userstories.models import UserStory
|
||||
from taiga.projects.tasks.models import Task
|
||||
from taiga.projects.issues.models import Issue
|
||||
from taiga.projects.models import Project
|
||||
|
||||
from . import sequences as seq
|
||||
|
||||
|
@ -103,11 +104,22 @@ def attach_sequence(sender, instance, created, **kwargs):
|
|||
instance.save(update_fields=['ref'])
|
||||
|
||||
|
||||
# Project
|
||||
models.signals.post_save.connect(create_sequence, sender=Project, dispatch_uid="refproj")
|
||||
models.signals.pre_save.connect(store_previous_project, sender=UserStory, dispatch_uid="refus")
|
||||
models.signals.pre_save.connect(store_previous_project, sender=Issue, dispatch_uid="refissue")
|
||||
models.signals.pre_save.connect(store_previous_project, sender=Task, dispatch_uid="reftask")
|
||||
models.signals.post_save.connect(attach_sequence, sender=UserStory, dispatch_uid="refus")
|
||||
models.signals.post_save.connect(attach_sequence, sender=Issue, dispatch_uid="refissue")
|
||||
models.signals.post_save.connect(attach_sequence, sender=Task, dispatch_uid="reftask")
|
||||
models.signals.post_delete.connect(delete_sequence, sender=Project, dispatch_uid="refprojdel")
|
||||
|
||||
# Epic
|
||||
models.signals.pre_save.connect(store_previous_project, sender=Epic, dispatch_uid="refepic")
|
||||
models.signals.post_save.connect(attach_sequence, sender=Epic, dispatch_uid="refepic")
|
||||
|
||||
# User Story
|
||||
models.signals.pre_save.connect(store_previous_project, sender=UserStory, dispatch_uid="refus")
|
||||
models.signals.post_save.connect(attach_sequence, sender=UserStory, dispatch_uid="refus")
|
||||
|
||||
# Task
|
||||
models.signals.pre_save.connect(store_previous_project, sender=Task, dispatch_uid="reftask")
|
||||
models.signals.post_save.connect(attach_sequence, sender=Task, dispatch_uid="reftask")
|
||||
|
||||
# Issue
|
||||
models.signals.pre_save.connect(store_previous_project, sender=Issue, dispatch_uid="refissue")
|
||||
models.signals.post_save.connect(attach_sequence, sender=Issue, dispatch_uid="refissue")
|
||||
|
|
|
@ -24,6 +24,7 @@ from taiga.base.exceptions import ValidationError
|
|||
class ResolverValidator(validators.Validator):
|
||||
project = serializers.CharField(max_length=512, required=True)
|
||||
milestone = serializers.CharField(max_length=512, required=False)
|
||||
epic = serializers.IntegerField(required=False)
|
||||
us = serializers.IntegerField(required=False)
|
||||
task = serializers.IntegerField(required=False)
|
||||
issue = serializers.IntegerField(required=False)
|
||||
|
@ -32,6 +33,8 @@ class ResolverValidator(validators.Validator):
|
|||
|
||||
def validate(self, attrs):
|
||||
if "ref" in attrs:
|
||||
if "epic" in attrs:
|
||||
raise ValidationError("'epic' param is incompatible with 'ref' in the same request")
|
||||
if "us" in attrs:
|
||||
raise ValidationError("'us' param is incompatible with 'ref' in the same request")
|
||||
if "task" in attrs:
|
||||
|
|
|
@ -37,11 +37,13 @@ from .notifications.choices import NotifyLevel
|
|||
# Custom values for selectors
|
||||
######################################################
|
||||
|
||||
class PointsSerializer(serializers.LightSerializer):
|
||||
class EpicStatusSerializer(serializers.LightSerializer):
|
||||
id = Field()
|
||||
name = I18NField()
|
||||
slug = Field()
|
||||
order = Field()
|
||||
value = Field()
|
||||
is_closed = Field()
|
||||
color = Field()
|
||||
project = Field(attr="project_id")
|
||||
|
||||
|
||||
|
@ -57,6 +59,14 @@ class UserStoryStatusSerializer(serializers.LightSerializer):
|
|||
project = Field(attr="project_id")
|
||||
|
||||
|
||||
class PointsSerializer(serializers.LightSerializer):
|
||||
id = Field()
|
||||
name = I18NField()
|
||||
order = Field()
|
||||
value = Field()
|
||||
project = Field(attr="project_id")
|
||||
|
||||
|
||||
class TaskStatusSerializer(serializers.LightSerializer):
|
||||
id = Field()
|
||||
name = I18NField()
|
||||
|
@ -203,6 +213,7 @@ class ProjectSerializer(serializers.LightSerializer):
|
|||
members = MethodField()
|
||||
total_milestones = Field()
|
||||
total_story_points = Field()
|
||||
is_epics_activated = Field()
|
||||
is_backlog_activated = Field()
|
||||
is_kanban_activated = Field()
|
||||
is_wiki_activated = Field()
|
||||
|
@ -230,6 +241,7 @@ class ProjectSerializer(serializers.LightSerializer):
|
|||
tags = Field()
|
||||
tags_colors = MethodField()
|
||||
|
||||
default_epic_status = Field(attr="default_epic_status_id")
|
||||
default_points = Field(attr="default_points_id")
|
||||
default_us_status = Field(attr="default_us_status_id")
|
||||
default_task_status = Field(attr="default_task_status_id")
|
||||
|
@ -281,14 +293,13 @@ class ProjectSerializer(serializers.LightSerializer):
|
|||
def get_my_permissions(self, obj):
|
||||
if "request" in self.context:
|
||||
user = self.context["request"].user
|
||||
return calculate_permissions(
|
||||
is_authenticated=user.is_authenticated(),
|
||||
is_superuser=user.is_superuser,
|
||||
is_member=self.get_i_am_member(obj),
|
||||
is_admin=self.get_i_am_admin(obj),
|
||||
role_permissions=obj.my_role_permissions_attr,
|
||||
anon_permissions=obj.anon_permissions,
|
||||
public_permissions=obj.public_permissions)
|
||||
return calculate_permissions(is_authenticated=user.is_authenticated(),
|
||||
is_superuser=user.is_superuser,
|
||||
is_member=self.get_i_am_member(obj),
|
||||
is_admin=self.get_i_am_admin(obj),
|
||||
role_permissions=obj.my_role_permissions_attr,
|
||||
anon_permissions=obj.anon_permissions,
|
||||
public_permissions=obj.public_permissions)
|
||||
return []
|
||||
|
||||
def get_owner(self, obj):
|
||||
|
@ -342,6 +353,7 @@ class ProjectSerializer(serializers.LightSerializer):
|
|||
|
||||
|
||||
class ProjectDetailSerializer(ProjectSerializer):
|
||||
epic_statuses = Field(attr="epic_statuses_attr")
|
||||
us_statuses = Field(attr="userstory_statuses_attr")
|
||||
points = Field(attr="points_attr")
|
||||
task_statuses = Field(attr="task_statuses_attr")
|
||||
|
@ -349,6 +361,7 @@ class ProjectDetailSerializer(ProjectSerializer):
|
|||
issue_types = Field(attr="issue_types_attr")
|
||||
priorities = Field(attr="priorities_attr")
|
||||
severities = Field(attr="severities_attr")
|
||||
epic_custom_attributes = Field(attr="epic_custom_attributes_attr")
|
||||
userstory_custom_attributes = Field(attr="userstory_custom_attributes_attr")
|
||||
task_custom_attributes = Field(attr="task_custom_attributes_attr")
|
||||
issue_custom_attributes = Field(attr="issue_custom_attributes_attr")
|
||||
|
@ -360,9 +373,10 @@ class ProjectDetailSerializer(ProjectSerializer):
|
|||
# Admin fields
|
||||
is_private_extra_info = MethodField()
|
||||
max_memberships = MethodField()
|
||||
issues_csv_uuid = Field()
|
||||
tasks_csv_uuid = Field()
|
||||
epics_csv_uuid = Field()
|
||||
userstories_csv_uuid = Field()
|
||||
tasks_csv_uuid = Field()
|
||||
issues_csv_uuid = Field()
|
||||
transfer_token = Field()
|
||||
milestones = MethodField()
|
||||
|
||||
|
@ -375,9 +389,9 @@ class ProjectDetailSerializer(ProjectSerializer):
|
|||
|
||||
def to_value(self, instance):
|
||||
# Name attributes must be translated
|
||||
for attr in ["userstory_statuses_attr", "points_attr", "task_statuses_attr",
|
||||
"issue_statuses_attr", "issue_types_attr", "priorities_attr",
|
||||
"severities_attr", "userstory_custom_attributes_attr",
|
||||
for attr in ["epic_statuses_attr", "userstory_statuses_attr", "points_attr", "task_statuses_attr",
|
||||
"issue_statuses_attr", "issue_types_attr", "priorities_attr", "severities_attr",
|
||||
"epic_custom_attributes_attr", "userstory_custom_attributes_attr",
|
||||
"task_custom_attributes_attr", "issue_custom_attributes_attr", "roles_attr"]:
|
||||
|
||||
assert hasattr(instance, attr), "instance must have a {} attribute".format(attr)
|
||||
|
@ -391,8 +405,8 @@ class ProjectDetailSerializer(ProjectSerializer):
|
|||
ret = super().to_value(instance)
|
||||
|
||||
admin_fields = [
|
||||
"is_private_extra_info", "max_memberships", "issues_csv_uuid",
|
||||
"tasks_csv_uuid", "userstories_csv_uuid", "transfer_token"
|
||||
"epics_csv_uuid", "userstories_csv_uuid", "tasks_csv_uuid", "issues_csv_uuid",
|
||||
"is_private_extra_info", "max_memberships", "transfer_token",
|
||||
]
|
||||
|
||||
is_admin_user = False
|
||||
|
@ -420,8 +434,10 @@ class ProjectDetailSerializer(ProjectSerializer):
|
|||
return len(obj.members_attr)
|
||||
|
||||
def get_is_out_of_owner_limits(self, obj):
|
||||
assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute"
|
||||
assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute"
|
||||
assert hasattr(obj, "private_projects_same_owner_attr"), ("instance must have a private_projects_same"
|
||||
"_owner_attr attribute")
|
||||
assert hasattr(obj, "public_projects_same_owner_attr"), ("instance must have a public_projects_same_"
|
||||
"owner_attr attribute")
|
||||
return services.check_if_project_is_out_of_owner_limits(
|
||||
obj,
|
||||
current_memberships=self.get_total_memberships(obj),
|
||||
|
@ -430,8 +446,10 @@ class ProjectDetailSerializer(ProjectSerializer):
|
|||
)
|
||||
|
||||
def get_is_private_extra_info(self, obj):
|
||||
assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute"
|
||||
assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute"
|
||||
assert hasattr(obj, "private_projects_same_owner_attr"), ("instance must have a private_projects_same_"
|
||||
"owner_attr attribute")
|
||||
assert hasattr(obj, "public_projects_same_owner_attr"), ("instance must have a public_projects_same"
|
||||
"_owner_attr attribute")
|
||||
return services.check_if_project_privacity_can_be_changed(
|
||||
obj,
|
||||
current_memberships=self.get_total_memberships(obj),
|
||||
|
@ -456,6 +474,7 @@ class ProjectTemplateSerializer(serializers.LightSerializer):
|
|||
created_date = Field()
|
||||
modified_date = Field()
|
||||
default_owner_role = Field()
|
||||
is_epics_activated = Field()
|
||||
is_backlog_activated = Field()
|
||||
is_kanban_activated = Field()
|
||||
is_wiki_activated = Field()
|
||||
|
@ -463,6 +482,7 @@ class ProjectTemplateSerializer(serializers.LightSerializer):
|
|||
videoconferences = Field()
|
||||
videoconferences_extra_data = Field()
|
||||
default_options = Field()
|
||||
epic_statuses = Field()
|
||||
us_statuses = Field()
|
||||
points = Field()
|
||||
task_statuses = Field()
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
# This makes all code that import services works and
|
||||
# is not the baddest practice ;)
|
||||
|
||||
from .bulk_update_order import update_projects_order_in_bulk
|
||||
from .bulk_update_order import apply_order_updates
|
||||
from .bulk_update_order import bulk_update_severity_order
|
||||
from .bulk_update_order import bulk_update_priority_order
|
||||
from .bulk_update_order import bulk_update_issue_type_order
|
||||
|
@ -27,7 +27,8 @@ from .bulk_update_order import bulk_update_issue_status_order
|
|||
from .bulk_update_order import bulk_update_task_status_order
|
||||
from .bulk_update_order import bulk_update_points_order
|
||||
from .bulk_update_order import bulk_update_userstory_status_order
|
||||
from .bulk_update_order import apply_order_updates
|
||||
from .bulk_update_order import bulk_update_epic_status_order
|
||||
from .bulk_update_order import update_projects_order_in_bulk
|
||||
|
||||
from .filters import get_all_tags
|
||||
|
||||
|
|
|
@ -86,6 +86,23 @@ def update_projects_order_in_bulk(bulk_data: list, field: str, user):
|
|||
db.update_attr_in_bulk_for_ids(memberships_orders, field, model=models.Membership)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def bulk_update_epic_status_order(project, user, data):
|
||||
cursor = connection.cursor()
|
||||
|
||||
sql = """
|
||||
prepare bulk_update_order as update projects_epicstatus set "order" = $1
|
||||
where projects_epicstatus.id = $2 and
|
||||
projects_epicstatus.project_id = $3;
|
||||
"""
|
||||
cursor.execute(sql)
|
||||
for id, order in data:
|
||||
cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);",
|
||||
(order, id, project.id))
|
||||
cursor.execute("DEALLOCATE bulk_update_order")
|
||||
cursor.close()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def bulk_update_userstory_status_order(project, user, data):
|
||||
cursor = connection.cursor()
|
||||
|
|
|
@ -60,11 +60,6 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin,
|
|||
filters.CreatedDateFilter,
|
||||
filters.ModifiedDateFilter,
|
||||
filters.FinishedDateFilter)
|
||||
retrieve_exclude_filters = (filters.OwnersFilter,
|
||||
filters.AssignedToFilter,
|
||||
filters.StatusesFilter,
|
||||
filters.TagsFilter,
|
||||
filters.WatchersFilter)
|
||||
filter_fields = ["user_story",
|
||||
"milestone",
|
||||
"project",
|
||||
|
@ -184,8 +179,8 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin,
|
|||
self.check_permissions(request, "destroy", self.object)
|
||||
self.check_permissions(request, "create", new_project)
|
||||
|
||||
sprint_id = request.DATA.get('milestone', None)
|
||||
if sprint_id is not None and new_project.milestones.filter(pk=sprint_id).count() == 0:
|
||||
milestone_id = request.DATA.get('milestone', None)
|
||||
if milestone_id is not None and new_project.milestones.filter(pk=milestone_id).count() == 0:
|
||||
request.DATA['milestone'] = None
|
||||
|
||||
us_id = request.DATA.get('user_story', None)
|
||||
|
@ -256,24 +251,28 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin,
|
|||
@list_route(methods=["POST"])
|
||||
def bulk_create(self, request, **kwargs):
|
||||
validator = validators.TasksBulkValidator(data=request.DATA)
|
||||
if validator.is_valid():
|
||||
data = validator.data
|
||||
project = Project.objects.get(id=data["project_id"])
|
||||
self.check_permissions(request, 'bulk_create', project)
|
||||
if project.blocked_code is not None:
|
||||
raise exc.Blocked(_("Blocked element"))
|
||||
if not validator.is_valid():
|
||||
return response.BadRequest(validator.errors)
|
||||
|
||||
tasks = services.create_tasks_in_bulk(
|
||||
data["bulk_tasks"], milestone_id=data["sprint_id"], user_story_id=data["us_id"],
|
||||
status_id=data.get("status_id") or project.default_task_status_id,
|
||||
project=project, owner=request.user, callback=self.post_save, precall=self.pre_save)
|
||||
data = validator.data
|
||||
project = Project.objects.get(id=data["project_id"])
|
||||
self.check_permissions(request, 'bulk_create', project)
|
||||
if project.blocked_code is not None:
|
||||
raise exc.Blocked(_("Blocked element"))
|
||||
|
||||
tasks = self.get_queryset().filter(id__in=[i.id for i in tasks])
|
||||
tasks_serialized = self.get_serializer_class()(tasks, many=True)
|
||||
tasks = services.create_tasks_in_bulk(
|
||||
data["bulk_tasks"], milestone_id=data["milestone_id"], user_story_id=data["us_id"],
|
||||
status_id=data.get("status_id") or project.default_task_status_id,
|
||||
project=project, owner=request.user, callback=self.post_save, precall=self.pre_save)
|
||||
|
||||
return response.Ok(tasks_serialized.data)
|
||||
tasks = self.get_queryset().filter(id__in=[i.id for i in tasks])
|
||||
for task in tasks:
|
||||
self.persist_history_snapshot(obj=task)
|
||||
|
||||
tasks_serialized = self.get_serializer_class()(tasks, many=True)
|
||||
|
||||
return response.Ok(tasks_serialized.data)
|
||||
|
||||
return response.BadRequest(validator.errors)
|
||||
|
||||
def _bulk_update_order(self, order_field, request, **kwargs):
|
||||
validator = validators.UpdateTasksOrderBulkValidator(data=request.DATA)
|
||||
|
|
|
@ -29,7 +29,6 @@ from taiga.projects.notifications.mixins import WatchedResourceSerializer
|
|||
from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer
|
||||
from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin
|
||||
|
||||
|
||||
class TaskListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer,
|
||||
OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin,
|
||||
StatusExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin,
|
||||
|
@ -54,6 +53,7 @@ class TaskListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer,
|
|||
is_blocked = Field()
|
||||
blocked_note = Field()
|
||||
is_closed = MethodField()
|
||||
user_story_extra_info = Field()
|
||||
|
||||
def get_milestone_slug(self, obj):
|
||||
return obj.milestone.slug if obj.milestone else None
|
||||
|
|
|
@ -25,8 +25,47 @@ from taiga.projects.votes.utils import attach_total_voters_to_queryset
|
|||
from taiga.projects.votes.utils import attach_is_voter_to_queryset
|
||||
|
||||
|
||||
def attach_extra_info(queryset, user=None, include_attachments=False):
|
||||
def attach_user_story_extra_info(queryset, as_field="user_story_extra_info"):
|
||||
"""Attach userstory extra info as json column to each object of the queryset.
|
||||
|
||||
:param queryset: A Django user stories queryset object.
|
||||
:param as_field: Attach the userstory extra info as an attribute with this name.
|
||||
|
||||
:return: Queryset object with the additional `as_field` field.
|
||||
"""
|
||||
|
||||
model = queryset.model
|
||||
sql = """SELECT row_to_json(u)
|
||||
FROM (SELECT "userstories_userstory"."id" AS "id",
|
||||
"userstories_userstory"."ref" AS "ref",
|
||||
"userstories_userstory"."subject" AS "subject",
|
||||
(SELECT json_agg(row_to_json(t))
|
||||
FROM (SELECT "epics_epic"."id" AS "id",
|
||||
"epics_epic"."ref" AS "ref",
|
||||
"epics_epic"."subject" AS "subject",
|
||||
"epics_epic"."color" AS "color",
|
||||
(SELECT row_to_json(p)
|
||||
FROM (SELECT "projects_project"."id" AS "id",
|
||||
"projects_project"."name" AS "name",
|
||||
"projects_project"."slug" AS "slug"
|
||||
) p
|
||||
) AS "project"
|
||||
FROM "epics_relateduserstory"
|
||||
INNER JOIN "epics_epic"
|
||||
ON "epics_epic"."id" = "epics_relateduserstory"."epic_id"
|
||||
INNER JOIN "projects_project"
|
||||
ON "projects_project"."id" = "epics_epic"."project_id"
|
||||
WHERE "epics_relateduserstory"."user_story_id" = "{tbl}"."user_story_id"
|
||||
ORDER BY "projects_project"."name", "epics_epic"."ref") t) AS "epics"
|
||||
FROM "userstories_userstory"
|
||||
WHERE "userstories_userstory"."id" = "{tbl}"."user_story_id") u"""
|
||||
|
||||
sql = sql.format(tbl=model._meta.db_table)
|
||||
queryset = queryset.extra(select={as_field: sql})
|
||||
return queryset
|
||||
|
||||
|
||||
def attach_extra_info(queryset, user=None, include_attachments=False):
|
||||
if include_attachments:
|
||||
queryset = attach_basic_attachments(queryset)
|
||||
queryset = queryset.extra(select={"include_attachments": "True"})
|
||||
|
@ -36,4 +75,5 @@ def attach_extra_info(queryset, user=None, include_attachments=False):
|
|||
queryset = attach_total_watchers_to_queryset(queryset)
|
||||
queryset = attach_is_voter_to_queryset(queryset, user)
|
||||
queryset = attach_is_watcher_to_queryset(queryset, user)
|
||||
queryset = attach_user_story_extra_info(queryset)
|
||||
return queryset
|
||||
|
|
|
@ -30,7 +30,6 @@ from taiga.projects.tagging.fields import TagsAndTagsColorsField
|
|||
from taiga.projects.userstories.models import UserStory
|
||||
from taiga.projects.validators import ProjectExistsValidator
|
||||
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
|
@ -45,23 +44,27 @@ class TaskValidator(WatchersValidator, EditableWatchedResourceSerializer, valida
|
|||
|
||||
class TasksBulkValidator(ProjectExistsValidator, validators.Validator):
|
||||
project_id = serializers.IntegerField()
|
||||
sprint_id = serializers.IntegerField()
|
||||
milestone_id = serializers.IntegerField()
|
||||
status_id = serializers.IntegerField(required=False)
|
||||
us_id = serializers.IntegerField(required=False)
|
||||
bulk_tasks = serializers.CharField()
|
||||
|
||||
def validate_sprint_id(self, attrs, source):
|
||||
filters = {"project__id": attrs["project_id"]}
|
||||
filters["id"] = attrs["sprint_id"]
|
||||
def validate_milestone_id(self, attrs, source):
|
||||
filters = {
|
||||
"project__id": attrs["project_id"],
|
||||
"id": attrs[source]
|
||||
}
|
||||
|
||||
if not Milestone.objects.filter(**filters).exists():
|
||||
raise ValidationError(_("Invalid sprint id."))
|
||||
raise ValidationError(_("Invalid milestone id."))
|
||||
|
||||
return attrs
|
||||
|
||||
def validate_status_id(self, attrs, source):
|
||||
filters = {"project__id": attrs["project_id"]}
|
||||
filters["id"] = attrs["status_id"]
|
||||
filters = {
|
||||
"project__id": attrs["project_id"],
|
||||
"id": attrs[source]
|
||||
}
|
||||
|
||||
if not TaskStatus.objects.filter(**filters).exists():
|
||||
raise ValidationError(_("Invalid task status id."))
|
||||
|
@ -71,13 +74,13 @@ class TasksBulkValidator(ProjectExistsValidator, validators.Validator):
|
|||
def validate_us_id(self, attrs, source):
|
||||
filters = {"project__id": attrs["project_id"]}
|
||||
|
||||
if "sprint_id" in attrs:
|
||||
filters["milestone__id"] = attrs["sprint_id"]
|
||||
if "milestone_id" in attrs:
|
||||
filters["milestone__id"] = attrs["milestone_id"]
|
||||
|
||||
filters["id"] = attrs["us_id"]
|
||||
|
||||
if not UserStory.objects.filter(**filters).exists():
|
||||
raise ValidationError(_("Invalid sprint id."))
|
||||
raise ValidationError(_("Invalid user story id."))
|
||||
|
||||
return attrs
|
||||
|
||||
|
@ -96,19 +99,55 @@ class UpdateTasksOrderBulkValidator(ProjectExistsValidator, validators.Validator
|
|||
milestone_id = serializers.IntegerField(required=False)
|
||||
bulk_tasks = _TaskOrderBulkValidator(many=True)
|
||||
|
||||
def validate(self, data):
|
||||
filters = {"project__id": data["project_id"]}
|
||||
if "status_id" in data:
|
||||
filters["status__id"] = data["status_id"]
|
||||
if "us_id" in data:
|
||||
filters["user_story__id"] = data["us_id"]
|
||||
if "milestone_id" in data:
|
||||
filters["milestone__id"] = data["milestone_id"]
|
||||
def validate_status_id(self, attrs, source):
|
||||
filters = {"project__id": attrs["project_id"]}
|
||||
filters["id"] = attrs[source]
|
||||
|
||||
filters["id__in"] = [t["task_id"] for t in data["bulk_tasks"]]
|
||||
if not TaskStatus.objects.filter(**filters).exists():
|
||||
raise ValidationError(_("Invalid task status id. The status must belong to "
|
||||
"the same project."))
|
||||
|
||||
return attrs
|
||||
|
||||
def validate_us_id(self, attrs, source):
|
||||
filters = {"project__id": attrs["project_id"]}
|
||||
|
||||
if "milestone_id" in attrs:
|
||||
filters["milestone__id"] = attrs["milestone_id"]
|
||||
|
||||
filters["id"] = attrs[source]
|
||||
|
||||
if not UserStory.objects.filter(**filters).exists():
|
||||
raise ValidationError(_("Invalid user story id. The user story must belong to "
|
||||
"the same project."))
|
||||
|
||||
return attrs
|
||||
|
||||
def validate_milestone_id(self, attrs, source):
|
||||
filters = {
|
||||
"project__id": attrs["project_id"],
|
||||
"id": attrs[source]
|
||||
}
|
||||
|
||||
if not Milestone.objects.filter(**filters).exists():
|
||||
raise ValidationError(_("Invalid milestone id. The milestone must belong to "
|
||||
"the same project."))
|
||||
|
||||
return attrs
|
||||
|
||||
def validate_bulk_tasks(self, attrs, source):
|
||||
filters = {"project__id": attrs["project_id"]}
|
||||
if "status_id" in attrs:
|
||||
filters["status__id"] = attrs["status_id"]
|
||||
if "us_id" in attrs:
|
||||
filters["user_story__id"] = attrs["us_id"]
|
||||
if "milestone_id" in attrs:
|
||||
filters["milestone__id"] = attrs["milestone_id"]
|
||||
|
||||
filters["id__in"] = [t["task_id"] for t in attrs[source]]
|
||||
|
||||
if models.Task.objects.filter(**filters).count() != len(filters["id__in"]):
|
||||
raise ValidationError(_("Invalid task ids. All tasks must belong to the same project and, "
|
||||
"if it exists, to the same status, user story and/or milestone."))
|
||||
|
||||
return data
|
||||
return attrs
|
||||
|
|
|
@ -22,7 +22,7 @@ from django.db import transaction
|
|||
from django.utils.translation import ugettext as _
|
||||
from django.http import HttpResponse
|
||||
|
||||
from taiga.base import filters
|
||||
from taiga.base import filters as base_filters
|
||||
from taiga.base import exceptions as exc
|
||||
from taiga.base import response
|
||||
from taiga.base import status
|
||||
|
@ -45,6 +45,7 @@ from taiga.projects.votes.mixins.viewsets import VotedResourceMixin
|
|||
from taiga.projects.votes.mixins.viewsets import VotersViewSetMixin
|
||||
from taiga.projects.userstories.utils import attach_extra_info
|
||||
|
||||
from . import filters
|
||||
from . import models
|
||||
from . import permissions
|
||||
from . import serializers
|
||||
|
@ -57,22 +58,19 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
|||
validator_class = validators.UserStoryValidator
|
||||
queryset = models.UserStory.objects.all()
|
||||
permission_classes = (permissions.UserStoryPermission,)
|
||||
filter_backends = (filters.CanViewUsFilterBackend,
|
||||
filters.OwnersFilter,
|
||||
filters.AssignedToFilter,
|
||||
filters.StatusesFilter,
|
||||
filters.TagsFilter,
|
||||
filters.WatchersFilter,
|
||||
filters.QFilter,
|
||||
filters.OrderByFilterMixin,
|
||||
filters.CreatedDateFilter,
|
||||
filters.ModifiedDateFilter,
|
||||
filters.FinishDateFilter)
|
||||
retrieve_exclude_filters = (filters.OwnersFilter,
|
||||
filters.AssignedToFilter,
|
||||
filters.StatusesFilter,
|
||||
filters.TagsFilter,
|
||||
filters.WatchersFilter)
|
||||
filter_backends = (base_filters.CanViewUsFilterBackend,
|
||||
filters.EpicsFilter,
|
||||
filters.EpicFilter,
|
||||
base_filters.OwnersFilter,
|
||||
base_filters.AssignedToFilter,
|
||||
base_filters.StatusesFilter,
|
||||
base_filters.TagsFilter,
|
||||
base_filters.WatchersFilter,
|
||||
base_filters.QFilter,
|
||||
base_filters.CreatedDateFilter,
|
||||
base_filters.ModifiedDateFilter,
|
||||
base_filters.FinishDateFilter,
|
||||
base_filters.OrderByFilterMixin)
|
||||
filter_fields = ["project",
|
||||
"project__slug",
|
||||
"milestone",
|
||||
|
@ -83,6 +81,7 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
|||
order_by_fields = ["backlog_order",
|
||||
"sprint_order",
|
||||
"kanban_order",
|
||||
"epic_order",
|
||||
"total_voters"]
|
||||
|
||||
def get_serializer_class(self, *args, **kwargs):
|
||||
|
@ -105,9 +104,11 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
|||
|
||||
include_attachments = "include_attachments" in self.request.QUERY_PARAMS
|
||||
include_tasks = "include_tasks" in self.request.QUERY_PARAMS
|
||||
epic_id = self.request.QUERY_PARAMS.get("epic", None)
|
||||
qs = attach_extra_info(qs, user=self.request.user,
|
||||
include_attachments=include_attachments,
|
||||
include_tasks=include_tasks)
|
||||
include_tasks=include_tasks,
|
||||
epic_id=epic_id)
|
||||
|
||||
return qs
|
||||
|
||||
|
@ -274,16 +275,18 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
|||
project = get_object_or_404(Project, id=project_id)
|
||||
|
||||
filter_backends = self.get_filter_backends()
|
||||
statuses_filter_backends = (f for f in filter_backends if f != filters.StatusesFilter)
|
||||
assigned_to_filter_backends = (f for f in filter_backends if f != filters.AssignedToFilter)
|
||||
owners_filter_backends = (f for f in filter_backends if f != filters.OwnersFilter)
|
||||
statuses_filter_backends = (f for f in filter_backends if f != base_filters.StatusesFilter)
|
||||
assigned_to_filter_backends = (f for f in filter_backends if f != base_filters.AssignedToFilter)
|
||||
owners_filter_backends = (f for f in filter_backends if f != base_filters.OwnersFilter)
|
||||
epics_filter_backends = (f for f in filter_backends if f != filters.EpicsFilter)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
querysets = {
|
||||
"statuses": self.filter_queryset(queryset, filter_backends=statuses_filter_backends),
|
||||
"assigned_to": self.filter_queryset(queryset, filter_backends=assigned_to_filter_backends),
|
||||
"owners": self.filter_queryset(queryset, filter_backends=owners_filter_backends),
|
||||
"tags": self.filter_queryset(queryset)
|
||||
"tags": self.filter_queryset(queryset),
|
||||
"epics": self.filter_queryset(queryset, filter_backends=epics_filter_backends)
|
||||
}
|
||||
return response.Ok(services.get_userstories_filters_data(project, querysets))
|
||||
|
||||
|
@ -337,6 +340,9 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
|||
callback=self.post_save, precall=self.pre_save)
|
||||
|
||||
user_stories = self.get_queryset().filter(id__in=[i.id for i in user_stories])
|
||||
for user_story in user_stories:
|
||||
self.persist_history_snapshot(obj=user_story)
|
||||
|
||||
user_stories_serialized = self.get_serializer_class()(user_stories, many=True)
|
||||
|
||||
return response.Ok(user_stories_serialized.data)
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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 taiga.base import filters
|
||||
|
||||
|
||||
class EpicFilter(filters.BaseRelatedFieldsFilter):
|
||||
filter_name = "epics"
|
||||
param_name = "epic"
|
||||
|
||||
|
||||
class EpicsFilter(filters.BaseRelatedFieldsFilter):
|
||||
filter_name = "epics"
|
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-07-22 10:18
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('userstories', '0012_auto_20160614_1201'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userstory',
|
||||
name='kanban_order',
|
||||
field=models.IntegerField(default=10000, verbose_name='kanban order'),
|
||||
),
|
||||
]
|
|
@ -80,7 +80,7 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod
|
|||
sprint_order = models.IntegerField(null=False, blank=False, default=10000,
|
||||
verbose_name=_("sprint order"))
|
||||
kanban_order = models.IntegerField(null=False, blank=False, default=10000,
|
||||
verbose_name=_("sprint order"))
|
||||
verbose_name=_("kanban order"))
|
||||
|
||||
created_date = models.DateTimeField(null=False, blank=False,
|
||||
verbose_name=_("created date"),
|
||||
|
|
|
@ -22,11 +22,12 @@ from taiga.base.neighbors import NeighborsSerializerMixin
|
|||
|
||||
from taiga.mdrender.service import render as mdrender
|
||||
from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin
|
||||
from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer
|
||||
from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin
|
||||
from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin
|
||||
from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin
|
||||
from taiga.projects.mixins.serializers import ProjectExtraInfoSerializerMixin
|
||||
from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin
|
||||
from taiga.projects.notifications.mixins import WatchedResourceSerializer
|
||||
from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer
|
||||
from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin
|
||||
|
||||
|
||||
|
@ -42,7 +43,7 @@ class OriginIssueSerializer(serializers.LightSerializer):
|
|||
return super().to_value(instance)
|
||||
|
||||
|
||||
class UserStoryListSerializer(
|
||||
class UserStoryListSerializer(ProjectExtraInfoSerializerMixin,
|
||||
VoteResourceSerializerMixin, WatchedResourceSerializer,
|
||||
OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin,
|
||||
StatusExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin,
|
||||
|
@ -76,9 +77,25 @@ class UserStoryListSerializer(
|
|||
total_points = MethodField()
|
||||
comment = MethodField()
|
||||
origin_issue = OriginIssueSerializer(attr="generated_from_issue")
|
||||
|
||||
epics = MethodField()
|
||||
epic_order = MethodField()
|
||||
tasks = MethodField()
|
||||
|
||||
def get_epic_order(self, obj):
|
||||
include_epic_order = getattr(obj, "include_epic_order", False)
|
||||
|
||||
if include_epic_order:
|
||||
assert hasattr(obj, "epic_order"), "instance must have a epic_order attribute"
|
||||
|
||||
if not include_epic_order or obj.epic_order is None:
|
||||
return None
|
||||
|
||||
return obj.epic_order
|
||||
|
||||
def get_epics(self, obj):
|
||||
assert hasattr(obj, "epics_attr"), "instance must have a epics_attr attribute"
|
||||
return obj.epics_attr
|
||||
|
||||
def get_milestone_slug(self, obj):
|
||||
return obj.milestone.slug if obj.milestone else None
|
||||
|
||||
|
|
|
@ -244,7 +244,7 @@ def userstories_to_csv(project, queryset):
|
|||
"tasks": ",".join([str(task.ref) for task in us.tasks.all()]),
|
||||
"tags": ",".join(us.tags or []),
|
||||
"watchers": us.watchers,
|
||||
"voters": us.total_voters,
|
||||
"voters": us.total_voters
|
||||
}
|
||||
|
||||
us_role_points_by_role_id = {us_rp.role.id: us_rp.points.value for us_rp in us.role_points.all()}
|
||||
|
@ -456,6 +456,78 @@ def _get_userstories_tags(project, queryset):
|
|||
return sorted(result, key=itemgetter("name"))
|
||||
|
||||
|
||||
def _get_userstories_epics(project, queryset):
|
||||
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
|
||||
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
|
||||
where = queryset_where_tuple[0]
|
||||
where_params = queryset_where_tuple[1]
|
||||
|
||||
extra_sql = """
|
||||
WITH counters AS (
|
||||
SELECT "epics_relateduserstory"."epic_id" AS "epic_id",
|
||||
count("epics_relateduserstory"."id") AS "counter"
|
||||
FROM "epics_relateduserstory"
|
||||
INNER JOIN "userstories_userstory"
|
||||
ON ("userstories_userstory"."id" = "epics_relateduserstory"."user_story_id")
|
||||
INNER JOIN "projects_project"
|
||||
ON ("userstories_userstory"."project_id" = "projects_project"."id")
|
||||
WHERE {where}
|
||||
GROUP BY "epics_relateduserstory"."epic_id"
|
||||
)
|
||||
SELECT "epics_epic"."id" AS "id",
|
||||
"epics_epic"."ref" AS "ref",
|
||||
"epics_epic"."subject" AS "subject",
|
||||
"epics_epic"."epics_order" AS "order",
|
||||
COALESCE("counters"."counter", 0) AS "counter"
|
||||
FROM "epics_epic"
|
||||
LEFT OUTER JOIN "counters"
|
||||
ON ("counters"."epic_id" = "epics_epic"."id")
|
||||
WHERE "epics_epic"."project_id" = %s
|
||||
|
||||
-- User stories with no epics (return results only if there are userstories)
|
||||
UNION
|
||||
SELECT NULL AS "id",
|
||||
NULL AS "ref",
|
||||
NULL AS "subject",
|
||||
0 AS "order",
|
||||
count(COALESCE("epics_relateduserstory"."epic_id", -1)) AS "counter"
|
||||
FROM "userstories_userstory"
|
||||
LEFT OUTER JOIN "epics_relateduserstory"
|
||||
ON ("epics_relateduserstory"."user_story_id" = "userstories_userstory"."id")
|
||||
INNER JOIN "projects_project"
|
||||
ON ("userstories_userstory"."project_id" = "projects_project"."id")
|
||||
WHERE {where} AND "epics_relateduserstory"."epic_id" IS NULL
|
||||
GROUP BY "epics_relateduserstory"."epic_id"
|
||||
""".format(where=where)
|
||||
|
||||
with closing(connection.cursor()) as cursor:
|
||||
cursor.execute(extra_sql, where_params + [project.id] + where_params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result = []
|
||||
for id, ref, subject, order, count in rows:
|
||||
result.append({
|
||||
"id": id,
|
||||
"ref": ref,
|
||||
"subject": subject,
|
||||
"order": order,
|
||||
"count": count,
|
||||
})
|
||||
|
||||
result = sorted(result, key=itemgetter("order"))
|
||||
|
||||
# Add row when there is no user stories with no epics
|
||||
if result == [] or result[0]["id"] is not None:
|
||||
result.insert(0, {
|
||||
"id": None,
|
||||
"ref": None,
|
||||
"subject": None,
|
||||
"order": 0,
|
||||
"count": 0,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_userstories_filters_data(project, querysets):
|
||||
"""
|
||||
Given a project and an userstories queryset, return a simple data structure
|
||||
|
@ -466,6 +538,7 @@ def get_userstories_filters_data(project, querysets):
|
|||
("assigned_to", _get_userstories_assigned_to(project, querysets["assigned_to"])),
|
||||
("owners", _get_userstories_owners(project, querysets["owners"])),
|
||||
("tags", _get_userstories_tags(project, querysets["tags"])),
|
||||
("epics", _get_userstories_epics(project, querysets["epics"])),
|
||||
])
|
||||
|
||||
return data
|
||||
|
|
|
@ -71,7 +71,7 @@ def attach_tasks(queryset, as_field="tasks_attr"):
|
|||
"""Attach tasks as json column to each object of the queryset.
|
||||
|
||||
:param queryset: A Django user stories queryset object.
|
||||
:param as_field: Attach the role points as an attribute with this name.
|
||||
:param as_field: Attach tasks as an attribute with this name.
|
||||
|
||||
:return: Queryset object with the additional `as_field` field.
|
||||
"""
|
||||
|
@ -99,9 +99,63 @@ def attach_tasks(queryset, as_field="tasks_attr"):
|
|||
return queryset
|
||||
|
||||
|
||||
def attach_extra_info(queryset, user=None, include_attachments=False, include_tasks=False):
|
||||
def attach_epics(queryset, as_field="epics_attr"):
|
||||
"""Attach epics as json column to each object of the queryset.
|
||||
|
||||
:param queryset: A Django user stories queryset object.
|
||||
:param as_field: Attach the epics as an attribute with this name.
|
||||
|
||||
:return: Queryset object with the additional `as_field` field.
|
||||
"""
|
||||
|
||||
model = queryset.model
|
||||
sql = """SELECT json_agg(row_to_json(t))
|
||||
FROM (SELECT "epics_epic"."id" AS "id",
|
||||
"epics_epic"."ref" AS "ref",
|
||||
"epics_epic"."subject" AS "subject",
|
||||
"epics_epic"."color" AS "color",
|
||||
(SELECT row_to_json(p)
|
||||
FROM (SELECT "projects_project"."id" AS "id",
|
||||
"projects_project"."name" AS "name",
|
||||
"projects_project"."slug" AS "slug"
|
||||
) p
|
||||
) AS "project"
|
||||
FROM "epics_relateduserstory"
|
||||
INNER JOIN "epics_epic" ON "epics_epic"."id" = "epics_relateduserstory"."epic_id"
|
||||
INNER JOIN "projects_project" ON "projects_project"."id" = "epics_epic"."project_id"
|
||||
WHERE "epics_relateduserstory"."user_story_id" = {tbl}.id
|
||||
ORDER BY "projects_project"."name", "epics_epic"."ref") t"""
|
||||
|
||||
sql = sql.format(tbl=model._meta.db_table)
|
||||
queryset = queryset.extra(select={as_field: sql})
|
||||
return queryset
|
||||
|
||||
|
||||
def attach_epic_order(queryset, epic_id, as_field="epic_order"):
|
||||
"""Attach epic_order column to each object of the queryset.
|
||||
|
||||
:param queryset: A Django user stories queryset object.
|
||||
:param epic_id: Order related to this epic.
|
||||
:param as_field: Attach order as an attribute with this name.
|
||||
|
||||
:return: Queryset object with the additional `as_field` field.
|
||||
"""
|
||||
|
||||
model = queryset.model
|
||||
sql = """SELECT "epics_relateduserstory"."order" AS "epic_order"
|
||||
FROM "epics_relateduserstory"
|
||||
WHERE "epics_relateduserstory"."user_story_id" = {tbl}.id and
|
||||
"epics_relateduserstory"."epic_id" = {epic_id}"""
|
||||
|
||||
sql = sql.format(tbl=model._meta.db_table, epic_id=epic_id)
|
||||
queryset = queryset.extra(select={as_field: sql})
|
||||
return queryset
|
||||
|
||||
|
||||
def attach_extra_info(queryset, user=None, include_attachments=False, include_tasks=False, epic_id=None):
|
||||
queryset = attach_total_points(queryset)
|
||||
queryset = attach_role_points(queryset)
|
||||
queryset = attach_epics(queryset)
|
||||
|
||||
if include_attachments:
|
||||
queryset = attach_basic_attachments(queryset)
|
||||
|
@ -111,6 +165,10 @@ def attach_extra_info(queryset, user=None, include_attachments=False, include_ta
|
|||
queryset = attach_tasks(queryset)
|
||||
queryset = queryset.extra(select={"include_tasks": "True"})
|
||||
|
||||
if epic_id is not None:
|
||||
queryset = attach_epic_order(queryset, epic_id)
|
||||
queryset = queryset.extra(select={"include_epic_order": "True"})
|
||||
|
||||
queryset = attach_total_voters_to_queryset(queryset)
|
||||
queryset = attach_watchers_to_queryset(queryset)
|
||||
queryset = attach_total_watchers_to_queryset(queryset)
|
||||
|
|
|
@ -20,22 +20,31 @@ from django.utils.translation import ugettext as _
|
|||
|
||||
from taiga.base.api import serializers
|
||||
from taiga.base.api import validators
|
||||
from taiga.base.api.utils import get_object_or_404
|
||||
from taiga.base.exceptions import ValidationError
|
||||
from taiga.base.fields import PgArrayField
|
||||
from taiga.base.fields import PickledObjectField
|
||||
from taiga.projects.milestones.validators import MilestoneExistsValidator
|
||||
from taiga.projects.models import Project
|
||||
from taiga.projects.milestones.models import Milestone
|
||||
from taiga.projects.models import UserStoryStatus
|
||||
from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer
|
||||
from taiga.projects.notifications.validators import WatchersValidator
|
||||
from taiga.projects.tagging.fields import TagsAndTagsColorsField
|
||||
from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator
|
||||
from taiga.projects.userstories.models import UserStory
|
||||
from taiga.projects.validators import ProjectExistsValidator
|
||||
|
||||
from . import models
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class UserStoryExistsValidator:
|
||||
def validate_us_id(self, attrs, source):
|
||||
value = attrs[source]
|
||||
if not models.UserStory.objects.filter(pk=value).exists():
|
||||
msg = _("There's no user story with that id")
|
||||
raise ValidationError(msg)
|
||||
return attrs
|
||||
|
||||
|
||||
class RolePointsField(serializers.WritableField):
|
||||
def to_native(self, obj):
|
||||
return {str(o.role.id): o.points.id for o in obj.all()}
|
||||
|
@ -58,12 +67,23 @@ class UserStoryValidator(WatchersValidator, EditableWatchedResourceSerializer, v
|
|||
read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner')
|
||||
|
||||
|
||||
class UserStoriesBulkValidator(ProjectExistsValidator, UserStoryStatusExistsValidator,
|
||||
validators.Validator):
|
||||
class UserStoriesBulkValidator(ProjectExistsValidator, validators.Validator):
|
||||
project_id = serializers.IntegerField()
|
||||
status_id = serializers.IntegerField(required=False)
|
||||
bulk_stories = serializers.CharField()
|
||||
|
||||
def validate_status_id(self, attrs, source):
|
||||
filters = {
|
||||
"project__id": attrs["project_id"],
|
||||
"id": attrs[source]
|
||||
}
|
||||
|
||||
if not UserStoryStatus.objects.filter(**filters).exists():
|
||||
raise ValidationError(_("Invalid user story status id. The status must belong to "
|
||||
"the same project."))
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
# Order bulk validators
|
||||
|
||||
|
@ -72,27 +92,50 @@ class _UserStoryOrderBulkValidator(validators.Validator):
|
|||
order = serializers.IntegerField()
|
||||
|
||||
|
||||
class UpdateUserStoriesOrderBulkValidator(ProjectExistsValidator, UserStoryStatusExistsValidator,
|
||||
validators.Validator):
|
||||
class UpdateUserStoriesOrderBulkValidator(ProjectExistsValidator, validators.Validator):
|
||||
project_id = serializers.IntegerField()
|
||||
status_id = serializers.IntegerField(required=False)
|
||||
milestone_id = serializers.IntegerField(required=False)
|
||||
bulk_stories = _UserStoryOrderBulkValidator(many=True)
|
||||
|
||||
def validate(self, data):
|
||||
filters = {"project__id": data["project_id"]}
|
||||
if "status_id" in data:
|
||||
filters["status__id"] = data["status_id"]
|
||||
if "milestone_id" in data:
|
||||
filters["milestone__id"] = data["milestone_id"]
|
||||
def validate_status_id(self, attrs, source):
|
||||
filters = {
|
||||
"project__id": attrs["project_id"],
|
||||
"id": attrs[source]
|
||||
}
|
||||
|
||||
filters["id__in"] = [us["us_id"] for us in data["bulk_stories"]]
|
||||
if not UserStoryStatus.objects.filter(**filters).exists():
|
||||
raise ValidationError(_("Invalid user story status id. The status must belong "
|
||||
"to the same project."))
|
||||
|
||||
return attrs
|
||||
|
||||
def validate_milestone_id(self, attrs, source):
|
||||
filters = {
|
||||
"project__id": attrs["project_id"],
|
||||
"id": attrs[source]
|
||||
}
|
||||
|
||||
if not Milestone.objects.filter(**filters).exists():
|
||||
raise ValidationError(_("Invalid milestone id. The milistone must belong to the "
|
||||
"same project."))
|
||||
|
||||
return attrs
|
||||
|
||||
def validate_bulk_stories(self, attrs, source):
|
||||
filters = {"project__id": attrs["project_id"]}
|
||||
if "status_id" in attrs:
|
||||
filters["status__id"] = attrs["status_id"]
|
||||
if "milestone_id" in attrs:
|
||||
filters["milestone__id"] = attrs["milestone_id"]
|
||||
|
||||
filters["id__in"] = [us["us_id"] for us in attrs[source]]
|
||||
|
||||
if models.UserStory.objects.filter(**filters).count() != len(filters["id__in"]):
|
||||
raise ValidationError(_("Invalid user story ids. All stories must belong to the same project and, "
|
||||
"if it exists, to the same status and milestone."))
|
||||
raise ValidationError(_("Invalid user story ids. All stories must belong to the same project "
|
||||
"and, if it exists, to the same status and milestone."))
|
||||
|
||||
return data
|
||||
return attrs
|
||||
|
||||
|
||||
# Milestone bulk validators
|
||||
|
@ -102,22 +145,27 @@ class _UserStoryMilestoneBulkValidator(validators.Validator):
|
|||
order = serializers.IntegerField()
|
||||
|
||||
|
||||
class UpdateMilestoneBulkValidator(ProjectExistsValidator, MilestoneExistsValidator, validators.Validator):
|
||||
class UpdateMilestoneBulkValidator(ProjectExistsValidator, validators.Validator):
|
||||
project_id = serializers.IntegerField()
|
||||
milestone_id = serializers.IntegerField()
|
||||
bulk_stories = _UserStoryMilestoneBulkValidator(many=True)
|
||||
|
||||
def validate(self, data):
|
||||
"""
|
||||
All the userstories and the milestone are from the same project
|
||||
"""
|
||||
user_story_ids = [us["us_id"] for us in data["bulk_stories"]]
|
||||
project = get_object_or_404(Project, pk=data["project_id"])
|
||||
def validate_milestone_id(self, attrs, source):
|
||||
filters = {
|
||||
"project__id": attrs["project_id"],
|
||||
"id": attrs[source]
|
||||
}
|
||||
if not Milestone.objects.filter(**filters).exists():
|
||||
raise ValidationError(_("The milestone isn't valid for the project"))
|
||||
return attrs
|
||||
|
||||
if project.user_stories.filter(id__in=user_story_ids).count() != len(user_story_ids):
|
||||
def validate_bulk_stories(self, attrs, source):
|
||||
filters = {
|
||||
"project__id": attrs["project_id"],
|
||||
"id__in": [us["us_id"] for us in attrs[source]]
|
||||
}
|
||||
|
||||
if UserStory.objects.filter(**filters).count() != len(filters["id__in"]):
|
||||
raise ValidationError(_("All the user stories must be from the same project"))
|
||||
|
||||
if project.milestones.filter(id=data["milestone_id"]).count() != 1:
|
||||
raise ValidationError(_("The milestone isn't valid for the project"))
|
||||
|
||||
return data
|
||||
return attrs
|
||||
|
|
|
@ -117,6 +117,26 @@ def attach_notify_policies(queryset, as_field="notify_policies_attr"):
|
|||
return queryset
|
||||
|
||||
|
||||
def attach_epic_statuses(queryset, as_field="epic_statuses_attr"):
|
||||
"""Attach a json epic statuses representation to each object of the queryset.
|
||||
|
||||
:param queryset: A Django projects queryset object.
|
||||
:param as_field: Attach the epic statuses as an attribute with this name.
|
||||
|
||||
:return: Queryset object with the additional `as_field` field.
|
||||
"""
|
||||
model = queryset.model
|
||||
sql = """SELECT json_agg(row_to_json(projects_epicstatus))
|
||||
FROM projects_epicstatus
|
||||
WHERE
|
||||
projects_epicstatus.project_id = {tbl}.id
|
||||
"""
|
||||
|
||||
sql = sql.format(tbl=model._meta.db_table)
|
||||
queryset = queryset.extra(select={as_field: sql})
|
||||
return queryset
|
||||
|
||||
|
||||
def attach_userstory_statuses(queryset, as_field="userstory_statuses_attr"):
|
||||
"""Attach a json userstory statuses representation to each object of the queryset.
|
||||
|
||||
|
@ -257,6 +277,26 @@ def attach_severities(queryset, as_field="severities_attr"):
|
|||
return queryset
|
||||
|
||||
|
||||
def attach_epic_custom_attributes(queryset, as_field="epic_custom_attributes_attr"):
|
||||
"""Attach a json epic custom attributes representation to each object of the queryset.
|
||||
|
||||
:param queryset: A Django projects queryset object.
|
||||
:param as_field: Attach the epic custom attributes as an attribute with this name.
|
||||
|
||||
:return: Queryset object with the additional `as_field` field.
|
||||
"""
|
||||
model = queryset.model
|
||||
sql = """SELECT json_agg(row_to_json(custom_attributes_epiccustomattribute))
|
||||
FROM custom_attributes_epiccustomattribute
|
||||
WHERE
|
||||
custom_attributes_epiccustomattribute.project_id = {tbl}.id
|
||||
"""
|
||||
|
||||
sql = sql.format(tbl=model._meta.db_table)
|
||||
queryset = queryset.extra(select={as_field: sql})
|
||||
return queryset
|
||||
|
||||
|
||||
def attach_userstory_custom_attributes(queryset, as_field="userstory_custom_attributes_attr"):
|
||||
"""Attach a json userstory custom attributes representation to each object of the queryset.
|
||||
|
||||
|
@ -443,6 +483,7 @@ def attach_extra_info(queryset, user=None):
|
|||
queryset = attach_members(queryset)
|
||||
queryset = attach_closed_milestones(queryset)
|
||||
queryset = attach_notify_policies(queryset)
|
||||
queryset = attach_epic_statuses(queryset)
|
||||
queryset = attach_userstory_statuses(queryset)
|
||||
queryset = attach_points(queryset)
|
||||
queryset = attach_task_statuses(queryset)
|
||||
|
@ -450,6 +491,7 @@ def attach_extra_info(queryset, user=None):
|
|||
queryset = attach_issue_types(queryset)
|
||||
queryset = attach_priorities(queryset)
|
||||
queryset = attach_severities(queryset)
|
||||
queryset = attach_epic_custom_attributes(queryset)
|
||||
queryset = attach_userstory_custom_attributes(queryset)
|
||||
queryset = attach_task_custom_attributes(queryset)
|
||||
queryset = attach_issue_custom_attributes(queryset)
|
||||
|
|
|
@ -24,7 +24,7 @@ from taiga.base.api import validators
|
|||
from taiga.base.exceptions import ValidationError
|
||||
from taiga.base.fields import JsonField
|
||||
from taiga.base.fields import PgArrayField
|
||||
from taiga.users.validators import RoleExistsValidator
|
||||
from taiga.users.models import Role
|
||||
|
||||
from .tagging.fields import TagsField
|
||||
|
||||
|
@ -33,7 +33,6 @@ from . import services
|
|||
|
||||
|
||||
class DuplicatedNameInProjectValidator:
|
||||
|
||||
def validate_name(self, attrs, source):
|
||||
"""
|
||||
Check the points name is not duplicated in the project on creation
|
||||
|
@ -64,31 +63,13 @@ class ProjectExistsValidator:
|
|||
return attrs
|
||||
|
||||
|
||||
class UserStoryStatusExistsValidator:
|
||||
def validate_status_id(self, attrs, source):
|
||||
value = attrs[source]
|
||||
if not models.UserStoryStatus.objects.filter(pk=value).exists():
|
||||
msg = _("There's no user story status with that id")
|
||||
raise ValidationError(msg)
|
||||
return attrs
|
||||
|
||||
|
||||
class TaskStatusExistsValidator:
|
||||
def validate_status_id(self, attrs, source):
|
||||
value = attrs[source]
|
||||
if not models.TaskStatus.objects.filter(pk=value).exists():
|
||||
msg = _("There's no task status with that id")
|
||||
raise ValidationError(msg)
|
||||
return attrs
|
||||
|
||||
|
||||
######################################################
|
||||
# Custom values for selectors
|
||||
######################################################
|
||||
|
||||
class PointsValidator(DuplicatedNameInProjectValidator, validators.ModelValidator):
|
||||
class EpicStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator):
|
||||
class Meta:
|
||||
model = models.Points
|
||||
model = models.EpicStatus
|
||||
|
||||
|
||||
class UserStoryStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator):
|
||||
|
@ -96,6 +77,11 @@ class UserStoryStatusValidator(DuplicatedNameInProjectValidator, validators.Mode
|
|||
model = models.UserStoryStatus
|
||||
|
||||
|
||||
class PointsValidator(DuplicatedNameInProjectValidator, validators.ModelValidator):
|
||||
class Meta:
|
||||
model = models.Points
|
||||
|
||||
|
||||
class TaskStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator):
|
||||
class Meta:
|
||||
model = models.TaskStatus
|
||||
|
@ -195,16 +181,27 @@ class MembershipAdminValidator(MembershipValidator):
|
|||
exclude = ("token",)
|
||||
|
||||
|
||||
class MemberBulkValidator(RoleExistsValidator, validators.Validator):
|
||||
class _MemberBulkValidator(validators.Validator):
|
||||
email = serializers.EmailField()
|
||||
role_id = serializers.IntegerField()
|
||||
|
||||
|
||||
class MembersBulkValidator(ProjectExistsValidator, validators.Validator):
|
||||
project_id = serializers.IntegerField()
|
||||
bulk_memberships = MemberBulkValidator(many=True)
|
||||
bulk_memberships = _MemberBulkValidator(many=True)
|
||||
invitation_extra_text = serializers.CharField(required=False, max_length=255)
|
||||
|
||||
def validate_bulk_memberships(self, attrs, source):
|
||||
filters = {
|
||||
"project__id": attrs["project_id"],
|
||||
"id__in": [r["role_id"] for r in attrs["bulk_memberships"]]
|
||||
}
|
||||
|
||||
if Role.objects.filter(**filters).count() != len(set(filters["id__in"])):
|
||||
raise ValidationError(_("Invalid role ids. All roles must belong to the same project."))
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
######################################################
|
||||
# Projects
|
||||
|
|
|
@ -54,6 +54,7 @@ from taiga.projects.api import ProjectFansViewSet
|
|||
from taiga.projects.api import ProjectWatchersViewSet
|
||||
from taiga.projects.api import MembershipViewSet
|
||||
from taiga.projects.api import InvitationViewSet
|
||||
from taiga.projects.api import EpicStatusViewSet
|
||||
from taiga.projects.api import UserStoryStatusViewSet
|
||||
from taiga.projects.api import PointsViewSet
|
||||
from taiga.projects.api import TaskStatusViewSet
|
||||
|
@ -69,6 +70,7 @@ router.register(r"projects/(?P<resource_id>\d+)/watchers", ProjectWatchersViewSe
|
|||
router.register(r"project-templates", ProjectTemplateViewSet, base_name="project-templates")
|
||||
router.register(r"memberships", MembershipViewSet, base_name="memberships")
|
||||
router.register(r"invitations", InvitationViewSet, base_name="invitations")
|
||||
router.register(r"epic-statuses", EpicStatusViewSet, base_name="epic-statuses")
|
||||
router.register(r"userstory-statuses", UserStoryStatusViewSet, base_name="userstory-statuses")
|
||||
router.register(r"points", PointsViewSet, base_name="points")
|
||||
router.register(r"task-statuses", TaskStatusViewSet, base_name="task-statuses")
|
||||
|
@ -79,13 +81,18 @@ router.register(r"severities",SeverityViewSet , base_name="severities")
|
|||
|
||||
|
||||
# Custom Attributes
|
||||
from taiga.projects.custom_attributes.api import EpicCustomAttributeViewSet
|
||||
from taiga.projects.custom_attributes.api import UserStoryCustomAttributeViewSet
|
||||
from taiga.projects.custom_attributes.api import TaskCustomAttributeViewSet
|
||||
from taiga.projects.custom_attributes.api import IssueCustomAttributeViewSet
|
||||
|
||||
from taiga.projects.custom_attributes.api import EpicCustomAttributesValuesViewSet
|
||||
from taiga.projects.custom_attributes.api import UserStoryCustomAttributesValuesViewSet
|
||||
from taiga.projects.custom_attributes.api import TaskCustomAttributesValuesViewSet
|
||||
from taiga.projects.custom_attributes.api import IssueCustomAttributesValuesViewSet
|
||||
|
||||
router.register(r"epic-custom-attributes", EpicCustomAttributeViewSet,
|
||||
base_name="epic-custom-attributes")
|
||||
router.register(r"userstory-custom-attributes", UserStoryCustomAttributeViewSet,
|
||||
base_name="userstory-custom-attributes")
|
||||
router.register(r"task-custom-attributes", TaskCustomAttributeViewSet,
|
||||
|
@ -93,6 +100,8 @@ router.register(r"task-custom-attributes", TaskCustomAttributeViewSet,
|
|||
router.register(r"issue-custom-attributes", IssueCustomAttributeViewSet,
|
||||
base_name="issue-custom-attributes")
|
||||
|
||||
router.register(r"epics/custom-attributes-values", EpicCustomAttributesValuesViewSet,
|
||||
base_name="epic-custom-attributes-values")
|
||||
router.register(r"userstories/custom-attributes-values", UserStoryCustomAttributesValuesViewSet,
|
||||
base_name="userstory-custom-attributes-values")
|
||||
router.register(r"tasks/custom-attributes-values", TaskCustomAttributesValuesViewSet,
|
||||
|
@ -114,58 +123,101 @@ router.register(r"resolver", ResolverViewSet, base_name="resolver")
|
|||
|
||||
|
||||
# Attachments
|
||||
from taiga.projects.attachments.api import EpicAttachmentViewSet
|
||||
from taiga.projects.attachments.api import UserStoryAttachmentViewSet
|
||||
from taiga.projects.attachments.api import IssueAttachmentViewSet
|
||||
from taiga.projects.attachments.api import TaskAttachmentViewSet
|
||||
from taiga.projects.attachments.api import WikiAttachmentViewSet
|
||||
|
||||
router.register(r"epics/attachments", EpicAttachmentViewSet,
|
||||
base_name="epic-attachments")
|
||||
router.register(r"userstories/attachments", UserStoryAttachmentViewSet,
|
||||
base_name="userstory-attachments")
|
||||
router.register(r"tasks/attachments", TaskAttachmentViewSet, base_name="task-attachments")
|
||||
router.register(r"issues/attachments", IssueAttachmentViewSet, base_name="issue-attachments")
|
||||
router.register(r"wiki/attachments", WikiAttachmentViewSet, base_name="wiki-attachments")
|
||||
router.register(r"tasks/attachments", TaskAttachmentViewSet,
|
||||
base_name="task-attachments")
|
||||
router.register(r"issues/attachments", IssueAttachmentViewSet,
|
||||
base_name="issue-attachments")
|
||||
router.register(r"wiki/attachments", WikiAttachmentViewSet,
|
||||
base_name="wiki-attachments")
|
||||
|
||||
|
||||
# Project components
|
||||
from taiga.projects.milestones.api import MilestoneViewSet
|
||||
from taiga.projects.milestones.api import MilestoneWatchersViewSet
|
||||
|
||||
from taiga.projects.epics.api import EpicViewSet
|
||||
from taiga.projects.epics.api import EpicRelatedUserStoryViewSet
|
||||
from taiga.projects.epics.api import EpicVotersViewSet
|
||||
from taiga.projects.epics.api import EpicWatchersViewSet
|
||||
|
||||
from taiga.projects.userstories.api import UserStoryViewSet
|
||||
from taiga.projects.userstories.api import UserStoryVotersViewSet
|
||||
from taiga.projects.userstories.api import UserStoryWatchersViewSet
|
||||
|
||||
from taiga.projects.tasks.api import TaskViewSet
|
||||
from taiga.projects.tasks.api import TaskVotersViewSet
|
||||
from taiga.projects.tasks.api import TaskWatchersViewSet
|
||||
|
||||
from taiga.projects.issues.api import IssueViewSet
|
||||
from taiga.projects.issues.api import IssueVotersViewSet
|
||||
from taiga.projects.issues.api import IssueWatchersViewSet
|
||||
|
||||
from taiga.projects.wiki.api import WikiViewSet
|
||||
from taiga.projects.wiki.api import WikiLinkViewSet
|
||||
from taiga.projects.wiki.api import WikiWatchersViewSet
|
||||
|
||||
router.register(r"milestones", MilestoneViewSet, base_name="milestones")
|
||||
router.register(r"milestones/(?P<resource_id>\d+)/watchers", MilestoneWatchersViewSet, base_name="milestone-watchers")
|
||||
router.register(r"userstories", UserStoryViewSet, base_name="userstories")
|
||||
router.register(r"userstories/(?P<resource_id>\d+)/voters", UserStoryVotersViewSet, base_name="userstory-voters")
|
||||
router.register(r"userstories/(?P<resource_id>\d+)/watchers", UserStoryWatchersViewSet, base_name="userstory-watchers")
|
||||
router.register(r"tasks", TaskViewSet, base_name="tasks")
|
||||
router.register(r"tasks/(?P<resource_id>\d+)/voters", TaskVotersViewSet, base_name="task-voters")
|
||||
router.register(r"tasks/(?P<resource_id>\d+)/watchers", TaskWatchersViewSet, base_name="task-watchers")
|
||||
router.register(r"issues", IssueViewSet, base_name="issues")
|
||||
router.register(r"issues/(?P<resource_id>\d+)/voters", IssueVotersViewSet, base_name="issue-voters")
|
||||
router.register(r"issues/(?P<resource_id>\d+)/watchers", IssueWatchersViewSet, base_name="issue-watchers")
|
||||
router.register(r"wiki", WikiViewSet, base_name="wiki")
|
||||
router.register(r"wiki/(?P<resource_id>\d+)/watchers", WikiWatchersViewSet, base_name="wiki-watchers")
|
||||
router.register(r"wiki-links", WikiLinkViewSet, base_name="wiki-links")
|
||||
router.register(r"milestones", MilestoneViewSet,
|
||||
base_name="milestones")
|
||||
router.register(r"milestones/(?P<resource_id>\d+)/watchers", MilestoneWatchersViewSet,
|
||||
base_name="milestone-watchers")
|
||||
|
||||
router.register(r"epics", EpicViewSet, base_name="epics")\
|
||||
.register(r"related_userstories", EpicRelatedUserStoryViewSet,
|
||||
base_name="epics-related-userstories",
|
||||
parents_query_lookups=["epic"])
|
||||
|
||||
router.register(r"epics/(?P<resource_id>\d+)/voters", EpicVotersViewSet,
|
||||
base_name="epic-voters")
|
||||
router.register(r"epics/(?P<resource_id>\d+)/watchers", EpicWatchersViewSet,
|
||||
base_name="epic-watchers")
|
||||
|
||||
router.register(r"userstories", UserStoryViewSet,
|
||||
base_name="userstories")
|
||||
router.register(r"userstories/(?P<resource_id>\d+)/voters", UserStoryVotersViewSet,
|
||||
base_name="userstory-voters")
|
||||
router.register(r"userstories/(?P<resource_id>\d+)/watchers", UserStoryWatchersViewSet,
|
||||
base_name="userstory-watchers")
|
||||
|
||||
router.register(r"tasks", TaskViewSet,
|
||||
base_name="tasks")
|
||||
router.register(r"tasks/(?P<resource_id>\d+)/voters", TaskVotersViewSet,
|
||||
base_name="task-voters")
|
||||
router.register(r"tasks/(?P<resource_id>\d+)/watchers", TaskWatchersViewSet,
|
||||
base_name="task-watchers")
|
||||
|
||||
router.register(r"issues", IssueViewSet,
|
||||
base_name="issues")
|
||||
router.register(r"issues/(?P<resource_id>\d+)/voters", IssueVotersViewSet,
|
||||
base_name="issue-voters")
|
||||
router.register(r"issues/(?P<resource_id>\d+)/watchers", IssueWatchersViewSet,
|
||||
base_name="issue-watchers")
|
||||
|
||||
router.register(r"wiki", WikiViewSet,
|
||||
base_name="wiki")
|
||||
router.register(r"wiki/(?P<resource_id>\d+)/watchers", WikiWatchersViewSet,
|
||||
base_name="wiki-watchers")
|
||||
router.register(r"wiki-links", WikiLinkViewSet,
|
||||
base_name="wiki-links")
|
||||
|
||||
|
||||
# History & Components
|
||||
from taiga.projects.history.api import EpicHistory
|
||||
from taiga.projects.history.api import UserStoryHistory
|
||||
from taiga.projects.history.api import TaskHistory
|
||||
from taiga.projects.history.api import IssueHistory
|
||||
from taiga.projects.history.api import WikiHistory
|
||||
|
||||
router.register(r"history/epic", EpicHistory, base_name="epic-history")
|
||||
router.register(r"history/userstory", UserStoryHistory, base_name="userstory-history")
|
||||
router.register(r"history/task", TaskHistory, base_name="task-history")
|
||||
router.register(r"history/issue", IssueHistory, base_name="issue-history")
|
||||
|
@ -223,11 +275,11 @@ router.register(r"exporter", ProjectExporterViewSet, base_name="exporter")
|
|||
|
||||
# External apps
|
||||
from taiga.external_apps.api import Application, ApplicationToken
|
||||
|
||||
router.register(r"applications", Application, base_name="applications")
|
||||
router.register(r"application-tokens", ApplicationToken, base_name="application-tokens")
|
||||
|
||||
|
||||
|
||||
# Stats
|
||||
# - see taiga.stats.routers and taiga.stats.apps
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue