From e9dfbe73787187b75e69824624be2cb9d1a16006 Mon Sep 17 00:00:00 2001 From: ikame Date: Thu, 20 Feb 2014 17:46:38 +0100 Subject: [PATCH] US #50: Return neighbors when fetching an US/Issue The response for an UserStory/Issue now contains: { ... "neighbors": { "previous": { "id": ..., "ref": ..., "subject": ... }, "next": { ... same as above } } } If there's a neighbor missing the response will contain the empty dict {}: { ... "neighbors": { "previous": {}, ... } } Neighbors are looked up applying the same filters defined in the corresponding ViewSets and the same ordering defined in those filters or ultimately, in the model's meta. In other words, using the same params you use to filter the object list, can be used to filter the neighbors when fetching the object's details. --- taiga/base/filters.py | 21 +++++++++ taiga/base/models.py | 54 +++++++++++++++++++++++ taiga/base/serializers.py | 18 ++++++++ taiga/projects/issues/api.py | 2 +- taiga/projects/issues/models.py | 5 ++- taiga/projects/issues/serializers.py | 19 ++++++-- taiga/projects/userstories/api.py | 7 ++- taiga/projects/userstories/models.py | 9 ++-- taiga/projects/userstories/serializers.py | 19 ++++++-- 9 files changed, 140 insertions(+), 14 deletions(-) diff --git a/taiga/base/filters.py b/taiga/base/filters.py index 2ecfafac..cdde8a0a 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -76,3 +76,24 @@ class IsProjectMemberFilterBackend(FilterBackend): queryset = queryset.filter(Q(project__members=request.user) | Q(project__owner=request.user)) return queryset.distinct() + + +class TagsFilter(FilterBackend): + FILTER_TAGS_SQL = "unpickle({table}.tags) && %s" + + def __init__(self, filter_name='tags'): + self.filter_name = filter_name + + def _get_tags_queryparams(self, params): + tags = params.get(self.filter_name, []) + if tags: + tags = list({tag.strip() for tag in tags.split(",")}) + return tags + + def filter_queryset(self, request, queryset, view): + tags = self._get_tags_queryparams(request.QUERY_PARAMS) + if tags: + where_sql = self.FILTER_TAGS_SQL.format(table=view.model._meta.db_table) + queryset = queryset.extra(where=[where_sql], params=[tags]) + + return queryset diff --git a/taiga/base/models.py b/taiga/base/models.py index ed0aa24c..5ae1c683 100644 --- a/taiga/base/models.py +++ b/taiga/base/models.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from django.db.models import Q # Patch api view for correctly return 401 responses on # request is authenticated instead of 403 @@ -7,3 +8,56 @@ monkey.patch_api_view() monkey.patch_serializer() monkey.patch_import_module() monkey.patch_south_hacks() + + +class NeighborsMixin: + + def get_neighbors(self, queryset=None): + """Get the objects around this object. + + :param queryset: A queryset object to use as a starting point. Useful if you need to + pre-filter the neighbor candidates. + + :return: The tuple `(previous, next)`. + """ + if queryset is None: + queryset = type(self).objects.get_queryset() + queryset = queryset.filter(~Q(id=self.id)) + + return self._get_previous_neighbor(queryset), self._get_next_neighbor(queryset) + + def _get_queryset_order_by(self, queryset): + return queryset.query.order_by or [self._meta.pk.name] + + def _field(self, field): + return getattr(self, field.lstrip("-")) + + def _filter(self, field, inc, desc): + if field.startswith("-"): + field = field[1:] + operator = desc + else: + operator = inc + return field, operator + + def _or(self, conditions): + result = Q(**conditions[0]) + for condition in conditions: + result = result | Q(**condition) + return result + + def _get_previous_neighbor(self, queryset): + conds = [{"{}__{}".format(*self._filter(field, "lt", "gt")): self._field(field)} + for field in self._get_queryset_order_by(queryset)] + try: + return queryset.filter(self._or(conds)).reverse()[0] + except IndexError: + return None + + def _get_next_neighbor(self, queryset): + conds = [{"{}__{}".format(*self._filter(field, "gt", "lt")): self._field(field)} + for field in self._get_queryset_order_by(queryset)] + try: + return queryset.filter(self._or(conds))[0] + except IndexError: + return None diff --git a/taiga/base/serializers.py b/taiga/base/serializers.py index 54e98dfd..e3c40ca3 100644 --- a/taiga/base/serializers.py +++ b/taiga/base/serializers.py @@ -77,3 +77,21 @@ class VersionSerializer(serializers.ModelSerializer): } return changed_fields + + +class NeighborsSerializerMixin: + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["neighbors"] = serializers.SerializerMethodField("get_neighbors") + + def serialize_neighbor(self, neighbor): + raise NotImplementedError + + def get_neighbors(self, obj): + view, request = self.context["view"], self.context["request"] + queryset = view.filter_queryset(view.get_queryset()) + previous, next = obj.get_neighbors(queryset) + + return {"previous": self.serialize_neighbor(previous), + "next": self.serialize_neighbor(next)} diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index 9e5af6d9..20475044 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -87,7 +87,7 @@ class IssuesOrdering(filters.FilterBackend): class IssueViewSet(NotificationSenderMixin, ModelCrudViewSet): model = models.Issue - serializer_class = serializers.IssueSerializer + serializer_class = serializers.IssueNeighborsSerializer permission_classes = (IsAuthenticated, permissions.IssuePermission) filter_backends = (filters.IsProjectMemberFilterBackend, IssuesFilter, IssuesOrdering) diff --git a/taiga/projects/issues/models.py b/taiga/projects/issues/models.py index 0ee89d76..761f0060 100644 --- a/taiga/projects/issues/models.py +++ b/taiga/projects/issues/models.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _ from picklefield.fields import PickledObjectField +from taiga.base.models import NeighborsMixin from taiga.base.utils.slug import ref_uniquely from taiga.base.notifications.models import WatchedMixin from taiga.projects.mixins.blocked.models import BlockedMixin @@ -16,7 +17,7 @@ from taiga.projects.mixins.blocked.models import BlockedMixin import reversion -class Issue(WatchedMixin, BlockedMixin): +class Issue(NeighborsMixin, WatchedMixin, BlockedMixin): ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, verbose_name=_("ref")) owner = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, default=None, @@ -115,11 +116,13 @@ def issue_finished_date_handler(sender, instance, **kwargs): elif not instance.status.is_closed and instance.finished_date: instance.finished_date = None + @receiver(models.signals.pre_save, sender=Issue, dispatch_uid="issue_ref_handler") def issue_ref_handler(sender, instance, **kwargs): if not instance.id and instance.project: instance.ref = ref_uniquely(instance.project, "last_issue_ref", instance.__class__) + @receiver(models.signals.pre_save, sender=Issue, dispatch_uid="issue-tags-normalization") def issue_tags_normalization(sender, instance, **kwargs): if isinstance(instance.tags, (list, tuple)): diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py index 267a7f8f..c25ebfb6 100644 --- a/taiga/projects/issues/serializers.py +++ b/taiga/projects/issues/serializers.py @@ -2,14 +2,12 @@ from rest_framework import serializers -from taiga.base.serializers import PickleField +from taiga.base.serializers import PickleField, NeighborsSerializerMixin from . import models -import reversion - -class IssueSerializer(serializers.ModelSerializer): +class IssueSerializer(NeighborsSerializerMixin, serializers.ModelSerializer): tags = PickleField(required=False) comment = serializers.SerializerMethodField("get_comment") is_closed = serializers.Field(source="is_closed") @@ -19,3 +17,16 @@ class IssueSerializer(serializers.ModelSerializer): def get_comment(self, obj): return "" + + +class IssueNeighborsSerializer(IssueSerializer): + + def serialize_neighbor(self, neighbor): + return NeighborIssueSerializer(neighbor).data + + +class NeighborIssueSerializer(serializers.ModelSerializer): + class Meta: + model = models.Issue + fields = ("id", "ref", "subject") + depth = 0 diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index b6ce0fbc..2ad7feed 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -57,16 +57,19 @@ class UserStoryAttachmentViewSet(ModelCrudViewSet): class UserStoryViewSet(NotificationSenderMixin, ModelCrudViewSet): model = models.UserStory - serializer_class = serializers.UserStorySerializer + serializer_class = serializers.UserStoryNeighborsSerializer permission_classes = (IsAuthenticated, permissions.UserStoryPermission) - filter_backends = (filters.IsProjectMemberFilterBackend,) + filter_backends = (filters.IsProjectMemberFilterBackend, filters.TagsFilter) filter_fields = ['project', 'milestone', 'milestone__isnull'] create_notification_template = "create_userstory_notification" update_notification_template = "update_userstory_notification" destroy_notification_template = "destroy_userstory_notification" + # Specific filter used for filtering neighbor user stories + _neighbor_tags_filter = filters.TagsFilter('neighbor_tags') + @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): bulk_stories = request.DATA.get('bulkStories', None) diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py index 071bdeae..e68aa3ea 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _ from picklefield.fields import PickledObjectField import reversion +from taiga.base.models import NeighborsMixin from taiga.base.utils.slug import ref_uniquely from taiga.base.notifications.models import WatchedMixin from taiga.projects.mixins.blocked.models import BlockedMixin @@ -33,11 +34,12 @@ class RolePoints(models.Model): permissions = ( ("view_rolepoints", "Can view role points"), ) + def __str__(self): return "{}: {}".format(self.role.name, self.points.name) -class UserStory(WatchedMixin, BlockedMixin): +class UserStory(NeighborsMixin, WatchedMixin, BlockedMixin, models.Model): ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, verbose_name=_("ref")) milestone = models.ForeignKey("milestones.Milestone", null=True, blank=True, @@ -98,7 +100,7 @@ class UserStory(WatchedMixin, BlockedMixin): class Meta: verbose_name = "user story" verbose_name_plural = "user stories" - ordering = ["project", "order"] + ordering = ["project", "order", "ref"] unique_together = ("ref", "project") permissions = ( ("view_userstory", "Can view user story"), @@ -133,7 +135,7 @@ class UserStory(WatchedMixin, BlockedMixin): def get_notifiable_points_display(self, value): if isinstance(value, models.manager.Manager): - return ", ".join(["{}: {}".format(rp.role.name,rp.points.name) + return ", ".join(["{}: {}".format(rp.role.name, rp.points.name) for rp in self.role_points.all().order_by("role")]) return None @@ -170,6 +172,7 @@ def us_task_reassignation(sender, instance, created, **kwargs): if not created: instance.tasks.update(milestone=instance.milestone) + @receiver(models.signals.pre_save, sender=UserStory, dispatch_uid="us-tags-normalization") def us_tags_normalization(sender, instance, **kwargs): if isinstance(instance.tags, (list, tuple)): diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index b88b8d20..acb0c32b 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -import json, reversion +import json from django.db.models import get_model from rest_framework import serializers -from taiga.base.serializers import PickleField +from taiga.base.serializers import PickleField, NeighborsSerializerMixin from . import models @@ -21,7 +21,7 @@ class RolePointsField(serializers.WritableField): class UserStorySerializer(serializers.ModelSerializer): tags = PickleField(default=[], required=False) # is_closed = serializers.Field(source="is_closed") - points = RolePointsField(source="role_points", required=False ) + points = RolePointsField(source="role_points", required=False) total_points = serializers.SerializerMethodField("get_total_points") comment = serializers.SerializerMethodField("get_comment") milestone_slug = serializers.SerializerMethodField("get_milestone_slug") @@ -55,3 +55,16 @@ class UserStorySerializer(serializers.ModelSerializer): return obj.milestone.slug else: return None + + +class UserStoryNeighborsSerializer(NeighborsSerializerMixin, UserStorySerializer): + + def serialize_neighbor(self, neighbor): + return NeighborUserStorySerializer(neighbor).data + + +class NeighborUserStorySerializer(serializers.ModelSerializer): + class Meta: + model = models.UserStory + fields = ("id", "ref", "subject") + depth = 0