From ff7d241a3755762795bdb2687095d58f7e1ad1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 26 Jul 2016 13:05:19 +0200 Subject: [PATCH] Filter userstories by epics --- taiga/projects/userstories/api.py | 36 +++++++------ taiga/projects/userstories/filters.py | 24 +++++++++ taiga/projects/userstories/services.py | 71 ++++++++++++++++++++++++- tests/factories.py | 13 ++++- tests/integration/test_userstories.py | 73 +++++++++++++++++--------- 5 files changed, 174 insertions(+), 43 deletions(-) create mode 100644 taiga/projects/userstories/filters.py diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 32b798a1..45d78f28 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -22,7 +22,7 @@ from django.db import transaction from django.utils.translation import ugettext as _ from django.http import HttpResponse -from taiga.base import filters +from taiga.base import filters as base_filters from taiga.base import exceptions as exc from taiga.base import response from taiga.base import status @@ -45,6 +45,7 @@ from taiga.projects.votes.mixins.viewsets import VotedResourceMixin from taiga.projects.votes.mixins.viewsets import VotersViewSetMixin from taiga.projects.userstories.utils import attach_extra_info +from . import filters from . import models from . import permissions from . import serializers @@ -57,17 +58,18 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi validator_class = validators.UserStoryValidator queryset = models.UserStory.objects.all() permission_classes = (permissions.UserStoryPermission,) - filter_backends = (filters.CanViewUsFilterBackend, - filters.OwnersFilter, - filters.AssignedToFilter, - filters.StatusesFilter, - filters.TagsFilter, - filters.WatchersFilter, - filters.QFilter, - filters.CreatedDateFilter, - filters.ModifiedDateFilter, - filters.FinishDateFilter, - filters.OrderByFilterMixin) + filter_backends = (base_filters.CanViewUsFilterBackend, + filters.EpicsFilter, + base_filters.OwnersFilter, + base_filters.AssignedToFilter, + base_filters.StatusesFilter, + base_filters.TagsFilter, + base_filters.WatchersFilter, + base_filters.QFilter, + base_filters.CreatedDateFilter, + base_filters.ModifiedDateFilter, + base_filters.FinishDateFilter, + base_filters.OrderByFilterMixin) filter_fields = ["project", "project__slug", "milestone", @@ -270,16 +272,18 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi project = get_object_or_404(Project, id=project_id) filter_backends = self.get_filter_backends() - statuses_filter_backends = (f for f in filter_backends if f != filters.StatusesFilter) - assigned_to_filter_backends = (f for f in filter_backends if f != filters.AssignedToFilter) - owners_filter_backends = (f for f in filter_backends if f != filters.OwnersFilter) + statuses_filter_backends = (f for f in filter_backends if f != base_filters.StatusesFilter) + assigned_to_filter_backends = (f for f in filter_backends if f != base_filters.AssignedToFilter) + owners_filter_backends = (f for f in filter_backends if f != base_filters.OwnersFilter) + epics_filter_backends = (f for f in filter_backends if f != filters.EpicsFilter) queryset = self.get_queryset() querysets = { "statuses": self.filter_queryset(queryset, filter_backends=statuses_filter_backends), "assigned_to": self.filter_queryset(queryset, filter_backends=assigned_to_filter_backends), "owners": self.filter_queryset(queryset, filter_backends=owners_filter_backends), - "tags": self.filter_queryset(queryset) + "tags": self.filter_queryset(queryset), + "epics": self.filter_queryset(queryset, filter_backends=epics_filter_backends) } return response.Ok(services.get_userstories_filters_data(project, querysets)) diff --git a/taiga/projects/userstories/filters.py b/taiga/projects/userstories/filters.py new file mode 100644 index 00000000..5f877618 --- /dev/null +++ b/taiga/projects/userstories/filters.py @@ -0,0 +1,24 @@ +# -*- 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 +# 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 taiga.base import filters + + +class EpicsFilter(filters.BaseRelatedFieldsFilter): + filter_name = 'epics' diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py index 0f260e2c..11fb2a2f 100644 --- a/taiga/projects/userstories/services.py +++ b/taiga/projects/userstories/services.py @@ -244,7 +244,7 @@ def userstories_to_csv(project, queryset): "tasks": ",".join([str(task.ref) for task in us.tasks.all()]), "tags": ",".join(us.tags or []), "watchers": us.watchers, - "voters": us.total_voters, + "voters": us.total_voters } us_role_points_by_role_id = {us_rp.role.id: us_rp.points.value for us_rp in us.role_points.all()} @@ -456,6 +456,74 @@ def _get_userstories_tags(project, queryset): return sorted(result, key=itemgetter("name")) +def _get_userstories_epics(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT "epics_relateduserstory"."epic_id" AS "epic_id", + count("epics_relateduserstory"."id") AS "counter" + FROM "epics_relateduserstory" + INNER JOIN "userstories_userstory" + ON ("userstories_userstory"."id" = "epics_relateduserstory"."user_story_id") + WHERE {where} + GROUP BY "epics_relateduserstory"."epic_id" + ) + SELECT "epics_epic"."id" AS "id", + "epics_epic"."ref" AS "ref", + "epics_epic"."subject" AS "subject", + "epics_epic"."epics_order" AS "order", + COALESCE("counters"."counter", 0) AS "counter" + FROM "epics_epic" + LEFT OUTER JOIN "counters" + ON ("counters"."epic_id" = "epics_epic"."id") + WHERE "epics_epic"."project_id" = %s + + -- User stories with no epics (return results only if there are userstories) + UNION + SELECT NULL AS "id", + NULL AS "ref", + NULL AS "subject", + 0 AS "order", + count(COALESCE("epics_relateduserstory"."epic_id", -1)) AS "counter" + FROM "userstories_userstory" + LEFT OUTER JOIN "epics_relateduserstory" + ON ("epics_relateduserstory"."user_story_id" = "userstories_userstory"."id") + WHERE {where} AND "epics_relateduserstory"."epic_id" IS NULL + GROUP BY "epics_relateduserstory"."epic_id" + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id] + where_params) + rows = cursor.fetchall() + + result = [] + for id, ref, subject, order, count in rows: + result.append({ + "id": id, + "ref": ref, + "subject": subject, + "order": order, + "count": count, + }) + + result = sorted(result, key=itemgetter("order")) + + # Add row when there is no user stories with no epics + if result[0]["id"] is not None: + result.insert(0, { + "id": None, + "ref": None, + "subject": None, + "order": 0, + "count": 0, + }) + return result + + def get_userstories_filters_data(project, querysets): """ Given a project and an userstories queryset, return a simple data structure @@ -466,6 +534,7 @@ def get_userstories_filters_data(project, querysets): ("assigned_to", _get_userstories_assigned_to(project, querysets["assigned_to"])), ("owners", _get_userstories_owners(project, querysets["owners"])), ("tags", _get_userstories_tags(project, querysets["tags"])), + ("epics", _get_userstories_epics(project, querysets["epics"])), ]) return data diff --git a/tests/factories.py b/tests/factories.py index 2e831c13..5cec5800 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -249,11 +249,20 @@ class EpicFactory(Factory): ref = factory.Sequence(lambda n: n) project = factory.SubFactory("tests.factories.ProjectFactory") owner = factory.SubFactory("tests.factories.UserFactory") - subject = factory.Sequence(lambda n: "User Story {}".format(n)) - description = factory.Sequence(lambda n: "User Story {} description".format(n)) + subject = factory.Sequence(lambda n: "Epic {}".format(n)) + description = factory.Sequence(lambda n: "Epic {} description".format(n)) status = factory.SubFactory("tests.factories.EpicStatusFactory") +class RelatedUserStory(Factory): + class Meta: + model = "epics.RelatedUserStory" + strategy = factory.CREATE_STRATEGY + + epic = factory.SubFactory("tests.factories.EpicFactory") + user_story = factory.SubFactory("tests.factories.UserStoryFactory") + + class MilestoneFactory(Factory): class Meta: model = "milestones.Milestone" diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index c9cd4bb1..e158b802 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -625,45 +625,55 @@ def test_api_filters_data(client): status2 = f.UserStoryStatusFactory.create(project=project) status3 = f.UserStoryStatusFactory.create(project=project) + epic0 = f.EpicFactory.create(project=project) + epic1 = f.EpicFactory.create(project=project) + epic2 = f.EpicFactory.create(project=project) + tag0 = "test1test2test3" tag1 = "test1" tag2 = "test2" tag3 = "test3" - # ------------------------------------------------------ - # | US | 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 | - # ------------------------------------------------------ + # ------------------------------------------------------------------------------ + # | US | Status | Owner | Assigned To | Tags | Epic | + # |-------#---------#--------#-------------#---------------------#-------------- + # | 0 | status3 | user2 | None | tag1 | epic0 | + # | 1 | status3 | user1 | None | tag2 | None | + # | 2 | status1 | user3 | None | tag1 tag2 | epic1 | + # | 3 | status0 | user2 | None | tag3 | None | + # | 4 | status0 | user1 | user1 | tag1 tag2 tag3 | epic0 | + # | 5 | status2 | user3 | user1 | tag3 | None | + # | 6 | status3 | user2 | user1 | tag1 tag2 | epic0 epic2 | + # | 7 | status0 | user1 | user2 | tag3 | None | + # | 8 | status3 | user3 | user2 | tag1 | epic2 | + # | 9 | status1 | user2 | user3 | tag0 | none | + # ------------------------------------------------------------------------------ - f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None, + us0 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None, status=status3, tags=[tag1]) - f.UserStoryFactory.create(project=project, owner=user1, assigned_to=None, + f.RelatedUserStory.create(user_story=us0, epic=epic0) + us1 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=None, status=status3, tags=[tag2]) - f.UserStoryFactory.create(project=project, owner=user3, assigned_to=None, + us2 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=None, status=status1, tags=[tag1, tag2]) - f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None, + f.RelatedUserStory.create(user_story=us2, epic=epic1) + us3 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None, status=status0, tags=[tag3]) - f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user1, + us4 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user1, status=status0, tags=[tag1, tag2, tag3]) - f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user1, + f.RelatedUserStory.create(user_story=us4, epic=epic0) + us5 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user1, status=status2, tags=[tag3]) - f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user1, + us6 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user1, status=status3, tags=[tag1, tag2]) - f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user2, + f.RelatedUserStory.create(user_story=us6, epic=epic0) + f.RelatedUserStory.create(user_story=us6, epic=epic2) + us7 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user2, status=status0, tags=[tag3]) - f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user2, + us8 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user2, status=status3, tags=[tag1]) - f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user3, + f.RelatedUserStory.create(user_story=us8, epic=epic2) + us9 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user3, status=status1, tags=[tag0]) url = reverse("userstories-filters-data") + "?project={}".format(project.id) @@ -693,6 +703,11 @@ def test_api_filters_data(client): 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 + assert next(filter(lambda i: i['id'] is None, response.data["epics"]))["count"] == 5 + assert next(filter(lambda i: i['id'] == epic0.id, response.data["epics"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == epic1.id, response.data["epics"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == epic2.id, response.data["epics"]))["count"] == 2 + # Filter ((status0 or status3) response = client.get(url + "&status={},{}".format(status3.id, status0.id)) assert response.status_code == 200 @@ -716,6 +731,11 @@ def test_api_filters_data(client): 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 + assert next(filter(lambda i: i['id'] is None, response.data["epics"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == epic0.id, response.data["epics"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == epic1.id, response.data["epics"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == epic2.id, response.data["epics"]))["count"] == 2 + # 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 @@ -739,6 +759,11 @@ def test_api_filters_data(client): 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 + assert next(filter(lambda i: i['id'] is None, response.data["epics"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == epic0.id, response.data["epics"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == epic1.id, response.data["epics"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == epic2.id, response.data["epics"]))["count"] == 1 + def test_get_invalid_csv(client): url = reverse("userstories-csv")