From 4827df0058fe83922650f4da3ed99f2a3d596f15 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 3 Jun 2016 08:13:38 +0200 Subject: [PATCH 1/6] Updating CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) 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. From 34446d8289791a9c11ee9a209474c1e05bae361e Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 2 Jun 2016 14:34:44 +0200 Subject: [PATCH 2/6] Adding tasks and attachments to userstory listing API calls and attachments to task listing API call --- taiga/base/filters.py | 6 +-- .../migrations/0006_auto_20160617_1233.py | 19 ++++++++ taiga/projects/attachments/models.py | 1 + taiga/projects/attachments/serializers.py | 32 ++++++++++++++ taiga/projects/attachments/utils.py | 44 +++++++++++++++++++ taiga/projects/issues/serializers.py | 11 ++--- taiga/projects/milestones/serializers.py | 16 ++++++- taiga/projects/mixins/serializers.py | 9 ++-- taiga/projects/tasks/api.py | 20 ++++++--- taiga/projects/tasks/serializers.py | 14 ++++-- taiga/projects/userstories/api.py | 12 +++++ taiga/projects/userstories/serializers.py | 39 +++++++++++----- taiga/projects/userstories/utils.py | 32 +++++++++++++- tests/integration/test_tasks.py | 21 +++++++++ tests/integration/test_userstories.py | 42 ++++++++++++++++++ 15 files changed, 280 insertions(+), 38 deletions(-) create mode 100644 taiga/projects/attachments/migrations/0006_auto_20160617_1233.py create mode 100644 taiga/projects/attachments/utils.py 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/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..d7723a26 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 @@ -94,13 +96,19 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa 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") + qs = qs.select_related( + "milestone", + "owner", + "assigned_to", + "status", + "project") - return self.attach_watchers_attrs_to_queryset(qs) + 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_save(self, obj): if obj.user_story: 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/userstories/api.py b/taiga/projects/userstories/api.py index dbe5c433..0c54f81d 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 @@ -105,10 +107,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/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_tasks.py b/tests/integration/test_tasks.py index fcecee3b..072c0595 100644 --- a/tests/integration/test_tasks.py +++ b/tests/integration/test_tasks.py @@ -185,3 +185,24 @@ 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 diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index bc3c5560..e05fff68 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -644,3 +644,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 From 13e0e79f4432c1432c233cc8cc0d9d63b97ced0a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 21 Jun 2016 14:04:15 +0200 Subject: [PATCH 3/6] Fixing filter_data endpoint for userstories and issues --- taiga/projects/issues/services.py | 49 +++++++++++++++++++++----- taiga/projects/userstories/services.py | 49 +++++++++++++++++++++----- 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/taiga/projects/issues/services.py b/taiga/projects/issues/services.py index a494b1f4..3ebcec7a 100644 --- a/taiga/projects/issues/services.py +++ b/taiga/projects/issues/services.py @@ -423,16 +423,47 @@ 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(tags) tag + FROM issues_issue + 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, issues_tags.counter 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": 0 if count is None else count, + }) + return result def get_issues_filters_data(project, querysets): @@ -447,7 +478,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/userstories/services.py b/taiga/projects/userstories/services.py index 5ce47635..1e2e11bf 100644 --- a/taiga/projects/userstories/services.py +++ b/taiga/projects/userstories/services.py @@ -379,16 +379,47 @@ 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(tags) tag + FROM userstories_userstory + 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, userstories_tags.counter 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": 0 if count is None else count, + }) + return result def get_userstories_filters_data(project, querysets): @@ -400,7 +431,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 From c82288faa3517b9ad9c67bc55bd4774551399328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 22 Jun 2016 22:24:07 +0200 Subject: [PATCH 4/6] Fix tests, identations and pass the Flake8 --- taiga/projects/issues/services.py | 47 ++++++++++-------- taiga/projects/tasks/permissions.py | 1 + taiga/projects/tasks/services.py | 2 +- taiga/projects/userstories/api.py | 3 -- taiga/projects/userstories/services.py | 68 +++++++++++++++----------- tests/integration/test_issues.py | 7 ++- tests/integration/test_userstories.py | 6 +-- 7 files changed, 73 insertions(+), 61 deletions(-) diff --git a/taiga/projects/issues/services.py b/taiga/projects/issues/services.py index 3ebcec7a..7786d0da 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 @@ -430,27 +442,22 @@ def _get_issues_tags(project, queryset): where_params = queryset_where_tuple[1] extra_sql = """ - WITH - issues_tags AS ( - SELECT tag, COUNT(tag) counter FROM ( - SELECT UNNEST(tags) tag - FROM issues_issue - WHERE {where} - ) tags - GROUP BY tag - ), - project_tags AS ( - SELECT reduce_dim(tags_colors) tag_color - FROM projects_project - WHERE id=%s - ) + WITH issues_tags AS ( + SELECT tag, + COUNT(tag) counter FROM ( + SELECT UNNEST(tags) tag + FROM issues_issue + 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, issues_tags.counter counter + SELECT tag_color[1] tag, issues_tags.counter counter FROM project_tags - LEFT JOIN - issues_tags ON project_tags.tag_color[1] = issues_tags.tag - ORDER BY tag + 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: 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/services.py b/taiga/projects/tasks/services.py index 427e4f28..5729f588 100644 --- a/taiga/projects/tasks/services.py +++ b/taiga/projects/tasks/services.py @@ -144,7 +144,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) diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 0c54f81d..87ecf18b 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -87,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 diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py index 1e2e11bf..d867f5e6 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 @@ -386,27 +401,22 @@ def _get_userstories_tags(project, queryset): where_params = queryset_where_tuple[1] extra_sql = """ - WITH - userstories_tags AS ( - SELECT tag, COUNT(tag) counter FROM ( - SELECT UNNEST(tags) tag - FROM userstories_userstory - WHERE {where} - ) tags - GROUP BY tag - ), - project_tags AS ( - SELECT reduce_dim(tags_colors) tag_color - FROM projects_project - WHERE id=%s - ) + WITH userstories_tags AS ( + SELECT tag, + COUNT(tag) counter FROM ( + SELECT UNNEST(tags) tag + FROM userstories_userstory + 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, userstories_tags.counter counter + SELECT tag_color[1] tag, userstories_tags.counter counter FROM project_tags - LEFT JOIN - userstories_tags ON project_tags.tag_color[1] = userstories_tags.tag - ORDER BY tag + 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: 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_userstories.py b/tests/integration/test_userstories.py index e05fff68..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 From 4904e1859fbf086a74e417f085163ca59cf70259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 23 Jun 2016 18:23:40 +0200 Subject: [PATCH 5/6] Fix api calls from the front --- taiga/projects/issues/services.py | 10 ++++++---- taiga/projects/userstories/services.py | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/taiga/projects/issues/services.py b/taiga/projects/issues/services.py index 7786d0da..56790e82 100644 --- a/taiga/projects/issues/services.py +++ b/taiga/projects/issues/services.py @@ -445,8 +445,10 @@ def _get_issues_tags(project, queryset): WITH issues_tags AS ( SELECT tag, COUNT(tag) counter FROM ( - SELECT UNNEST(tags) tag + 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 ( @@ -454,7 +456,7 @@ def _get_issues_tags(project, queryset): FROM projects_project WHERE id=%s) - SELECT tag_color[1] tag, issues_tags.counter counter + 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 @@ -468,9 +470,9 @@ def _get_issues_tags(project, queryset): for name, count in rows: result.append({ "name": name, - "count": 0 if count is None else count, + "count": count, }) - return result + return sorted(result, key=itemgetter("name")) def get_issues_filters_data(project, querysets): diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py index d867f5e6..61fe52ec 100644 --- a/taiga/projects/userstories/services.py +++ b/taiga/projects/userstories/services.py @@ -404,8 +404,10 @@ def _get_userstories_tags(project, queryset): WITH userstories_tags AS ( SELECT tag, COUNT(tag) counter FROM ( - SELECT UNNEST(tags) tag + 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 ( @@ -413,7 +415,7 @@ def _get_userstories_tags(project, queryset): FROM projects_project WHERE id=%s) - SELECT tag_color[1] tag, userstories_tags.counter counter + 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 @@ -427,9 +429,9 @@ def _get_userstories_tags(project, queryset): for name, count in rows: result.append({ "name": name, - "count": 0 if count is None else count, + "count": count, }) - return result + return sorted(result, key=itemgetter("name")) def get_userstories_filters_data(project, querysets): From 6b6f6e80bec17e48e81cd56b7fcfbd6c0c7bba08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 22 Jun 2016 22:24:52 +0200 Subject: [PATCH 6/6] Add filter_data to tasks API endpoint --- taiga/projects/tasks/api.py | 104 +++++++++----- taiga/projects/tasks/services.py | 233 ++++++++++++++++++++++++++++++- tests/integration/test_tasks.py | 129 +++++++++++++++++ 3 files changed, 422 insertions(+), 44 deletions(-) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index d7723a26..01ae057e 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -44,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", @@ -62,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) @@ -93,44 +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) - 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"}) + 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) - return qs - - 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/services.py b/taiga/projects/tasks/services.py index 5729f588..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", @@ -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/tests/integration/test_tasks.py b/tests/integration/test_tasks.py index 072c0595..c12e1ecb 100644 --- a/tests/integration/test_tasks.py +++ b/tests/integration/test_tasks.py @@ -206,3 +206,132 @@ def test_get_tasks_including_attachments(client): 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