├── .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, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python-version: 9 | - "3.7" 10 | - "3.11" 11 | django-version: 12 | - "2.2" 13 | - "3.1" 14 | - "3.2" 15 | - "4.1" 16 | drf-version: 17 | - "3.11" 18 | - "3.12" 19 | - "3.13" 20 | - "3.14" 21 | exclude: 22 | - django-version: "2.2" 23 | drf-version: "3.13" 24 | - django-version: "3.1" 25 | drf-version: "3.13" 26 | - django-version: "2.2" 27 | drf-version: "3.14" 28 | - django-version: "3.1" 29 | drf-version: "3.14" 30 | - django-version: "4.1" 31 | drf-version: "3.11" 32 | - django-version: "4.1" 33 | drf-version: "3.12" 34 | - django-version: "4.1" 35 | python-version: "3.7" 36 | 37 | name: "py${{ matrix.python-version}} dj${{ matrix.django-version }} drf${{ matrix.drf-version }}" 38 | 39 | steps: 40 | - uses: actions/checkout@v2 41 | - name: Set up Python ${{ matrix.python-version }} 42 | uses: actions/setup-python@v2 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | - name: Install dependencies (django ${{ matrix.django-version }}) 46 | run: | 47 | python -m pip install --upgrade pip 48 | pip install flake8 pytest-django django==${{ matrix.django-version }}.* djangorestframework==${{ matrix.drf-version }}.* 49 | pip install -e . 50 | - name: Lint with flake8 51 | run: | 52 | # stop the build if there are Python syntax errors or undefined names 53 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 54 | - name: Test with pytest 55 | run: | 56 | pytest --ds=testsettings generic_relations/tests/ 57 | -------------------------------------------------------------------------------- /.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 | from rest_framework.settings import api_settings 7 | 8 | 9 | __all__ = ('GenericSerializerMixin', 'GenericModelSerializer',) 10 | 11 | 12 | class GenericSerializerMixin(object): 13 | default_error_messages = { 14 | 'no_model_match': _('Invalid model - model not available.'), 15 | 'no_url_match': _('Invalid hyperlink - No URL match'), 16 | 'incorrect_url_match': _( 17 | 'Invalid hyperlink - view name not available'), 18 | } 19 | 20 | form_field_class = forms.URLField 21 | 22 | def __init__(self, serializers, *args, **kwargs): 23 | """ 24 | Needs an extra parameter `serializers` which has to be a dict 25 | key: value being `Model`: serializer. 26 | """ 27 | super(GenericSerializerMixin, self).__init__(*args, **kwargs) 28 | self.serializers = serializers 29 | for serializer in self.serializers.values(): 30 | if serializer.source is not None: 31 | msg = '{}() cannot be re-used. Create a new instance.' 32 | raise RuntimeError(msg.format(type(serializer).__name__)) 33 | serializer.bind('', self) 34 | 35 | def to_internal_value(self, data): 36 | try: 37 | serializer = self.get_deserializer_for_data(data) 38 | except ImproperlyConfigured as e: 39 | raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: e}) 40 | return serializer.to_internal_value(data) 41 | 42 | def to_representation(self, instance): 43 | serializer = self.get_serializer_for_instance(instance) 44 | return serializer.to_representation(instance) 45 | 46 | def get_serializer_for_instance(self, instance): 47 | # Use registered superclasses, rather than only the exact model. 48 | # (But prefer things earlier in the MRO, so if the exact model is registered, 49 | # use that in preference to any superclasses) 50 | for klass in instance.__class__.mro(): 51 | if klass in self.serializers: 52 | return self.serializers[klass] 53 | 54 | raise serializers.ValidationError(self.error_messages['no_model_match']) 55 | 56 | def get_deserializer_for_data(self, value): 57 | # While one could easily execute the "try" block within 58 | # to_internal_value and reduce operations, I consider the concept of 59 | # serializing is already very naive and vague, that's why I'd 60 | # go for stringency with the deserialization process here. 61 | serializers = [] 62 | for serializer in self.serializers.values(): 63 | try: 64 | serializer.to_internal_value(value) 65 | # Collects all serializers that can handle the input data. 66 | serializers.append(serializer) 67 | except Exception: 68 | pass 69 | # If no serializer found, raise error. 70 | l = len(serializers) 71 | if l < 1: 72 | raise ImproperlyConfigured( 73 | 'Could not determine a valid serializer for value %r.' % value) 74 | elif l > 1: 75 | raise ImproperlyConfigured( 76 | 'There were multiple serializers found for value %r.' % value) 77 | return serializers[0] 78 | 79 | 80 | class GenericModelSerializer(GenericSerializerMixin, serializers.Serializer): 81 | """ 82 | Delegates serialization and deserialization to registered serializers 83 | based on the type of the model. 84 | """ 85 | -------------------------------------------------------------------------------- /generic_relations/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LilyAcorn/rest-framework-generic-relations/bdc028ca9457c95f46a57d6a63c6b0a274edd7dc/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/LilyAcorn/rest-framework-generic-relations/bdc028ca9457c95f46a57d6a63c6b0a274edd7dc/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 | try: 6 | from django.urls import re_path as url 7 | except ImportError: 8 | from django.conf.urls import url 9 | 10 | from django.core.exceptions import ImproperlyConfigured 11 | from django.test import TestCase, RequestFactory 12 | from django.test.utils import override_settings 13 | 14 | from rest_framework import serializers 15 | from rest_framework.reverse import reverse 16 | from rest_framework.settings import api_settings 17 | 18 | from generic_relations.relations import GenericRelatedField 19 | from generic_relations.tests.models import Bookmark, Detachable, Note, NoteProxy, Tag 20 | 21 | 22 | warnings.simplefilter("default", DeprecationWarning) 23 | 24 | 25 | factory = RequestFactory() 26 | # Just to ensure we have a request in the serializer context 27 | request = factory.get('/') 28 | 29 | 30 | def dummy_view(request, pk): 31 | pass 32 | 33 | urlpatterns = [ 34 | url(r'^bookmark/(?P[0-9]+)/$', dummy_view, name='bookmark-detail'), 35 | url(r'^detachable/(?P[0-9]+)/$', dummy_view, name='detachable-detail'), 36 | url(r'^note/(?P[0-9]+)/$', dummy_view, name='note-detail'), 37 | url(r'^tag/(?P[0-9]+)/$', dummy_view, name='tag-detail'), 38 | url( 39 | r'^contact/(?P[-\w]+)/$', 40 | dummy_view, 41 | name='contact-detail' 42 | ), 43 | ] 44 | 45 | 46 | class BookmarkSerializer(serializers.ModelSerializer): 47 | class Meta: 48 | model = Bookmark 49 | exclude = ('id', ) 50 | 51 | 52 | class NoteSerializer(serializers.ModelSerializer): 53 | class Meta: 54 | model = Note 55 | exclude = ('id', ) 56 | 57 | 58 | class NoteProxySerializer(serializers.ModelSerializer): 59 | text = serializers.SerializerMethodField() 60 | 61 | class Meta: 62 | model = NoteProxy 63 | exclude = ('id',) 64 | 65 | def get_text(self, instance): 66 | return 'proxied: %s' % instance.text 67 | 68 | 69 | @override_settings(ROOT_URLCONF='generic_relations.tests.test_relations') 70 | class TestGenericRelatedFieldSerialization(TestCase): 71 | def setUp(self): 72 | self.bookmark = Bookmark.objects.create( 73 | url='https://www.djangoproject.com/') 74 | Tag.objects.create(tagged_item=self.bookmark, tag='django') 75 | Tag.objects.create(tagged_item=self.bookmark, tag='python') 76 | self.note = Note.objects.create(text='Remember the milk') 77 | Tag.objects.create(tagged_item=self.note, tag='reminder') 78 | 79 | Detachable.objects.create(content_object=self.note, name='attached') 80 | Detachable.objects.create(name='detached') 81 | 82 | def test_relations_as_hyperlinks(self): 83 | 84 | class TagSerializer(serializers.ModelSerializer): 85 | tagged_item = GenericRelatedField( 86 | { 87 | Bookmark: serializers.HyperlinkedRelatedField( 88 | view_name='bookmark-detail', 89 | queryset=Bookmark.objects.all()), 90 | Note: serializers.HyperlinkedRelatedField( 91 | view_name='note-detail', 92 | queryset=Note.objects.all()), 93 | }, 94 | read_only=True, 95 | ) 96 | 97 | class Meta: 98 | model = Tag 99 | exclude = ('id', 'content_type', 'object_id', ) 100 | 101 | serializer = TagSerializer(Tag.objects.all(), many=True, context={'request': request}) 102 | expected = [ 103 | { 104 | 'tagged_item': 'http://testserver/bookmark/1/', 105 | 'tag': 'django', 106 | }, 107 | { 108 | 'tagged_item': 'http://testserver/bookmark/1/', 109 | 'tag': 'python', 110 | }, 111 | { 112 | 'tagged_item': 'http://testserver/note/1/', 113 | 'tag': 'reminder' 114 | } 115 | ] 116 | self.assertEqual(serializer.data, expected) 117 | 118 | def test_relations_as_nested(self): 119 | 120 | class TagSerializer(serializers.ModelSerializer): 121 | tagged_item = GenericRelatedField({ 122 | Bookmark: BookmarkSerializer(), 123 | Note: NoteSerializer(), 124 | }, read_only=True) 125 | 126 | class Meta: 127 | model = Tag 128 | exclude = ('id', 'content_type', 'object_id', ) 129 | 130 | serializer = TagSerializer(Tag.objects.all(), many=True) 131 | expected = [ 132 | { 133 | 'tagged_item': { 134 | 'url': 'https://www.djangoproject.com/' 135 | }, 136 | 'tag': 'django' 137 | }, 138 | { 139 | 'tagged_item': { 140 | 'url': 'https://www.djangoproject.com/' 141 | }, 142 | 'tag': 'python' 143 | }, 144 | { 145 | 'tagged_item': { 146 | 'text': 'Remember the milk', 147 | }, 148 | 'tag': 'reminder' 149 | } 150 | ] 151 | self.assertEqual(serializer.data, expected) 152 | 153 | def test_mixed_serializers(self): 154 | class TagSerializer(serializers.ModelSerializer): 155 | tagged_item = GenericRelatedField( 156 | { 157 | Bookmark: BookmarkSerializer(), 158 | Note: serializers.HyperlinkedRelatedField( 159 | view_name='note-detail', 160 | queryset=Note.objects.all()), 161 | }, 162 | read_only=True, 163 | ) 164 | 165 | class Meta: 166 | model = Tag 167 | exclude = ('id', 'content_type', 'object_id', ) 168 | 169 | serializer = TagSerializer(Tag.objects.all(), many=True, context={'request': request}) 170 | expected = [ 171 | { 172 | 'tagged_item': { 173 | 'url': 'https://www.djangoproject.com/' 174 | }, 175 | 'tag': 'django' 176 | }, 177 | { 178 | 'tagged_item': { 179 | 'url': 'https://www.djangoproject.com/' 180 | }, 181 | 'tag': 'python' 182 | }, 183 | { 184 | 'tagged_item': 'http://testserver/note/1/', 185 | 'tag': 'reminder' 186 | } 187 | ] 188 | self.assertEqual(serializer.data, expected) 189 | 190 | def test_invalid_model(self): 191 | # Leaving out the Note model should result in a ValidationError 192 | class TagSerializer(serializers.ModelSerializer): 193 | tagged_item = GenericRelatedField({ 194 | Bookmark: BookmarkSerializer(), 195 | }, read_only=True) 196 | 197 | class Meta: 198 | model = Tag 199 | exclude = ('id', 'content_type', 'object_id', ) 200 | serializer = TagSerializer(Tag.objects.all(), many=True) 201 | 202 | with self.assertRaises(serializers.ValidationError): 203 | serializer.data 204 | 205 | def test_relation_as_null(self): 206 | class DetachableSerializer(serializers.ModelSerializer): 207 | content_object = GenericRelatedField( 208 | { 209 | Bookmark: serializers.HyperlinkedRelatedField( 210 | view_name='bookmark-detail', 211 | queryset=Bookmark.objects.all()), 212 | Note: serializers.HyperlinkedRelatedField( 213 | view_name='note-detail', 214 | queryset=Note.objects.all()), 215 | }, 216 | read_only=True, 217 | ) 218 | 219 | class Meta: 220 | model = Detachable 221 | exclude = ('id', 'content_type', 'object_id', ) 222 | 223 | serializer = DetachableSerializer(Detachable.objects.all(), many=True, context={'request': request}) 224 | expected = [ 225 | { 226 | 'content_object': 'http://testserver/note/1/', 227 | 'name': 'attached', 228 | }, 229 | { 230 | 'content_object': None, 231 | 'name': 'detached', 232 | } 233 | ] 234 | self.assertEqual(serializer.data, expected) 235 | 236 | def test_deprecated_method_overridden(self): 237 | with warnings.catch_warnings(record=True) as w: 238 | class MyRelatedField(GenericRelatedField): 239 | def determine_deserializer_for_data(self, value): 240 | return super(MyRelatedField, self).determine_deserializer_for_data(value) 241 | 242 | self.assertEqual(len(w), 1) 243 | self.assertIs(w[0].category, DeprecationWarning) 244 | 245 | def test_deprecated_method_called(self): 246 | f = GenericRelatedField({ 247 | Bookmark: serializers.HyperlinkedRelatedField( 248 | view_name='bookmark-detail', 249 | queryset=Bookmark.objects.all()), 250 | Note: serializers.HyperlinkedRelatedField( 251 | view_name='note-detail', 252 | queryset=Note.objects.all()), 253 | }) 254 | with warnings.catch_warnings(record=True) as w: 255 | f.determine_deserializer_for_data(self.bookmark) 256 | 257 | self.assertEqual(len(w), 1) 258 | self.assertIs(w[0].category, DeprecationWarning) 259 | 260 | def test_subclass_uses_registered_parent(self): 261 | tagged_item = GenericRelatedField({ 262 | Note: NoteSerializer(), 263 | }, read_only=True) 264 | 265 | # NoteProxy instance should use the NoteSerializer, 266 | # since no more specific serializer is registered 267 | proxied = NoteProxy.objects.get(pk=self.note.pk) 268 | serializer = tagged_item.get_serializer_for_instance(proxied) 269 | self.assertIsInstance(serializer, NoteSerializer) 270 | 271 | def test_subclass_uses_registered_subclass(self): 272 | tagged_item = GenericRelatedField({ 273 | Note: NoteSerializer(), 274 | NoteProxy: NoteProxySerializer(), 275 | }, read_only=True) 276 | 277 | # NoteProxy instance should use the NoteProxySerializer in 278 | # preference to the NoteSerializer 279 | proxied = NoteProxy.objects.get(pk=self.note.pk) 280 | serializer = tagged_item.get_serializer_for_instance(proxied) 281 | self.assertIsInstance(serializer, NoteProxySerializer) 282 | 283 | # But Note instance should use the NoteSerializer 284 | serializer = tagged_item.get_serializer_for_instance(self.note) 285 | self.assertIsInstance(serializer, NoteSerializer) 286 | 287 | 288 | @override_settings(ROOT_URLCONF='generic_relations.tests.test_relations') 289 | class TestGenericRelatedFieldDeserialization(TestCase): 290 | def setUp(self): 291 | self.bookmark = Bookmark.objects.create( 292 | url='https://www.djangoproject.com/') 293 | Tag.objects.create(tagged_item=self.bookmark, tag='django') 294 | Tag.objects.create(tagged_item=self.bookmark, tag='python') 295 | self.note = Note.objects.create(text='Remember the milk') 296 | 297 | def test_hyperlink_serialization(self): 298 | class TagSerializer(serializers.ModelSerializer): 299 | tagged_item = GenericRelatedField( 300 | { 301 | Bookmark: serializers.HyperlinkedRelatedField( 302 | view_name='bookmark-detail', 303 | queryset=Bookmark.objects.all()), 304 | Note: serializers.HyperlinkedRelatedField( 305 | view_name='note-detail', 306 | queryset=Note.objects.all()), 307 | }, 308 | read_only=False, 309 | ) 310 | 311 | class Meta: 312 | model = Tag 313 | exclude = ('id', 'content_type', 'object_id', ) 314 | 315 | serializer = TagSerializer(data={ 316 | 'tag': 'reminder', 317 | 'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk}) 318 | }, context={'request': request}) 319 | serializer.is_valid(raise_exception=True) 320 | expected = { 321 | 'tagged_item': 'http://testserver/note/1/', 322 | 'tag': 'reminder' 323 | } 324 | self.assertEqual(serializer.data, expected) 325 | 326 | def test_configuration_error(self): 327 | class TagSerializer(serializers.ModelSerializer): 328 | tagged_item = GenericRelatedField( 329 | { 330 | Bookmark: BookmarkSerializer(), 331 | Note: serializers.HyperlinkedRelatedField( 332 | view_name='note-detail', 333 | queryset=Note.objects.all()), 334 | }, 335 | read_only=False, 336 | ) 337 | 338 | class Meta: 339 | model = Tag 340 | exclude = ('id', 'content_type', 'object_id', ) 341 | 342 | serializer = TagSerializer(data={ 343 | 'tag': 'reminder', 344 | 'tagged_item': 'just a string' 345 | }) 346 | 347 | with self.assertRaises(ImproperlyConfigured): 348 | tagged_item = serializer.fields['tagged_item'] 349 | tagged_item.get_deserializer_for_data('just a string') 350 | 351 | def test_not_registered_view_name(self): 352 | class TagSerializer(serializers.ModelSerializer): 353 | tagged_item = GenericRelatedField( 354 | { 355 | Bookmark: serializers.HyperlinkedRelatedField( 356 | view_name='bookmark-detail', 357 | queryset=Bookmark.objects.all()), 358 | }, 359 | read_only=False 360 | ) 361 | 362 | class Meta: 363 | model = Tag 364 | exclude = ('id', 'content_type', 'object_id', ) 365 | 366 | serializer = TagSerializer(data={ 367 | 'tag': 'reminder', 368 | 'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk}) 369 | }) 370 | self.assertFalse(serializer.is_valid()) 371 | 372 | def test_invalid_url(self): 373 | class TagSerializer(serializers.ModelSerializer): 374 | tagged_item = GenericRelatedField( 375 | { 376 | Bookmark: serializers.HyperlinkedRelatedField( 377 | view_name='bookmark-detail', 378 | queryset=Bookmark.objects.all()), 379 | }, 380 | read_only=False, 381 | ) 382 | 383 | class Meta: 384 | model = Tag 385 | exclude = ('id', 'content_type', 'object_id', ) 386 | 387 | serializer = TagSerializer(data={ 388 | 'tag': 'reminder', 389 | 'tagged_item': 'foo-bar' 390 | }) 391 | 392 | message = 'Could not determine a valid serializer for value %r.' 393 | expected = {'tagged_item': {api_settings.NON_FIELD_ERRORS_KEY: message % 'foo-bar'}} 394 | 395 | self.assertFalse(serializer.is_valid()) 396 | self.assertEqual(expected, serializer.errors) 397 | 398 | def test_serializer_save(self): 399 | class TagSerializer(serializers.ModelSerializer): 400 | tagged_item = GenericRelatedField( 401 | { 402 | Bookmark: serializers.HyperlinkedRelatedField( 403 | view_name='bookmark-detail', 404 | queryset=Bookmark.objects.all()), 405 | Note: serializers.HyperlinkedRelatedField( 406 | view_name='note-detail', 407 | queryset=Note.objects.all()), 408 | }, 409 | read_only=False, 410 | ) 411 | 412 | class Meta: 413 | model = Tag 414 | exclude = ('id', 'content_type', 'object_id', ) 415 | 416 | serializer = TagSerializer(data={ 417 | 'tag': 'reminder', 418 | 'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk}) 419 | }) 420 | serializer.is_valid(raise_exception=True) 421 | serializer.save() 422 | tag = Tag.objects.get(pk=3) 423 | self.assertEqual(tag.tagged_item, self.note) 424 | 425 | def test_nullable_relation_serializer_save(self): 426 | class DetachableSerializer(serializers.ModelSerializer): 427 | content_object = GenericRelatedField( 428 | { 429 | Bookmark: serializers.HyperlinkedRelatedField( 430 | view_name='bookmark-detail', 431 | queryset=Bookmark.objects.all()), 432 | Note: serializers.HyperlinkedRelatedField( 433 | view_name='note-detail', 434 | queryset=Note.objects.all()), 435 | }, 436 | read_only=False, 437 | required=False 438 | ) 439 | 440 | class Meta: 441 | model = Detachable 442 | exclude = ('id', 'content_type', 'object_id', ) 443 | 444 | serializer = DetachableSerializer(data={'name': 'foo'}) 445 | serializer.is_valid(raise_exception=True) 446 | serializer.save() 447 | freeagent = Detachable.objects.get(pk=1) 448 | self.assertEqual(freeagent.name, 'foo') 449 | self.assertEqual(freeagent.content_object, None) 450 | 451 | def test_deprecated_method_overridden(self): 452 | with warnings.catch_warnings(record=True) as w: 453 | class MyRelatedField(GenericRelatedField): 454 | def determine_serializer_for_data(self, value): 455 | return super(MyRelatedField, self).determine_serializer_for_data(value) 456 | 457 | self.assertEqual(len(w), 1) 458 | self.assertIs(w[0].category, DeprecationWarning) 459 | 460 | def test_deprecated_method_called(self): 461 | f = GenericRelatedField({ 462 | Bookmark: serializers.HyperlinkedRelatedField( 463 | view_name='bookmark-detail', 464 | queryset=Bookmark.objects.all()), 465 | }) 466 | with warnings.catch_warnings(record=True) as w: 467 | f.determine_serializer_for_data('http://testserver/bookmark/1/') 468 | 469 | self.assertEqual(len(w), 1) 470 | self.assertIs(w[0].category, DeprecationWarning) 471 | 472 | 473 | class TestGenericRelatedField(TestCase): 474 | def test_multiple_declaration(self): 475 | with self.assertRaises(RuntimeError): 476 | class TagSerializer(serializers.ModelSerializer): 477 | fields = { 478 | Bookmark: BookmarkSerializer(), 479 | Note: NoteSerializer(), 480 | } 481 | first = GenericRelatedField(fields) 482 | second = GenericRelatedField(fields) 483 | 484 | class Meta: 485 | model = Tag 486 | -------------------------------------------------------------------------------- /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 | 75 | def test_is_valid_raise_exception(self): 76 | serializer = GenericModelSerializer( 77 | serializers={Bookmark: BookmarkSerializer()}, 78 | data={'url': 'not-a-url'}, 79 | ) 80 | 81 | with self.assertRaises(serializers.ValidationError): 82 | serializer.is_valid(raise_exception=True) 83 | -------------------------------------------------------------------------------- /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 :: 3.10", 42 | "Programming Language :: Python :: 3.11", 43 | "Programming Language :: Python :: Implementation :: CPython", 44 | "Programming Language :: Python :: Implementation :: PyPy", 45 | "Framework :: Django :: 2.2", 46 | "Framework :: Django :: 3.0", 47 | "Framework :: Django :: 3.1", 48 | "Framework :: Django :: 3.2", 49 | "Framework :: Django :: 4.0", 50 | "Framework :: Django :: 4.1", 51 | ], 52 | ) 53 | -------------------------------------------------------------------------------- /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 | {py37,py39}-{dj22,dj31,dj32}-{drf311,drf312} 6 | py311-{dj32,dj41}-{drf313,drf314} 7 | [testenv] 8 | changedir = {toxinidir} 9 | commands = pytest --ds=testsettings {posargs} 10 | deps = 11 | pytest-django 12 | dj22: Django~=2.2.17 13 | dj31: Django~=3.1.0 14 | dj32: Django~=3.2.0 15 | dj41: Django~=4.1.0 16 | drf311: djangorestframework~=3.11.0 17 | drf312: djangorestframework~=3.12.0 18 | drf313: djangorestframework~=3.13.0 19 | drf314: djangorestframework~=3.14.0 20 | --------------------------------------------------------------------------------