Repository: Ian-Foote/rest-framework-generic-relations Branch: master Commit: bdc028ca9457 Files: 19 Total size: 40.4 KB Directory structure: gitextract_9csql789/ ├── .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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/tests.yml ================================================ name: tests on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: - "3.7" - "3.11" django-version: - "2.2" - "3.1" - "3.2" - "4.1" drf-version: - "3.11" - "3.12" - "3.13" - "3.14" exclude: - django-version: "2.2" drf-version: "3.13" - django-version: "3.1" drf-version: "3.13" - django-version: "2.2" drf-version: "3.14" - django-version: "3.1" drf-version: "3.14" - django-version: "4.1" drf-version: "3.11" - django-version: "4.1" drf-version: "3.12" - django-version: "4.1" python-version: "3.7" name: "py${{ matrix.python-version}} dj${{ matrix.django-version }} drf${{ matrix.drf-version }}" steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies (django ${{ matrix.django-version }}) run: | python -m pip install --upgrade pip pip install flake8 pytest-django django==${{ matrix.django-version }}.* djangorestframework==${{ matrix.drf-version }}.* pip install -e . - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - name: Test with pytest run: | pytest --ds=testsettings generic_relations/tests/ ================================================ FILE: .gitignore ================================================ *.pyc __pycache__ MANIFEST dist/ build/ *.egg-info/ .eggs .tox ================================================ FILE: Changelog.md ================================================ # Rest Framework Generic Relations Changelog ## v2.1.0 General dependency update * Minimum Python version is now 3.6 * Minimum DRF version is now 3.11 * Supported Django versions are now [2.2, 3.1, 3.2] ## v2.0.0 * Add Python 3.8, Django 3.0 and DRF 3.11 support * Drop Python 2 * Drop DRF 3.7 Minimum supported dependencies are now: * Python 3.4 * Django 1.11 * DRF 3.8 ## v1.2.1 * Add error handling for reusing a `Serializer` instance in a `GenericRelatedField`. ## v1.2.0 * Add Django 2.0 support (need Python min 3.4, Django min. 1.11). ## v1.1.0 * Add `GenericModelSerializer` as a counterpart to `GenericRelatedField`. * Dynamically determine the best serializer to use to serialize a model instance. * Rename `determine_serializer_for_data` to `get_deserializer_for_data` * Rename `determine_deserializer_for_data` to `get_serializer_for_instance` ## v1.0.0 * Add support for Django 1.8 and 1.9 * Add support for Django Rest Framework 3 * Drop support for earlier versions of Django and DRF ## v0.1.0 * Initial release - Forked from https://github.com/encode/django-rest-framework/pull/755 ================================================ FILE: Makefile ================================================ SHELL := /bin/bash help: @echo "Usage:" @echo " make help -- displays this help" @echo " make test -- runs tests" @echo " make release -- pushes to pypi" test: tox release: rm -rf dist python setup.py sdist bdist_wheel twine upload dist/* ================================================ FILE: README.md ================================================ # 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) This library implements [Django REST Framework](http://www.django-rest-framework.org/) serializers to handle generic foreign keys. # Requirements Any currently-supported combination of Django REST Framework, Python, and Django. # Installation Install using `pip`... ```sh pip install rest-framework-generic-relations ``` Add `'generic_relations'` to your `INSTALLED_APPS` setting. ```python INSTALLED_APPS = ( ... 'generic_relations', ) ``` # API Reference ## GenericRelatedField This field serializes generic foreign keys. For a primer on generic foreign keys, first see: https://docs.djangoproject.com/en/dev/ref/contrib/contenttypes/ Let's assume a `TaggedItem` model which has a generic relationship with other arbitrary models: ```python class TaggedItem(models.Model): tag_name = models.SlugField() content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() tagged_object = GenericForeignKey('content_type', 'object_id') ``` And the following two models, which may have associated tags: ```python class Bookmark(models.Model): """ A bookmark consists of a URL, and 0 or more descriptive tags. """ url = models.URLField() tags = GenericRelation(TaggedItem) class Note(models.Model): """ A note consists of some text, and 0 or more descriptive tags. """ text = models.CharField(max_length=1000) tags = GenericRelation(TaggedItem) ``` Now we define serializers for each model that may get associated with tags. ```python class BookmarkSerializer(serializers.ModelSerializer): class Meta: model = Bookmark fields = ('url',) class NoteSerializer(serializers.ModelSerializer): class Meta: model = Note fields = ('text',) ``` The model serializer for the `TaggedItem` model could look like this: ```python from generic_relations.relations import GenericRelatedField class TagSerializer(serializers.ModelSerializer): """ A `TaggedItem` serializer with a `GenericRelatedField` mapping all possible models to their respective serializers. """ tagged_object = GenericRelatedField({ Bookmark: BookmarkSerializer(), Note: NoteSerializer() }) class Meta: model = TaggedItem fields = ('tag_name', 'tagged_object') ``` 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: ```json { "tagged_object": { "url": "https://www.djangoproject.com/" }, "tag_name": "django" } ``` If you want to have your generic foreign key represented as hyperlink, simply use `HyperlinkedRelatedField` objects: ```python class TagSerializer(serializers.ModelSerializer): """ A `Tag` serializer with a `GenericRelatedField` mapping all possible models to properly set up `HyperlinkedRelatedField`s. """ tagged_object = GenericRelatedField({ Bookmark: serializers.HyperlinkedRelatedField( queryset = Bookmark.objects.all(), view_name='bookmark-detail', ), Note: serializers.HyperlinkedRelatedField( queryset = Note.objects.all(), view_name='note-detail', ), }) class Meta: model = TaggedItem fields = ('tag_name', 'tagged_object') ``` The JSON representation of the same `TaggedItem` example object could now look something like this: ```json { "tagged_object": "/bookmark/1/", "tag_name": "django" } ``` ## Writing to generic foreign keys 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. Note, that (at the moment) only `HyperlinkedRelatedField` is able to serialize model objects out of the box. 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. ```python tag_serializer = TagSerializer(data={ 'tag_name': 'python', 'tagged_object': '/bookmark/1/' }) tag_serializer.is_valid() tag_serializer.save() ``` 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. ## GenericModelSerializer 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: ```python from django.core.validators import MaxValueValidator class Book(models.Model): title = models.CharField(max_length=255) author = models.CharField(max_length=255) class Bluray(models.Model): title = models.CharField(max_length=255) rating = models.PositiveSmallIntegerField( validators=[MaxValueValidator(5)], ) ``` Then we could have a serializer for each type of object: ```python class BookSerializer(serializers.ModelSerializer): class Meta: model = Book fields = ('title', 'author') class BluraySerializer(serializers.ModelSerializer): class Meta: model = Bluray fields = ('title', 'rating') ``` Now we can create a generic list serializer, which delegates to the above serializers based on the type of model it's serializing: ```python bookshelf_item_serializer = GenericModelSerializer( { Book: BookSerializer(), Bluray: BluraySerializer(), }, many=True, ) ``` Then we can serialize a mixed list of items: ```python >>> bookshelf_item_serializer.to_representation([ Book.objects.get(title='War and Peace'), Bluray.objects.get(title='Die Hard'), Bluray.objects.get(title='Shawshank Redemption'), Book.objects.get(title='To Kill a Mockingbird'), ]) [ {'title': 'War and Peace', 'author': 'Leo Tolstoy'}, {'title': 'Die Hard', 'rating': 5}, {'title': 'Shawshank Redemption', 'rating': 5}, {'title': 'To Kill a Mockingbird', 'author': 'Harper Lee'} ] ``` ## A few things you should note: * Although `GenericForeignKey` fields can be set to any model object, the `GenericRelatedField` only handles models explicitly defined in its configuration dictionary. * 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. * The order in which you register serializers matters as far as write operations are concerned. * Unless you provide a custom `get_deserializer_for_data()` method, only `HyperlinkedRelatedField` provides write access to generic model relations. ================================================ FILE: generic_relations/__init__.py ================================================ pkg_resources = __import__('pkg_resources') distribution = pkg_resources.get_distribution('rest-framework-generic-relations') __version__ = distribution.version ================================================ FILE: generic_relations/relations.py ================================================ from django.utils.deprecation import RenameMethodsBase from rest_framework import serializers from .serializers import GenericSerializerMixin __all__ = ('GenericRelatedField',) class RenamedMethods(RenameMethodsBase): renamed_methods = ( ('determine_deserializer_for_data', 'get_serializer_for_instance', DeprecationWarning), ('determine_serializer_for_data', 'get_deserializer_for_data', DeprecationWarning), ) class GenericRelatedField(GenericSerializerMixin, serializers.Field, metaclass=RenamedMethods): """ Represents a generic relation / foreign key. It's actually more of a wrapper, that delegates the logic to registered serializers based on the `Model` class. """ ================================================ FILE: generic_relations/serializers.py ================================================ from django.core.exceptions import ImproperlyConfigured from django.utils.translation import gettext_lazy as _ from django import forms from rest_framework import serializers from rest_framework.settings import api_settings __all__ = ('GenericSerializerMixin', 'GenericModelSerializer',) class GenericSerializerMixin(object): default_error_messages = { 'no_model_match': _('Invalid model - model not available.'), 'no_url_match': _('Invalid hyperlink - No URL match'), 'incorrect_url_match': _( 'Invalid hyperlink - view name not available'), } form_field_class = forms.URLField def __init__(self, serializers, *args, **kwargs): """ Needs an extra parameter `serializers` which has to be a dict key: value being `Model`: serializer. """ super(GenericSerializerMixin, self).__init__(*args, **kwargs) self.serializers = serializers for serializer in self.serializers.values(): if serializer.source is not None: msg = '{}() cannot be re-used. Create a new instance.' raise RuntimeError(msg.format(type(serializer).__name__)) serializer.bind('', self) def to_internal_value(self, data): try: serializer = self.get_deserializer_for_data(data) except ImproperlyConfigured as e: raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: e}) return serializer.to_internal_value(data) def to_representation(self, instance): serializer = self.get_serializer_for_instance(instance) return serializer.to_representation(instance) def get_serializer_for_instance(self, instance): # Use registered superclasses, rather than only the exact model. # (But prefer things earlier in the MRO, so if the exact model is registered, # use that in preference to any superclasses) for klass in instance.__class__.mro(): if klass in self.serializers: return self.serializers[klass] raise serializers.ValidationError(self.error_messages['no_model_match']) def get_deserializer_for_data(self, value): # While one could easily execute the "try" block within # to_internal_value and reduce operations, I consider the concept of # serializing is already very naive and vague, that's why I'd # go for stringency with the deserialization process here. serializers = [] for serializer in self.serializers.values(): try: serializer.to_internal_value(value) # Collects all serializers that can handle the input data. serializers.append(serializer) except Exception: pass # If no serializer found, raise error. l = len(serializers) if l < 1: raise ImproperlyConfigured( 'Could not determine a valid serializer for value %r.' % value) elif l > 1: raise ImproperlyConfigured( 'There were multiple serializers found for value %r.' % value) return serializers[0] class GenericModelSerializer(GenericSerializerMixin, serializers.Serializer): """ Delegates serialization and deserialization to registered serializers based on the type of the model. """ ================================================ FILE: generic_relations/tests/__init__.py ================================================ ================================================ FILE: generic_relations/tests/migrations/0001_initial.py ================================================ # -*- coding: utf-8 -*- # Generated by Django 1.11.22 on 2019-08-01 04:03 from django.db import migrations, models import django.db.models.deletion class Migration(migrations.Migration): initial = True dependencies = [ ('contenttypes', '0002_remove_content_type_name'), ] operations = [ migrations.CreateModel( name='Bookmark', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('url', models.URLField()), ], ), migrations.CreateModel( name='Detachable', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=50)), ('object_id', models.PositiveIntegerField(blank=True, null=True)), ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), ], ), migrations.CreateModel( name='Note', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('text', models.TextField()), ], ), migrations.CreateModel( name='Tag', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('tag', models.SlugField()), ('object_id', models.PositiveIntegerField()), ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), ], ), migrations.CreateModel( name='NoteProxy', fields=[ ], options={ 'proxy': True, 'indexes': [], }, bases=('tests.note',), ), ] ================================================ FILE: generic_relations/tests/migrations/__init__.py ================================================ ================================================ FILE: generic_relations/tests/models.py ================================================ from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.db import models class Tag(models.Model): """ Tags have a descriptive slug, and are attached to an arbitrary object. """ tag = models.SlugField() content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() tagged_item = GenericForeignKey('content_type', 'object_id') def __unicode__(self): return self.tag class Detachable(models.Model): """ Model with an optional GenericForeignKey relation """ name = models.CharField(max_length=50) content_type = models.ForeignKey( ContentType, null=True, blank=True, on_delete=models.CASCADE) object_id = models.PositiveIntegerField(null=True, blank=True) content_object = GenericForeignKey('content_type', 'object_id') class Bookmark(models.Model): """ A URL bookmark that may have multiple tags attached. """ url = models.URLField() tags = GenericRelation(Tag) def __unicode__(self): return 'Bookmark: %s' % self.url class Note(models.Model): """ A textual note that may have multiple tags attached. """ text = models.TextField() tags = GenericRelation(Tag) def __unicode__(self): return 'Note: %s' % self.text class NoteProxy(Note): class Meta: proxy = True ================================================ FILE: generic_relations/tests/test_relations.py ================================================ import warnings try: from django.urls import re_path as url except ImportError: from django.conf.urls import url from django.core.exceptions import ImproperlyConfigured from django.test import TestCase, RequestFactory from django.test.utils import override_settings from rest_framework import serializers from rest_framework.reverse import reverse from rest_framework.settings import api_settings from generic_relations.relations import GenericRelatedField from generic_relations.tests.models import Bookmark, Detachable, Note, NoteProxy, Tag warnings.simplefilter("default", DeprecationWarning) factory = RequestFactory() # Just to ensure we have a request in the serializer context request = factory.get('/') def dummy_view(request, pk): pass urlpatterns = [ url(r'^bookmark/(?P[0-9]+)/$', dummy_view, name='bookmark-detail'), url(r'^detachable/(?P[0-9]+)/$', dummy_view, name='detachable-detail'), url(r'^note/(?P[0-9]+)/$', dummy_view, name='note-detail'), url(r'^tag/(?P[0-9]+)/$', dummy_view, name='tag-detail'), url( r'^contact/(?P[-\w]+)/$', dummy_view, name='contact-detail' ), ] class BookmarkSerializer(serializers.ModelSerializer): class Meta: model = Bookmark exclude = ('id', ) class NoteSerializer(serializers.ModelSerializer): class Meta: model = Note exclude = ('id', ) class NoteProxySerializer(serializers.ModelSerializer): text = serializers.SerializerMethodField() class Meta: model = NoteProxy exclude = ('id',) def get_text(self, instance): return 'proxied: %s' % instance.text @override_settings(ROOT_URLCONF='generic_relations.tests.test_relations') class TestGenericRelatedFieldSerialization(TestCase): def setUp(self): self.bookmark = Bookmark.objects.create( url='https://www.djangoproject.com/') Tag.objects.create(tagged_item=self.bookmark, tag='django') Tag.objects.create(tagged_item=self.bookmark, tag='python') self.note = Note.objects.create(text='Remember the milk') Tag.objects.create(tagged_item=self.note, tag='reminder') Detachable.objects.create(content_object=self.note, name='attached') Detachable.objects.create(name='detached') def test_relations_as_hyperlinks(self): class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField( { Bookmark: serializers.HyperlinkedRelatedField( view_name='bookmark-detail', queryset=Bookmark.objects.all()), Note: serializers.HyperlinkedRelatedField( view_name='note-detail', queryset=Note.objects.all()), }, read_only=True, ) class Meta: model = Tag exclude = ('id', 'content_type', 'object_id', ) serializer = TagSerializer(Tag.objects.all(), many=True, context={'request': request}) expected = [ { 'tagged_item': 'http://testserver/bookmark/1/', 'tag': 'django', }, { 'tagged_item': 'http://testserver/bookmark/1/', 'tag': 'python', }, { 'tagged_item': 'http://testserver/note/1/', 'tag': 'reminder' } ] self.assertEqual(serializer.data, expected) def test_relations_as_nested(self): class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField({ Bookmark: BookmarkSerializer(), Note: NoteSerializer(), }, read_only=True) class Meta: model = Tag exclude = ('id', 'content_type', 'object_id', ) serializer = TagSerializer(Tag.objects.all(), many=True) expected = [ { 'tagged_item': { 'url': 'https://www.djangoproject.com/' }, 'tag': 'django' }, { 'tagged_item': { 'url': 'https://www.djangoproject.com/' }, 'tag': 'python' }, { 'tagged_item': { 'text': 'Remember the milk', }, 'tag': 'reminder' } ] self.assertEqual(serializer.data, expected) def test_mixed_serializers(self): class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField( { Bookmark: BookmarkSerializer(), Note: serializers.HyperlinkedRelatedField( view_name='note-detail', queryset=Note.objects.all()), }, read_only=True, ) class Meta: model = Tag exclude = ('id', 'content_type', 'object_id', ) serializer = TagSerializer(Tag.objects.all(), many=True, context={'request': request}) expected = [ { 'tagged_item': { 'url': 'https://www.djangoproject.com/' }, 'tag': 'django' }, { 'tagged_item': { 'url': 'https://www.djangoproject.com/' }, 'tag': 'python' }, { 'tagged_item': 'http://testserver/note/1/', 'tag': 'reminder' } ] self.assertEqual(serializer.data, expected) def test_invalid_model(self): # Leaving out the Note model should result in a ValidationError class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField({ Bookmark: BookmarkSerializer(), }, read_only=True) class Meta: model = Tag exclude = ('id', 'content_type', 'object_id', ) serializer = TagSerializer(Tag.objects.all(), many=True) with self.assertRaises(serializers.ValidationError): serializer.data def test_relation_as_null(self): class DetachableSerializer(serializers.ModelSerializer): content_object = GenericRelatedField( { Bookmark: serializers.HyperlinkedRelatedField( view_name='bookmark-detail', queryset=Bookmark.objects.all()), Note: serializers.HyperlinkedRelatedField( view_name='note-detail', queryset=Note.objects.all()), }, read_only=True, ) class Meta: model = Detachable exclude = ('id', 'content_type', 'object_id', ) serializer = DetachableSerializer(Detachable.objects.all(), many=True, context={'request': request}) expected = [ { 'content_object': 'http://testserver/note/1/', 'name': 'attached', }, { 'content_object': None, 'name': 'detached', } ] self.assertEqual(serializer.data, expected) def test_deprecated_method_overridden(self): with warnings.catch_warnings(record=True) as w: class MyRelatedField(GenericRelatedField): def determine_deserializer_for_data(self, value): return super(MyRelatedField, self).determine_deserializer_for_data(value) self.assertEqual(len(w), 1) self.assertIs(w[0].category, DeprecationWarning) def test_deprecated_method_called(self): f = GenericRelatedField({ Bookmark: serializers.HyperlinkedRelatedField( view_name='bookmark-detail', queryset=Bookmark.objects.all()), Note: serializers.HyperlinkedRelatedField( view_name='note-detail', queryset=Note.objects.all()), }) with warnings.catch_warnings(record=True) as w: f.determine_deserializer_for_data(self.bookmark) self.assertEqual(len(w), 1) self.assertIs(w[0].category, DeprecationWarning) def test_subclass_uses_registered_parent(self): tagged_item = GenericRelatedField({ Note: NoteSerializer(), }, read_only=True) # NoteProxy instance should use the NoteSerializer, # since no more specific serializer is registered proxied = NoteProxy.objects.get(pk=self.note.pk) serializer = tagged_item.get_serializer_for_instance(proxied) self.assertIsInstance(serializer, NoteSerializer) def test_subclass_uses_registered_subclass(self): tagged_item = GenericRelatedField({ Note: NoteSerializer(), NoteProxy: NoteProxySerializer(), }, read_only=True) # NoteProxy instance should use the NoteProxySerializer in # preference to the NoteSerializer proxied = NoteProxy.objects.get(pk=self.note.pk) serializer = tagged_item.get_serializer_for_instance(proxied) self.assertIsInstance(serializer, NoteProxySerializer) # But Note instance should use the NoteSerializer serializer = tagged_item.get_serializer_for_instance(self.note) self.assertIsInstance(serializer, NoteSerializer) @override_settings(ROOT_URLCONF='generic_relations.tests.test_relations') class TestGenericRelatedFieldDeserialization(TestCase): def setUp(self): self.bookmark = Bookmark.objects.create( url='https://www.djangoproject.com/') Tag.objects.create(tagged_item=self.bookmark, tag='django') Tag.objects.create(tagged_item=self.bookmark, tag='python') self.note = Note.objects.create(text='Remember the milk') def test_hyperlink_serialization(self): class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField( { Bookmark: serializers.HyperlinkedRelatedField( view_name='bookmark-detail', queryset=Bookmark.objects.all()), Note: serializers.HyperlinkedRelatedField( view_name='note-detail', queryset=Note.objects.all()), }, read_only=False, ) class Meta: model = Tag exclude = ('id', 'content_type', 'object_id', ) serializer = TagSerializer(data={ 'tag': 'reminder', 'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk}) }, context={'request': request}) serializer.is_valid(raise_exception=True) expected = { 'tagged_item': 'http://testserver/note/1/', 'tag': 'reminder' } self.assertEqual(serializer.data, expected) def test_configuration_error(self): class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField( { Bookmark: BookmarkSerializer(), Note: serializers.HyperlinkedRelatedField( view_name='note-detail', queryset=Note.objects.all()), }, read_only=False, ) class Meta: model = Tag exclude = ('id', 'content_type', 'object_id', ) serializer = TagSerializer(data={ 'tag': 'reminder', 'tagged_item': 'just a string' }) with self.assertRaises(ImproperlyConfigured): tagged_item = serializer.fields['tagged_item'] tagged_item.get_deserializer_for_data('just a string') def test_not_registered_view_name(self): class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField( { Bookmark: serializers.HyperlinkedRelatedField( view_name='bookmark-detail', queryset=Bookmark.objects.all()), }, read_only=False ) class Meta: model = Tag exclude = ('id', 'content_type', 'object_id', ) serializer = TagSerializer(data={ 'tag': 'reminder', 'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk}) }) self.assertFalse(serializer.is_valid()) def test_invalid_url(self): class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField( { Bookmark: serializers.HyperlinkedRelatedField( view_name='bookmark-detail', queryset=Bookmark.objects.all()), }, read_only=False, ) class Meta: model = Tag exclude = ('id', 'content_type', 'object_id', ) serializer = TagSerializer(data={ 'tag': 'reminder', 'tagged_item': 'foo-bar' }) message = 'Could not determine a valid serializer for value %r.' expected = {'tagged_item': {api_settings.NON_FIELD_ERRORS_KEY: message % 'foo-bar'}} self.assertFalse(serializer.is_valid()) self.assertEqual(expected, serializer.errors) def test_serializer_save(self): class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField( { Bookmark: serializers.HyperlinkedRelatedField( view_name='bookmark-detail', queryset=Bookmark.objects.all()), Note: serializers.HyperlinkedRelatedField( view_name='note-detail', queryset=Note.objects.all()), }, read_only=False, ) class Meta: model = Tag exclude = ('id', 'content_type', 'object_id', ) serializer = TagSerializer(data={ 'tag': 'reminder', 'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk}) }) serializer.is_valid(raise_exception=True) serializer.save() tag = Tag.objects.get(pk=3) self.assertEqual(tag.tagged_item, self.note) def test_nullable_relation_serializer_save(self): class DetachableSerializer(serializers.ModelSerializer): content_object = GenericRelatedField( { Bookmark: serializers.HyperlinkedRelatedField( view_name='bookmark-detail', queryset=Bookmark.objects.all()), Note: serializers.HyperlinkedRelatedField( view_name='note-detail', queryset=Note.objects.all()), }, read_only=False, required=False ) class Meta: model = Detachable exclude = ('id', 'content_type', 'object_id', ) serializer = DetachableSerializer(data={'name': 'foo'}) serializer.is_valid(raise_exception=True) serializer.save() freeagent = Detachable.objects.get(pk=1) self.assertEqual(freeagent.name, 'foo') self.assertEqual(freeagent.content_object, None) def test_deprecated_method_overridden(self): with warnings.catch_warnings(record=True) as w: class MyRelatedField(GenericRelatedField): def determine_serializer_for_data(self, value): return super(MyRelatedField, self).determine_serializer_for_data(value) self.assertEqual(len(w), 1) self.assertIs(w[0].category, DeprecationWarning) def test_deprecated_method_called(self): f = GenericRelatedField({ Bookmark: serializers.HyperlinkedRelatedField( view_name='bookmark-detail', queryset=Bookmark.objects.all()), }) with warnings.catch_warnings(record=True) as w: f.determine_serializer_for_data('http://testserver/bookmark/1/') self.assertEqual(len(w), 1) self.assertIs(w[0].category, DeprecationWarning) class TestGenericRelatedField(TestCase): def test_multiple_declaration(self): with self.assertRaises(RuntimeError): class TagSerializer(serializers.ModelSerializer): fields = { Bookmark: BookmarkSerializer(), Note: NoteSerializer(), } first = GenericRelatedField(fields) second = GenericRelatedField(fields) class Meta: model = Tag ================================================ FILE: generic_relations/tests/test_serializers.py ================================================ from django.test import TestCase from rest_framework import serializers from generic_relations.serializers import GenericModelSerializer from generic_relations.tests.models import Bookmark, Note from .test_relations import BookmarkSerializer, NoteSerializer class TestGenericModelSerializer(TestCase): def setUp(self): self.bookmark = Bookmark.objects.create( url='https://www.djangoproject.com/') self.note = Note.objects.create(text='Remember the milk') self.note2 = Note.objects.create(text='Reticulate the splines') self.serializer = GenericModelSerializer( { Bookmark: BookmarkSerializer(), Note: NoteSerializer(), } ) self.list_serializer = serializers.ListSerializer(child=self.serializer) def test_serialize(self): self.assertEqual( self.serializer.to_representation(self.bookmark), {'url': 'https://www.djangoproject.com/'}, ) self.assertEqual( self.serializer.to_representation(self.note), {'text': 'Remember the milk'}, ) def test_deserialize(self): self.assertEqual( self.serializer.to_internal_value({'url': 'https://www.djangoproject.com/'}), {'url': 'https://www.djangoproject.com/'}, ) self.assertEqual( self.serializer.to_internal_value({'text': 'Remember the milk'}), {'text': 'Remember the milk'}, ) def test_serialize_list(self): actual = self.list_serializer.to_representation([ self.bookmark, self.note, self.note2, self.bookmark, ]) expected = [ {'url': 'https://www.djangoproject.com/'}, {'text': 'Remember the milk'}, {'text': 'Reticulate the splines'}, {'url': 'https://www.djangoproject.com/'}, ] self.assertEqual(actual, expected) def test_deserialize_list(self): validated_data = self.list_serializer.to_internal_value([ {'url': 'https://www.djangoproject.com/'}, {'text': 'Remember the milk'}, {'text': 'Reticulate the splines'}, {'url': 'https://www.djangoproject.com/'}, ]) self.assertEqual(validated_data, [ {'url': 'https://www.djangoproject.com/'}, {'text': 'Remember the milk'}, {'text': 'Reticulate the splines'}, {'url': 'https://www.djangoproject.com/'}, ]) def test_is_valid_raise_exception(self): serializer = GenericModelSerializer( serializers={Bookmark: BookmarkSerializer()}, data={'url': 'not-a-url'}, ) with self.assertRaises(serializers.ValidationError): serializer.is_valid(raise_exception=True) ================================================ FILE: manage.py ================================================ #!/usr/bin/env python import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testsettings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) ================================================ FILE: setup.cfg ================================================ [bdist_wheel] universal = 1 ================================================ FILE: setup.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- from setuptools import setup, find_packages from os.path import abspath, dirname, join def read_relative_file(filename): """ Returns contents of the given file, whose path is supposed relative to this module. """ with open(join(dirname(abspath(__file__)), filename), "r") as f: return f.read() setup( name="rest-framework-generic-relations", version="2.1.0", url="https://github.com/Ian-Foote/rest-framework-generic-relations", license="BSD", description="Generic Relations for Django Rest Framework", long_description=read_relative_file("README.md"), long_description_content_type="text/markdown", author="Ian Foote", author_email="python@ian.feete.org", packages=find_packages(), include_package_data=True, install_requires=["djangorestframework>=3.11.0"], python_requires=">=3.6", classifiers=[ "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Framework :: Django :: 2.2", "Framework :: Django :: 3.0", "Framework :: Django :: 3.1", "Framework :: Django :: 3.2", "Framework :: Django :: 4.0", "Framework :: Django :: 4.1", ], ) ================================================ FILE: testsettings.py ================================================ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:', }, } INSTALLED_APPS = ( 'django.contrib.contenttypes', 'generic_relations', 'generic_relations.tests', ) ROOT_URLCONF = '' SECRET_KEY = 'abcde12345' MIDDLEWARE_CLASSES = ( 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware' ) ================================================ FILE: tox.ini ================================================ [tox] envlist = # note: min/max python versions specified here; # testing in-between versions here seems a waste of resources. {py37,py39}-{dj22,dj31,dj32}-{drf311,drf312} py311-{dj32,dj41}-{drf313,drf314} [testenv] changedir = {toxinidir} commands = pytest --ds=testsettings {posargs} deps = pytest-django dj22: Django~=2.2.17 dj31: Django~=3.1.0 dj32: Django~=3.2.0 dj41: Django~=4.1.0 drf311: djangorestframework~=3.11.0 drf312: djangorestframework~=3.12.0 drf313: djangorestframework~=3.13.0 drf314: djangorestframework~=3.14.0