Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 96c533b

Browse files
Anton-Shutiksliverc
authored andcommitted
Add support for related links using parent view and its permissions (#451)
Add RelatedMixin. This introduces new approach in handling related urls/entities. RelatedMixin will handle all related entities, configured in related_serializers dict with no related views required. Also it will check permissions for parent object for any related entity.
1 parent 3bfff93 commit 96c533b

File tree

8 files changed

+269
-4
lines changed

8 files changed

+269
-4
lines changed

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](https://www.django-rest-framework.org/api-guide/testing/#configuration)
44
* Add sorting configuration to `REST_FRAMEWORK` as defined in [json api spec](http://jsonapi.org/format/#fetching-sorting)
55
* Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields)
6+
* Add related urls support. See [usage docs](docs/usage.md#related-urls)
67

78

89
v2.5.0 - Released July 11, 2018

‎docs/usage.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,53 @@ class LineItemViewSet(viewsets.ModelViewSet):
443443
not render `data`. Use this in case you only need links of relationships and want to lower payload
444444
and increase performance.
445445

446+
#### Related urls
447+
448+
There is a nice way to handle "related" urls like `/orders/3/lineitems/` or `/orders/3/customer/`.
449+
All you need is just add to `urls.py`:
450+
```python
451+
url(r'^orders/(?P<pk>[^/.]+)/$',
452+
OrderViewSet.as_view({'get': 'retrieve'}),
453+
name='order-detail'),
454+
url(r'^orders/(?P<pk>[^/.]+)/(?P<related_field>\w+)/$',
455+
OrderViewSet.as_view({'get': 'retrieve_related'}),
456+
name='order-related'),
457+
```
458+
Make sure that RelatedField declaration has `related_link_url_kwarg='pk'` or simply skipped (will be set by default):
459+
```python
460+
line_items = ResourceRelatedField(
461+
queryset=LineItem.objects,
462+
many=True,
463+
related_link_view_name='order-related',
464+
related_link_url_kwarg='pk',
465+
self_link_view_name='order-relationships'
466+
)
467+
468+
customer = ResourceRelatedField(
469+
queryset=Customer.objects,
470+
related_link_view_name='order-related',
471+
self_link_view_name='order-relationships'
472+
)
473+
```
474+
And, the most important part - declare serializer for each related entity:
475+
```python
476+
class OrderSerializer(serializers.HyperlinkedModelSerializer):
477+
...
478+
related_serializers = {
479+
'customer': 'example.serializers.CustomerSerializer',
480+
'line_items': 'example.serializers.LineItemSerializer'
481+
}
482+
```
483+
Or, if you already have `included_serializers` declared and your `related_serializers` look the same, just skip it:
484+
```python
485+
class OrderSerializer(serializers.HyperlinkedModelSerializer):
486+
...
487+
included_serializers = {
488+
'customer': 'example.serializers.CustomerSerializer',
489+
'line_items': 'example.serializers.LineItemSerializer'
490+
}
491+
```
492+
446493
### RelationshipView
447494
`rest_framework_json_api.views.RelationshipView` is used to build
448495
relationship views (see the

‎example/serializers.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,40 @@ class Meta:
155155

156156

157157
class AuthorSerializer(serializers.ModelSerializer):
158+
bio = relations.ResourceRelatedField(
159+
related_link_view_name='author-related',
160+
self_link_view_name='author-relationships',
161+
queryset=AuthorBio.objects,
162+
)
163+
entries = relations.ResourceRelatedField(
164+
related_link_view_name='author-related',
165+
self_link_view_name='author-relationships',
166+
queryset=Entry.objects,
167+
many=True
168+
)
169+
first_entry = relations.SerializerMethodResourceRelatedField(
170+
related_link_view_name='author-related',
171+
self_link_view_name='author-relationships',
172+
model=Entry,
173+
read_only=True,
174+
source='get_first_entry'
175+
)
158176
included_serializers = {
159177
'bio': AuthorBioSerializer,
160178
'type': AuthorTypeSerializer
161179
}
180+
related_serializers = {
181+
'bio': 'example.serializers.AuthorBioSerializer',
182+
'entries': 'example.serializers.EntrySerializer',
183+
'first_entry': 'example.serializers.EntrySerializer'
184+
}
162185

163186
class Meta:
164187
model = Author
165-
fields = ('name', 'email', 'bio', 'entries', 'type')
188+
fields = ('name', 'email', 'bio', 'entries', 'first_entry', 'type')
189+
190+
def get_first_entry(self, obj):
191+
return obj.entries.first()
166192

167193

168194
class WriterSerializer(serializers.ModelSerializer):

‎example/tests/test_views.py‎

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@
22

33
from django.test import RequestFactory
44
from django.utils import timezone
5+
from rest_framework.exceptions import NotFound
6+
from rest_framework.request import Request
57
from rest_framework.reverse import reverse
6-
from rest_framework.test import APITestCase, force_authenticate
8+
from rest_framework.test import APIRequestFactory, APITestCase, force_authenticate
79

810
from rest_framework_json_api.utils import format_resource_type
911

1012
from . import TestBase
1113
from .. import views
14+
from example.factories import AuthorFactory, EntryFactory
1215
from example.models import Author, Blog, Comment, Entry
16+
from example.serializers import AuthorBioSerializer, AuthorTypeSerializer, EntrySerializer
17+
from example.views import AuthorViewSet
1318

1419

1520
class TestRelationshipView(APITestCase):
@@ -225,6 +230,96 @@ def test_delete_to_many_relationship_with_change(self):
225230
assert response.status_code == 200, response.content.decode()
226231

227232

233+
class TestRelatedMixin(APITestCase):
234+
235+
def setUp(self):
236+
self.author = AuthorFactory()
237+
238+
def _get_view(self, kwargs):
239+
factory = APIRequestFactory()
240+
request = Request(factory.get('', content_type='application/vnd.api+json'))
241+
return AuthorViewSet(request=request, kwargs=kwargs)
242+
243+
def test_get_related_field_name(self):
244+
kwargs = {'pk': self.author.id, 'related_field': 'bio'}
245+
view = self._get_view(kwargs)
246+
got = view.get_related_field_name()
247+
self.assertEqual(got, kwargs['related_field'])
248+
249+
def test_get_related_instance_serializer_field(self):
250+
kwargs = {'pk': self.author.id, 'related_field': 'bio'}
251+
view = self._get_view(kwargs)
252+
got = view.get_related_instance()
253+
self.assertEqual(got, self.author.bio)
254+
255+
def test_get_related_instance_model_field(self):
256+
kwargs = {'pk': self.author.id, 'related_field': 'id'}
257+
view = self._get_view(kwargs)
258+
got = view.get_related_instance()
259+
self.assertEqual(got, self.author.id)
260+
261+
def test_get_serializer_class(self):
262+
kwargs = {'pk': self.author.id, 'related_field': 'bio'}
263+
view = self._get_view(kwargs)
264+
got = view.get_serializer_class()
265+
self.assertEqual(got, AuthorBioSerializer)
266+
267+
def test_get_serializer_class_many(self):
268+
kwargs = {'pk': self.author.id, 'related_field': 'entries'}
269+
view = self._get_view(kwargs)
270+
got = view.get_serializer_class()
271+
self.assertEqual(got, EntrySerializer)
272+
273+
def test_get_serializer_comes_from_included_serializers(self):
274+
kwargs = {'pk': self.author.id, 'related_field': 'type'}
275+
view = self._get_view(kwargs)
276+
related_serializers = view.serializer_class.related_serializers
277+
delattr(view.serializer_class, 'related_serializers')
278+
got = view.get_serializer_class()
279+
self.assertEqual(got, AuthorTypeSerializer)
280+
281+
view.serializer_class.related_serializers = related_serializers
282+
283+
def test_get_serializer_class_raises_error(self):
284+
kwargs = {'pk': self.author.id, 'related_field': 'type'}
285+
view = self._get_view(kwargs)
286+
self.assertRaises(NotFound, view.get_serializer_class)
287+
288+
def test_retrieve_related_single(self):
289+
url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'bio'})
290+
resp = self.client.get(url)
291+
expected = {
292+
'data': {
293+
'type': 'authorBios', 'id': str(self.author.bio.id),
294+
'relationships': {
295+
'author': {'data': {'type': 'authors', 'id': str(self.author.id)}}},
296+
'attributes': {
297+
'body': str(self.author.bio.body)
298+
},
299+
}
300+
}
301+
self.assertEqual(resp.status_code, 200)
302+
self.assertEqual(resp.json(), expected)
303+
304+
def test_retrieve_related_many(self):
305+
entry = EntryFactory(authors=self.author)
306+
url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'entries'})
307+
resp = self.client.get(url)
308+
309+
self.assertEqual(resp.status_code, 200)
310+
self.assertTrue(isinstance(resp.json()['data'], list))
311+
self.assertEqual(len(resp.json()['data']), 1)
312+
self.assertEqual(resp.json()['data'][0]['id'], str(entry.id))
313+
314+
def test_retrieve_related_None(self):
315+
kwargs = {'pk': self.author.pk, 'related_field': 'first_entry'}
316+
url = reverse('author-related', kwargs=kwargs)
317+
resp = self.client.get(url)
318+
319+
self.assertEqual(resp.status_code, 200)
320+
self.assertEqual(resp.json(), {'data': None})
321+
322+
228323
class TestValidationErrorResponses(TestBase):
229324
def test_if_returns_error_on_empty_post(self):
230325
view = views.BlogViewSet.as_view({'post': 'create'})

‎example/urls.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@
4545
EntryViewSet.as_view({'get': 'retrieve'}),
4646
name='entry-featured'),
4747

48+
url(r'^authors/(?P<pk>[^/.]+)/(?P<related_field>\w+)/$',
49+
AuthorViewSet.as_view({'get': 'retrieve_related'}),
50+
name='author-related'),
51+
4852
url(r'^entries/(?P<pk>[^/.]+)/relationships/(?P<related_field>\w+)',
4953
EntryRelationshipView.as_view(),
5054
name='entry-relationships'),

‎example/urls_test.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@
5656
EntryViewSet.as_view({'get': 'retrieve'}),
5757
name='entry-featured'),
5858

59+
url(r'^authors/(?P<pk>[^/.]+)/(?P<related_field>\w+)/$',
60+
AuthorViewSet.as_view({'get': 'retrieve_related'}),
61+
name='author-related'),
62+
5963
url(r'^entries/(?P<pk>[^/.]+)/relationships/(?P<related_field>\w+)',
6064
EntryRelationshipView.as_view(),
6165
name='entry-relationships'),

‎rest_framework_json_api/relations.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,19 @@ def get_links(self, obj=None, lookup_field='pk'):
116116
})
117117
self_link = self.get_url('self', self.self_link_view_name, self_kwargs, request)
118118

119-
related_kwargs = {self.related_link_url_kwarg: kwargs[self.related_link_lookup_field]}
119+
"""
120+
Assuming RelatedField will be declared in two ways:
121+
1. url(r'^authors/(?P<pk>[^/.]+)/(?P<related_field>\w+)/$',
122+
AuthorViewSet.as_view({'get': 'retrieve_related'}))
123+
2. url(r'^authors/(?P<author_pk>[^/.]+)/bio/$',
124+
AuthorBioViewSet.as_view({'get': 'retrieve'}))
125+
So, if related_link_url_kwarg == 'pk' it will add 'related_field' parameter to reverse()
126+
"""
127+
if self.related_link_url_kwarg == 'pk':
128+
related_kwargs = self_kwargs
129+
else:
130+
related_kwargs = {self.related_link_url_kwarg: kwargs[self.related_link_lookup_field]}
131+
120132
related_link = self.get_url('related', self.related_link_view_name, related_kwargs, request)
121133

122134
if self_link:

‎rest_framework_json_api/views.py‎

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from collections import Iterable
2+
13
from django.core.exceptions import ImproperlyConfigured
24
from django.db.models import Model
35
from django.db.models.fields.related_descriptors import (
@@ -9,6 +11,7 @@
911
from django.db.models.manager import Manager
1012
from django.db.models.query import QuerySet
1113
from django.urls import NoReverseMatch
14+
from django.utils.module_loading import import_string as import_class_from_dotted_path
1215
from rest_framework import generics, viewsets
1316
from rest_framework.exceptions import MethodNotAllowed, NotFound
1417
from rest_framework.response import Response
@@ -98,12 +101,85 @@ def get_queryset(self, *args, **kwargs):
98101
return qs
99102

100103

101-
class ModelViewSet(AutoPrefetchMixin, PrefetchForIncludesHelperMixin, viewsets.ModelViewSet):
104+
class RelatedMixin(object):
105+
"""
106+
This mixin handles all related entities, whose Serializers are declared in "related_serializers"
107+
"""
108+
109+
def retrieve_related(self, request, *args, **kwargs):
110+
serializer_kwargs = {}
111+
instance = self.get_related_instance()
112+
113+
if hasattr(instance, 'all'):
114+
instance = instance.all()
115+
116+
if callable(instance):
117+
instance = instance()
118+
119+
if instance is None:
120+
return Response(data=None)
121+
122+
if isinstance(instance, Iterable):
123+
serializer_kwargs['many'] = True
124+
125+
serializer = self.get_serializer(instance, **serializer_kwargs)
126+
return Response(serializer.data)
127+
128+
def get_serializer_class(self):
129+
parent_serializer_class = super(RelatedMixin, self).get_serializer_class()
130+
131+
if 'related_field' in self.kwargs:
132+
field_name = self.kwargs['related_field']
133+
134+
# Try get the class from related_serializers
135+
if hasattr(parent_serializer_class, 'related_serializers'):
136+
_class = parent_serializer_class.related_serializers.get(field_name, None)
137+
if _class is None:
138+
raise NotFound
139+
140+
elif hasattr(parent_serializer_class, 'included_serializers'):
141+
_class = parent_serializer_class.included_serializers.get(field_name, None)
142+
if _class is None:
143+
raise NotFound
144+
145+
else:
146+
assert False, \
147+
'Either "included_serializers" or "related_serializers" should be configured'
148+
149+
if not isinstance(_class, type):
150+
return import_class_from_dotted_path(_class)
151+
return _class
152+
153+
return parent_serializer_class
154+
155+
def get_related_field_name(self):
156+
return self.kwargs['related_field']
157+
158+
def get_related_instance(self):
159+
parent_obj = self.get_object()
160+
parent_serializer = self.serializer_class(parent_obj)
161+
field_name = self.get_related_field_name()
162+
field = parent_serializer.fields.get(field_name, None)
163+
164+
if field is not None:
165+
return field.get_attribute(parent_obj)
166+
else:
167+
try:
168+
return getattr(parent_obj, field_name)
169+
except AttributeError:
170+
raise NotFound
171+
172+
173+
class ModelViewSet(AutoPrefetchMixin,
174+
PrefetchForIncludesHelperMixin,
175+
RelatedMixin,
176+
viewsets.ModelViewSet):
102177
pass
103178

104179

105180
class ReadOnlyModelViewSet(AutoPrefetchMixin,
106181
PrefetchForIncludesHelperMixin,
182+
RelatedMixin,
107183
viewsets.ReadOnlyModelViewSet):
108184
pass
109185

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /