Merge pull request #770 from taigaio/us/1563/epics

US #1563: Epics
remotes/origin/issue/4795/notification_even_they_are_disabled
David Barragán Merino 2016-09-19 11:42:36 +02:00 committed by David Barragán Merino
commit b8befbc28f
145 changed files with 8706 additions and 1361 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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:
@ -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"]}
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
@ -606,6 +698,7 @@ 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
(enough_slots, error_message) = users_service.has_available_slot_for_import_new_project(
owner,
@ -655,6 +748,7 @@ def _populate_project_object(project, data):
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)

View File

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

View File

@ -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 = {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,8 @@ class Migration(migrations.Migration):
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[])
reverse_sql="""
DROP FUNCTION IF EXISTS "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[])
CASCADE;"""
),
@ -58,7 +59,6 @@ class Migration(migrations.Migration):
RETURN NULL;
END; $clean_key_in_custom_attributes_values$
LANGUAGE plpgsql;
"""
),
@ -72,7 +72,8 @@ class Migration(migrations.Migration):
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');
"""
),
@ -86,7 +87,8 @@ class Migration(migrations.Migration):
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');
"""
),
@ -100,7 +102,8 @@ class Migration(migrations.Migration):
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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

315
taiga/projects/epics/api.py Normal file
View File

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

View File

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

View File

@ -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();
"""
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %}
[{{ project }}] Updated the epic #{{ ref }} "{{ subject }}"
{% endtrans %}

View File

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

View File

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

View File

@ -0,0 +1,3 @@
{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %}
[{{ project }}] Created the epic #{{ ref }} "{{ subject }}"
{% endtrans %}

View File

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

View File

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

View File

@ -0,0 +1,3 @@
{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %}
[{{ project }}] Deleted the epic #{{ ref }} "{{ subject }}"
{% endtrans %}

View File

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

View File

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

View File

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

View File

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

View File

@ -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,8 +293,7 @@ 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(),
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),
@ -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()

View File

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

View File

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

View File

@ -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,7 +251,9 @@ 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():
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)
@ -264,16 +261,18 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin,
raise exc.Blocked(_("Blocked element"))
tasks = services.create_tasks_in_bulk(
data["bulk_tasks"], milestone_id=data["sprint_id"], user_story_id=data["us_id"],
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)
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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