diff --git a/CHANGELOG.md b/CHANGELOG.md index e47c5da0..fc160067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](https://www.django-rest-framework.org/api-guide/testing/#configuration) * Add sorting configuration to `REST_FRAMEWORK` as defined in [json api spec](http://jsonapi.org/format/#fetching-sorting) * Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields) +* Add related urls support. See [usage docs](docs/usage.md#related-urls) v2.5.0 - Released July 11, 2018 diff --git a/docs/usage.md b/docs/usage.md index d0fee78e..25bb7310 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -443,6 +443,53 @@ class LineItemViewSet(viewsets.ModelViewSet): not render `data`. Use this in case you only need links of relationships and want to lower payload and increase performance. +#### Related urls + +There is a nice way to handle "related" urls like `/orders/3/lineitems/` or `/orders/3/customer/`. +All you need is just add to `urls.py`: +```python +url(r'^orders/(?P[^/.]+)/$', + OrderViewSet.as_view({'get': 'retrieve'}), + name='order-detail'), +url(r'^orders/(?P[^/.]+)/(?P\w+)/$', + OrderViewSet.as_view({'get': 'retrieve_related'}), + name='order-related'), +``` +Make sure that RelatedField declaration has `related_link_url_kwarg='pk'` or simply skipped (will be set by default): +```python + line_items = ResourceRelatedField( + queryset=LineItem.objects, + many=True, + related_link_view_name='order-related', + related_link_url_kwarg='pk', + self_link_view_name='order-relationships' + ) + + customer = ResourceRelatedField( + queryset=Customer.objects, + related_link_view_name='order-related', + self_link_view_name='order-relationships' + ) +``` +And, the most important part - declare serializer for each related entity: +```python +class OrderSerializer(serializers.HyperlinkedModelSerializer): + ... + related_serializers = { + 'customer': 'example.serializers.CustomerSerializer', + 'line_items': 'example.serializers.LineItemSerializer' + } +``` +Or, if you already have `included_serializers` declared and your `related_serializers` look the same, just skip it: +```python +class OrderSerializer(serializers.HyperlinkedModelSerializer): + ... + included_serializers = { + 'customer': 'example.serializers.CustomerSerializer', + 'line_items': 'example.serializers.LineItemSerializer' + } +``` + ### RelationshipView `rest_framework_json_api.views.RelationshipView` is used to build relationship views (see the diff --git a/example/serializers.py b/example/serializers.py index f43accac..d96a917d 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -155,14 +155,40 @@ class Meta: class AuthorSerializer(serializers.ModelSerializer): + bio = relations.ResourceRelatedField( + related_link_view_name='author-related', + self_link_view_name='author-relationships', + queryset=AuthorBio.objects, + ) + entries = relations.ResourceRelatedField( + related_link_view_name='author-related', + self_link_view_name='author-relationships', + queryset=Entry.objects, + many=True + ) + first_entry = relations.SerializerMethodResourceRelatedField( + related_link_view_name='author-related', + self_link_view_name='author-relationships', + model=Entry, + read_only=True, + source='get_first_entry' + ) included_serializers = { 'bio': AuthorBioSerializer, 'type': AuthorTypeSerializer } + related_serializers = { + 'bio': 'example.serializers.AuthorBioSerializer', + 'entries': 'example.serializers.EntrySerializer', + 'first_entry': 'example.serializers.EntrySerializer' + } class Meta: model = Author - fields = ('name', 'email', 'bio', 'entries', 'type') + fields = ('name', 'email', 'bio', 'entries', 'first_entry', 'type') + + def get_first_entry(self, obj): + return obj.entries.first() class WriterSerializer(serializers.ModelSerializer): diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 9cccf2f9..48e1bfa6 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -2,14 +2,19 @@ from django.test import RequestFactory from django.utils import timezone +from rest_framework.exceptions import NotFound +from rest_framework.request import Request from rest_framework.reverse import reverse -from rest_framework.test import APITestCase, force_authenticate +from rest_framework.test import APIRequestFactory, APITestCase, force_authenticate from rest_framework_json_api.utils import format_resource_type from . import TestBase from .. import views +from example.factories import AuthorFactory, EntryFactory from example.models import Author, Blog, Comment, Entry +from example.serializers import AuthorBioSerializer, AuthorTypeSerializer, EntrySerializer +from example.views import AuthorViewSet class TestRelationshipView(APITestCase): @@ -225,6 +230,96 @@ def test_delete_to_many_relationship_with_change(self): assert response.status_code == 200, response.content.decode() +class TestRelatedMixin(APITestCase): + + def setUp(self): + self.author = AuthorFactory() + + def _get_view(self, kwargs): + factory = APIRequestFactory() + request = Request(factory.get('', content_type='application/vnd.api+json')) + return AuthorViewSet(request=request, kwargs=kwargs) + + def test_get_related_field_name(self): + kwargs = {'pk': self.author.id, 'related_field': 'bio'} + view = self._get_view(kwargs) + got = view.get_related_field_name() + self.assertEqual(got, kwargs['related_field']) + + def test_get_related_instance_serializer_field(self): + kwargs = {'pk': self.author.id, 'related_field': 'bio'} + view = self._get_view(kwargs) + got = view.get_related_instance() + self.assertEqual(got, self.author.bio) + + def test_get_related_instance_model_field(self): + kwargs = {'pk': self.author.id, 'related_field': 'id'} + view = self._get_view(kwargs) + got = view.get_related_instance() + self.assertEqual(got, self.author.id) + + def test_get_serializer_class(self): + kwargs = {'pk': self.author.id, 'related_field': 'bio'} + view = self._get_view(kwargs) + got = view.get_serializer_class() + self.assertEqual(got, AuthorBioSerializer) + + def test_get_serializer_class_many(self): + kwargs = {'pk': self.author.id, 'related_field': 'entries'} + view = self._get_view(kwargs) + got = view.get_serializer_class() + self.assertEqual(got, EntrySerializer) + + def test_get_serializer_comes_from_included_serializers(self): + kwargs = {'pk': self.author.id, 'related_field': 'type'} + view = self._get_view(kwargs) + related_serializers = view.serializer_class.related_serializers + delattr(view.serializer_class, 'related_serializers') + got = view.get_serializer_class() + self.assertEqual(got, AuthorTypeSerializer) + + view.serializer_class.related_serializers = related_serializers + + def test_get_serializer_class_raises_error(self): + kwargs = {'pk': self.author.id, 'related_field': 'type'} + view = self._get_view(kwargs) + self.assertRaises(NotFound, view.get_serializer_class) + + def test_retrieve_related_single(self): + url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'bio'}) + resp = self.client.get(url) + expected = { + 'data': { + 'type': 'authorBios', 'id': str(self.author.bio.id), + 'relationships': { + 'author': {'data': {'type': 'authors', 'id': str(self.author.id)}}}, + 'attributes': { + 'body': str(self.author.bio.body) + }, + } + } + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), expected) + + def test_retrieve_related_many(self): + entry = EntryFactory(authors=self.author) + url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'entries'}) + resp = self.client.get(url) + + self.assertEqual(resp.status_code, 200) + self.assertTrue(isinstance(resp.json()['data'], list)) + self.assertEqual(len(resp.json()['data']), 1) + self.assertEqual(resp.json()['data'][0]['id'], str(entry.id)) + + def test_retrieve_related_None(self): + kwargs = {'pk': self.author.pk, 'related_field': 'first_entry'} + url = reverse('author-related', kwargs=kwargs) + resp = self.client.get(url) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), {'data': None}) + + class TestValidationErrorResponses(TestBase): def test_if_returns_error_on_empty_post(self): view = views.BlogViewSet.as_view({'post': 'create'}) diff --git a/example/urls.py b/example/urls.py index 469ce53a..fa06499f 100644 --- a/example/urls.py +++ b/example/urls.py @@ -45,6 +45,10 @@ EntryViewSet.as_view({'get': 'retrieve'}), name='entry-featured'), + url(r'^authors/(?P[^/.]+)/(?P\w+)/$', + AuthorViewSet.as_view({'get': 'retrieve_related'}), + name='author-related'), + url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)', EntryRelationshipView.as_view(), name='entry-relationships'), diff --git a/example/urls_test.py b/example/urls_test.py index 3ec07380..e7a27ce4 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -56,6 +56,10 @@ EntryViewSet.as_view({'get': 'retrieve'}), name='entry-featured'), + url(r'^authors/(?P[^/.]+)/(?P\w+)/$', + AuthorViewSet.as_view({'get': 'retrieve_related'}), + name='author-related'), + url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)', EntryRelationshipView.as_view(), name='entry-relationships'), diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 1c51d2da..040a84f1 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -116,7 +116,19 @@ def get_links(self, obj=None, lookup_field='pk'): }) self_link = self.get_url('self', self.self_link_view_name, self_kwargs, request) - related_kwargs = {self.related_link_url_kwarg: kwargs[self.related_link_lookup_field]} + """ + Assuming RelatedField will be declared in two ways: + 1. url(r'^authors/(?P[^/.]+)/(?P\w+)/$', + AuthorViewSet.as_view({'get': 'retrieve_related'})) + 2. url(r'^authors/(?P[^/.]+)/bio/$', + AuthorBioViewSet.as_view({'get': 'retrieve'})) + So, if related_link_url_kwarg == 'pk' it will add 'related_field' parameter to reverse() + """ + if self.related_link_url_kwarg == 'pk': + related_kwargs = self_kwargs + else: + related_kwargs = {self.related_link_url_kwarg: kwargs[self.related_link_lookup_field]} + related_link = self.get_url('related', self.related_link_view_name, related_kwargs, request) if self_link: diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 64e5d12e..b77e6a99 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -1,3 +1,5 @@ +from collections import Iterable + from django.core.exceptions import ImproperlyConfigured from django.db.models import Model from django.db.models.fields.related_descriptors import ( @@ -9,6 +11,7 @@ from django.db.models.manager import Manager from django.db.models.query import QuerySet from django.urls import NoReverseMatch +from django.utils.module_loading import import_string as import_class_from_dotted_path from rest_framework import generics, viewsets from rest_framework.exceptions import MethodNotAllowed, NotFound from rest_framework.response import Response @@ -98,12 +101,85 @@ def get_queryset(self, *args, **kwargs): return qs -class ModelViewSet(AutoPrefetchMixin, PrefetchForIncludesHelperMixin, viewsets.ModelViewSet): +class RelatedMixin(object): + """ + This mixin handles all related entities, whose Serializers are declared in "related_serializers" + """ + + def retrieve_related(self, request, *args, **kwargs): + serializer_kwargs = {} + instance = self.get_related_instance() + + if hasattr(instance, 'all'): + instance = instance.all() + + if callable(instance): + instance = instance() + + if instance is None: + return Response(data=None) + + if isinstance(instance, Iterable): + serializer_kwargs['many'] = True + + serializer = self.get_serializer(instance, **serializer_kwargs) + return Response(serializer.data) + + def get_serializer_class(self): + parent_serializer_class = super(RelatedMixin, self).get_serializer_class() + + if 'related_field' in self.kwargs: + field_name = self.kwargs['related_field'] + + # Try get the class from related_serializers + if hasattr(parent_serializer_class, 'related_serializers'): + _class = parent_serializer_class.related_serializers.get(field_name, None) + if _class is None: + raise NotFound + + elif hasattr(parent_serializer_class, 'included_serializers'): + _class = parent_serializer_class.included_serializers.get(field_name, None) + if _class is None: + raise NotFound + + else: + assert False, \ + 'Either "included_serializers" or "related_serializers" should be configured' + + if not isinstance(_class, type): + return import_class_from_dotted_path(_class) + return _class + + return parent_serializer_class + + def get_related_field_name(self): + return self.kwargs['related_field'] + + def get_related_instance(self): + parent_obj = self.get_object() + parent_serializer = self.serializer_class(parent_obj) + field_name = self.get_related_field_name() + field = parent_serializer.fields.get(field_name, None) + + if field is not None: + return field.get_attribute(parent_obj) + else: + try: + return getattr(parent_obj, field_name) + except AttributeError: + raise NotFound + + +class ModelViewSet(AutoPrefetchMixin, + PrefetchForIncludesHelperMixin, + RelatedMixin, + viewsets.ModelViewSet): pass class ReadOnlyModelViewSet(AutoPrefetchMixin, PrefetchForIncludesHelperMixin, + RelatedMixin, viewsets.ReadOnlyModelViewSet): pass