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