From d5b2bc95ab9e097da0ef71200bf6e867dab966fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 7 Jul 2016 12:27:42 +0200 Subject: [PATCH] Add initial Epic viewset (+ Voters and watchers) --- taiga/base/filters.py | 4 + taiga/projects/epics/api.py | 223 +++++++++++++++++ taiga/projects/epics/serializers.py | 74 ++++++ taiga/projects/epics/services.py | 376 ++++++++++++++++++++++++++++ taiga/projects/epics/utils.py | 39 +++ taiga/projects/epics/validators.py | 67 +++++ taiga/routers.py | 11 + 7 files changed, 794 insertions(+) create mode 100644 taiga/projects/epics/api.py create mode 100644 taiga/projects/epics/serializers.py create mode 100644 taiga/projects/epics/services.py create mode 100644 taiga/projects/epics/utils.py create mode 100644 taiga/projects/epics/validators.py diff --git a/taiga/base/filters.py b/taiga/base/filters.py index bddec10e..187033c1 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -160,6 +160,10 @@ class CanViewProjectFilterBackend(PermissionBasedFilterBackend): permission = "view_project" +class CanViewEpicsFilterBackend(PermissionBasedFilterBackend): + permission = "view_epics" + + class CanViewUsFilterBackend(PermissionBasedFilterBackend): permission = "view_us" diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py new file mode 100644 index 00000000..7b703ba5 --- /dev/null +++ b/taiga/projects/epics/api.py @@ -0,0 +1,223 @@ +# -*- 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 django.http import HttpResponse +from django.utils.translation import ugettext as _ + +from taiga.base.api.utils import get_object_or_404 +from taiga.base import filters, response +from taiga.base import exceptions as exc +from taiga.base.decorators import list_route +from taiga.base.api import ModelCrudViewSet, ModelListViewSet +from taiga.base.api.mixins import BlockedByProjectMixin + +from taiga.projects.history.mixins import HistoryResourceMixin +from taiga.projects.models import Project, EpicStatus +from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin +from taiga.projects.occ import OCCResourceMixin +from taiga.projects.tagging.api import TaggedResourceMixin +from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin + +from . import models +from . import permissions +from . import serializers +from . import services +from . import validators +from . import utils as epics_utils + + +class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, + WatchedResourceMixin, TaggedResourceMixin, BlockedByProjectMixin, + ModelCrudViewSet): + validator_class = validators.EpicValidator + queryset = models.Epic.objects.all() + permission_classes = (permissions.EpicPermission,) + filter_backends = (filters.CanViewEpicsFilterBackend, + filters.OwnersFilter, + filters.AssignedToFilter, + filters.StatusesFilter, + filters.TagsFilter, + filters.WatchersFilter, + filters.QFilter) + retrieve_exclude_filters = (filters.OwnersFilter, + filters.AssignedToFilter, + filters.StatusesFilter, + filters.TagsFilter, + filters.WatchersFilter) + filter_fields = ["project", + "project__slug", + "assigned_to", + "status__is_closed"] + + def get_serializer_class(self, *args, **kwargs): + if self.action in ["retrieve", "by_ref"]: + return serializers.EpicNeighborsSerializer + + if self.action == "list": + return serializers.EpicListSerializer + + return serializers.EpicSerializer + + def get_queryset(self): + qs = super().get_queryset() + qs = qs.select_related("project", + "status", + "owner", + "assigned_to") + + include_attachments = "include_attachments" in self.request.QUERY_PARAMS + qs = epics_utils.attach_extra_info(qs, user=self.request.user, + include_attachments=include_attachments) + + return qs + + def pre_conditions_on_save(self, obj): + super().pre_conditions_on_save(obj) + + if obj.status and obj.status.project != obj.project: + raise exc.WrongArguments(_("You don't have permissions to set this status to this epic.")) + + def pre_save(self, obj): + 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) + if project_id and self.object and self.object.project.id != project_id: + try: + new_project = Project.objects.get(pk=project_id) + self.check_permissions(request, "destroy", self.object) + self.check_permissions(request, "create", new_project) + + status_id = request.DATA.get('status', None) + if status_id is not None: + try: + old_status = self.object.project.epic_statuses.get(pk=status_id) + new_status = new_project.epic_statuses.get(slug=old_status.slug) + request.DATA['status'] = new_status.id + except EpicStatus.DoesNotExist: + request.DATA['status'] = new_project.default_epic_status.id + + except Project.DoesNotExist: + return response.BadRequest(_("The project doesn't exist")) + + return super().update(request, *args, **kwargs) + + @list_route(methods=["GET"]) + def filters_data(self, request, *args, **kwargs): + project_id = request.QUERY_PARAMS.get("project", None) + project = get_object_or_404(Project, id=project_id) + + filter_backends = self.get_filter_backends() + statuses_filter_backends = (f for f in filter_backends if f != filters.StatusesFilter) + assigned_to_filter_backends = (f for f in filter_backends if f != filters.AssignedToFilter) + owners_filter_backends = (f for f in filter_backends if f != filters.OwnersFilter) + + queryset = self.get_queryset() + querysets = { + "statuses": self.filter_queryset(queryset, filter_backends=statuses_filter_backends), + "assigned_to": self.filter_queryset(queryset, filter_backends=assigned_to_filter_backends), + "owners": self.filter_queryset(queryset, filter_backends=owners_filter_backends), + "tags": self.filter_queryset(queryset) + } + return response.Ok(services.get_epics_filters_data(project, querysets)) + + @list_route(methods=["GET"]) + def by_ref(self, request): + retrieve_kwargs = { + "ref": request.QUERY_PARAMS.get("ref", None) + } + project_id = request.QUERY_PARAMS.get("project", None) + if project_id is not None: + retrieve_kwargs["project_id"] = project_id + + project_slug = request.QUERY_PARAMS.get("project__slug", None) + if project_slug is not None: + retrieve_kwargs["project__slug"] = project_slug + + return self.retrieve(request, **retrieve_kwargs) + + #@list_route(methods=["GET"]) + #def csv(self, request): + # uuid = request.QUERY_PARAMS.get("uuid", None) + # if uuid is None: + # return response.NotFound() + + # project = get_object_or_404(Project, epics_csv_uuid=uuid) + # queryset = project.epics.all().order_by('ref') + # data = services.epics_to_csv(project, queryset) + # csv_response = HttpResponse(data.getvalue(), content_type='application/csv; charset=utf-8') + # csv_response['Content-Disposition'] = 'attachment; filename="epics.csv"' + # return csv_response + + @list_route(methods=["POST"]) + def bulk_create(self, request, **kwargs): + validator = validators.EpicsBulkValidator(data=request.DATA) + if validator.is_valid(): + data = validator.data + project = Project.objects.get(id=data["project_id"]) + self.check_permissions(request, 'bulk_create', project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + epics = services.create_epics_in_bulk( + data["bulk_epics"], milestone_id=data["sprint_id"], user_story_id=data["us_id"], + status_id=data.get("status_id") or project.default_epic_status_id, + project=project, owner=request.user, callback=self.post_save, precall=self.pre_save) + + epics = self.get_queryset().filter(id__in=[i.id for i in epics]) + epics_serialized = self.get_serializer_class()(epics, many=True) + + return response.Ok(epics_serialized.data) + + return response.BadRequest(validator.errors) + + def _bulk_update_order(self, order_field, request, **kwargs): + validator = validators.UpdateEpicsOrderBulkValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + project = get_object_or_404(Project, pk=data["project_id"]) + + self.check_permissions(request, "bulk_update_order", project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + services.update_epics_order_in_bulk(data["bulk_epics"], + project=project, + field=order_field) + services.snapshot_epics_in_bulk(data["bulk_epics"], request.user) + + return response.NoContent() + + @list_route(methods=["POST"]) + def bulk_update_epic_order(self, request, **kwargs): + return self._bulk_update_order("epic_order", request, **kwargs) + + +class EpicVotersViewSet(VotersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.EpicVotersPermission,) + resource_model = models.Epic + + +class EpicWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.EpicWatchersPermission,) + resource_model = models.Epic diff --git a/taiga/projects/epics/serializers.py b/taiga/projects/epics/serializers.py new file mode 100644 index 00000000..d9d45b2b --- /dev/null +++ b/taiga/projects/epics/serializers.py @@ -0,0 +1,74 @@ +# -*- 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.api import serializers +from taiga.base.fields import Field, MethodField +from taiga.base.neighbors import NeighborsSerializerMixin + +from taiga.mdrender.service import render as mdrender +from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin +from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin +from taiga.projects.notifications.mixins import WatchedResourceSerializer +from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin + + +class EpicListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, + OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, + StatusExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin, + serializers.LightSerializer): + + id = Field() + ref = Field() + project = Field(attr="project_id") + created_date = Field() + modified_date = Field() + subject = Field() + epic_order = Field() + client_requirement = Field() + team_requirement = Field() + version = Field() + watchers = Field() + is_blocked = Field() + blocked_note = Field() + tags = Field() + is_closed = MethodField() + + def get_is_closed(self, obj): + return obj.status is not None and obj.status.is_closed + + +class EpicSerializer(EpicListSerializer): + comment = MethodField() + blocked_note_html = MethodField() + description = Field() + description_html = MethodField() + + def get_comment(self, obj): + return "" + + def get_blocked_note_html(self, obj): + return mdrender(obj.project, obj.blocked_note) + + def get_description_html(self, obj): + return mdrender(obj.project, obj.description) + + +class EpicNeighborsSerializer(NeighborsSerializerMixin, EpicSerializer): + pass diff --git a/taiga/projects/epics/services.py b/taiga/projects/epics/services.py new file mode 100644 index 00000000..01a0f0fa --- /dev/null +++ b/taiga/projects/epics/services.py @@ -0,0 +1,376 @@ +# -*- 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 . + +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.epics.apps import connect_epics_signals +from taiga.projects.epics.apps import disconnect_epics_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 + +from . import models + + +##################################################### +# Bulk actions +##################################################### + +def get_epics_from_bulk(bulk_data, **additional_fields): + """Convert `bulk_data` into a list of epics. + + :param bulk_data: List of epics in bulk format. + :param additional_fields: Additional fields when instantiating each epic. + + :return: List of `Epic` instances. + """ + return [models.Epic(subject=line, **additional_fields) + for line in text.split_in_lines(bulk_data)] + + +def create_epics_in_bulk(bulk_data, callback=None, precall=None, **additional_fields): + """Create epics from `bulk_data`. + + :param bulk_data: List of epics in bulk format. + :param callback: Callback to execute after each epic save. + :param additional_fields: Additional fields when instantiating each epic. + + :return: List of created `Epic` instances. + """ + epics = get_epics_from_bulk(bulk_data, **additional_fields) + + disconnect_epics_signals() + + try: + db.save_in_bulk(epics, callback, precall) + finally: + connect_epics_signals() + + return epics + + +def update_epics_order_in_bulk(bulk_data: list, field: str, project: object): + """ + Update the order of some epics. + `bulk_data` should be a list of tuples with the following format: + + [(, {: , ...}), ...] + """ + epic_ids = [] + new_order_values = [] + for epic_data in bulk_data: + epic_ids.append(epic_data["epic_id"]) + new_order_values.append({field: epic_data["order"]}) + + events.emit_event_for_ids(ids=epic_ids, + content_type="epics.epic", + projectid=project.pk) + + db.update_in_bulk_with_ids(epic_ids, new_order_values, model=models.Epic) + + +def snapshot_epics_in_bulk(bulk_data, user): + for epic_data in bulk_data: + try: + epic = models.Epic.objects.get(pk=epic_data['epic_id']) + take_snapshot(epic, user=user) + except models.Epic.DoesNotExist: + pass + + +##################################################### +# CSV +##################################################### +# +#def epics_to_csv(project, queryset): +# csv_data = io.StringIO() +# fieldnames = ["ref", "subject", "description", "user_story", "sprint", "sprint_estimated_start", +# "sprint_estimated_finish", "owner", "owner_full_name", "assigned_to", +# "assigned_to_full_name", "status", "is_iocaine", "is_closed", "us_order", +# "epicboard_order", "attachments", "external_reference", "tags", "watchers", "voters", +# "created_date", "modified_date", "finished_date"] +# +# custom_attrs = project.epiccustomattributes.all() +# for custom_attr in custom_attrs: +# fieldnames.append(custom_attr.name) +# +# queryset = queryset.prefetch_related("attachments", +# "custom_attributes_values") +# queryset = queryset.select_related("milestone", +# "owner", +# "assigned_to", +# "status", +# "project") +# +# queryset = attach_total_voters_to_queryset(queryset) +# queryset = attach_watchers_to_queryset(queryset) +# +# writer = csv.DictWriter(csv_data, fieldnames=fieldnames) +# writer.writeheader() +# for epic in queryset: +# epic_data = { +# "ref": epic.ref, +# "subject": epic.subject, +# "description": epic.description, +# "user_story": epic.user_story.ref if epic.user_story else None, +# "sprint": epic.milestone.name if epic.milestone else None, +# "sprint_estimated_start": epic.milestone.estimated_start if epic.milestone else None, +# "sprint_estimated_finish": epic.milestone.estimated_finish if epic.milestone else None, +# "owner": epic.owner.username if epic.owner else None, +# "owner_full_name": epic.owner.get_full_name() if epic.owner else None, +# "assigned_to": epic.assigned_to.username if epic.assigned_to else None, +# "assigned_to_full_name": epic.assigned_to.get_full_name() if epic.assigned_to else None, +# "status": epic.status.name if epic.status else None, +# "is_iocaine": epic.is_iocaine, +# "is_closed": epic.status is not None and epic.status.is_closed, +# "us_order": epic.us_order, +# "epicboard_order": epic.epicboard_order, +# "attachments": epic.attachments.count(), +# "external_reference": epic.external_reference, +# "tags": ",".join(epic.tags or []), +# "watchers": epic.watchers, +# "voters": epic.total_voters, +# "created_date": epic.created_date, +# "modified_date": epic.modified_date, +# "finished_date": epic.finished_date, +# } +# for custom_attr in custom_attrs: +# value = epic.custom_attributes_values.attributes_values.get(str(custom_attr.id), None) +# epic_data[custom_attr.name] = value +# +# writer.writerow(epic_data) +# +# return csv_data + + +##################################################### +# Api filter data +##################################################### + +def _get_epics_statuses(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + SELECT "projects_epicstatus"."id", + "projects_epicstatus"."name", + "projects_epicstatus"."color", + "projects_epicstatus"."order", + (SELECT count(*) + FROM "epics_epic" + INNER JOIN "projects_project" ON + ("epics_epic"."project_id" = "projects_project"."id") + WHERE {where} AND "epics_epic"."status_id" = "projects_epicstatus"."id") + FROM "projects_epicstatus" + WHERE "projects_epicstatus"."project_id" = %s + ORDER BY "projects_epicstatus"."order"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, name, color, order, count in rows: + result.append({ + "id": id, + "name": _(name), + "color": color, + "order": order, + "count": count, + }) + return sorted(result, key=itemgetter("order")) + + +def _get_epics_assigned_to(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT assigned_to_id, count(assigned_to_id) count + FROM "epics_epic" + INNER JOIN "projects_project" ON ("epics_epic"."project_id" = "projects_project"."id") + WHERE {where} AND "epics_epic"."assigned_to_id" IS NOT NULL + GROUP BY assigned_to_id + ) + + SELECT "projects_membership"."user_id" user_id, + "users_user"."full_name", + "users_user"."username", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."assigned_to_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- unassigned epics + UNION + + SELECT NULL user_id, NULL, NULL, count(coalesce(assigned_to_id, -1)) count + FROM "epics_epic" + INNER JOIN "projects_project" ON ("epics_epic"."project_id" = "projects_project"."id") + WHERE {where} AND "epics_epic"."assigned_to_id" IS NULL + GROUP BY assigned_to_id + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id] + where_params) + rows = cursor.fetchall() + + result = [] + none_valued_added = False + for id, full_name, username, count in rows: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + }) + + if id is None: + none_valued_added = True + + # If there was no epic with null assigned_to we manually add it + if not none_valued_added: + result.append({ + "id": None, + "full_name": "", + "count": 0, + }) + + return sorted(result, key=itemgetter("full_name")) + + +def _get_epics_owners(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT "epics_epic"."owner_id" owner_id, + count(coalesce("epics_epic"."owner_id", -1)) count + FROM "epics_epic" + INNER JOIN "projects_project" ON ("epics_epic"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY "epics_epic"."owner_id" + ) + + SELECT "projects_membership"."user_id" id, + "users_user"."full_name", + "users_user"."username", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- System users + UNION + + SELECT "users_user"."id" user_id, + "users_user"."full_name" full_name, + "users_user"."username" username, + COALESCE("counters".count, 0) count + FROM users_user + LEFT OUTER JOIN counters ON ("users_user"."id" = "counters"."owner_id") + WHERE ("users_user"."is_system" IS TRUE) + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, full_name, username, count in rows: + if count > 0: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + }) + return sorted(result, key=itemgetter("full_name")) + + +def _get_epics_tags(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH epics_tags AS ( + SELECT tag, + COUNT(tag) counter FROM ( + SELECT UNNEST(epics_epic.tags) tag + FROM epics_epic + INNER JOIN projects_project + ON (epics_epic.project_id = projects_project.id) + WHERE {where}) tags + GROUP BY tag), + project_tags AS ( + SELECT reduce_dim(tags_colors) tag_color + FROM projects_project + WHERE id=%s) + + SELECT tag_color[1] tag, COALESCE(epics_tags.counter, 0) counter + FROM project_tags + LEFT JOIN epics_tags ON project_tags.tag_color[1] = epics_tags.tag + ORDER BY tag + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for name, count in rows: + result.append({ + "name": name, + "count": count, + }) + return sorted(result, key=itemgetter("name")) + + +def get_epics_filters_data(project, querysets): + """ + Given a project and an epics queryset, return a simple data structure + of all possible filters for the epics in the queryset. + """ + data = OrderedDict([ + ("statuses", _get_epics_statuses(project, querysets["statuses"])), + ("assigned_to", _get_epics_assigned_to(project, querysets["assigned_to"])), + ("owners", _get_epics_owners(project, querysets["owners"])), + ("tags", _get_epics_tags(project, querysets["tags"])), + ]) + + return data diff --git a/taiga/projects/epics/utils.py b/taiga/projects/epics/utils.py new file mode 100644 index 00000000..d10dddab --- /dev/null +++ b/taiga/projects/epics/utils.py @@ -0,0 +1,39 @@ +# -*- 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 taiga.projects.attachments.utils import attach_basic_attachments +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.notifications.utils import attach_total_watchers_to_queryset +from taiga.projects.notifications.utils import attach_is_watcher_to_queryset +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.votes.utils import attach_is_voter_to_queryset + + +def attach_extra_info(queryset, user=None, include_attachments=False): + + if include_attachments: + queryset = attach_basic_attachments(queryset) + queryset = queryset.extra(select={"include_attachments": "True"}) + + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + queryset = attach_total_watchers_to_queryset(queryset) + queryset = attach_is_voter_to_queryset(queryset, user) + queryset = attach_is_watcher_to_queryset(queryset, user) + return queryset diff --git a/taiga/projects/epics/validators.py b/taiga/projects/epics/validators.py new file mode 100644 index 00000000..9d7a617f --- /dev/null +++ b/taiga/projects/epics/validators.py @@ -0,0 +1,67 @@ +# -*- 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 django.utils.translation import ugettext as _ + +from taiga.base.api import serializers +from taiga.base.api import validators +from taiga.base.exceptions import ValidationError +from taiga.base.fields import PgArrayField +from taiga.projects.milestones.validators import MilestoneExistsValidator +from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer +from taiga.projects.notifications.validators import WatchersValidator +from taiga.projects.tagging.fields import TagsAndTagsColorsField +from taiga.projects.validators import ProjectExistsValidator +from . import models + + +class EpicExistsValidator: + def validate_epic_id(self, attrs, source): + value = attrs[source] + if not models.Epic.objects.filter(pk=value).exists(): + msg = _("There's no epic with that id") + raise ValidationError(msg) + return attrs + + +class EpicValidator(WatchersValidator, EditableWatchedResourceSerializer, validators.ModelValidator): + tags = TagsAndTagsColorsField(default=[], required=False) + external_reference = PgArrayField(required=False) + + class Meta: + model = models.Epic + read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner') + + +class EpicsBulkValidator(ProjectExistsValidator, EpicExistsValidator, + validators.Validator): + project_id = serializers.IntegerField() + status_id = serializers.IntegerField(required=False) + bulk_epics = serializers.CharField() + + +# Order bulk validators + +class _EpicOrderBulkValidator(EpicExistsValidator, validators.Validator): + epic_id = serializers.IntegerField() + order = serializers.IntegerField() + + +class UpdateEpicsOrderBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + bulk_epics = _EpicOrderBulkValidator(many=True) diff --git a/taiga/routers.py b/taiga/routers.py index 49db63b2..e0d0baa1 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -145,6 +145,10 @@ router.register(r"wiki/attachments", WikiAttachmentViewSet, from taiga.projects.milestones.api import MilestoneViewSet from taiga.projects.milestones.api import MilestoneWatchersViewSet +from taiga.projects.epics.api import EpicViewSet +from taiga.projects.epics.api import EpicVotersViewSet +from taiga.projects.epics.api import EpicWatchersViewSet + from taiga.projects.userstories.api import UserStoryViewSet from taiga.projects.userstories.api import UserStoryVotersViewSet from taiga.projects.userstories.api import UserStoryWatchersViewSet @@ -166,6 +170,13 @@ router.register(r"milestones", MilestoneViewSet, router.register(r"milestones/(?P\d+)/watchers", MilestoneWatchersViewSet, base_name="milestone-watchers") +router.register(r"epics", EpicViewSet, + base_name="epics") +router.register(r"epics/(?P\d+)/voters", EpicVotersViewSet, + base_name="epic-voters") +router.register(r"epics/(?P\d+)/watchers", EpicWatchersViewSet, + base_name="epic-watchers") + router.register(r"userstories", UserStoryViewSet, base_name="userstories") router.register(r"userstories/(?P\d+)/voters", UserStoryVotersViewSet,