diff --git a/example/models.py b/example/models.py index f2719923..a86cc8b1 100644 --- a/example/models.py +++ b/example/models.py @@ -49,3 +49,16 @@ class Entry(BaseModel): def __str__(self): return self.headline + +@python_2_unicode_compatible +class Comment(BaseModel): + entry = models.ForeignKey(Entry) + body = models.TextField() + author = models.ForeignKey( + Author, + null=True, + blank=True + ) + + def __str__(self): + return self.body diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py new file mode 100644 index 00000000..ea60ae9a --- /dev/null +++ b/example/tests/test_relations.py @@ -0,0 +1,131 @@ +from __future__ import absolute_import + +from django.utils import timezone + +from rest_framework import serializers + +from . import TestBase +from rest_framework_json_api.utils import format_relation_name +from example.models import Blog, Entry, Comment, Author +from rest_framework_json_api.relations import ResourceRelatedField + + +class TestResourceRelatedField(TestBase): + + def setUp(self): + super(TestResourceRelatedField, self).setUp() + self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.entry = Entry.objects.create( + blog=self.blog, + headline='headline', + body_text='body_text', + pub_date=timezone.now(), + mod_date=timezone.now(), + n_comments=0, + n_pingbacks=0, + rating=3 + ) + for i in range(1,6): + name = 'some_author{}'.format(i) + self.entry.authors.add( + Author.objects.create(name=name, email='{}@example.org'.format(name)) + ) + + self.comment = Comment.objects.create( + entry=self.entry, + body='testing one two three', + author=Author.objects.first() + ) + + def test_data_in_correct_format_when_instantiated_with_blog_object(self): + serializer = BlogFKSerializer(instance={'blog': self.blog}) + + expected_data = { + 'type': format_relation_name('Blog'), + 'id': str(self.blog.id) + } + + actual_data = serializer.data['blog'] + + self.assertEqual(actual_data, expected_data) + + def test_data_in_correct_format_when_instantiated_with_entry_object(self): + serializer = EntryFKSerializer(instance={'entry': self.entry}) + + expected_data = { + 'type': format_relation_name('Entry'), + 'id': str(self.entry.id) + } + + actual_data = serializer.data['entry'] + + self.assertEqual(actual_data, expected_data) + + def test_deserialize_primitive_data_blog(self): + serializer = BlogFKSerializer(data={ + 'blog': { + 'type': format_relation_name('Blog'), + 'id': str(self.blog.id) + } + } + ) + + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data['blog'], self.blog) + + def test_validation_fails_for_wrong_type(self): + serializer = BlogFKSerializer(data={ + 'blog': { + 'type': 'Entries', + 'id': str(self.blog.id) + } + } + ) + + self.assertFalse(serializer.is_valid()) + + def test_serialize_many_to_many_relation(self): + serializer = EntryModelSerializer(instance=self.entry) + + type_string = format_relation_name('Author') + author_pks = Author.objects.values_list('pk', flat=True) + expected_data = [{'type': type_string, 'id': str(pk)} for pk in author_pks] + + self.assertEqual( + serializer.data['authors'], + expected_data + ) + + def test_deserialize_many_to_many_relation(self): + type_string = format_relation_name('Author') + author_pks = Author.objects.values_list('pk', flat=True) + authors = [{'type': type_string, 'id': pk} for pk in author_pks] + + serializer = EntryModelSerializer(data={'authors': authors, 'comment_set': []}) + + self.assertTrue(serializer.is_valid()) + self.assertEqual(len(serializer.validated_data['authors']), Author.objects.count()) + for author in serializer.validated_data['authors']: + self.assertIsInstance(author, Author) + + def test_read_only(self): + serializer = EntryModelSerializer(data={'authors': [], 'comment_set': [{'type': 'Comments', 'id': 2}]}) + serializer.is_valid(raise_exception=True) + self.assertNotIn('comment_set', serializer.validated_data) + + +class BlogFKSerializer(serializers.Serializer): + blog = ResourceRelatedField(queryset=Blog.objects) + + +class EntryFKSerializer(serializers.Serializer): + entry = ResourceRelatedField(queryset=Entry.objects) + + +class EntryModelSerializer(serializers.ModelSerializer): + authors = ResourceRelatedField(many=True, queryset=Author.objects) + comment_set = ResourceRelatedField(many=True, read_only=True) + + class Meta: + model = Entry + fields = ('authors', 'comment_set') diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py new file mode 100644 index 00000000..6712ec7e --- /dev/null +++ b/example/tests/test_serializers.py @@ -0,0 +1,73 @@ +from django.test import TestCase +from django.utils import timezone + +from rest_framework_json_api.utils import format_relation_name +from rest_framework_json_api.serializers import ResourceIdentifierObjectSerializer + +from example.models import Blog, Entry, Author + + +class TestResourceIdentifierObjectSerializer(TestCase): + def setUp(self): + self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.entry = Entry.objects.create( + blog=self.blog, + headline='headline', + body_text='body_text', + pub_date=timezone.now(), + mod_date=timezone.now(), + n_comments=0, + n_pingbacks=0, + rating=3 + ) + for i in range(1,6): + name = 'some_author{}'.format(i) + self.entry.authors.add( + Author.objects.create(name=name, email='{}@example.org'.format(name)) + ) + + def test_data_in_correct_format_when_instantiated_with_blog_object(self): + serializer = ResourceIdentifierObjectSerializer(instance=self.blog) + + expected_data = {'type': format_relation_name('Blog'), 'id': str(self.blog.id)} + + assert serializer.data == expected_data + + def test_data_in_correct_format_when_instantiated_with_entry_object(self): + serializer = ResourceIdentifierObjectSerializer(instance=self.entry) + + expected_data = {'type': format_relation_name('Entry'), 'id': str(self.entry.id)} + + assert serializer.data == expected_data + + def test_deserialize_primitive_data_blog(self): + initial_data = { + 'type': format_relation_name('Blog'), + 'id': str(self.blog.id) + } + serializer = ResourceIdentifierObjectSerializer(data=initial_data, model_class=Blog) + + self.assertTrue(serializer.is_valid(), msg=serializer.errors) + assert serializer.validated_data == self.blog + + def test_data_in_correct_format_when_instantiated_with_queryset(self): + qs = Author.objects.all() + serializer = ResourceIdentifierObjectSerializer(instance=qs, many=True) + + type_string = format_relation_name('Author') + author_pks = Author.objects.values_list('pk', flat=True) + expected_data = [{'type': type_string, 'id': str(pk)} for pk in author_pks] + + assert serializer.data == expected_data + + def test_deserialize_many(self): + type_string = format_relation_name('Author') + author_pks = Author.objects.values_list('pk', flat=True) + initial_data = [{'type': type_string, 'id': str(pk)} for pk in author_pks] + + serializer = ResourceIdentifierObjectSerializer(data=initial_data, model_class=Author, many=True) + + self.assertTrue(serializer.is_valid(), msg=serializer.errors) + + print(serializer.data) + diff --git a/example/tests/test_views.py b/example/tests/test_views.py new file mode 100644 index 00000000..3f14c031 --- /dev/null +++ b/example/tests/test_views.py @@ -0,0 +1,170 @@ +import json + +from django.utils import timezone +from rest_framework.reverse import reverse + +from rest_framework.test import APITestCase + +from rest_framework_json_api.utils import format_relation_name +from example.models import Blog, Entry, Comment, Author + + +class TestRelationshipView(APITestCase): + def setUp(self): + self.author = Author.objects.create(name='Super powerful superhero', email='i.am@lost.com') + self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.other_blog = Blog.objects.create(name='Other blog', tagline="It's another blog") + self.first_entry = Entry.objects.create( + blog=self.blog, + headline='headline one', + body_text='body_text two', + pub_date=timezone.now(), + mod_date=timezone.now(), + n_comments=0, + n_pingbacks=0, + rating=3 + ) + self.second_entry = Entry.objects.create( + blog=self.blog, + headline='headline two', + body_text='body_text one', + pub_date=timezone.now(), + mod_date=timezone.now(), + n_comments=0, + n_pingbacks=0, + rating=1 + ) + self.first_comment = Comment.objects.create(entry=self.first_entry, body="This entry is cool", author=None) + self.second_comment = Comment.objects.create( + entry=self.second_entry, + body="This entry is not cool", + author=self.author + ) + + def test_get_entry_relationship_blog(self): + url = reverse('entry-relationships', kwargs={'pk': self.first_entry.id, 'related_field': 'blog'}) + response = self.client.get(url) + expected_data = {'type': format_relation_name('Blog'), 'id': str(self.first_entry.blog.id)} + + assert response.data == expected_data + + def test_get_entry_relationship_invalid_field(self): + response = self.client.get('/entries/{}/relationships/invalid_field'.format(self.first_entry.id)) + + assert response.status_code == 404 + + def test_get_blog_relationship_entry_set(self): + response = self.client.get('/blogs/{}/relationships/entry_set'.format(self.blog.id)) + expected_data = [{'type': format_relation_name('Entry'), 'id': str(self.first_entry.id)}, + {'type': format_relation_name('Entry'), 'id': str(self.second_entry.id)}] + + assert response.data == expected_data + + def test_put_entry_relationship_blog_returns_405(self): + url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + response = self.client.put(url, data={}) + assert response.status_code == 405 + + def test_patch_invalid_entry_relationship_blog_returns_400(self): + url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + response = self.client.patch(url, + data=json.dumps({'data': {'invalid': ''}}), + content_type='application/vnd.api+json') + assert response.status_code == 400 + + def test_get_empty_to_one_relationship(self): + url = '/comments/{}/relationships/author'.format(self.first_entry.id) + response = self.client.get(url) + expected_data = None + + assert response.data == expected_data + + def test_get_to_many_relationship_self_link(self): + url = '/authors/{}/relationships/comment_set'.format(self.author.id) + + response = self.client.get(url) + expected_data = { + 'links': {'self': 'http://testserver/authors/1/relationships/comment_set'}, + 'data': [{'id': str(self.second_comment.id), 'type': format_relation_name('Comment')}] + } + assert json.loads(response.content.decode('utf-8')) == expected_data + + def test_patch_to_one_relationship(self): + url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + request_data = { + 'data': {'type': format_relation_name('Blog'), 'id': str(self.other_blog.id)} + } + response = self.client.patch(url, data=json.dumps(request_data), content_type='application/vnd.api+json') + assert response.status_code == 200, response.content.decode() + + response = self.client.get(url) + assert response.data == request_data['data'] + + def test_patch_to_many_relationship(self): + url = '/blogs/{}/relationships/entry_set'.format(self.first_entry.id) + request_data = { + 'data': [{'type': format_relation_name('Entry'), 'id': str(self.first_entry.id)}, ] + } + response = self.client.patch(url, data=json.dumps(request_data), content_type='application/vnd.api+json') + assert response.status_code == 200, response.content.decode() + + response = self.client.get(url) + assert response.data == request_data['data'] + + def test_post_to_one_relationship_should_fail(self): + url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + request_data = { + 'data': {'type': format_relation_name('Blog'), 'id': str(self.other_blog.id)} + } + response = self.client.post(url, data=json.dumps(request_data), content_type='application/vnd.api+json') + assert response.status_code == 405, response.content.decode() + + def test_post_to_many_relationship_with_no_change(self): + url = '/entries/{}/relationships/comment_set'.format(self.first_entry.id) + request_data = { + 'data': [{'type': format_relation_name('Comment'), 'id': str(self.first_comment.id)}, ] + } + response = self.client.post(url, data=json.dumps(request_data), content_type='application/vnd.api+json') + assert response.status_code == 204, response.content.decode() + + def test_post_to_many_relationship_with_change(self): + url = '/entries/{}/relationships/comment_set'.format(self.first_entry.id) + request_data = { + 'data': [{'type': format_relation_name('Comment'), 'id': str(self.second_comment.id)}, ] + } + response = self.client.post(url, data=json.dumps(request_data), content_type='application/vnd.api+json') + assert response.status_code == 200, response.content.decode() + + assert request_data['data'][0] in response.data + + def test_delete_to_one_relationship_should_fail(self): + url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + request_data = { + 'data': {'type': format_relation_name('Blog'), 'id': str(self.other_blog.id)} + } + response = self.client.delete(url, data=json.dumps(request_data), content_type='application/vnd.api+json') + assert response.status_code == 405, response.content.decode() + + def test_delete_to_many_relationship_with_no_change(self): + url = '/entries/{}/relationships/comment_set'.format(self.first_entry.id) + request_data = { + 'data': [{'type': format_relation_name('Comment'), 'id': str(self.second_comment.id)}, ] + } + response = self.client.delete(url, data=json.dumps(request_data), content_type='application/vnd.api+json') + assert response.status_code == 204, response.content.decode() + + def test_delete_one_to_many_relationship_with_not_null_constraint(self): + url = '/entries/{}/relationships/comment_set'.format(self.first_entry.id) + request_data = { + 'data': [{'type': format_relation_name('Comment'), 'id': str(self.first_comment.id)}, ] + } + response = self.client.delete(url, data=json.dumps(request_data), content_type='application/vnd.api+json') + assert response.status_code == 409, response.content.decode() + + def test_delete_to_many_relationship_with_change(self): + url = '/authors/{}/relationships/comment_set'.format(self.author.id) + request_data = { + 'data': [{'type': format_relation_name('Comment'), 'id': str(self.second_comment.id)}, ] + } + response = self.client.delete(url, data=json.dumps(request_data), content_type='application/vnd.api+json') + assert response.status_code == 200, response.content.decode() diff --git a/example/urls_test.py b/example/urls_test.py index a31670b7..96f415fd 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -1,7 +1,8 @@ from django.conf.urls import include, url from rest_framework import routers -from example.views import BlogViewSet, EntryViewSet, AuthorViewSet +from example.views import BlogViewSet, EntryViewSet, AuthorViewSet, EntryRelationshipView, BlogRelationshipView, \ + CommentRelationshipView, AuthorRelationshipView from .api.resources.identity import Identity, GenericIdentity router = routers.DefaultRouter(trailing_slash=False) @@ -19,5 +20,19 @@ # old tests url(r'identities/default/(?P\d+)', GenericIdentity.as_view(), name='user-default'), + + + url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)', + EntryRelationshipView.as_view(), + name='entry-relationships'), + url(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)', + BlogRelationshipView.as_view(), + name='blog-relationships'), + url(r'^comments/(?P[^/.]+)/relationships/(?P\w+)', + CommentRelationshipView.as_view(), + name='comment-relationships'), + url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)', + AuthorRelationshipView.as_view(), + name='author-relationships'), ] diff --git a/example/views.py b/example/views.py index 4a41f5d8..28e2b245 100644 --- a/example/views.py +++ b/example/views.py @@ -1,21 +1,38 @@ from rest_framework import viewsets -from example.models import Blog, Entry, Author + +from rest_framework_json_api.views import RelationshipView +from example.models import Blog, Entry, Author, Comment from example.serializers import BlogSerializer, EntrySerializer, AuthorSerializer class BlogViewSet(viewsets.ModelViewSet): - queryset = Blog.objects.all() serializer_class = BlogSerializer -class EntryViewSet(viewsets.ModelViewSet): +class EntryViewSet(viewsets.ModelViewSet): queryset = Entry.objects.all() serializer_class = EntrySerializer resource_name = 'posts' -class AuthorViewSet(viewsets.ModelViewSet): +class AuthorViewSet(viewsets.ModelViewSet): queryset = Author.objects.all() serializer_class = AuthorSerializer + +class EntryRelationshipView(RelationshipView): + queryset = Entry.objects + + +class BlogRelationshipView(RelationshipView): + queryset = Blog.objects + + +class CommentRelationshipView(RelationshipView): + queryset = Comment.objects + + +class AuthorRelationshipView(RelationshipView): + queryset = Author.objects + self_link_view_name = 'author-relationships' diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 10d61041..0afc3551 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -2,6 +2,7 @@ Parsers """ from rest_framework import parsers +from rest_framework.exceptions import ParseError from . import utils, renderers, exceptions @@ -31,9 +32,22 @@ def parse(self, stream, media_type=None, parser_context=None): Parses the incoming bytestream as JSON and returns the resulting data """ result = super(JSONParser, self).parse(stream, media_type=media_type, parser_context=parser_context) - data = result.get('data', {}) + data = result.get('data') if data: + from rest_framework_json_api.views import RelationshipView + if isinstance(parser_context['view'], RelationshipView): + # We skip parsing the object as JSONAPI Resource Identifier Object and not a regular Resource Object + if isinstance(data, list): + for resource_identifier_object in data: + if not (resource_identifier_object.get('id') and resource_identifier_object.get('type')): + raise ParseError( + 'Received data contains one or more malformed JSONAPI Resource Identifier Object(s)' + ) + elif not (data.get('id') and data.get('type')): + raise ParseError('Received data is not a valid JSONAPI Resource Identifier Object') + + return data # Check for inconsistencies resource_name = utils.get_resource_name(parser_context) if data.get('type') != resource_name: @@ -67,3 +81,6 @@ def parse(self, stream, media_type=None, parser_context=None): parsed_data.update(attributes) parsed_data.update(parsed_relationships) return parsed_data + + else: + raise ParseError('Received document does not contain primary data') diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py new file mode 100644 index 00000000..2f33af0a --- /dev/null +++ b/rest_framework_json_api/relations.py @@ -0,0 +1,61 @@ +from rest_framework.exceptions import ValidationError +from rest_framework.relations import * +from rest_framework_json_api.utils import format_relation_name, get_related_resource_type, \ + get_resource_type_from_queryset, get_resource_type_from_instance +from django.utils.translation import ugettext_lazy as _ + + +class HyperlinkedRelatedField(HyperlinkedRelatedField): + """ + This field exists for the sole purpose of accepting PKs as well as URLs + when data is submitted back to the serializer + """ + default_error_messages = { + 'required': _('This field is required.'), + 'no_match': _('Invalid hyperlink - No URL match.'), + 'incorrect_match': _('Invalid hyperlink - Incorrect URL match.'), + 'does_not_exist': _('Invalid hyperlink - Object does not exist.'), + 'incorrect_type': _('Incorrect type. Expected URL string, received {data_type}.'), + 'pk_does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), + 'incorrect_pk_type': _('Incorrect type. Expected pk value, received {data_type}.'), + } + + def __init__(self, **kwargs): + self.pk_field = kwargs.pop('pk_field', None) + super(HyperlinkedRelatedField, self).__init__(**kwargs) + + def to_internal_value(self, data): + try: + # Try parsing links first for the browseable API + return super(HyperlinkedRelatedField, self).to_internal_value(data) + except ValidationError: + if self.pk_field is not None: + data = self.pk_field.to_internal_value(data) + try: + return self.get_queryset().get(pk=data) + except ObjectDoesNotExist: + self.fail('pk_does_not_exist', pk_value=data) + except (TypeError, ValueError): + self.fail('incorrect_pk_type', data_type=type(data).__name__) + + +class ResourceRelatedField(PrimaryKeyRelatedField): + default_error_messages = { + 'required': _('This field is required.'), + 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), + 'incorrect_type': _('Incorrect type. Expected pk value, received {data_type}.'), + 'incorrect_relation_type': _('Incorrect relation type. Expected {relation_type}, received {received_type}.'), + } + + def to_internal_value(self, data): + expected_relation_type = get_resource_type_from_queryset(self.queryset) + if data['type'] != expected_relation_type: + self.fail('incorrect_relation_type', relation_type=expected_relation_type, received_type=data['type']) + return super(ResourceRelatedField, self).to_internal_value(data['id']) + + def to_representation(self, value): + return { + 'type': format_relation_name(get_resource_type_from_instance(value)), + 'id': str(value.pk) + } + diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 774a6242..3cea2bb8 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -32,12 +32,26 @@ class JSONRenderer(renderers.JSONRenderer): format = 'vnd.api+json' def render(self, data, accepted_media_type=None, renderer_context=None): - # Get the resource name. - resource_name = utils.get_resource_name(renderer_context) view = renderer_context.get("view", None) request = renderer_context.get("request", None) + from rest_framework_json_api.views import RelationshipView + if isinstance(view, RelationshipView): + # Special case for RelationshipView + render_data = OrderedDict([ + ('data', data) + ]) + links = view.get_links() + if links: + render_data.update({'links': links}), + return super(JSONRenderer, self).render( + render_data, accepted_media_type, renderer_context + ) + + # Get the resource name. + resource_name = utils.get_resource_name(renderer_context) + # If `resource_name` is set to None then render default as the dev # wants to build the output format manually. if resource_name is None or resource_name is False: @@ -55,11 +69,11 @@ def render(self, data, accepted_media_type=None, renderer_context=None): json_api_included = list() - # If detail view then json api spec expects dict, otherwise a list - # - http://jsonapi.org/format/#document-top-level - # The `results` key may be missing if unpaginated or an OPTIONS request if view and hasattr(view, 'action') and view.action == 'list' and \ isinstance(data, dict) and 'results' in data: + # If detail view then json api spec expects dict, otherwise a list + # - http://jsonapi.org/format/#document-top-level + # The `results` key may be missing if unpaginated or an OPTIONS request results = data["results"] diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py new file mode 100644 index 00000000..fafdb2d4 --- /dev/null +++ b/rest_framework_json_api/serializers.py @@ -0,0 +1,48 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework.serializers import * +from rest_framework_json_api.utils import format_relation_name, get_resource_type_from_instance + +from rest_framework_json_api.relations import HyperlinkedRelatedField + + +class HyperlinkedModelSerializer(HyperlinkedModelSerializer): + """ + A type of `ModelSerializer` that uses hyperlinked relationships instead + of primary key relationships. Specifically: + + * A 'url' field is included instead of the 'id' field. + * Relationships to other instances are hyperlinks, instead of primary keys. + * Uses django-rest-framework-json-api HyperlinkedRelatedField instead of the default HyperlinkedRelatedField + """ + serializer_related_field = HyperlinkedRelatedField + + +class ResourceIdentifierObjectSerializer(BaseSerializer): + default_error_messages = { + 'incorrect_model_type': _('Incorrect model type. Expected {model_type}, received {received_type}.'), + 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), + 'incorrect_type': _('Incorrect type. Expected pk value, received {data_type}.'), + } + + def __init__(self, *args, **kwargs): + self.model_class = kwargs.pop('model_class', None) + if 'instance' not in kwargs and not self.model_class: + raise RuntimeError('ResourceIdentifierObjectsSerializer must be initialized with a model class.') + super(ResourceIdentifierObjectSerializer, self).__init__(*args, **kwargs) + + def to_representation(self, instance): + return { + 'type': format_relation_name(get_resource_type_from_instance(instance)), + 'id': str(instance.pk) + } + + def to_internal_value(self, data): + if data['type'] != format_relation_name(self.model_class.__name__): + self.fail('incorrect_model_type', model_type=self.model_class, received_type=data['type']) + pk = data['id'] + try: + return self.model_class.objects.get(pk=pk) + except ObjectDoesNotExist: + self.fail('does_not_exist', pk_value=pk) + except (TypeError, ValueError): + self.fail('incorrect_type', data_type=type(data['pk']).__name__) diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 5706960c..17da3941 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -2,7 +2,6 @@ Utils. """ import inflection -from django.core import urlresolvers from django.conf import settings from django.utils import six, encoding from django.utils.translation import ugettext_lazy as _ @@ -12,8 +11,6 @@ from rest_framework.settings import api_settings from rest_framework.exceptions import APIException -from django.utils.six.moves.urllib.parse import urlparse - try: from rest_framework.compat import OrderedDict except ImportError: @@ -190,12 +187,33 @@ def get_related_resource_type(relation): if hasattr(parent_model_relation, 'related'): relation_model = parent_model_relation.related.related_model elif hasattr(parent_model_relation, 'field'): - relation_model = parent_model_relation.field.related_model + relation_model = parent_model_relation.field.related.model else: raise APIException('Unable to find related model for relation {relation}'.format(relation=relation)) return format_relation_name(relation_model.__name__) +def get_instance_or_manager_resource_type(resource_instance_or_manager): + + if hasattr(resource_instance_or_manager, 'model'): + return get_resource_type_from_manager(resource_instance_or_manager) + if hasattr(resource_instance_or_manager, '_meta'): + return get_resource_type_from_instance(resource_instance_or_manager) + pass + + +def get_resource_type_from_queryset(qs): + return format_relation_name(qs.model._meta.model.__name__) + + +def get_resource_type_from_instance(instance): + return format_relation_name(instance._meta.model.__name__) + + +def get_resource_type_from_manager(manager): + return format_relation_name(manager.model.__name__) + + def extract_attributes(fields, resource): data = OrderedDict() for field_name, field in six.iteritems(fields): @@ -205,6 +223,12 @@ def extract_attributes(fields, resource): # Skip fields with relations if isinstance(field, (RelatedField, BaseSerializer, ManyRelatedField)): continue + + # Skip read_only attribute fields when the resource is non-existent + # Needed for the "Raw data" form of the browseable API + if resource.get('id') is None and fields[field_name].read_only: + continue + data.update({ field_name: resource.get(field_name) }) @@ -277,7 +301,7 @@ def extract_relationships(fields, resource, resource_instance): if isinstance(field, ManyRelatedField): relation_data = list() for related_object in relation_instance_or_manager.all(): - related_object_type = get_related_resource_type(related_object) + related_object_type = get_instance_or_manager_resource_type(relation_instance_or_manager) relation_data.append(OrderedDict([ ('type', related_object_type), ('id', encoding.force_text(related_object.pk)) @@ -300,8 +324,7 @@ def extract_relationships(fields, resource, resource_instance): if isinstance(serializer_data, list): for position in range(len(serializer_data)): nested_resource_instance = resource_instance_queryset[position] - nested_resource_instance_type = get_related_resource_type( - nested_resource_instance) + nested_resource_instance_type = get_resource_type_from_instance(nested_resource_instance) relation_data.append(OrderedDict([ ('type', nested_resource_instance_type), ('id', encoding.force_text(nested_resource_instance.pk)) diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py new file mode 100644 index 00000000..9fc2e67a --- /dev/null +++ b/rest_framework_json_api/views.py @@ -0,0 +1,169 @@ +from django.core.exceptions import ImproperlyConfigured +from django.core.urlresolvers import NoReverseMatch +from django.db.models import Model +from django.db.models.query import QuerySet +from django.db.models.manager import Manager +from django.utils import six +from rest_framework import generics +from rest_framework.response import Response +from rest_framework.exceptions import NotFound, MethodNotAllowed +from rest_framework.reverse import reverse + +from rest_framework_json_api.exceptions import Conflict +from rest_framework_json_api.serializers import ResourceIdentifierObjectSerializer +from rest_framework_json_api.utils import format_relation_name, get_resource_type_from_instance, OrderedDict + + +class RelationshipView(generics.GenericAPIView): + serializer_class = ResourceIdentifierObjectSerializer + self_link_view_name = None + related_link_view_name = None + + def __init__(self, **kwargs): + super(RelationshipView, self).__init__(**kwargs) + # We include this simply for dependency injection in tests. + # We can't add it as a class attributes or it would expect an + # implicit `self` argument to be passed. + self.reverse = reverse + + def get_url(self, name, view_name, kwargs, request): + """ + Given an object, return the URL that hyperlinks to the object. + + May raise a `NoReverseMatch` if the `view_name` and `lookup_field` + attributes are not configured to correctly match the URL conf. + """ + + class Hyperlink(six.text_type): + """ + A string like object that additionally has an associated name. + We use this for hyperlinked URLs that may render as a named link + in some contexts, or render as a plain URL in others. + + Comes from Django REST framework 3.2 + https://github.com/tomchristie/django-rest-framework + """ + + def __new__(self, url, name): + ret = six.text_type.__new__(self, url) + ret.name = name + return ret + + is_hyperlink = True + + # Return None if the view name is not supplied + if not view_name: + return None + + # Return the hyperlink, or error if incorrectly configured. + try: + url = self.reverse(view_name, kwargs=kwargs, request=request) + except NoReverseMatch: + msg = ( + 'Could not resolve URL for hyperlinked relationship using ' + 'view name "%s". You may have failed to include the related ' + 'model in your API, or incorrectly configured the ' + '`lookup_field` attribute on this field.' + ) + raise ImproperlyConfigured(msg % view_name) + + if url is None: + return None + + return Hyperlink(url, name) + + def get_links(self): + return_data = OrderedDict() + self_link = self.get_url('self', self.self_link_view_name, self.kwargs, self.request) + related_kwargs = {self.lookup_field: self.kwargs.get(self.lookup_field)} + related_link = self.get_url('related', self.related_link_view_name, related_kwargs, self.request) + if self_link: + return_data.update({'self': self_link}) + if related_link: + return_data.update({'related': related_link}) + return return_data + + def get(self, request, *args, **kwargs): + related_instance = self.get_related_instance() + serializer_instance = self._instantiate_serializer(related_instance) + return Response(serializer_instance.data) + + def patch(self, request, *args, **kwargs): + parent_obj = self.get_object() + related_instance_or_manager = self.get_related_instance() + + if isinstance(related_instance_or_manager, Manager): + related_model_class = related_instance_or_manager.model + serializer = self.get_serializer(data=request.data, model_class=related_model_class, many=True) + serializer.is_valid(raise_exception=True) + related_instance_or_manager.all().delete() + related_instance_or_manager.add(*serializer.validated_data) + else: + related_model_class = related_instance_or_manager.__class__ + serializer = self.get_serializer(data=request.data, model_class=related_model_class) + serializer.is_valid(raise_exception=True) + setattr(parent_obj, kwargs['related_field'], serializer.validated_data) + parent_obj.save() + result_serializer = self._instantiate_serializer(related_instance_or_manager) + return Response(result_serializer.data) + + def post(self, request, *args, **kwargs): + related_instance_or_manager = self.get_related_instance() + + if isinstance(related_instance_or_manager, Manager): + related_model_class = related_instance_or_manager.model + serializer = self.get_serializer(data=request.data, model_class=related_model_class, many=True) + serializer.is_valid(raise_exception=True) + if frozenset(serializer.validated_data) <= frozenset(related_instance_or_manager.all()): + return Response(status=204) + related_instance_or_manager.add(*serializer.validated_data) + else: + raise MethodNotAllowed('POST') + result_serializer = self._instantiate_serializer(related_instance_or_manager) + return Response(result_serializer.data) + + def delete(self, request, *args, **kwargs): + related_instance_or_manager = self.get_related_instance() + + if isinstance(related_instance_or_manager, Manager): + related_model_class = related_instance_or_manager.model + serializer = self.get_serializer(data=request.data, model_class=related_model_class, many=True) + serializer.is_valid(raise_exception=True) + if frozenset(serializer.validated_data).isdisjoint(frozenset(related_instance_or_manager.all())): + return Response(status=204) + try: + related_instance_or_manager.remove(*serializer.validated_data) + except AttributeError: + raise Conflict( + 'This object cannot be removed from this relationship without being added to another' + ) + else: + raise MethodNotAllowed('DELETE') + result_serializer = self._instantiate_serializer(related_instance_or_manager) + return Response(result_serializer.data) + + def get_related_instance(self): + try: + return getattr(self.get_object(), self.kwargs['related_field']) + except AttributeError: + raise NotFound + + def _instantiate_serializer(self, instance): + if isinstance(instance, Model) or instance is None: + return self.get_serializer(instance=instance) + else: + if isinstance(instance, (QuerySet, Manager)): + instance = instance.all() + + return self.get_serializer(instance=instance, many=True) + + def get_resource_name(self): + if not hasattr(self, '_resource_name'): + instance = getattr(self.get_object(), self.kwargs['related_field']) + self._resource_name = format_relation_name(get_resource_type_from_instance(instance)) + return self._resource_name + + def set_resource_name(self, value): + self._resource_name = value + + resource_name = property(get_resource_name, set_resource_name)