├── .github └── workflows │ └── tests.yml ├── .gitignore ├── Changelog.md ├── Makefile ├── README.md ├── generic_relations ├── __init__.py ├── relations.py ├── serializers.py └── tests │ ├── __init__.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── test_relations.py │ └── test_serializers.py ├── manage.py ├── setup.cfg ├── setup.py ├── testsettings.py └── tox.ini /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python-version: 9 | - "3.6" 10 | - "3.9" 11 | django-version: 12 | - "2.2" 13 | - "3.1" 14 | - "3.2" 15 | drf-version: 16 | - "3.11" 17 | - "3.12" 18 | 19 | name: "py${{ matrix.python-version}} dj${{ matrix.django-version }} drf${{ matrix.drf-version }}" 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies (django ${{ matrix.django-version }}) 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install flake8 pytest-django django==${{ matrix.django-version }}.* djangorestframework==${{ matrix.drf-version }}.* 31 | pip install -e . 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | - name: Test with pytest 37 | run: | 38 | pytest --ds=testsettings generic_relations/tests/ 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | MANIFEST 4 | dist/ 5 | build/ 6 | *.egg-info/ 7 | .eggs 8 | .tox -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Rest Framework Generic Relations Changelog 2 | 3 | ## v2.1.0 4 | 5 | General dependency update 6 | 7 | * Minimum Python version is now 3.6 8 | * Minimum DRF version is now 3.11 9 | * Supported Django versions are now [2.2, 3.1, 3.2] 10 | 11 | ## v2.0.0 12 | 13 | * Add Python 3.8, Django 3.0 and DRF 3.11 support 14 | * Drop Python 2 15 | * Drop DRF 3.7 16 | 17 | Minimum supported dependencies are now: 18 | * Python 3.4 19 | * Django 1.11 20 | * DRF 3.8 21 | 22 | ## v1.2.1 23 | * Add error handling for reusing a `Serializer` instance in a `GenericRelatedField`. 24 | 25 | ## v1.2.0 26 | 27 | * Add Django 2.0 support (need Python min 3.4, Django min. 1.11). 28 | 29 | ## v1.1.0 30 | 31 | * Add `GenericModelSerializer` as a counterpart to `GenericRelatedField`. 32 | * Dynamically determine the best serializer to use to serialize a model instance. 33 | * Rename `determine_serializer_for_data` to `get_deserializer_for_data` 34 | * Rename `determine_deserializer_for_data` to `get_serializer_for_instance` 35 | 36 | ## v1.0.0 37 | 38 | * Add support for Django 1.8 and 1.9 39 | * Add support for Django Rest Framework 3 40 | * Drop support for earlier versions of Django and DRF 41 | 42 | ## v0.1.0 43 | 44 | * Initial release - Forked from https://github.com/encode/django-rest-framework/pull/755 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | 3 | help: 4 | @echo "Usage:" 5 | @echo " make help -- displays this help" 6 | @echo " make test -- runs tests" 7 | @echo " make release -- pushes to pypi" 8 | 9 | test: 10 | tox 11 | 12 | release: 13 | rm -rf dist 14 | python setup.py sdist bdist_wheel 15 | twine upload dist/* 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rest Framework Generic Relations [![Build Status](https://github.com/Ian-Foote/rest-framework-generic-relations/actions/workflows/tests.yml/badge.svg)](https://github.com/Ian-Foote/rest-framework-generic-relations/actions/workflows/tests.yml) 2 | 3 | 4 | This library implements [Django REST Framework](http://www.django-rest-framework.org/) serializers to handle generic foreign keys. 5 | 6 | # Requirements 7 | 8 | Any currently-supported combination of Django REST Framework, Python, and Django. 9 | 10 | # Installation 11 | 12 | Install using `pip`... 13 | ```sh 14 | pip install rest-framework-generic-relations 15 | ``` 16 | Add `'generic_relations'` to your `INSTALLED_APPS` setting. 17 | ```python 18 | INSTALLED_APPS = ( 19 | ... 20 | 'generic_relations', 21 | ) 22 | ``` 23 | 24 | 25 | # API Reference 26 | 27 | ## GenericRelatedField 28 | 29 | This field serializes generic foreign keys. For a primer on generic foreign keys, first see: https://docs.djangoproject.com/en/dev/ref/contrib/contenttypes/ 30 | 31 | 32 | Let's assume a `TaggedItem` model which has a generic relationship with other arbitrary models: 33 | 34 | ```python 35 | class TaggedItem(models.Model): 36 | tag_name = models.SlugField() 37 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 38 | object_id = models.PositiveIntegerField() 39 | tagged_object = GenericForeignKey('content_type', 'object_id') 40 | ``` 41 | 42 | And the following two models, which may have associated tags: 43 | 44 | ```python 45 | class Bookmark(models.Model): 46 | """ 47 | A bookmark consists of a URL, and 0 or more descriptive tags. 48 | """ 49 | url = models.URLField() 50 | tags = GenericRelation(TaggedItem) 51 | 52 | class Note(models.Model): 53 | """ 54 | A note consists of some text, and 0 or more descriptive tags. 55 | """ 56 | text = models.CharField(max_length=1000) 57 | tags = GenericRelation(TaggedItem) 58 | ``` 59 | 60 | Now we define serializers for each model that may get associated with tags. 61 | 62 | ```python 63 | class BookmarkSerializer(serializers.ModelSerializer): 64 | class Meta: 65 | model = Bookmark 66 | fields = ('url',) 67 | 68 | class NoteSerializer(serializers.ModelSerializer): 69 | class Meta: 70 | model = Note 71 | fields = ('text',) 72 | ``` 73 | 74 | The model serializer for the `TaggedItem` model could look like this: 75 | 76 | ```python 77 | from generic_relations.relations import GenericRelatedField 78 | 79 | class TagSerializer(serializers.ModelSerializer): 80 | """ 81 | A `TaggedItem` serializer with a `GenericRelatedField` mapping all possible 82 | models to their respective serializers. 83 | """ 84 | tagged_object = GenericRelatedField({ 85 | Bookmark: BookmarkSerializer(), 86 | Note: NoteSerializer() 87 | }) 88 | 89 | class Meta: 90 | model = TaggedItem 91 | fields = ('tag_name', 'tagged_object') 92 | ``` 93 | 94 | The JSON representation of a `TaggedItem` object with `name='django'` and its generic foreign key pointing at a `Bookmark` object with `url='https://www.djangoproject.com/'` would look like this: 95 | 96 | ```json 97 | { 98 | "tagged_object": { 99 | "url": "https://www.djangoproject.com/" 100 | }, 101 | "tag_name": "django" 102 | } 103 | ``` 104 | 105 | If you want to have your generic foreign key represented as hyperlink, simply use `HyperlinkedRelatedField` objects: 106 | 107 | ```python 108 | class TagSerializer(serializers.ModelSerializer): 109 | """ 110 | A `Tag` serializer with a `GenericRelatedField` mapping all possible 111 | models to properly set up `HyperlinkedRelatedField`s. 112 | """ 113 | tagged_object = GenericRelatedField({ 114 | Bookmark: serializers.HyperlinkedRelatedField( 115 | queryset = Bookmark.objects.all(), 116 | view_name='bookmark-detail', 117 | ), 118 | Note: serializers.HyperlinkedRelatedField( 119 | queryset = Note.objects.all(), 120 | view_name='note-detail', 121 | ), 122 | }) 123 | 124 | class Meta: 125 | model = TaggedItem 126 | fields = ('tag_name', 'tagged_object') 127 | ``` 128 | 129 | The JSON representation of the same `TaggedItem` example object could now look something like this: 130 | 131 | ```json 132 | { 133 | "tagged_object": "/bookmark/1/", 134 | "tag_name": "django" 135 | } 136 | ``` 137 | 138 | ## Writing to generic foreign keys 139 | 140 | The above `TagSerializer` is also writable. By default, a `GenericRelatedField` iterates over its nested serializers and returns the value of the first serializer that is actually able to perform `to_internal_value()` without any errors. 141 | Note, that (at the moment) only `HyperlinkedRelatedField` is able to serialize model objects out of the box. 142 | 143 | 144 | The following operations would create a `TaggedItem` object with it's `tagged_object` property pointing at the `Bookmark` object found at the given detail end point. 145 | 146 | ```python 147 | tag_serializer = TagSerializer(data={ 148 | 'tag_name': 'python', 149 | 'tagged_object': '/bookmark/1/' 150 | }) 151 | 152 | tag_serializer.is_valid() 153 | tag_serializer.save() 154 | ``` 155 | 156 | If you feel that this default behavior doesn't suit your needs, you can subclass `GenericRelatedField` and override its `get_serializer_for_instance` or `get_deserializer_for_data` respectively to implement your own way of decision-making. 157 | 158 | ## GenericModelSerializer 159 | 160 | Sometimes you may want to serialize a single list of different top-level things. For instance, suppose I have an API view that returns what items are on my bookshelf. Let's define some models: 161 | 162 | ```python 163 | from django.core.validators import MaxValueValidator 164 | 165 | class Book(models.Model): 166 | title = models.CharField(max_length=255) 167 | author = models.CharField(max_length=255) 168 | 169 | class Bluray(models.Model): 170 | title = models.CharField(max_length=255) 171 | rating = models.PositiveSmallIntegerField( 172 | validators=[MaxValueValidator(5)], 173 | ) 174 | ``` 175 | 176 | Then we could have a serializer for each type of object: 177 | 178 | ```python 179 | class BookSerializer(serializers.ModelSerializer): 180 | class Meta: 181 | model = Book 182 | fields = ('title', 'author') 183 | 184 | class BluraySerializer(serializers.ModelSerializer): 185 | class Meta: 186 | model = Bluray 187 | fields = ('title', 'rating') 188 | ``` 189 | 190 | Now we can create a generic list serializer, which delegates to the above serializers based on the type of model it's serializing: 191 | 192 | ```python 193 | bookshelf_item_serializer = GenericModelSerializer( 194 | { 195 | Book: BookSerializer(), 196 | Bluray: BluraySerializer(), 197 | }, 198 | many=True, 199 | ) 200 | ``` 201 | 202 | Then we can serialize a mixed list of items: 203 | 204 | ```python 205 | >>> bookshelf_item_serializer.to_representation([ 206 | Book.objects.get(title='War and Peace'), 207 | Bluray.objects.get(title='Die Hard'), 208 | Bluray.objects.get(title='Shawshank Redemption'), 209 | Book.objects.get(title='To Kill a Mockingbird'), 210 | ]) 211 | 212 | [ 213 | {'title': 'War and Peace', 'author': 'Leo Tolstoy'}, 214 | {'title': 'Die Hard', 'rating': 5}, 215 | {'title': 'Shawshank Redemption', 'rating': 5}, 216 | {'title': 'To Kill a Mockingbird', 'author': 'Harper Lee'} 217 | ] 218 | ``` 219 | 220 | 221 | ## A few things you should note: 222 | 223 | * Although `GenericForeignKey` fields can be set to any model object, the `GenericRelatedField` only handles models explicitly defined in its configuration dictionary. 224 | * Reverse generic keys, expressed using the `GenericRelation` field, can be serialized using the regular relational field types, since the type of the target in the relationship is always known. 225 | * The order in which you register serializers matters as far as write operations are concerned. 226 | * Unless you provide a custom `get_deserializer_for_data()` method, only `HyperlinkedRelatedField` provides write access to generic model relations. 227 | -------------------------------------------------------------------------------- /generic_relations/__init__.py: -------------------------------------------------------------------------------- 1 | pkg_resources = __import__('pkg_resources') 2 | distribution = pkg_resources.get_distribution('rest-framework-generic-relations') 3 | 4 | __version__ = distribution.version 5 | -------------------------------------------------------------------------------- /generic_relations/relations.py: -------------------------------------------------------------------------------- 1 | from django.utils.deprecation import RenameMethodsBase 2 | 3 | from rest_framework import serializers 4 | 5 | from .serializers import GenericSerializerMixin 6 | 7 | 8 | __all__ = ('GenericRelatedField',) 9 | 10 | 11 | class RenamedMethods(RenameMethodsBase): 12 | renamed_methods = ( 13 | ('determine_deserializer_for_data', 'get_serializer_for_instance', DeprecationWarning), 14 | ('determine_serializer_for_data', 'get_deserializer_for_data', DeprecationWarning), 15 | ) 16 | 17 | 18 | class GenericRelatedField(GenericSerializerMixin, serializers.Field, metaclass=RenamedMethods): 19 | """ 20 | Represents a generic relation / foreign key. 21 | It's actually more of a wrapper, that delegates the logic to registered 22 | serializers based on the `Model` class. 23 | """ 24 | -------------------------------------------------------------------------------- /generic_relations/serializers.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.utils.translation import gettext_lazy as _ 3 | from django import forms 4 | 5 | from rest_framework import serializers 6 | 7 | 8 | __all__ = ('GenericSerializerMixin', 'GenericModelSerializer',) 9 | 10 | 11 | class GenericSerializerMixin(object): 12 | default_error_messages = { 13 | 'no_model_match': _('Invalid model - model not available.'), 14 | 'no_url_match': _('Invalid hyperlink - No URL match'), 15 | 'incorrect_url_match': _( 16 | 'Invalid hyperlink - view name not available'), 17 | } 18 | 19 | form_field_class = forms.URLField 20 | 21 | def __init__(self, serializers, *args, **kwargs): 22 | """ 23 | Needs an extra parameter `serializers` which has to be a dict 24 | key: value being `Model`: serializer. 25 | """ 26 | super(GenericSerializerMixin, self).__init__(*args, **kwargs) 27 | self.serializers = serializers 28 | for serializer in self.serializers.values(): 29 | if serializer.source is not None: 30 | msg = '{}() cannot be re-used. Create a new instance.' 31 | raise RuntimeError(msg.format(type(serializer).__name__)) 32 | serializer.bind('', self) 33 | 34 | def to_internal_value(self, data): 35 | try: 36 | serializer = self.get_deserializer_for_data(data) 37 | except ImproperlyConfigured as e: 38 | raise serializers.ValidationError(e) 39 | return serializer.to_internal_value(data) 40 | 41 | def to_representation(self, instance): 42 | serializer = self.get_serializer_for_instance(instance) 43 | return serializer.to_representation(instance) 44 | 45 | def get_serializer_for_instance(self, instance): 46 | # Use registered superclasses, rather than only the exact model. 47 | # (But prefer things earlier in the MRO, so if the exact model is registered, 48 | # use that in preference to any superclasses) 49 | for klass in instance.__class__.mro(): 50 | if klass in self.serializers: 51 | return self.serializers[klass] 52 | 53 | raise serializers.ValidationError(self.error_messages['no_model_match']) 54 | 55 | def get_deserializer_for_data(self, value): 56 | # While one could easily execute the "try" block within 57 | # to_internal_value and reduce operations, I consider the concept of 58 | # serializing is already very naive and vague, that's why I'd 59 | # go for stringency with the deserialization process here. 60 | serializers = [] 61 | for serializer in self.serializers.values(): 62 | try: 63 | serializer.to_internal_value(value) 64 | # Collects all serializers that can handle the input data. 65 | serializers.append(serializer) 66 | except Exception: 67 | pass 68 | # If no serializer found, raise error. 69 | l = len(serializers) 70 | if l < 1: 71 | raise ImproperlyConfigured( 72 | 'Could not determine a valid serializer for value %r.' % value) 73 | elif l > 1: 74 | raise ImproperlyConfigured( 75 | 'There were multiple serializers found for value %r.' % value) 76 | return serializers[0] 77 | 78 | 79 | class GenericModelSerializer(GenericSerializerMixin, serializers.Serializer): 80 | """ 81 | Delegates serialization and deserialization to registered serializers 82 | based on the type of the model. 83 | """ 84 | -------------------------------------------------------------------------------- /generic_relations/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naruhitokaide/rest-framework-generic-relations/d6da4a599fe2f1bf6727c4ae69083b3cac20fbc7/generic_relations/tests/__init__.py -------------------------------------------------------------------------------- /generic_relations/tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.22 on 2019-08-01 04:03 3 | 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('contenttypes', '0002_remove_content_type_name'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Bookmark', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('url', models.URLField()), 23 | ], 24 | ), 25 | migrations.CreateModel( 26 | name='Detachable', 27 | fields=[ 28 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 29 | ('name', models.CharField(max_length=50)), 30 | ('object_id', models.PositiveIntegerField(blank=True, null=True)), 31 | ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), 32 | ], 33 | ), 34 | migrations.CreateModel( 35 | name='Note', 36 | fields=[ 37 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 38 | ('text', models.TextField()), 39 | ], 40 | ), 41 | migrations.CreateModel( 42 | name='Tag', 43 | fields=[ 44 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 45 | ('tag', models.SlugField()), 46 | ('object_id', models.PositiveIntegerField()), 47 | ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), 48 | ], 49 | ), 50 | migrations.CreateModel( 51 | name='NoteProxy', 52 | fields=[ 53 | ], 54 | options={ 55 | 'proxy': True, 56 | 'indexes': [], 57 | }, 58 | bases=('tests.note',), 59 | ), 60 | ] 61 | -------------------------------------------------------------------------------- /generic_relations/tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naruhitokaide/rest-framework-generic-relations/d6da4a599fe2f1bf6727c4ae69083b3cac20fbc7/generic_relations/tests/migrations/__init__.py -------------------------------------------------------------------------------- /generic_relations/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation 3 | 4 | from django.db import models 5 | 6 | 7 | class Tag(models.Model): 8 | """ 9 | Tags have a descriptive slug, and are attached to an arbitrary object. 10 | """ 11 | tag = models.SlugField() 12 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 13 | object_id = models.PositiveIntegerField() 14 | tagged_item = GenericForeignKey('content_type', 'object_id') 15 | 16 | def __unicode__(self): 17 | return self.tag 18 | 19 | 20 | class Detachable(models.Model): 21 | """ 22 | Model with an optional GenericForeignKey relation 23 | """ 24 | name = models.CharField(max_length=50) 25 | content_type = models.ForeignKey( 26 | ContentType, null=True, blank=True, on_delete=models.CASCADE) 27 | object_id = models.PositiveIntegerField(null=True, blank=True) 28 | content_object = GenericForeignKey('content_type', 'object_id') 29 | 30 | 31 | class Bookmark(models.Model): 32 | """ 33 | A URL bookmark that may have multiple tags attached. 34 | """ 35 | url = models.URLField() 36 | tags = GenericRelation(Tag) 37 | 38 | def __unicode__(self): 39 | return 'Bookmark: %s' % self.url 40 | 41 | 42 | class Note(models.Model): 43 | """ 44 | A textual note that may have multiple tags attached. 45 | """ 46 | text = models.TextField() 47 | tags = GenericRelation(Tag) 48 | 49 | def __unicode__(self): 50 | return 'Note: %s' % self.text 51 | 52 | 53 | class NoteProxy(Note): 54 | class Meta: 55 | proxy = True 56 | -------------------------------------------------------------------------------- /generic_relations/tests/test_relations.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import warnings 4 | 5 | from django.conf.urls import url 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.test import TestCase, RequestFactory 8 | from django.test.utils import override_settings 9 | 10 | from rest_framework import serializers 11 | from rest_framework.reverse import reverse 12 | 13 | from generic_relations.relations import GenericRelatedField 14 | from generic_relations.tests.models import Bookmark, Detachable, Note, NoteProxy, Tag 15 | 16 | 17 | warnings.simplefilter("default", DeprecationWarning) 18 | 19 | 20 | factory = RequestFactory() 21 | # Just to ensure we have a request in the serializer context 22 | request = factory.get('/') 23 | 24 | 25 | def dummy_view(request, pk): 26 | pass 27 | 28 | urlpatterns = [ 29 | url(r'^bookmark/(?P[0-9]+)/$', dummy_view, name='bookmark-detail'), 30 | url(r'^detachable/(?P[0-9]+)/$', dummy_view, name='detachable-detail'), 31 | url(r'^note/(?P[0-9]+)/$', dummy_view, name='note-detail'), 32 | url(r'^tag/(?P[0-9]+)/$', dummy_view, name='tag-detail'), 33 | url( 34 | r'^contact/(?P[-\w]+)/$', 35 | dummy_view, 36 | name='contact-detail' 37 | ), 38 | ] 39 | 40 | 41 | class BookmarkSerializer(serializers.ModelSerializer): 42 | class Meta: 43 | model = Bookmark 44 | exclude = ('id', ) 45 | 46 | 47 | class NoteSerializer(serializers.ModelSerializer): 48 | class Meta: 49 | model = Note 50 | exclude = ('id', ) 51 | 52 | 53 | class NoteProxySerializer(serializers.ModelSerializer): 54 | text = serializers.SerializerMethodField() 55 | 56 | class Meta: 57 | model = NoteProxy 58 | exclude = ('id',) 59 | 60 | def get_text(self, instance): 61 | return 'proxied: %s' % instance.text 62 | 63 | 64 | @override_settings(ROOT_URLCONF='generic_relations.tests.test_relations') 65 | class TestGenericRelatedFieldSerialization(TestCase): 66 | def setUp(self): 67 | self.bookmark = Bookmark.objects.create( 68 | url='https://www.djangoproject.com/') 69 | Tag.objects.create(tagged_item=self.bookmark, tag='django') 70 | Tag.objects.create(tagged_item=self.bookmark, tag='python') 71 | self.note = Note.objects.create(text='Remember the milk') 72 | Tag.objects.create(tagged_item=self.note, tag='reminder') 73 | 74 | Detachable.objects.create(content_object=self.note, name='attached') 75 | Detachable.objects.create(name='detached') 76 | 77 | def test_relations_as_hyperlinks(self): 78 | 79 | class TagSerializer(serializers.ModelSerializer): 80 | tagged_item = GenericRelatedField( 81 | { 82 | Bookmark: serializers.HyperlinkedRelatedField( 83 | view_name='bookmark-detail', 84 | queryset=Bookmark.objects.all()), 85 | Note: serializers.HyperlinkedRelatedField( 86 | view_name='note-detail', 87 | queryset=Note.objects.all()), 88 | }, 89 | read_only=True, 90 | ) 91 | 92 | class Meta: 93 | model = Tag 94 | exclude = ('id', 'content_type', 'object_id', ) 95 | 96 | serializer = TagSerializer(Tag.objects.all(), many=True, context={'request': request}) 97 | expected = [ 98 | { 99 | 'tagged_item': 'http://testserver/bookmark/1/', 100 | 'tag': 'django', 101 | }, 102 | { 103 | 'tagged_item': 'http://testserver/bookmark/1/', 104 | 'tag': 'python', 105 | }, 106 | { 107 | 'tagged_item': 'http://testserver/note/1/', 108 | 'tag': 'reminder' 109 | } 110 | ] 111 | self.assertEqual(serializer.data, expected) 112 | 113 | def test_relations_as_nested(self): 114 | 115 | class TagSerializer(serializers.ModelSerializer): 116 | tagged_item = GenericRelatedField({ 117 | Bookmark: BookmarkSerializer(), 118 | Note: NoteSerializer(), 119 | }, read_only=True) 120 | 121 | class Meta: 122 | model = Tag 123 | exclude = ('id', 'content_type', 'object_id', ) 124 | 125 | serializer = TagSerializer(Tag.objects.all(), many=True) 126 | expected = [ 127 | { 128 | 'tagged_item': { 129 | 'url': 'https://www.djangoproject.com/' 130 | }, 131 | 'tag': 'django' 132 | }, 133 | { 134 | 'tagged_item': { 135 | 'url': 'https://www.djangoproject.com/' 136 | }, 137 | 'tag': 'python' 138 | }, 139 | { 140 | 'tagged_item': { 141 | 'text': 'Remember the milk', 142 | }, 143 | 'tag': 'reminder' 144 | } 145 | ] 146 | self.assertEqual(serializer.data, expected) 147 | 148 | def test_mixed_serializers(self): 149 | class TagSerializer(serializers.ModelSerializer): 150 | tagged_item = GenericRelatedField( 151 | { 152 | Bookmark: BookmarkSerializer(), 153 | Note: serializers.HyperlinkedRelatedField( 154 | view_name='note-detail', 155 | queryset=Note.objects.all()), 156 | }, 157 | read_only=True, 158 | ) 159 | 160 | class Meta: 161 | model = Tag 162 | exclude = ('id', 'content_type', 'object_id', ) 163 | 164 | serializer = TagSerializer(Tag.objects.all(), many=True, context={'request': request}) 165 | expected = [ 166 | { 167 | 'tagged_item': { 168 | 'url': 'https://www.djangoproject.com/' 169 | }, 170 | 'tag': 'django' 171 | }, 172 | { 173 | 'tagged_item': { 174 | 'url': 'https://www.djangoproject.com/' 175 | }, 176 | 'tag': 'python' 177 | }, 178 | { 179 | 'tagged_item': 'http://testserver/note/1/', 180 | 'tag': 'reminder' 181 | } 182 | ] 183 | self.assertEqual(serializer.data, expected) 184 | 185 | def test_invalid_model(self): 186 | # Leaving out the Note model should result in a ValidationError 187 | class TagSerializer(serializers.ModelSerializer): 188 | tagged_item = GenericRelatedField({ 189 | Bookmark: BookmarkSerializer(), 190 | }, read_only=True) 191 | 192 | class Meta: 193 | model = Tag 194 | exclude = ('id', 'content_type', 'object_id', ) 195 | serializer = TagSerializer(Tag.objects.all(), many=True) 196 | 197 | with self.assertRaises(serializers.ValidationError): 198 | serializer.data 199 | 200 | def test_relation_as_null(self): 201 | class DetachableSerializer(serializers.ModelSerializer): 202 | content_object = GenericRelatedField( 203 | { 204 | Bookmark: serializers.HyperlinkedRelatedField( 205 | view_name='bookmark-detail', 206 | queryset=Bookmark.objects.all()), 207 | Note: serializers.HyperlinkedRelatedField( 208 | view_name='note-detail', 209 | queryset=Note.objects.all()), 210 | }, 211 | read_only=True, 212 | ) 213 | 214 | class Meta: 215 | model = Detachable 216 | exclude = ('id', 'content_type', 'object_id', ) 217 | 218 | serializer = DetachableSerializer(Detachable.objects.all(), many=True, context={'request': request}) 219 | expected = [ 220 | { 221 | 'content_object': 'http://testserver/note/1/', 222 | 'name': 'attached', 223 | }, 224 | { 225 | 'content_object': None, 226 | 'name': 'detached', 227 | } 228 | ] 229 | self.assertEqual(serializer.data, expected) 230 | 231 | def test_deprecated_method_overridden(self): 232 | with warnings.catch_warnings(record=True) as w: 233 | class MyRelatedField(GenericRelatedField): 234 | def determine_deserializer_for_data(self, value): 235 | return super(MyRelatedField, self).determine_deserializer_for_data(value) 236 | 237 | self.assertEqual(len(w), 1) 238 | self.assertIs(w[0].category, DeprecationWarning) 239 | 240 | def test_deprecated_method_called(self): 241 | f = GenericRelatedField({ 242 | Bookmark: serializers.HyperlinkedRelatedField( 243 | view_name='bookmark-detail', 244 | queryset=Bookmark.objects.all()), 245 | Note: serializers.HyperlinkedRelatedField( 246 | view_name='note-detail', 247 | queryset=Note.objects.all()), 248 | }) 249 | with warnings.catch_warnings(record=True) as w: 250 | f.determine_deserializer_for_data(self.bookmark) 251 | 252 | self.assertEqual(len(w), 1) 253 | self.assertIs(w[0].category, DeprecationWarning) 254 | 255 | def test_subclass_uses_registered_parent(self): 256 | tagged_item = GenericRelatedField({ 257 | Note: NoteSerializer(), 258 | }, read_only=True) 259 | 260 | # NoteProxy instance should use the NoteSerializer, 261 | # since no more specific serializer is registered 262 | proxied = NoteProxy.objects.get(pk=self.note.pk) 263 | serializer = tagged_item.get_serializer_for_instance(proxied) 264 | self.assertIsInstance(serializer, NoteSerializer) 265 | 266 | def test_subclass_uses_registered_subclass(self): 267 | tagged_item = GenericRelatedField({ 268 | Note: NoteSerializer(), 269 | NoteProxy: NoteProxySerializer(), 270 | }, read_only=True) 271 | 272 | # NoteProxy instance should use the NoteProxySerializer in 273 | # preference to the NoteSerializer 274 | proxied = NoteProxy.objects.get(pk=self.note.pk) 275 | serializer = tagged_item.get_serializer_for_instance(proxied) 276 | self.assertIsInstance(serializer, NoteProxySerializer) 277 | 278 | # But Note instance should use the NoteSerializer 279 | serializer = tagged_item.get_serializer_for_instance(self.note) 280 | self.assertIsInstance(serializer, NoteSerializer) 281 | 282 | 283 | @override_settings(ROOT_URLCONF='generic_relations.tests.test_relations') 284 | class TestGenericRelatedFieldDeserialization(TestCase): 285 | def setUp(self): 286 | self.bookmark = Bookmark.objects.create( 287 | url='https://www.djangoproject.com/') 288 | Tag.objects.create(tagged_item=self.bookmark, tag='django') 289 | Tag.objects.create(tagged_item=self.bookmark, tag='python') 290 | self.note = Note.objects.create(text='Remember the milk') 291 | 292 | def test_hyperlink_serialization(self): 293 | class TagSerializer(serializers.ModelSerializer): 294 | tagged_item = GenericRelatedField( 295 | { 296 | Bookmark: serializers.HyperlinkedRelatedField( 297 | view_name='bookmark-detail', 298 | queryset=Bookmark.objects.all()), 299 | Note: serializers.HyperlinkedRelatedField( 300 | view_name='note-detail', 301 | queryset=Note.objects.all()), 302 | }, 303 | read_only=False, 304 | ) 305 | 306 | class Meta: 307 | model = Tag 308 | exclude = ('id', 'content_type', 'object_id', ) 309 | 310 | serializer = TagSerializer(data={ 311 | 'tag': 'reminder', 312 | 'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk}) 313 | }, context={'request': request}) 314 | serializer.is_valid(raise_exception=True) 315 | expected = { 316 | 'tagged_item': 'http://testserver/note/1/', 317 | 'tag': 'reminder' 318 | } 319 | self.assertEqual(serializer.data, expected) 320 | 321 | def test_configuration_error(self): 322 | class TagSerializer(serializers.ModelSerializer): 323 | tagged_item = GenericRelatedField( 324 | { 325 | Bookmark: BookmarkSerializer(), 326 | Note: serializers.HyperlinkedRelatedField( 327 | view_name='note-detail', 328 | queryset=Note.objects.all()), 329 | }, 330 | read_only=False, 331 | ) 332 | 333 | class Meta: 334 | model = Tag 335 | exclude = ('id', 'content_type', 'object_id', ) 336 | 337 | serializer = TagSerializer(data={ 338 | 'tag': 'reminder', 339 | 'tagged_item': 'just a string' 340 | }) 341 | 342 | with self.assertRaises(ImproperlyConfigured): 343 | tagged_item = serializer.fields['tagged_item'] 344 | tagged_item.get_deserializer_for_data('just a string') 345 | 346 | def test_not_registered_view_name(self): 347 | class TagSerializer(serializers.ModelSerializer): 348 | tagged_item = GenericRelatedField( 349 | { 350 | Bookmark: serializers.HyperlinkedRelatedField( 351 | view_name='bookmark-detail', 352 | queryset=Bookmark.objects.all()), 353 | }, 354 | read_only=False 355 | ) 356 | 357 | class Meta: 358 | model = Tag 359 | exclude = ('id', 'content_type', 'object_id', ) 360 | 361 | serializer = TagSerializer(data={ 362 | 'tag': 'reminder', 363 | 'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk}) 364 | }) 365 | self.assertFalse(serializer.is_valid()) 366 | 367 | def test_invalid_url(self): 368 | class TagSerializer(serializers.ModelSerializer): 369 | tagged_item = GenericRelatedField( 370 | { 371 | Bookmark: serializers.HyperlinkedRelatedField( 372 | view_name='bookmark-detail', 373 | queryset=Bookmark.objects.all()), 374 | }, 375 | read_only=False, 376 | ) 377 | 378 | class Meta: 379 | model = Tag 380 | exclude = ('id', 'content_type', 'object_id', ) 381 | 382 | serializer = TagSerializer(data={ 383 | 'tag': 'reminder', 384 | 'tagged_item': 'foo-bar' 385 | }) 386 | 387 | message = 'Could not determine a valid serializer for value %r.' 388 | expected = {'tagged_item': [message % 'foo-bar']} 389 | 390 | self.assertFalse(serializer.is_valid()) 391 | self.assertEqual(expected, serializer.errors) 392 | 393 | def test_serializer_save(self): 394 | class TagSerializer(serializers.ModelSerializer): 395 | tagged_item = GenericRelatedField( 396 | { 397 | Bookmark: serializers.HyperlinkedRelatedField( 398 | view_name='bookmark-detail', 399 | queryset=Bookmark.objects.all()), 400 | Note: serializers.HyperlinkedRelatedField( 401 | view_name='note-detail', 402 | queryset=Note.objects.all()), 403 | }, 404 | read_only=False, 405 | ) 406 | 407 | class Meta: 408 | model = Tag 409 | exclude = ('id', 'content_type', 'object_id', ) 410 | 411 | serializer = TagSerializer(data={ 412 | 'tag': 'reminder', 413 | 'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk}) 414 | }) 415 | serializer.is_valid(raise_exception=True) 416 | serializer.save() 417 | tag = Tag.objects.get(pk=3) 418 | self.assertEqual(tag.tagged_item, self.note) 419 | 420 | def test_nullable_relation_serializer_save(self): 421 | class DetachableSerializer(serializers.ModelSerializer): 422 | content_object = GenericRelatedField( 423 | { 424 | Bookmark: serializers.HyperlinkedRelatedField( 425 | view_name='bookmark-detail', 426 | queryset=Bookmark.objects.all()), 427 | Note: serializers.HyperlinkedRelatedField( 428 | view_name='note-detail', 429 | queryset=Note.objects.all()), 430 | }, 431 | read_only=False, 432 | required=False 433 | ) 434 | 435 | class Meta: 436 | model = Detachable 437 | exclude = ('id', 'content_type', 'object_id', ) 438 | 439 | serializer = DetachableSerializer(data={'name': 'foo'}) 440 | serializer.is_valid(raise_exception=True) 441 | serializer.save() 442 | freeagent = Detachable.objects.get(pk=1) 443 | self.assertEqual(freeagent.name, 'foo') 444 | self.assertEqual(freeagent.content_object, None) 445 | 446 | def test_deprecated_method_overridden(self): 447 | with warnings.catch_warnings(record=True) as w: 448 | class MyRelatedField(GenericRelatedField): 449 | def determine_serializer_for_data(self, value): 450 | return super(MyRelatedField, self).determine_serializer_for_data(value) 451 | 452 | self.assertEqual(len(w), 1) 453 | self.assertIs(w[0].category, DeprecationWarning) 454 | 455 | def test_deprecated_method_called(self): 456 | f = GenericRelatedField({ 457 | Bookmark: serializers.HyperlinkedRelatedField( 458 | view_name='bookmark-detail', 459 | queryset=Bookmark.objects.all()), 460 | }) 461 | with warnings.catch_warnings(record=True) as w: 462 | f.determine_serializer_for_data('http://testserver/bookmark/1/') 463 | 464 | self.assertEqual(len(w), 1) 465 | self.assertIs(w[0].category, DeprecationWarning) 466 | 467 | 468 | class TestGenericRelatedField(TestCase): 469 | def test_multiple_declaration(self): 470 | with self.assertRaises(RuntimeError): 471 | class TagSerializer(serializers.ModelSerializer): 472 | fields = { 473 | Bookmark: BookmarkSerializer(), 474 | Note: NoteSerializer(), 475 | } 476 | first = GenericRelatedField(fields) 477 | second = GenericRelatedField(fields) 478 | 479 | class Meta: 480 | model = Tag 481 | -------------------------------------------------------------------------------- /generic_relations/tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from django.test import TestCase 4 | 5 | from rest_framework import serializers 6 | 7 | from generic_relations.serializers import GenericModelSerializer 8 | from generic_relations.tests.models import Bookmark, Note 9 | 10 | from .test_relations import BookmarkSerializer, NoteSerializer 11 | 12 | 13 | class TestGenericModelSerializer(TestCase): 14 | def setUp(self): 15 | self.bookmark = Bookmark.objects.create( 16 | url='https://www.djangoproject.com/') 17 | self.note = Note.objects.create(text='Remember the milk') 18 | self.note2 = Note.objects.create(text='Reticulate the splines') 19 | 20 | self.serializer = GenericModelSerializer( 21 | { 22 | Bookmark: BookmarkSerializer(), 23 | Note: NoteSerializer(), 24 | } 25 | ) 26 | self.list_serializer = serializers.ListSerializer(child=self.serializer) 27 | 28 | def test_serialize(self): 29 | self.assertEqual( 30 | self.serializer.to_representation(self.bookmark), 31 | {'url': 'https://www.djangoproject.com/'}, 32 | ) 33 | self.assertEqual( 34 | self.serializer.to_representation(self.note), 35 | {'text': 'Remember the milk'}, 36 | ) 37 | 38 | def test_deserialize(self): 39 | self.assertEqual( 40 | self.serializer.to_internal_value({'url': 'https://www.djangoproject.com/'}), 41 | {'url': 'https://www.djangoproject.com/'}, 42 | ) 43 | self.assertEqual( 44 | self.serializer.to_internal_value({'text': 'Remember the milk'}), 45 | {'text': 'Remember the milk'}, 46 | ) 47 | 48 | def test_serialize_list(self): 49 | actual = self.list_serializer.to_representation([ 50 | self.bookmark, self.note, self.note2, self.bookmark, 51 | ]) 52 | expected = [ 53 | {'url': 'https://www.djangoproject.com/'}, 54 | {'text': 'Remember the milk'}, 55 | {'text': 'Reticulate the splines'}, 56 | {'url': 'https://www.djangoproject.com/'}, 57 | ] 58 | self.assertEqual(actual, expected) 59 | 60 | def test_deserialize_list(self): 61 | validated_data = self.list_serializer.to_internal_value([ 62 | {'url': 'https://www.djangoproject.com/'}, 63 | {'text': 'Remember the milk'}, 64 | {'text': 'Reticulate the splines'}, 65 | {'url': 'https://www.djangoproject.com/'}, 66 | ]) 67 | 68 | self.assertEqual(validated_data, [ 69 | {'url': 'https://www.djangoproject.com/'}, 70 | {'text': 'Remember the milk'}, 71 | {'text': 'Reticulate the splines'}, 72 | {'url': 'https://www.djangoproject.com/'}, 73 | ]) 74 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testsettings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup, find_packages 5 | from os.path import abspath, dirname, join 6 | 7 | 8 | def read_relative_file(filename): 9 | """ 10 | Returns contents of the given file, whose path is supposed relative 11 | to this module. 12 | """ 13 | with open(join(dirname(abspath(__file__)), filename), "r") as f: 14 | return f.read() 15 | 16 | 17 | setup( 18 | name="rest-framework-generic-relations", 19 | version="2.1.0", 20 | url="https://github.com/Ian-Foote/rest-framework-generic-relations", 21 | license="BSD", 22 | description="Generic Relations for Django Rest Framework", 23 | long_description=read_relative_file("README.md"), 24 | long_description_content_type="text/markdown", 25 | author="Ian Foote", 26 | author_email="python@ian.feete.org", 27 | packages=find_packages(), 28 | include_package_data=True, 29 | install_requires=["djangorestframework>=3.11.0"], 30 | python_requires=">=3.6", 31 | classifiers=[ 32 | "Environment :: Web Environment", 33 | "Framework :: Django", 34 | "Intended Audience :: Developers", 35 | "Operating System :: OS Independent", 36 | "Programming Language :: Python", 37 | "Programming Language :: Python :: 3.6", 38 | "Programming Language :: Python :: 3.7", 39 | "Programming Language :: Python :: 3.8", 40 | "Programming Language :: Python :: 3.9", 41 | "Programming Language :: Python :: Implementation :: CPython", 42 | "Programming Language :: Python :: Implementation :: PyPy", 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /testsettings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | 'default': { 3 | 'ENGINE': 'django.db.backends.sqlite3', 4 | 'NAME': ':memory:', 5 | }, 6 | } 7 | 8 | INSTALLED_APPS = ( 9 | 'django.contrib.contenttypes', 10 | 11 | 'generic_relations', 12 | 'generic_relations.tests', 13 | ) 14 | 15 | ROOT_URLCONF = '' 16 | 17 | SECRET_KEY = 'abcde12345' 18 | 19 | MIDDLEWARE_CLASSES = ( 20 | 'django.middleware.common.CommonMiddleware', 21 | 'django.middleware.csrf.CsrfViewMiddleware' 22 | ) 23 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | # note: min/max python versions specified here; 4 | # testing in-between versions here seems a waste of resources. 5 | {py36,py39}-{dj22,dj31,dj32}-{drf311,drf312} 6 | [testenv] 7 | changedir = {toxinidir} 8 | commands = pytest --ds=testsettings {posargs} 9 | deps = 10 | pytest-django 11 | dj22: Django~=2.2.17 12 | dj31: Django~=3.1.0 13 | dj32: Django~=3.2.0 14 | drf311: djangorestframework~=3.11.0 15 | drf312: djangorestframework~=3.12.0 16 | --------------------------------------------------------------------------------