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.
remotes/origin/enhancement/email-actions
ikame 2014-02-20 17:46:38 +01:00
parent d165d55a56
commit e9dfbe7378
9 changed files with 140 additions and 14 deletions

View File

@ -76,3 +76,24 @@ class IsProjectMemberFilterBackend(FilterBackend):
queryset = queryset.filter(Q(project__members=request.user) | queryset = queryset.filter(Q(project__members=request.user) |
Q(project__owner=request.user)) Q(project__owner=request.user))
return queryset.distinct() 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

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.db.models import Q
# Patch api view for correctly return 401 responses on # Patch api view for correctly return 401 responses on
# request is authenticated instead of 403 # request is authenticated instead of 403
@ -7,3 +8,56 @@ monkey.patch_api_view()
monkey.patch_serializer() monkey.patch_serializer()
monkey.patch_import_module() monkey.patch_import_module()
monkey.patch_south_hacks() 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

View File

@ -77,3 +77,21 @@ class VersionSerializer(serializers.ModelSerializer):
} }
return changed_fields 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)}

View File

@ -87,7 +87,7 @@ class IssuesOrdering(filters.FilterBackend):
class IssueViewSet(NotificationSenderMixin, ModelCrudViewSet): class IssueViewSet(NotificationSenderMixin, ModelCrudViewSet):
model = models.Issue model = models.Issue
serializer_class = serializers.IssueSerializer serializer_class = serializers.IssueNeighborsSerializer
permission_classes = (IsAuthenticated, permissions.IssuePermission) permission_classes = (IsAuthenticated, permissions.IssuePermission)
filter_backends = (filters.IsProjectMemberFilterBackend, IssuesFilter, IssuesOrdering) filter_backends = (filters.IsProjectMemberFilterBackend, IssuesFilter, IssuesOrdering)

View File

@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
from picklefield.fields import PickledObjectField from picklefield.fields import PickledObjectField
from taiga.base.models import NeighborsMixin
from taiga.base.utils.slug import ref_uniquely from taiga.base.utils.slug import ref_uniquely
from taiga.base.notifications.models import WatchedMixin from taiga.base.notifications.models import WatchedMixin
from taiga.projects.mixins.blocked.models import BlockedMixin from taiga.projects.mixins.blocked.models import BlockedMixin
@ -16,7 +17,7 @@ from taiga.projects.mixins.blocked.models import BlockedMixin
import reversion import reversion
class Issue(WatchedMixin, BlockedMixin): class Issue(NeighborsMixin, WatchedMixin, BlockedMixin):
ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None,
verbose_name=_("ref")) verbose_name=_("ref"))
owner = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, default=None, 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: elif not instance.status.is_closed and instance.finished_date:
instance.finished_date = None instance.finished_date = None
@receiver(models.signals.pre_save, sender=Issue, dispatch_uid="issue_ref_handler") @receiver(models.signals.pre_save, sender=Issue, dispatch_uid="issue_ref_handler")
def issue_ref_handler(sender, instance, **kwargs): def issue_ref_handler(sender, instance, **kwargs):
if not instance.id and instance.project: if not instance.id and instance.project:
instance.ref = ref_uniquely(instance.project, "last_issue_ref", instance.__class__) instance.ref = ref_uniquely(instance.project, "last_issue_ref", instance.__class__)
@receiver(models.signals.pre_save, sender=Issue, dispatch_uid="issue-tags-normalization") @receiver(models.signals.pre_save, sender=Issue, dispatch_uid="issue-tags-normalization")
def issue_tags_normalization(sender, instance, **kwargs): def issue_tags_normalization(sender, instance, **kwargs):
if isinstance(instance.tags, (list, tuple)): if isinstance(instance.tags, (list, tuple)):

View File

@ -2,14 +2,12 @@
from rest_framework import serializers from rest_framework import serializers
from taiga.base.serializers import PickleField from taiga.base.serializers import PickleField, NeighborsSerializerMixin
from . import models from . import models
import reversion
class IssueSerializer(NeighborsSerializerMixin, serializers.ModelSerializer):
class IssueSerializer(serializers.ModelSerializer):
tags = PickleField(required=False) tags = PickleField(required=False)
comment = serializers.SerializerMethodField("get_comment") comment = serializers.SerializerMethodField("get_comment")
is_closed = serializers.Field(source="is_closed") is_closed = serializers.Field(source="is_closed")
@ -19,3 +17,16 @@ class IssueSerializer(serializers.ModelSerializer):
def get_comment(self, obj): def get_comment(self, obj):
return "" 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

View File

@ -57,16 +57,19 @@ class UserStoryAttachmentViewSet(ModelCrudViewSet):
class UserStoryViewSet(NotificationSenderMixin, ModelCrudViewSet): class UserStoryViewSet(NotificationSenderMixin, ModelCrudViewSet):
model = models.UserStory model = models.UserStory
serializer_class = serializers.UserStorySerializer serializer_class = serializers.UserStoryNeighborsSerializer
permission_classes = (IsAuthenticated, permissions.UserStoryPermission) permission_classes = (IsAuthenticated, permissions.UserStoryPermission)
filter_backends = (filters.IsProjectMemberFilterBackend,) filter_backends = (filters.IsProjectMemberFilterBackend, filters.TagsFilter)
filter_fields = ['project', 'milestone', 'milestone__isnull'] filter_fields = ['project', 'milestone', 'milestone__isnull']
create_notification_template = "create_userstory_notification" create_notification_template = "create_userstory_notification"
update_notification_template = "update_userstory_notification" update_notification_template = "update_userstory_notification"
destroy_notification_template = "destroy_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"]) @list_route(methods=["POST"])
def bulk_create(self, request, **kwargs): def bulk_create(self, request, **kwargs):
bulk_stories = request.DATA.get('bulkStories', None) bulk_stories = request.DATA.get('bulkStories', None)

View File

@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
from picklefield.fields import PickledObjectField from picklefield.fields import PickledObjectField
import reversion import reversion
from taiga.base.models import NeighborsMixin
from taiga.base.utils.slug import ref_uniquely from taiga.base.utils.slug import ref_uniquely
from taiga.base.notifications.models import WatchedMixin from taiga.base.notifications.models import WatchedMixin
from taiga.projects.mixins.blocked.models import BlockedMixin from taiga.projects.mixins.blocked.models import BlockedMixin
@ -33,11 +34,12 @@ class RolePoints(models.Model):
permissions = ( permissions = (
("view_rolepoints", "Can view role points"), ("view_rolepoints", "Can view role points"),
) )
def __str__(self): def __str__(self):
return "{}: {}".format(self.role.name, self.points.name) 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, ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None,
verbose_name=_("ref")) verbose_name=_("ref"))
milestone = models.ForeignKey("milestones.Milestone", null=True, blank=True, milestone = models.ForeignKey("milestones.Milestone", null=True, blank=True,
@ -98,7 +100,7 @@ class UserStory(WatchedMixin, BlockedMixin):
class Meta: class Meta:
verbose_name = "user story" verbose_name = "user story"
verbose_name_plural = "user stories" verbose_name_plural = "user stories"
ordering = ["project", "order"] ordering = ["project", "order", "ref"]
unique_together = ("ref", "project") unique_together = ("ref", "project")
permissions = ( permissions = (
("view_userstory", "Can view user story"), ("view_userstory", "Can view user story"),
@ -133,7 +135,7 @@ class UserStory(WatchedMixin, BlockedMixin):
def get_notifiable_points_display(self, value): def get_notifiable_points_display(self, value):
if isinstance(value, models.manager.Manager): 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")]) for rp in self.role_points.all().order_by("role")])
return None return None
@ -170,6 +172,7 @@ def us_task_reassignation(sender, instance, created, **kwargs):
if not created: if not created:
instance.tasks.update(milestone=instance.milestone) instance.tasks.update(milestone=instance.milestone)
@receiver(models.signals.pre_save, sender=UserStory, dispatch_uid="us-tags-normalization") @receiver(models.signals.pre_save, sender=UserStory, dispatch_uid="us-tags-normalization")
def us_tags_normalization(sender, instance, **kwargs): def us_tags_normalization(sender, instance, **kwargs):
if isinstance(instance.tags, (list, tuple)): if isinstance(instance.tags, (list, tuple)):

View File

@ -1,10 +1,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import json, reversion import json
from django.db.models import get_model from django.db.models import get_model
from rest_framework import serializers from rest_framework import serializers
from taiga.base.serializers import PickleField from taiga.base.serializers import PickleField, NeighborsSerializerMixin
from . import models from . import models
@ -21,7 +21,7 @@ class RolePointsField(serializers.WritableField):
class UserStorySerializer(serializers.ModelSerializer): class UserStorySerializer(serializers.ModelSerializer):
tags = PickleField(default=[], required=False) tags = PickleField(default=[], required=False)
# is_closed = serializers.Field(source="is_closed") # 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") total_points = serializers.SerializerMethodField("get_total_points")
comment = serializers.SerializerMethodField("get_comment") comment = serializers.SerializerMethodField("get_comment")
milestone_slug = serializers.SerializerMethodField("get_milestone_slug") milestone_slug = serializers.SerializerMethodField("get_milestone_slug")
@ -55,3 +55,16 @@ class UserStorySerializer(serializers.ModelSerializer):
return obj.milestone.slug return obj.milestone.slug
else: else:
return None 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