diff --git a/CHANGELOG.md b/CHANGELOG.md
index d043cbc5..c4bdf62a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,11 @@
- New API endpoints over projects to create, rename, edit, delete and mix tags.
- Tag color assignation is not automatic.
- Select a color (or not) to a tag when add it to stories, issues and tasks.
+- Now comment owners and project admins can edit existing comments with the history Entry endpoint.
+- Add a new permissions to allow add comments instead of use the existent modify permission for this purpose.
+- Include created, modified and finished dates for tasks in CSV reports
+- User stories and tasks listing API call support extra params to include more data (tasks and attachemnts and attachments, respectively)
+
### Misc
- [API] Improve performance of some calls over list.
diff --git a/taiga/base/filters.py b/taiga/base/filters.py
index 1cd19e64..e26e7911 100644
--- a/taiga/base/filters.py
+++ b/taiga/base/filters.py
@@ -152,7 +152,7 @@ class PermissionBasedFilterBackend(FilterBackend):
else:
qs = qs.filter(project__anon_permissions__contains=[self.permission])
- return super().filter_queryset(request, qs.distinct(), view)
+ return super().filter_queryset(request, qs, view)
class CanViewProjectFilterBackend(PermissionBasedFilterBackend):
@@ -268,7 +268,7 @@ class MembersFilterBackend(PermissionBasedFilterBackend):
qs = qs.filter(memberships__project__anon_permissions__contains=[self.permission])
- return qs.distinct()
+ return qs
#####################################################################
@@ -307,7 +307,7 @@ class IsProjectAdminFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend
else:
queryset = queryset.filter(project_id__in=project_ids)
- return super().filter_queryset(request, queryset.distinct(), view)
+ return super().filter_queryset(request, queryset, view)
class IsProjectAdminFromWebhookLogFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend):
diff --git a/taiga/projects/attachments/migrations/0006_auto_20160617_1233.py b/taiga/projects/attachments/migrations/0006_auto_20160617_1233.py
new file mode 100644
index 00000000..ee291a9f
--- /dev/null
+++ b/taiga/projects/attachments/migrations/0006_auto_20160617_1233.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.2 on 2016-06-17 12:33
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('attachments', '0005_attachment_sha1'),
+ ]
+
+ operations = [
+ migrations.AlterIndexTogether(
+ name='attachment',
+ index_together=set([('content_type', 'object_id')]),
+ ),
+ ]
diff --git a/taiga/projects/attachments/models.py b/taiga/projects/attachments/models.py
index 8bbbee16..a5110a4b 100644
--- a/taiga/projects/attachments/models.py
+++ b/taiga/projects/attachments/models.py
@@ -70,6 +70,7 @@ class Attachment(models.Model):
permissions = (
("view_attachment", "Can view attachment"),
)
+ index_together = [("content_type", "object_id")]
def __init__(self, *args, **kwargs):
super(Attachment, self).__init__(*args, **kwargs)
diff --git a/taiga/projects/attachments/serializers.py b/taiga/projects/attachments/serializers.py
index 904498a9..6c5ee05b 100644
--- a/taiga/projects/attachments/serializers.py
+++ b/taiga/projects/attachments/serializers.py
@@ -16,11 +16,17 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+from django.conf import settings
+
from taiga.base.api import serializers
+from taiga.base.utils.thumbnails import get_thumbnail_url
from . import services
from . import models
+import json
+import serpy
+
class AttachmentSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField("get_url")
@@ -37,5 +43,31 @@ class AttachmentSerializer(serializers.ModelSerializer):
def get_url(self, obj):
return obj.attached_file.url
+
def get_thumbnail_card_url(self, obj):
return services.get_card_image_thumbnail_url(obj)
+
+
+class ListBasicAttachmentsInfoSerializerMixin(serpy.Serializer):
+ """
+ Assumptions:
+ - The queryset has an attribute called "include_attachments" indicating if the attachments array should contain information
+ about the related elements, otherwise it will be empty
+ - The method attach_basic_attachments has been used to include the necessary
+ json data about the attachments in the "attachments_attr" column
+ """
+ attachments = serpy.MethodField()
+
+ def get_attachments(self, obj):
+ include_attachments = getattr(obj, "include_attachments", False)
+
+ if include_attachments:
+ assert hasattr(obj, "attachments_attr"), "instance must have a attachments_attr attribute"
+
+ if not include_attachments or obj.attachments_attr is None:
+ return []
+
+ for at in obj.attachments_attr:
+ at["thumbnail_card_url"] = get_thumbnail_url(at["attached_file"], settings.THN_ATTACHMENT_CARD)
+
+ return obj.attachments_attr
diff --git a/taiga/projects/attachments/utils.py b/taiga/projects/attachments/utils.py
new file mode 100644
index 00000000..33e36c44
--- /dev/null
+++ b/taiga/projects/attachments/utils.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2014-2016 Andrey Antukh
+# Copyright (C) 2014-2016 Jesús Espino
+# Copyright (C) 2014-2016 David Barragán
+# Copyright (C) 2014-2016 Alejandro Alonso
+# Copyright (C) 2014-2016 Anler Hernández
+# 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 .
+
+from django.apps import apps
+
+def attach_basic_attachments(queryset, as_field="attachments_attr"):
+ """Attach basic attachments info 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.
+
+ :return: Queryset object with the additional `as_field` field.
+ """
+
+ model = queryset.model
+ type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model)
+
+ sql = """SELECT json_agg(row_to_json(t))
+ FROM(
+ SELECT
+ attachments_attachment.id,
+ attachments_attachment.attached_file
+ FROM attachments_attachment
+ WHERE attachments_attachment.object_id = {tbl}.id AND attachments_attachment.content_type_id = {type_id}) t"""
+
+ sql = sql.format(tbl=model._meta.db_table, type_id=type.id)
+ queryset = queryset.extra(select={as_field: sql})
+ return queryset
diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py
index 4243ea31..099171a1 100644
--- a/taiga/projects/issues/serializers.py
+++ b/taiga/projects/issues/serializers.py
@@ -21,9 +21,9 @@ from taiga.base.fields import PgArrayField
from taiga.base.neighbors import NeighborsSerializerMixin
from taiga.mdrender.service import render as mdrender
-from taiga.projects.mixins.serializers import OwnerExtraInfoMixin
-from taiga.projects.mixins.serializers import AssigedToExtraInfoMixin
-from taiga.projects.mixins.serializers import StatusExtraInfoMixin
+from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin
+from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin
+from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer
from taiga.projects.notifications.validators import WatchersValidator
@@ -39,6 +39,7 @@ from . import models
import serpy
+
class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
serializers.ModelSerializer):
tags = TagsAndTagsColorsField(default=[], required=False)
@@ -75,8 +76,8 @@ class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWa
class IssueListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer,
- OwnerExtraInfoMixin, AssigedToExtraInfoMixin, StatusExtraInfoMixin,
- serializers.LightSerializer):
+ ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin,
+ ListStatusExtraInfoSerializerMixin, serializers.LightSerializer):
id = serpy.Field()
ref = serpy.Field()
severity = serpy.Field(attr="severity_id")
diff --git a/taiga/projects/issues/services.py b/taiga/projects/issues/services.py
index a494b1f4..56790e82 100644
--- a/taiga/projects/issues/services.py
+++ b/taiga/projects/issues/services.py
@@ -35,6 +35,10 @@ from taiga.projects.notifications.utils import attach_watchers_to_queryset
from . import models
+#####################################################
+# Bulk actions
+#####################################################
+
def get_issues_from_bulk(bulk_data, **additional_fields):
"""Convert `bulk_data` into a list of issues.
@@ -83,6 +87,10 @@ def update_issues_order_in_bulk(bulk_data):
db.update_in_bulk_with_ids(issue_ids, new_order_values, model=models.Issue)
+#####################################################
+# CSV
+#####################################################
+
def issues_to_csv(project, queryset):
csv_data = io.StringIO()
fieldnames = ["ref", "subject", "description", "sprint", "sprint_estimated_start",
@@ -143,6 +151,10 @@ def issues_to_csv(project, queryset):
return csv_data
+#####################################################
+# Api filter data
+#####################################################
+
def _get_issues_statuses(project, queryset):
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
@@ -394,7 +406,7 @@ def _get_issues_owners(project, queryset):
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)
+ WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL
-- System users
UNION
@@ -423,16 +435,44 @@ def _get_issues_owners(project, queryset):
return sorted(result, key=itemgetter("full_name"))
-def _get_issues_tags(queryset):
- tags = []
- for t_list in queryset.values_list("tags", flat=True):
- if t_list is None:
- continue
- tags += list(t_list)
+def _get_issues_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]
- tags = [{"name":e, "count":tags.count(e)} for e in set(tags)]
+ extra_sql = """
+ WITH issues_tags AS (
+ SELECT tag,
+ COUNT(tag) counter FROM (
+ SELECT UNNEST(issues_issue.tags) tag
+ FROM issues_issue
+ INNER JOIN projects_project
+ ON (issues_issue.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)
- return sorted(tags, key=itemgetter("name"))
+ SELECT tag_color[1] tag, COALESCE(issues_tags.counter, 0) counter
+ FROM project_tags
+ LEFT JOIN issues_tags ON project_tags.tag_color[1] = issues_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_issues_filters_data(project, querysets):
@@ -447,7 +487,7 @@ def get_issues_filters_data(project, querysets):
("severities", _get_issues_severities(project, querysets["severities"])),
("assigned_to", _get_issues_assigned_to(project, querysets["assigned_to"])),
("owners", _get_issues_owners(project, querysets["owners"])),
- ("tags", _get_issues_tags(querysets["tags"])),
+ ("tags", _get_issues_tags(project, querysets["tags"])),
])
return data
diff --git a/taiga/projects/milestones/serializers.py b/taiga/projects/milestones/serializers.py
index b3df7c8f..724126fd 100644
--- a/taiga/projects/milestones/serializers.py
+++ b/taiga/projects/milestones/serializers.py
@@ -35,6 +35,7 @@ class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer,
ValidateDuplicatedNameInProjectMixin):
total_points = serializers.SerializerMethodField("get_total_points")
closed_points = serializers.SerializerMethodField("get_closed_points")
+ user_stories = serializers.SerializerMethodField("get_user_stories")
class Meta:
model = models.Milestone
@@ -46,6 +47,9 @@ class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer,
def get_closed_points(self, obj):
return sum(obj.closed_points.values())
+ def get_user_stories(self, obj):
+ return UserStoryListSerializer(obj.user_stories.all(), many=True).data
+
class MilestoneListSerializer(ListWatchedResourceModelSerializer, serializers.LightSerializer):
id = serpy.Field()
@@ -62,8 +66,16 @@ class MilestoneListSerializer(ListWatchedResourceModelSerializer, serializers.Li
order = serpy.Field()
watchers = serpy.Field()
user_stories = serpy.MethodField("get_user_stories")
- total_points = serializers.Field(source="total_points_attr")
- closed_points = serializers.Field(source="closed_points_attr")
+ total_points = serpy.MethodField()
+ closed_points = serpy.MethodField()
def get_user_stories(self, obj):
return UserStoryListSerializer(obj.user_stories.all(), many=True).data
+
+ def get_total_points(self, obj):
+ assert hasattr(obj, "total_points_attr"), "instance must have a total_points_attr attribute"
+ return obj.total_points_attr
+
+ def get_closed_points(self, obj):
+ assert hasattr(obj, "closed_points_attr"), "instance must have a closed_points_attr attribute"
+ return obj.closed_points_attr
diff --git a/taiga/projects/mixins/serializers.py b/taiga/projects/mixins/serializers.py
index 2d788298..a47d9bed 100644
--- a/taiga/projects/mixins/serializers.py
+++ b/taiga/projects/mixins/serializers.py
@@ -44,7 +44,7 @@ class ValidateDuplicatedNameInProjectMixin(serializers.ModelSerializer):
return attrs
-class CachedSerializedUsersMixin(serpy.Serializer):
+class ListCachedUsersSerializerMixin(serpy.Serializer):
def to_value(self, instance):
self._serialized_users = {}
return super().to_value(instance)
@@ -61,7 +61,7 @@ class CachedSerializedUsersMixin(serpy.Serializer):
return serialized_user
-class OwnerExtraInfoMixin(CachedSerializedUsersMixin):
+class ListOwnerExtraInfoSerializerMixin(ListCachedUsersSerializerMixin):
owner = serpy.Field(attr="owner_id")
owner_extra_info = serpy.MethodField()
@@ -69,7 +69,7 @@ class OwnerExtraInfoMixin(CachedSerializedUsersMixin):
return self.get_user_extra_info(obj.owner)
-class AssigedToExtraInfoMixin(CachedSerializedUsersMixin):
+class ListAssignedToExtraInfoSerializerMixin(ListCachedUsersSerializerMixin):
assigned_to = serpy.Field(attr="assigned_to_id")
assigned_to_extra_info = serpy.MethodField()
@@ -77,9 +77,10 @@ class AssigedToExtraInfoMixin(CachedSerializedUsersMixin):
return self.get_user_extra_info(obj.assigned_to)
-class StatusExtraInfoMixin(serpy.Serializer):
+class ListStatusExtraInfoSerializerMixin(serpy.Serializer):
status = serpy.Field(attr="status_id")
status_extra_info = serpy.MethodField()
+
def to_value(self, instance):
self._serialized_status = {}
return super().to_value(instance)
diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py
index b7c13ab1..01ae057e 100644
--- a/taiga/projects/tasks/api.py
+++ b/taiga/projects/tasks/api.py
@@ -25,6 +25,8 @@ 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.projects.attachments.utils import attach_basic_attachments
from taiga.projects.history.mixins import HistoryResourceMixin
from taiga.projects.models import Project, TaskStatus
from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
@@ -42,8 +44,18 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa
TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
queryset = models.Task.objects.all()
permission_classes = (permissions.TaskPermission,)
- filter_backends = (filters.CanViewTasksFilterBackend, filters.WatchersFilter)
- retrieve_exclude_filters = (filters.WatchersFilter,)
+ filter_backends = (filters.CanViewTasksFilterBackend,
+ filters.OwnersFilter,
+ filters.AssignedToFilter,
+ filters.StatusesFilter,
+ filters.TagsFilter,
+ filters.WatchersFilter,
+ filters.QFilter)
+ retrieve_exclude_filters = (filters.OwnersFilter,
+ filters.AssignedToFilter,
+ filters.StatusesFilter,
+ filters.TagsFilter,
+ filters.WatchersFilter)
filter_fields = ["user_story",
"milestone",
"project",
@@ -60,6 +72,44 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa
return serializers.TaskSerializer
+ def get_queryset(self):
+ qs = super().get_queryset()
+ qs = self.attach_votes_attrs_to_queryset(qs)
+ qs = qs.select_related("milestone",
+ "project",
+ "status",
+ "owner",
+ "assigned_to")
+
+ qs = self.attach_watchers_attrs_to_queryset(qs)
+ if "include_attachments" in self.request.QUERY_PARAMS:
+ qs = attach_basic_attachments(qs)
+ qs = qs.extra(select={"include_attachments": "True"})
+
+ return qs
+
+ def pre_conditions_on_save(self, obj):
+ super().pre_conditions_on_save(obj)
+
+ if obj.milestone and obj.milestone.project != obj.project:
+ raise exc.WrongArguments(_("You don't have permissions to set this sprint to this task."))
+
+ if obj.user_story and obj.user_story.project != obj.project:
+ raise exc.WrongArguments(_("You don't have permissions to set this user story to this task."))
+
+ if obj.status and obj.status.project != obj.project:
+ raise exc.WrongArguments(_("You don't have permissions to set this status to this task."))
+
+ if obj.milestone and obj.user_story and obj.milestone != obj.user_story.milestone:
+ raise exc.WrongArguments(_("You don't have permissions to set this sprint to this task."))
+
+ def pre_save(self, obj):
+ if obj.user_story:
+ obj.milestone = obj.user_story.milestone
+ if not obj.id:
+ obj.owner = self.request.user
+ super().pre_save(obj)
+
def update(self, request, *args, **kwargs):
self.object = self.get_object_or_none()
project_id = request.DATA.get('project', None)
@@ -91,38 +141,24 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa
return super().update(request, *args, **kwargs)
- def get_queryset(self):
- qs = super().get_queryset()
- qs = self.attach_votes_attrs_to_queryset(qs)
- qs = qs.select_related("milestone",
- "owner",
- "assigned_to",
- "status",
- "project")
+ @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)
- return self.attach_watchers_attrs_to_queryset(qs)
+ 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)
- def pre_save(self, obj):
- if obj.user_story:
- obj.milestone = obj.user_story.milestone
- if not obj.id:
- obj.owner = self.request.user
- super().pre_save(obj)
-
- def pre_conditions_on_save(self, obj):
- super().pre_conditions_on_save(obj)
-
- if obj.milestone and obj.milestone.project != obj.project:
- raise exc.WrongArguments(_("You don't have permissions to set this sprint to this task."))
-
- if obj.user_story and obj.user_story.project != obj.project:
- raise exc.WrongArguments(_("You don't have permissions to set this user story to this task."))
-
- if obj.status and obj.status.project != obj.project:
- raise exc.WrongArguments(_("You don't have permissions to set this status to this task."))
-
- if obj.milestone and obj.user_story and obj.milestone != obj.user_story.milestone:
- raise exc.WrongArguments(_("You don't have permissions to set this sprint to this task."))
+ 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_tasks_filters_data(project, querysets))
@list_route(methods=["GET"])
def by_ref(self, request):
diff --git a/taiga/projects/tasks/permissions.py b/taiga/projects/tasks/permissions.py
index 8cf40dd7..a1cbdfe1 100644
--- a/taiga/projects/tasks/permissions.py
+++ b/taiga/projects/tasks/permissions.py
@@ -31,6 +31,7 @@ class TaskPermission(TaigaResourcePermission):
partial_update_perms = CommentAndOrUpdatePerm('modify_task', 'comment_task')
destroy_perms = HasProjectPerm('delete_task')
list_perms = AllowAny()
+ filters_data_perms = AllowAny()
csv_perms = AllowAny()
bulk_create_perms = HasProjectPerm('add_task')
bulk_update_order_perms = HasProjectPerm('modify_task')
diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py
index d7423e66..ac82c570 100644
--- a/taiga/projects/tasks/serializers.py
+++ b/taiga/projects/tasks/serializers.py
@@ -23,10 +23,12 @@ from taiga.base.api import serializers
from taiga.base.fields import PgArrayField
from taiga.base.neighbors import NeighborsSerializerMixin
+from taiga.mdrender.service import render as mdrender
+from taiga.projects.attachments.serializers import ListBasicAttachmentsInfoSerializerMixin
from taiga.projects.milestones.validators import SprintExistsValidator
-from taiga.projects.mixins.serializers import OwnerExtraInfoMixin
-from taiga.projects.mixins.serializers import AssigedToExtraInfoMixin
-from taiga.projects.mixins.serializers import StatusExtraInfoMixin
+from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin
+from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin
+from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer
from taiga.projects.notifications.validators import WatchersValidator
@@ -46,8 +48,10 @@ from . import models
import serpy
+
class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
serializers.ModelSerializer):
+
tags = TagsAndTagsColorsField(default=[], required=False)
external_reference = PgArrayField(required=False)
comment = serializers.SerializerMethodField("get_comment")
@@ -83,8 +87,10 @@ class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWat
class TaskListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer,
- OwnerExtraInfoMixin, AssigedToExtraInfoMixin, StatusExtraInfoMixin,
+ ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin,
+ ListStatusExtraInfoSerializerMixin, ListBasicAttachmentsInfoSerializerMixin,
serializers.LightSerializer):
+
id = serpy.Field()
user_story = serpy.Field(attr="user_story_id")
ref = serpy.Field()
diff --git a/taiga/projects/tasks/services.py b/taiga/projects/tasks/services.py
index 427e4f28..ac7a6478 100644
--- a/taiga/projects/tasks/services.py
+++ b/taiga/projects/tasks/services.py
@@ -16,14 +16,19 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-import io
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.history.services import take_snapshot
-from taiga.projects.tasks.apps import (
- connect_tasks_signals,
- disconnect_tasks_signals)
+from taiga.projects.tasks.apps import connect_tasks_signals
+from taiga.projects.tasks.apps import disconnect_tasks_signals
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
@@ -31,6 +36,10 @@ from taiga.projects.notifications.utils import attach_watchers_to_queryset
from . import models
+#####################################################
+# Bulk actions
+#####################################################
+
def get_tasks_from_bulk(bulk_data, **additional_fields):
"""Convert `bulk_data` into a list of tasks.
@@ -64,7 +73,7 @@ def create_tasks_in_bulk(bulk_data, callback=None, precall=None, **additional_fi
return tasks
-def update_tasks_order_in_bulk(bulk_data:list, field:str, project:object):
+def update_tasks_order_in_bulk(bulk_data: list, field: str, project: object):
"""
Update the order of some tasks.
`bulk_data` should be a list of tuples with the following format:
@@ -85,7 +94,6 @@ def update_tasks_order_in_bulk(bulk_data:list, field:str, project:object):
def snapshot_tasks_in_bulk(bulk_data, user):
- task_ids = []
for task_data in bulk_data:
try:
task = models.Task.objects.get(pk=task_data['task_id'])
@@ -94,6 +102,10 @@ def snapshot_tasks_in_bulk(bulk_data, user):
pass
+#####################################################
+# CSV
+#####################################################
+
def tasks_to_csv(project, queryset):
csv_data = io.StringIO()
fieldnames = ["ref", "subject", "description", "user_story", "sprint", "sprint_estimated_start",
@@ -144,7 +156,7 @@ def tasks_to_csv(project, queryset):
"voters": task.total_voters,
"created_date": task.created_date,
"modified_date": task.modified_date,
- "finished_date": task.finished_date,
+ "finished_date": task.finished_date,
}
for custom_attr in custom_attrs:
value = task.custom_attributes_values.attributes_values.get(str(custom_attr.id), None)
@@ -153,3 +165,212 @@ def tasks_to_csv(project, queryset):
writer.writerow(task_data)
return csv_data
+
+
+#####################################################
+# Api filter data
+#####################################################
+
+def _get_tasks_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_taskstatus"."id",
+ "projects_taskstatus"."name",
+ "projects_taskstatus"."color",
+ "projects_taskstatus"."order",
+ (SELECT count(*)
+ FROM "tasks_task"
+ INNER JOIN "projects_project" ON
+ ("tasks_task"."project_id" = "projects_project"."id")
+ WHERE {where} AND "tasks_task"."status_id" = "projects_taskstatus"."id")
+ FROM "projects_taskstatus"
+ WHERE "projects_taskstatus"."project_id" = %s
+ ORDER BY "projects_taskstatus"."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_tasks_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 "tasks_task"
+ INNER JOIN "projects_project" ON ("tasks_task"."project_id" = "projects_project"."id")
+ WHERE {where} AND "tasks_task"."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 tasks
+ UNION
+
+ SELECT NULL user_id, NULL, NULL, count(coalesce(assigned_to_id, -1)) count
+ FROM "tasks_task"
+ INNER JOIN "projects_project" ON ("tasks_task"."project_id" = "projects_project"."id")
+ WHERE {where} AND "tasks_task"."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 task 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_tasks_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 "tasks_task"."owner_id" owner_id,
+ count(coalesce("tasks_task"."owner_id", -1)) count
+ FROM "tasks_task"
+ INNER JOIN "projects_project" ON ("tasks_task"."project_id" = "projects_project"."id")
+ WHERE {where}
+ GROUP BY "tasks_task"."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_tasks_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 tasks_tags AS (
+ SELECT tag,
+ COUNT(tag) counter FROM (
+ SELECT UNNEST(tasks_task.tags) tag
+ FROM tasks_task
+ INNER JOIN projects_project
+ ON (tasks_task.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(tasks_tags.counter, 0) counter
+ FROM project_tags
+ LEFT JOIN tasks_tags ON project_tags.tag_color[1] = tasks_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_tasks_filters_data(project, querysets):
+ """
+ Given a project and an tasks queryset, return a simple data structure
+ of all possible filters for the tasks in the queryset.
+ """
+ data = OrderedDict([
+ ("statuses", _get_tasks_statuses(project, querysets["statuses"])),
+ ("assigned_to", _get_tasks_assigned_to(project, querysets["assigned_to"])),
+ ("owners", _get_tasks_owners(project, querysets["owners"])),
+ ("tags", _get_tasks_tags(project, querysets["tags"])),
+ ])
+
+ return data
diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py
index dbe5c433..87ecf18b 100644
--- a/taiga/projects/userstories/api.py
+++ b/taiga/projects/userstories/api.py
@@ -36,6 +36,7 @@ from taiga.base.api import ModelCrudViewSet
from taiga.base.api import ModelListViewSet
from taiga.base.api.utils import get_object_or_404
+from taiga.projects.attachments.utils import attach_basic_attachments
from taiga.projects.history.mixins import HistoryResourceMixin
from taiga.projects.history.services import take_snapshot
from taiga.projects.milestones.models import Milestone
@@ -49,6 +50,7 @@ from taiga.projects.votes.mixins.viewsets import VotedResourceMixin
from taiga.projects.votes.mixins.viewsets import VotersViewSetMixin
from taiga.projects.userstories.utils import attach_total_points
from taiga.projects.userstories.utils import attach_role_points
+from taiga.projects.userstories.utils import attach_tasks
from . import models
from . import permissions
@@ -85,9 +87,6 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
"kanban_order",
"total_voters"]
- # Specific filter used for filtering neighbor user stories
- _neighbor_tags_filter = filters.TagsFilter('neighbor_tags')
-
def get_serializer_class(self, *args, **kwargs):
if self.action in ["retrieve", "by_ref"]:
return serializers.UserStoryNeighborsSerializer
@@ -105,10 +104,20 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
"owner",
"assigned_to",
"generated_from_issue")
+
qs = self.attach_votes_attrs_to_queryset(qs)
qs = self.attach_watchers_attrs_to_queryset(qs)
qs = attach_total_points(qs)
qs = attach_role_points(qs)
+
+ if "include_attachments" in self.request.QUERY_PARAMS:
+ qs = attach_basic_attachments(qs)
+ qs = qs.extra(select={"include_attachments": "True"})
+
+ if "include_tasks" in self.request.QUERY_PARAMS:
+ qs = attach_tasks(qs)
+ qs = qs.extra(select={"include_tasks": "True"})
+
return qs
def pre_conditions_on_save(self, obj):
diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py
index c863c87b..236a54d8 100644
--- a/taiga/projects/userstories/serializers.py
+++ b/taiga/projects/userstories/serializers.py
@@ -29,20 +29,19 @@ from taiga.base.neighbors import NeighborsSerializerMixin
from taiga.base.utils import json
from taiga.mdrender.service import render as mdrender
-
+from taiga.projects.attachments.serializers import ListBasicAttachmentsInfoSerializerMixin
from taiga.projects.milestones.validators import SprintExistsValidator
+from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin
+from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin
+from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin
from taiga.projects.models import Project, UserStoryStatus
-from taiga.projects.mixins.serializers import OwnerExtraInfoMixin
-from taiga.projects.mixins.serializers import AssigedToExtraInfoMixin
-from taiga.projects.mixins.serializers import StatusExtraInfoMixin
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer
from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicUserStoryStatusSerializer
from taiga.projects.tagging.fields import TagsAndTagsColorsField
from taiga.projects.userstories.validators import UserStoryExistsValidator
-from taiga.projects.validators import ProjectExistsValidator
-from taiga.projects.validators import UserStoryStatusExistsValidator
+from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator
from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin
from taiga.projects.votes.mixins.serializers import ListVoteResourceSerializerMixin
@@ -136,7 +135,9 @@ class ListOriginIssueSerializer(serializers.LightSerializer):
class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer,
- OwnerExtraInfoMixin, AssigedToExtraInfoMixin, StatusExtraInfoMixin, serializers.LightSerializer):
+ ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin,
+ ListStatusExtraInfoSerializerMixin, ListBasicAttachmentsInfoSerializerMixin,
+ serializers.LightSerializer):
id = serpy.Field()
ref = serpy.Field()
@@ -163,13 +164,11 @@ class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResour
is_blocked = serpy.Field()
blocked_note = serpy.Field()
tags = serpy.Field()
- total_points = serpy.Field("total_points_attr")
+ total_points = serpy.MethodField()
comment = serpy.MethodField("get_comment")
origin_issue = ListOriginIssueSerializer(attr="generated_from_issue")
- def to_value(self, instance):
- self._serialized_status = {}
- return super().to_value(instance)
+ tasks = serpy.MethodField()
def get_milestone_slug(self, obj):
return obj.milestone.slug if obj.milestone else None
@@ -177,15 +176,31 @@ class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResour
def get_milestone_name(self, obj):
return obj.milestone.name if obj.milestone else None
+ def get_total_points(self, obj):
+ assert hasattr(obj, "total_points_attr"), "instance must have a total_points_attr attribute"
+ return obj.total_points_attr
+
def get_points(self, obj):
+ assert hasattr(obj, "role_points_attr"), "instance must have a role_points_attr attribute"
if obj.role_points_attr is None:
return {}
- return dict(ChainMap(*json.loads(obj.role_points_attr)))
+ return dict(ChainMap(*obj.role_points_attr))
def get_comment(self, obj):
return ""
+ def get_tasks(self, obj):
+ include_tasks = getattr(obj, "include_tasks", False)
+
+ if include_tasks:
+ assert hasattr(obj, "tasks_attr"), "instance must have a tasks_attr attribute"
+
+ if not include_tasks or obj.tasks_attr is None:
+ return []
+
+ return obj.tasks_attr
+
class UserStoryNeighborsSerializer(NeighborsSerializerMixin, UserStorySerializer):
def serialize_neighbor(self, neighbor):
diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py
index 5ce47635..61fe52ec 100644
--- a/taiga/projects/userstories/services.py
+++ b/taiga/projects/userstories/services.py
@@ -28,9 +28,8 @@ from django.utils.translation import ugettext as _
from taiga.base.utils import db, text
from taiga.projects.history.services import take_snapshot
-from taiga.projects.userstories.apps import (
- connect_userstories_signals,
- disconnect_userstories_signals)
+from taiga.projects.userstories.apps import connect_userstories_signals
+from taiga.projects.userstories.apps import disconnect_userstories_signals
from taiga.events import events
from taiga.projects.votes.utils import attach_total_voters_to_queryset
@@ -39,6 +38,10 @@ from taiga.projects.notifications.utils import attach_watchers_to_queryset
from . import models
+#####################################################
+# Bulk actions
+#####################################################
+
def get_userstories_from_bulk(bulk_data, **additional_fields):
"""Convert `bulk_data` into a list of user stories.
@@ -72,7 +75,7 @@ def create_userstories_in_bulk(bulk_data, callback=None, precall=None, **additio
return userstories
-def update_userstories_order_in_bulk(bulk_data:list, field:str, project:object):
+def update_userstories_order_in_bulk(bulk_data: list, field: str, project: object):
"""
Update the order of some user stories.
`bulk_data` should be a list of tuples with the following format:
@@ -92,7 +95,7 @@ def update_userstories_order_in_bulk(bulk_data:list, field:str, project:object):
db.update_in_bulk_with_ids(user_story_ids, new_order_values, model=models.UserStory)
-def update_userstories_milestone_in_bulk(bulk_data:list, milestone:object):
+def update_userstories_milestone_in_bulk(bulk_data: list, milestone: object):
"""
Update the milestone of some user stories.
`bulk_data` should be a list of user story ids:
@@ -108,7 +111,6 @@ def update_userstories_milestone_in_bulk(bulk_data:list, milestone:object):
def snapshot_userstories_in_bulk(bulk_data, user):
- user_story_ids = []
for us_data in bulk_data:
try:
us = models.UserStory.objects.get(pk=us_data['us_id'])
@@ -117,6 +119,10 @@ def snapshot_userstories_in_bulk(bulk_data, user):
pass
+#####################################################
+# Open/Close calcs
+#####################################################
+
def calculate_userstory_is_closed(user_story):
if user_story.status is None:
return False
@@ -144,7 +150,11 @@ def open_userstory(us):
us.save(update_fields=["is_closed", "finish_date"])
-def userstories_to_csv(project,queryset):
+#####################################################
+# CSV
+#####################################################
+
+def userstories_to_csv(project, queryset):
csv_data = io.StringIO()
fieldnames = ["ref", "subject", "description", "sprint", "sprint_estimated_start",
"sprint_estimated_finish", "owner", "owner_full_name", "assigned_to",
@@ -160,7 +170,7 @@ def userstories_to_csv(project,queryset):
"created_date", "modified_date", "finish_date",
"client_requirement", "team_requirement", "attachments",
"generated_from_issue", "external_reference", "tasks",
- "tags","watchers", "voters"]
+ "tags", "watchers", "voters"]
custom_attrs = project.userstorycustomattributes.all()
for custom_attr in custom_attrs:
@@ -230,6 +240,10 @@ def userstories_to_csv(project,queryset):
return csv_data
+#####################################################
+# Api filter data
+#####################################################
+
def _get_userstories_statuses(project, queryset):
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
@@ -336,7 +350,8 @@ def _get_userstories_owners(project, queryset):
extra_sql = """
WITH counters AS (
- SELECT "userstories_userstory"."owner_id" owner_id, count(coalesce("userstories_userstory"."owner_id", -1)) count
+ SELECT "userstories_userstory"."owner_id" owner_id,
+ count(coalesce("userstories_userstory"."owner_id", -1)) count
FROM "userstories_userstory"
INNER JOIN "projects_project" ON ("userstories_userstory"."project_id" = "projects_project"."id")
WHERE {where}
@@ -350,7 +365,7 @@ def _get_userstories_owners(project, queryset):
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)
+ WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL
-- System users
UNION
@@ -379,16 +394,44 @@ def _get_userstories_owners(project, queryset):
return sorted(result, key=itemgetter("full_name"))
-def _get_userstories_tags(queryset):
- tags = []
- for t_list in queryset.values_list("tags", flat=True):
- if t_list is None:
- continue
- tags += list(t_list)
+def _get_userstories_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]
- tags = [{"name":e, "count":tags.count(e)} for e in set(tags)]
+ extra_sql = """
+ WITH userstories_tags AS (
+ SELECT tag,
+ COUNT(tag) counter FROM (
+ SELECT UNNEST(userstories_userstory.tags) tag
+ FROM userstories_userstory
+ INNER JOIN projects_project
+ ON (userstories_userstory.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)
- return sorted(tags, key=itemgetter("name"))
+ SELECT tag_color[1] tag, COALESCE(userstories_tags.counter, 0) counter
+ FROM project_tags
+ LEFT JOIN userstories_tags ON project_tags.tag_color[1] = userstories_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_userstories_filters_data(project, querysets):
@@ -400,7 +443,7 @@ def get_userstories_filters_data(project, querysets):
("statuses", _get_userstories_statuses(project, querysets["statuses"])),
("assigned_to", _get_userstories_assigned_to(project, querysets["assigned_to"])),
("owners", _get_userstories_owners(project, querysets["owners"])),
- ("tags", _get_userstories_tags(querysets["tags"])),
+ ("tags", _get_userstories_tags(project, querysets["tags"])),
])
return data
diff --git a/taiga/projects/userstories/utils.py b/taiga/projects/userstories/utils.py
index 36a9970d..809248f7 100644
--- a/taiga/projects/userstories/utils.py
+++ b/taiga/projects/userstories/utils.py
@@ -46,11 +46,39 @@ def attach_role_points(queryset, as_field="role_points_attr"):
:return: Queryset object with the additional `as_field` field.
"""
model = queryset.model
- sql = """SELECT json_agg(json_build_object(userstories_rolepoints.role_id,
- userstories_rolepoints.points_id))::text
+ sql = """SELECT json_agg((userstories_rolepoints.role_id, userstories_rolepoints.points_id))
FROM userstories_rolepoints
WHERE userstories_rolepoints.user_story_id = {tbl}.id"""
sql = sql.format(tbl=model._meta.db_table)
queryset = queryset.extra(select={as_field: sql})
return queryset
+
+
+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.
+
+ :return: Queryset object with the additional `as_field` field.
+ """
+
+ model = queryset.model
+ sql = """SELECT json_agg(row_to_json(t))
+ FROM(
+ SELECT
+ tasks_task.id,
+ tasks_task.ref,
+ tasks_task.subject,
+ tasks_task.status_id,
+ tasks_task.is_blocked,
+ tasks_task.is_iocaine,
+ projects_taskstatus.is_closed
+ FROM tasks_task
+ INNER JOIN projects_taskstatus on projects_taskstatus.id = tasks_task.status_id
+ WHERE user_story_id = {tbl}.id) t"""
+
+ sql = sql.format(tbl=model._meta.db_table)
+ queryset = queryset.extra(select={as_field: sql})
+ return queryset
diff --git a/tests/integration/test_issues.py b/tests/integration/test_issues.py
index a14b2db4..4ea78a35 100644
--- a/tests/integration/test_issues.py
+++ b/tests/integration/test_issues.py
@@ -229,6 +229,7 @@ def test_api_filter_by_text_6(client):
assert response.status_code == 200
assert number_of_issues == 1
+
def test_api_filters_data(client):
project = f.ProjectFactory.create()
user1 = f.UserFactory.create(is_superuser=True)
@@ -378,8 +379,7 @@ def test_api_filters_data(client):
assert next(filter(lambda i: i['id'] == severity2.id, response.data["severities"]))["count"] == 0
assert next(filter(lambda i: i['id'] == severity3.id, response.data["severities"]))["count"] == 1
- with pytest.raises(StopIteration):
- assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
+ assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 4
assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 2
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1
@@ -415,8 +415,7 @@ def test_api_filters_data(client):
assert next(filter(lambda i: i['id'] == severity2.id, response.data["severities"]))["count"] == 0
assert next(filter(lambda i: i['id'] == severity3.id, response.data["severities"]))["count"] == 1
- with pytest.raises(StopIteration):
- assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
+ assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 2
assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 2
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1
diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py
index fcecee3b..c12e1ecb 100644
--- a/tests/integration/test_tasks.py
+++ b/tests/integration/test_tasks.py
@@ -185,3 +185,153 @@ def test_custom_fields_csv_generation():
assert row[24] == attr.name
row = next(reader)
assert row[24] == "val1"
+
+
+def test_get_tasks_including_attachments(client):
+ user = f.UserFactory.create()
+ project = f.ProjectFactory.create(owner=user)
+ f.MembershipFactory.create(project=project, user=user, is_admin=True)
+
+ task = f.TaskFactory.create(project=project)
+ f.TaskAttachmentFactory(project=project, content_object=task)
+ url = reverse("tasks-list")
+
+ client.login(project.owner)
+
+ response = client.get(url)
+ assert response.status_code == 200
+ assert response.data[0].get("attachments") == []
+
+ url = reverse("tasks-list") + "?include_attachments=1"
+ response = client.get(url)
+ assert response.status_code == 200
+ assert len(response.data[0].get("attachments")) == 1
+
+
+def test_api_filters_data(client):
+ project = f.ProjectFactory.create()
+ user1 = f.UserFactory.create(is_superuser=True)
+ f.MembershipFactory.create(user=user1, project=project)
+ user2 = f.UserFactory.create(is_superuser=True)
+ f.MembershipFactory.create(user=user2, project=project)
+ user3 = f.UserFactory.create(is_superuser=True)
+ f.MembershipFactory.create(user=user3, project=project)
+
+ status0 = f.TaskStatusFactory.create(project=project)
+ status1 = f.TaskStatusFactory.create(project=project)
+ status2 = f.TaskStatusFactory.create(project=project)
+ status3 = f.TaskStatusFactory.create(project=project)
+
+ tag0 = "test1test2test3"
+ tag1 = "test1"
+ tag2 = "test2"
+ tag3 = "test3"
+
+ # ------------------------------------------------------
+ # | Task | Owner | Assigned To | Tags |
+ # |-------#--------#-------------#---------------------|
+ # | 0 | user2 | None | tag1 |
+ # | 1 | user1 | None | tag2 |
+ # | 2 | user3 | None | tag1 tag2 |
+ # | 3 | user2 | None | tag3 |
+ # | 4 | user1 | user1 | tag1 tag2 tag3 |
+ # | 5 | user3 | user1 | tag3 |
+ # | 6 | user2 | user1 | tag1 tag2 |
+ # | 7 | user1 | user2 | tag3 |
+ # | 8 | user3 | user2 | tag1 |
+ # | 9 | user2 | user3 | tag0 |
+ # ------------------------------------------------------
+
+ task0 = f.TaskFactory.create(project=project, owner=user2, assigned_to=None,
+ status=status3, tags=[tag1])
+ task1 = f.TaskFactory.create(project=project, owner=user1, assigned_to=None,
+ status=status3, tags=[tag2])
+ task2 = f.TaskFactory.create(project=project, owner=user3, assigned_to=None,
+ status=status1, tags=[tag1, tag2])
+ task3 = f.TaskFactory.create(project=project, owner=user2, assigned_to=None,
+ status=status0, tags=[tag3])
+ task4 = f.TaskFactory.create(project=project, owner=user1, assigned_to=user1,
+ status=status0, tags=[tag1, tag2, tag3])
+ task5 = f.TaskFactory.create(project=project, owner=user3, assigned_to=user1,
+ status=status2, tags=[tag3])
+ task6 = f.TaskFactory.create(project=project, owner=user2, assigned_to=user1,
+ status=status3, tags=[tag1, tag2])
+ task7 = f.TaskFactory.create(project=project, owner=user1, assigned_to=user2,
+ status=status0, tags=[tag3])
+ task8 = f.TaskFactory.create(project=project, owner=user3, assigned_to=user2,
+ status=status3, tags=[tag1])
+ task9 = f.TaskFactory.create(project=project, owner=user2, assigned_to=user3,
+ status=status1, tags=[tag0])
+
+ url = reverse("tasks-filters-data") + "?project={}".format(project.id)
+
+ client.login(user1)
+
+ ## No filter
+ response = client.get(url)
+ assert response.status_code == 200
+
+ assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 3
+ assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 4
+ assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 3
+
+ assert next(filter(lambda i: i['id'] == None, response.data["assigned_to"]))["count"] == 4
+ assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 3
+ assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 2
+ assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 1
+
+ assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 3
+ assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 2
+ assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 1
+ assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 4
+
+ assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 1
+ assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 5
+ assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 4
+ assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 4
+
+ ## Filter ((status0 or status3)
+ response = client.get(url + "&status={},{}".format(status3.id, status0.id))
+ assert response.status_code == 200
+
+ assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 3
+ assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 3
+ assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 1
+
+ assert next(filter(lambda i: i['id'] == None, response.data["assigned_to"]))["count"] == 3
+ assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2
+ assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 2
+ assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0
+
+ assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 3
+ assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 2
+ assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 1
+ assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 4
+
+ assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
+ assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 4
+ assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 3
+ assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 3
+
+ ## Filter ((tag1 and tag2) and (user1 or user2))
+ response = client.get(url + "&tags={},{}&owner={},{}".format(tag1, tag2, user1.id, user2.id))
+ assert response.status_code == 200
+
+ assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 1
+ assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 1
+ assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 1
+
+ assert next(filter(lambda i: i['id'] == None, response.data["assigned_to"]))["count"] == 0
+ assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2
+ assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 0
+ assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0
+
+ assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 1
+ assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 0
+ assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 0
+ assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 1
+
+ assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
+ assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 2
+ assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 2
+ assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1
diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py
index bc3c5560..7eac9b06 100644
--- a/tests/integration/test_userstories.py
+++ b/tests/integration/test_userstories.py
@@ -504,8 +504,7 @@ def test_api_filters_data(client):
assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 1
assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 4
- with pytest.raises(StopIteration):
- assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
+ assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 4
assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 3
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 3
@@ -528,8 +527,7 @@ def test_api_filters_data(client):
assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 0
assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 1
- with pytest.raises(StopIteration):
- assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
+ assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 2
assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 2
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1
@@ -644,3 +642,45 @@ def test_update_userstory_update_tribe_gig(client):
assert response.status_code == 200
assert response.data["tribe_gig"] == data["tribe_gig"]
+
+
+def test_get_user_stories_including_tasks(client):
+ user = f.UserFactory.create()
+ project = f.ProjectFactory.create(owner=user)
+ f.MembershipFactory.create(project=project, user=user, is_admin=True)
+
+ user_story = f.UserStoryFactory.create(project=project)
+ f.TaskFactory.create(user_story=user_story)
+ url = reverse("userstories-list")
+
+ client.login(project.owner)
+
+ response = client.get(url)
+ assert response.status_code == 200
+ assert response.data[0].get("tasks") == []
+
+ url = reverse("userstories-list") + "?include_tasks=1"
+ response = client.get(url)
+ assert response.status_code == 200
+ assert len(response.data[0].get("tasks")) == 1
+
+
+def test_get_user_stories_including_attachments(client):
+ user = f.UserFactory.create()
+ project = f.ProjectFactory.create(owner=user)
+ f.MembershipFactory.create(project=project, user=user, is_admin=True)
+
+ user_story = f.UserStoryFactory.create(project=project)
+ f.UserStoryAttachmentFactory(project=project, content_object=user_story)
+ url = reverse("userstories-list")
+
+ client.login(project.owner)
+
+ response = client.get(url)
+ assert response.status_code == 200
+ assert response.data[0].get("attachments") == []
+
+ url = reverse("userstories-list") + "?include_attachments=1"
+ response = client.get(url)
+ assert response.status_code == 200
+ assert len(response.data[0].get("attachments")) == 1