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
parent
d165d55a56
commit
e9dfbe7378
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)):
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue