Refactoring epics API

remotes/origin/issue/4795/notification_even_they_are_disabled
Alejandro Alonso 2016-08-04 14:08:34 +02:00 committed by David Barragán Merino
parent 890c668e7e
commit 32267af4f4
11 changed files with 183 additions and 90 deletions

View File

@ -134,6 +134,25 @@ class ViewSetMixin(object):
return super().check_permissions(request, action=action, obj=obj) return super().check_permissions(request, action=action, obj=obj)
class NestedViewSetMixin(object):
def get_queryset(self):
return self._filter_queryset_by_parents_lookups(super().get_queryset())
def _filter_queryset_by_parents_lookups(self, queryset):
parents_query_dict = self._get_parents_query_dict()
if parents_query_dict:
return queryset.filter(**parents_query_dict)
else:
return queryset
def _get_parents_query_dict(self):
result = {}
for kwarg_name in self.kwargs:
query_value = self.kwargs.get(kwarg_name)
result[kwarg_name] = query_value
return result
class ViewSet(ViewSetMixin, views.APIView): class ViewSet(ViewSetMixin, views.APIView):
""" """
The base ViewSet class does not provide any actions by default. The base ViewSet class does not provide any actions by default.

View File

@ -318,7 +318,58 @@ class DRFDefaultRouter(SimpleRouter):
return urls return urls
class DefaultRouter(DRFDefaultRouter): class NestedRegistryItem(object):
def __init__(self, router, parent_prefix, parent_item=None):
self.router = router
self.parent_prefix = parent_prefix
self.parent_item = parent_item
def register(self, prefix, viewset, base_name, parents_query_lookups):
self.router._register(
prefix=self.get_prefix(current_prefix=prefix, parents_query_lookups=parents_query_lookups),
viewset=viewset,
base_name=base_name,
)
return NestedRegistryItem(
router=self.router,
parent_prefix=prefix,
parent_item=self
)
def get_prefix(self, current_prefix, parents_query_lookups):
return "{0}/{1}".format(
self.get_parent_prefix(parents_query_lookups),
current_prefix
)
def get_parent_prefix(self, parents_query_lookups):
prefix = "/"
current_item = self
i = len(parents_query_lookups) - 1
while current_item:
prefix = "{parent_prefix}/(?P<{parent_pk_kwarg_name}>[^/.]+)/{prefix}".format(
parent_prefix=current_item.parent_prefix,
parent_pk_kwarg_name=parents_query_lookups[i],
prefix=prefix
)
i -= 1
current_item = current_item.parent_item
return prefix.strip("/")
class NestedRouterMixin:
def _register(self, *args, **kwargs):
return super().register(*args, **kwargs)
def register(self, *args, **kwargs):
self._register(*args, **kwargs)
return NestedRegistryItem(
router=self,
parent_prefix=self.registry[-1][0]
)
class DefaultRouter(NestedRouterMixin, DRFDefaultRouter):
pass pass
__all__ = ["DefaultRouter"] __all__ = ["DefaultRouter"]

View File

@ -24,10 +24,17 @@ from taiga.projects.votes.admin import VoteInline
from . import models from . import models
class RelatedUserStoriesInline(admin.TabularInline):
model = models.RelatedUserStory
sortable_field_name = "order"
raw_id_fields = ["user_story", ]
extra = 0
class EpicAdmin(admin.ModelAdmin): class EpicAdmin(admin.ModelAdmin):
list_display = ["project", "ref", "subject"] list_display = ["project", "ref", "subject"]
list_display_links = ["ref", "subject"] list_display_links = ["ref", "subject"]
inlines = [WatchedInline, VoteInline] inlines = [WatchedInline, VoteInline, RelatedUserStoriesInline]
raw_id_fields = ["project"] raw_id_fields = ["project"]
search_fields = ["subject", "description", "id", "ref"] search_fields = ["subject", "description", "id", "ref"]

View File

@ -25,6 +25,7 @@ from taiga.base import exceptions as exc
from taiga.base.decorators import list_route, detail_route from taiga.base.decorators import list_route, detail_route
from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.api.mixins import BlockedByProjectMixin from taiga.base.api.mixins import BlockedByProjectMixin
from taiga.base.api.viewsets import NestedViewSetMixin
from taiga.base.utils import json from taiga.base.utils import json
from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.history.mixins import HistoryResourceMixin
@ -108,17 +109,16 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin,
super().pre_save(obj) super().pre_save(obj)
def _reorder_if_needed(self, obj, old_order_key, order_key, order_attr, project): def _reorder_if_needed(self, obj, old_order_key, order_key):
# Executes the extra ordering if there is a difference in the ordering keys # Executes the extra ordering if there is a difference in the ordering keys
if old_order_key != order_key: if old_order_key != order_key:
extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}")) extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}"))
data = [{"epic_id": obj.id, "order": getattr(obj, order_attr)}] data = [{"epic_id": obj.id, "order": getattr(obj, "epics_order")}]
for id, order in extra_orders.items(): for id, order in extra_orders.items():
data.append({"epic_id": int(id), "order": order}) data.append({"epic_id": int(id), "order": order})
return services.update_epics_order_in_bulk(data, return services.update_epics_order_in_bulk(data,
field=order_attr, project=obj.project)
project=project)
return {} return {}
def post_save(self, obj, created=False): def post_save(self, obj, created=False):
@ -126,9 +126,7 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin,
# Let's reorder the related stuff after edit the element # Let's reorder the related stuff after edit the element
orders_updated = self._reorder_if_needed(obj, orders_updated = self._reorder_if_needed(obj,
self._old_epics_order_key, self._old_epics_order_key,
self._epics_order_key(obj), self._epics_order_key(obj))
"epics_order",
obj.project)
self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated) self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated)
super().post_save(obj, created) super().post_save(obj, created)
@ -227,77 +225,78 @@ class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin,
return response.BadRequest(validator.errors) return response.BadRequest(validator.errors)
@detail_route(methods=["POST"])
def bulk_create_related_userstories(self, request, **kwargs): class EpicRelatedUserStoryViewSet(NestedViewSetMixin, BlockedByProjectMixin, ModelCrudViewSet):
queryset = models.RelatedUserStory.objects.all()
serializer_class = serializers.EpicRelatedUserStorySerializer
validator_class = validators.EpicRelatedUserStoryValidator
model = models.RelatedUserStory
permission_classes = (permissions.EpicRelatedUserStoryPermission,)
"""
Updating the order attribute can affect the ordering of another userstories in the epic
This method generate a key for the userstory and can be used to be compared before and after
saving
If there is any difference it means an extra ordering update must be done
"""
def _order_key(self, obj):
return "{}-{}".format(obj.user_story.project_id, obj.order)
def pre_save(self, obj):
if not obj.id:
obj.epic_id = self.kwargs["epic"]
else:
self._old_order_key = self._order_key(self.get_object())
super().pre_save(obj)
def _reorder_if_needed(self, obj, old_order_key, order_key):
# Executes the extra ordering if there is a difference in the ordering keys
if old_order_key != order_key:
extra_orders = json.loads(self.request.META.get("HTTP_SET_ORDERS", "{}"))
data = [{"us_id": obj.id, "order": getattr(obj, "order")}]
for id, order in extra_orders.items():
data.append({"epic_id": int(id), "order": order})
return services.update_epic_related_userstories_order_in_bulk(
data,
epic=obj.epic
)
return {}
def post_save(self, obj, created=False):
if not created:
# Let's reorder the related stuff after edit the element
orders_updated = self._reorder_if_needed(obj,
self._old_epics_order_key,
self._epics_order_key(obj))
self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated)
super().post_save(obj, created)
@list_route(methods=["POST"])
def bulk_create(self, request, **kwargs):
validator = validators.CrateRelatedUserStoriesBulkValidator(data=request.DATA) validator = validators.CrateRelatedUserStoriesBulkValidator(data=request.DATA)
if validator.is_valid(): if validator.is_valid():
data = validator.data data = validator.data
obj = self.get_object()
project = obj.project epic = get_object_or_404(models.Epic, id=kwargs["epic"])
self.check_permissions(request, 'bulk_create_userstories', project) project = epic.project
self.check_permissions(request, 'bulk_create', project)
if project.blocked_code is not None: if project.blocked_code is not None:
raise exc.Blocked(_("Blocked element")) raise exc.Blocked(_("Blocked element"))
services.create_related_userstories_in_bulk( services.create_related_userstories_in_bulk(
data["userstories"], data["userstories"],
obj, epic,
project=project, project=project,
owner=request.user owner=request.user
) )
obj = self.get_queryset().get(id=obj.id)
epic_serialized = self.get_serializer_class()(obj)
return response.Ok(epic_serialized.data)
return response.BadRequest(validator.errors) related_uss_serialized = self.get_serializer_class()(epic.relateduserstory_set.all(), many=True)
return response.Ok(related_uss_serialized.data)
@detail_route(methods=["POST"])
def set_related_userstory(self, request, **kwargs):
validator = validators.SetRelatedUserStoryValidator(data=request.DATA)
if validator.is_valid():
data = validator.data
epic = self.get_object()
project = epic.project
user_story = UserStory.objects.get(id=data["us_id"])
self.check_permissions(request, "update", epic)
self.check_permissions(request, "select_related_userstory", user_story.project)
if project.blocked_code is not None:
raise exc.Blocked(_("Blocked element"))
obj, created = models.RelatedUserStory.objects.update_or_create(
epic=epic,
user_story=user_story,
defaults={
"order": data["order"]
})
epic = self.get_queryset().get(id=epic.id)
epic_serialized = self.get_serializer_class()(epic)
return response.Ok(epic_serialized.data)
return response.BadRequest(validator.errors)
@detail_route(methods=["POST"])
def unset_related_userstory(self, request, **kwargs):
validator = validators.UnsetRelatedUserStoryValidator(data=request.DATA)
if validator.is_valid():
data = validator.data
epic = self.get_object()
project = epic.project
user_story = UserStory.objects.get(id=data["us_id"])
self.check_permissions(request, "update", epic)
if project.blocked_code is not None:
raise exc.Blocked(_("Blocked element"))
related_us = get_object_or_404(
models.RelatedUserStory,
epic=epic,
user_story=user_story
)
related_us.delete()
epic = self.get_queryset().get(id=epic.id)
epic_serialized = self.get_serializer_class()(epic)
return response.Ok(epic_serialized.data)
return response.BadRequest(validator.errors) return response.BadRequest(validator.errors)

View File

@ -105,4 +105,4 @@ class RelatedUserStory(models.Model):
ordering = ["user_story", "order", "id"] ordering = ["user_story", "order", "id"]
def __str__(self): def __str__(self):
return "{0} - {1}".format(self.epic, self.user_story) return "{0} - {1}".format(self.epic_id, self.user_story_id)

View File

@ -34,14 +34,24 @@ class EpicPermission(TaigaResourcePermission):
filters_data_perms = AllowAny() filters_data_perms = AllowAny()
csv_perms = AllowAny() csv_perms = AllowAny()
bulk_create_perms = HasProjectPerm('add_epic') bulk_create_perms = HasProjectPerm('add_epic')
bulk_create_userstories_perms = HasProjectPerm('modify_epic') & (HasProjectPerm('add_us_to_project') | HasProjectPerm('add_us'))
select_related_userstory_perms = HasProjectPerm('view_us')
upvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') upvote_perms = IsAuthenticated() & HasProjectPerm('view_epics')
downvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') downvote_perms = IsAuthenticated() & HasProjectPerm('view_epics')
watch_perms = IsAuthenticated() & HasProjectPerm('view_epics') watch_perms = IsAuthenticated() & HasProjectPerm('view_epics')
unwatch_perms = IsAuthenticated() & HasProjectPerm('view_epics') unwatch_perms = IsAuthenticated() & HasProjectPerm('view_epics')
class EpicRelatedUserStoryPermission(TaigaResourcePermission):
enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None
retrieve_perms = HasProjectPerm('view_epics')
create_perms = HasProjectPerm('modify_epic')
update_perms = HasProjectPerm('modify_epic')
partial_update_perms = HasProjectPerm('modify_epic')
destroy_perms = HasProjectPerm('modify_epic')
list_perms = AllowAny()
bulk_create_perms = HasProjectPerm('modify_epic')
class EpicVotersPermission(TaigaResourcePermission): class EpicVotersPermission(TaigaResourcePermission):
enought_perms = IsProjectAdmin() | IsSuperUser() enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None global_perms = None

View File

@ -78,3 +78,8 @@ class EpicSerializer(EpicListSerializer):
class EpicNeighborsSerializer(NeighborsSerializerMixin, EpicSerializer): class EpicNeighborsSerializer(NeighborsSerializerMixin, EpicSerializer):
pass pass
class EpicRelatedUserStorySerializer(serializers.LightSerializer):
user_story = Field(attr="user_story_id")
order = Field()

View File

@ -60,10 +60,7 @@ class CrateRelatedUserStoriesBulkValidator(ProjectExistsValidator, EpicExistsVal
userstories = serializers.CharField() userstories = serializers.CharField()
class SetRelatedUserStoryValidator(UserStoryExistsValidator, validators.Validator): class EpicRelatedUserStoryValidator(validators.ModelValidator):
us_id = serializers.IntegerField() class Meta:
order = serializers.IntegerField(required=False, default=10000) model = models.RelatedUserStory
read_only_fields = ('id', 'epic', 'user_story')
class UnsetRelatedUserStoryValidator(UserStoryExistsValidator, validators.Validator):
us_id = serializers.IntegerField()

View File

@ -146,6 +146,7 @@ from taiga.projects.milestones.api import MilestoneViewSet
from taiga.projects.milestones.api import MilestoneWatchersViewSet from taiga.projects.milestones.api import MilestoneWatchersViewSet
from taiga.projects.epics.api import EpicViewSet from taiga.projects.epics.api import EpicViewSet
from taiga.projects.epics.api import EpicRelatedUserStoryViewSet
from taiga.projects.epics.api import EpicVotersViewSet from taiga.projects.epics.api import EpicVotersViewSet
from taiga.projects.epics.api import EpicWatchersViewSet from taiga.projects.epics.api import EpicWatchersViewSet
@ -170,8 +171,11 @@ router.register(r"milestones", MilestoneViewSet,
router.register(r"milestones/(?P<resource_id>\d+)/watchers", MilestoneWatchersViewSet, router.register(r"milestones/(?P<resource_id>\d+)/watchers", MilestoneWatchersViewSet,
base_name="milestone-watchers") base_name="milestone-watchers")
router.register(r"epics", EpicViewSet, router.register(r"epics", EpicViewSet, base_name="epics")\
base_name="epics") .register(r"related_userstories", EpicRelatedUserStoryViewSet,
base_name="epics-related-userstories",
parents_query_lookups=["epic"])
router.register(r"epics/(?P<resource_id>\d+)/voters", EpicVotersViewSet, router.register(r"epics/(?P<resource_id>\d+)/voters", EpicVotersViewSet,
base_name="epic-voters") base_name="epic-voters")
router.register(r"epics/(?P<resource_id>\d+)/watchers", EpicWatchersViewSet, router.register(r"epics/(?P<resource_id>\d+)/watchers", EpicWatchersViewSet,

View File

@ -676,10 +676,10 @@ def test_epic_action_bulk_create(client, data):
def test_bulk_create_related_userstories(client, data): def test_bulk_create_related_userstories(client, data):
public_url = reverse('epics-bulk-create-related-userstories', kwargs={"pk": data.public_epic.pk}) public_url = reverse('epics-related-userstories-bulk-create', args=[data.public_epic.pk])
private_url1 = reverse('epics-bulk-create-related-userstories', kwargs={"pk": data.private_epic1.pk}) private_url1 = reverse('epics-related-userstories-bulk-create', args=[data.private_epic1.pk])
private_url2 = reverse('epics-bulk-create-related-userstories', kwargs={"pk": data.private_epic2.pk}) private_url2 = reverse('epics-related-userstories-bulk-create', args=[data.private_epic2.pk])
blocked_url = reverse('epics-bulk-create-related-userstories', kwargs={"pk": data.blocked_epic.pk}) blocked_url = reverse('epics-related-userstories-bulk-create', args=[data.blocked_epic.pk])
users = [ users = [
None, None,
@ -698,9 +698,9 @@ def test_bulk_create_related_userstories(client, data):
results = helper_test_http_method(client, 'post', private_url1, bulk_data, users) results = helper_test_http_method(client, 'post', private_url1, bulk_data, users)
assert results == [401, 403, 403, 200, 200] assert results == [401, 403, 403, 200, 200]
results = helper_test_http_method(client, 'post', private_url2, bulk_data, users) results = helper_test_http_method(client, 'post', private_url2, bulk_data, users)
assert results == [404, 404, 404, 200, 200] assert results == [401, 403, 403, 200, 200]
results = helper_test_http_method(client, 'post', blocked_url, bulk_data, users) results = helper_test_http_method(client, 'post', blocked_url, bulk_data, users)
assert results == [404, 404, 404, 451, 451] assert results == [401, 403, 403, 451, 451]
def test_set_related_user_story(client, data): def test_set_related_user_story(client, data):

View File

@ -98,7 +98,7 @@ def test_bulk_create_related_userstories(client):
epic = f.EpicFactory.create(project=project) epic = f.EpicFactory.create(project=project)
f.MembershipFactory.create(project=project, user=user, is_admin=True) f.MembershipFactory.create(project=project, user=user, is_admin=True)
url = reverse('epics-bulk-create-related-userstories', kwargs={"pk": epic.pk}) url = reverse('epics-related-userstories-bulk-create', args=[epic.pk])
data = { data = {
"userstories": "test1\ntest2" "userstories": "test1\ntest2"
@ -106,7 +106,7 @@ def test_bulk_create_related_userstories(client):
client.login(user) client.login(user)
response = client.json.post(url, json.dumps(data)) response = client.json.post(url, json.dumps(data))
assert response.status_code == 200 assert response.status_code == 200
assert response.data['user_stories_counts'] == {'opened': 2, 'closed': 0} assert len(response.data) == 2
def test_set_related_userstory(client): def test_set_related_userstory(client):
@ -116,13 +116,14 @@ def test_set_related_userstory(client):
f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) f.MembershipFactory.create(project=epic.project, user=user, is_admin=True)
f.MembershipFactory.create(project=us.project, user=user, is_admin=True) f.MembershipFactory.create(project=us.project, user=user, is_admin=True)
url = reverse('epics-set-related-userstory', kwargs={"pk": epic.pk}) url = reverse('epics-related-userstories-list', args=[epic.pk])
data = { data = {
"us_id": us.id "user_story": us.id
} }
client.login(user) client.login(user)
response = client.json.post(url, json.dumps(data)) response = client.json.post(url, json.dumps(data))
print(response.data)
assert response.status_code == 200 assert response.status_code == 200
assert response.data['user_stories_counts'] == {'opened': 1, 'closed': 0} assert response.data['user_stories_counts'] == {'opened': 1, 'closed': 0}