From c926a6629efa05a1a7786447db4f9bd9e360c75a Mon Sep 17 00:00:00 2001 From: Adam Ziolkowski Date: Tue, 16 Jan 2018 18:56:00 +0000 Subject: [PATCH 001/488] Polymorphic list fixes (#402) * support polymorphic list Render polymorphic list of items with different fields * pep8 * fix polymorphic models serializer fix get_polymorphic_serializer_for_instance only for PolymorphicModelSerializer * fix typo * pep8 * fix circular import and pep8 * Fix import in renderers * Fix remaining flake8 issue * Fix code style issue in renderers. * Add Adam Ziolkowski and Roberto Barreda as Authors --- AUTHORS | 2 ++ rest_framework_json_api/renderers.py | 21 +++++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/AUTHORS b/AUTHORS index 97498e12..a7efe523 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,4 +1,5 @@ Adam Wróbel +Adam Ziolkowski Christian Zosel Greg Aker Jamie Bliss @@ -8,5 +9,6 @@ Matt Layman Ola Tarkowska Oliver Sauder Raphael Cohen +Roberto Barreda santiavenda Yaniv Peer diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 6360412a..1c74ffdf 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -11,6 +11,7 @@ from rest_framework.serializers import BaseSerializer, ListSerializer, Serializer from rest_framework.settings import api_settings +import rest_framework_json_api from rest_framework_json_api import utils @@ -543,12 +544,6 @@ def render(self, data, accepted_media_type=None, renderer_context=None): if serializer is not None: - # Get the serializer fields - fields = utils.get_serializer_fields(serializer) - - # Determine if resource name must be resolved on each instance (polymorphic serializer) - force_type_resolution = getattr(serializer, '_poly_force_type_resolution', False) - # Extract root meta for any type of serializer json_api_meta.update(self.extract_root_meta(serializer, serializer_data)) @@ -559,6 +554,17 @@ def render(self, data, accepted_media_type=None, renderer_context=None): resource = serializer_data[position] # Get current resource resource_instance = serializer.instance[position] # Get current instance + if isinstance(serializer.child, rest_framework_json_api. + serializers.PolymorphicModelSerializer): + resource_serializer_class = serializer.child.\ + get_polymorphic_serializer_for_instance(resource_instance)() + else: + resource_serializer_class = serializer.child + + fields = utils.get_serializer_fields(resource_serializer_class) + force_type_resolution = getattr( + resource_serializer_class, '_poly_force_type_resolution', False) + json_resource_obj = self.build_json_resource_obj( fields, resource, resource_instance, resource_name, force_type_resolution ) @@ -573,6 +579,9 @@ def render(self, data, accepted_media_type=None, renderer_context=None): if included: json_api_included.extend(included) else: + fields = utils.get_serializer_fields(serializer) + force_type_resolution = getattr(serializer, '_poly_force_type_resolution', False) + resource_instance = serializer.instance json_api_data = self.build_json_resource_obj( fields, serializer_data, resource_instance, resource_name, force_type_resolution From 1bbf6faa2a3c5a90d760efd9340a788ffb88d416 Mon Sep 17 00:00:00 2001 From: Matt Layman Date: Wed, 24 Jan 2018 11:50:22 -0500 Subject: [PATCH 002/488] Add DRF 3.7 and Django 2.0 to the test matrix. (#394) * Add DRF 3.7 to the test matrix. Fixes #388 * Switch to an explicit build matrix. * Change env format. Without the equals sign, Travis will drop the double quotes and screw up the environment variable export. * DRF 3.6.x does not support Django 2.0. * Add required on_delete to ForeignKey fields. * Add on_delete to OneToOneField. * Switch core.urlresolvers to urls for Django 2.0 compatibility. * Update minimum required django-polymorphic. Older versions of django-polymorphic don't support Django 2.0 --- .travis.yml | 35 +++++++++++++++---- CHANGELOG.md | 2 ++ README.rst | 4 +-- docs/getting-started.md | 4 +-- example/models.py | 14 ++++---- example/tests/integration/test_includes.py | 2 +- example/tests/integration/test_meta.py | 2 +- .../integration/test_model_resource_name.py | 2 +- .../test_non_paginated_responses.py | 2 +- example/tests/integration/test_pagination.py | 2 +- .../tests/integration/test_polymorphism.py | 2 +- .../integration/test_sparse_fieldsets.py | 2 +- example/tests/test_format_keys.py | 2 +- example/tests/test_generic_validation.py | 2 +- example/tests/test_generic_viewset.py | 2 +- example/tests/test_model_viewsets.py | 2 +- example/tests/test_multiple_id_mixin.py | 2 +- example/tests/test_serializers.py | 2 +- example/tests/test_sideload_resources.py | 2 +- requirements-development.txt | 2 +- setup.py | 2 +- tox.ini | 3 +- 22 files changed, 60 insertions(+), 34 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1656f959..fce5e4bd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,13 +2,34 @@ language: python sudo: false cache: pip -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" -env: - - DJANGO=">=1.11,<1.12" DRF=">=3.6.3,<3.7" +# Favor explicit over implicit and use an explicit build matrix. +matrix: + include: + - python: 2.7 + env: DJANGO=">=1.11,<2.0" DRF=">=3.6.3,<3.7" + - python: 2.7 + env: DJANGO=">=1.11,<2.0" DRF=">=3.7.0,<3.8" + + - python: 3.4 + env: DJANGO=">=1.11,<2.0" DRF=">=3.6.3,<3.7" + - python: 3.4 + env: DJANGO=">=1.11,<2.0" DRF=">=3.7.0,<3.8" + - python: 3.4 + env: DJANGO=">=2.0,<2.1" DRF=">=3.7.0,<3.8" + + - python: 3.5 + env: DJANGO=">=1.11,<2.0" DRF=">=3.6.3,<3.7" + - python: 3.5 + env: DJANGO=">=1.11,<2.0" DRF=">=3.7.0,<3.8" + - python: 3.5 + env: DJANGO=">=2.0,<2.1" DRF=">=3.7.0,<3.8" + + - python: 3.6 + env: DJANGO=">=1.11,<2.0" DRF=">=3.6.3,<3.7" + - python: 3.6 + env: DJANGO=">=1.11,<2.0" DRF=">=3.7.0,<3.8" + - python: 3.6 + env: DJANGO=">=2.0,<2.1" DRF=">=3.7.0,<3.8" before_install: # Force an upgrade of py & pytest to avoid VersionConflict - pip install --upgrade py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6e0aba..a3daf4a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ v2.4.0 +* Add support for Django REST Framework 3.7.x. +* Add support for Django 2.0. * Drop support for Django 1.8 - 1.10 (EOL) * Drop support for Django REST Framework < 3.6.3 (3.6.3 is the first to support Django 1.11) diff --git a/README.rst b/README.rst index 05d18413..3e6b1713 100644 --- a/README.rst +++ b/README.rst @@ -67,8 +67,8 @@ Requirements ------------ 1. Python (2.7, 3.4, 3.5, 3.6) -2. Django (1.11) -3. Django REST Framework (3.6) +2. Django (1.11, 2.0) +3. Django REST Framework (3.6, 3.7) ------------ Installation diff --git a/docs/getting-started.md b/docs/getting-started.md index aac1cfc2..4afcf2df 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,8 +52,8 @@ like the following: ## Requirements 1. Python (2.7, 3.4, 3.5, 3.6) -2. Django (1.11) -3. Django REST Framework (3.6) +2. Django (1.11, 2.0) +3. Django REST Framework (3.6, 3.7) ## Installation diff --git a/example/models.py b/example/models.py index 5395607f..d94219f3 100644 --- a/example/models.py +++ b/example/models.py @@ -60,7 +60,7 @@ class Meta: class Author(BaseModel): name = models.CharField(max_length=50) email = models.EmailField() - type = models.ForeignKey(AuthorType, null=True) + type = models.ForeignKey(AuthorType, null=True, on_delete=models.CASCADE) def __str__(self): return self.name @@ -71,7 +71,7 @@ class Meta: @python_2_unicode_compatible class AuthorBio(BaseModel): - author = models.OneToOneField(Author, related_name='bio') + author = models.OneToOneField(Author, related_name='bio', on_delete=models.CASCADE) body = models.TextField() def __str__(self): @@ -83,7 +83,7 @@ class Meta: @python_2_unicode_compatible class Entry(BaseModel): - blog = models.ForeignKey(Blog) + blog = models.ForeignKey(Blog, on_delete=models.CASCADE) headline = models.CharField(max_length=255) body_text = models.TextField(null=True) pub_date = models.DateField(null=True) @@ -103,12 +103,13 @@ class Meta: @python_2_unicode_compatible class Comment(BaseModel): - entry = models.ForeignKey(Entry, related_name='comments') + entry = models.ForeignKey(Entry, related_name='comments', on_delete=models.CASCADE) body = models.TextField() author = models.ForeignKey( Author, null=True, - blank=True + blank=True, + on_delete=models.CASCADE, ) def __str__(self): @@ -133,7 +134,8 @@ class ResearchProject(Project): @python_2_unicode_compatible class Company(models.Model): name = models.CharField(max_length=100) - current_project = models.ForeignKey(Project, related_name='companies') + current_project = models.ForeignKey( + Project, related_name='companies', on_delete=models.CASCADE) future_projects = models.ManyToManyField(Project) def __str__(self): diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index 332c7b73..6c0d6958 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -1,5 +1,5 @@ import pytest -from django.core.urlresolvers import reverse +from django.urls import reverse pytestmark = pytest.mark.django_db diff --git a/example/tests/integration/test_meta.py b/example/tests/integration/test_meta.py index 1c28996a..20fb0778 100644 --- a/example/tests/integration/test_meta.py +++ b/example/tests/integration/test_meta.py @@ -1,7 +1,7 @@ from datetime import datetime import pytest -from django.core.urlresolvers import reverse +from django.urls import reverse pytestmark = pytest.mark.django_db diff --git a/example/tests/integration/test_model_resource_name.py b/example/tests/integration/test_model_resource_name.py index 2d30b21e..a69503ae 100644 --- a/example/tests/integration/test_model_resource_name.py +++ b/example/tests/integration/test_model_resource_name.py @@ -1,7 +1,7 @@ from copy import deepcopy import pytest -from django.core.urlresolvers import reverse +from django.urls import reverse from rest_framework import status from example import models, serializers, views diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 029ba1ce..5769f6da 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -1,5 +1,5 @@ import pytest -from django.core.urlresolvers import reverse +from django.urls import reverse try: from unittest import mock diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 8d7a6f64..cff9d9af 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -1,5 +1,5 @@ import pytest -from django.core.urlresolvers import reverse +from django.urls import reverse try: from unittest import mock diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 6185e743..cfaad5fd 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -1,7 +1,7 @@ import random import pytest -from django.core.urlresolvers import reverse +from django.urls import reverse pytestmark = pytest.mark.django_db diff --git a/example/tests/integration/test_sparse_fieldsets.py b/example/tests/integration/test_sparse_fieldsets.py index ffdba796..c76f1efd 100644 --- a/example/tests/integration/test_sparse_fieldsets.py +++ b/example/tests/integration/test_sparse_fieldsets.py @@ -1,5 +1,5 @@ import pytest -from django.core.urlresolvers import reverse +from django.urls import reverse pytestmark = pytest.mark.django_db diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index ca36cbb7..1481103a 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -1,5 +1,5 @@ from django.contrib.auth import get_user_model -from django.core.urlresolvers import reverse +from django.urls import reverse from django.utils import encoding from example.tests import TestBase diff --git a/example/tests/test_generic_validation.py b/example/tests/test_generic_validation.py index 3bc48b6a..3005f1c4 100644 --- a/example/tests/test_generic_validation.py +++ b/example/tests/test_generic_validation.py @@ -1,4 +1,4 @@ -from django.core.urlresolvers import reverse +from django.urls import reverse from example.tests import TestBase diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index d53433d7..9d32da06 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.core.urlresolvers import reverse +from django.urls import reverse from example.tests import TestBase diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index 385c45c6..f84c4ae4 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -1,7 +1,7 @@ import pytest from django.conf import settings from django.contrib.auth import get_user_model -from django.core.urlresolvers import reverse +from django.urls import reverse from django.utils import encoding from example.tests import TestBase diff --git a/example/tests/test_multiple_id_mixin.py b/example/tests/test_multiple_id_mixin.py index f3edd171..6ec73ad8 100644 --- a/example/tests/test_multiple_id_mixin.py +++ b/example/tests/test_multiple_id_mixin.py @@ -1,6 +1,6 @@ import json -from django.core.urlresolvers import reverse +from django.urls import reverse from django.utils import encoding from example.tests import TestBase diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index d52c42af..66860b61 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -1,6 +1,6 @@ import pytest -from django.core.urlresolvers import reverse from django.test import TestCase +from django.urls import reverse from django.utils import timezone from rest_framework_json_api.serializers import ResourceIdentifierObjectSerializer diff --git a/example/tests/test_sideload_resources.py b/example/tests/test_sideload_resources.py index b06570c6..4c9c1525 100644 --- a/example/tests/test_sideload_resources.py +++ b/example/tests/test_sideload_resources.py @@ -3,7 +3,7 @@ """ import json -from django.core.urlresolvers import reverse +from django.urls import reverse from django.utils import encoding from example.tests import TestBase diff --git a/requirements-development.txt b/requirements-development.txt index debf5495..467dc587 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -1,5 +1,5 @@ -e . -django-polymorphic +django-polymorphic>=2.0 Faker isort mock diff --git a/setup.py b/setup.py index 4dfe6bed..90dc4e6c 100755 --- a/setup.py +++ b/setup.py @@ -108,7 +108,7 @@ def get_package_data(package): 'factory-boy<2.9.0', 'pytest-django', 'pytest>=2.8,<3', - 'django-polymorphic', + 'django-polymorphic>=2.0', 'packaging', 'django-debug-toolbar' ] + mock, diff --git a/tox.ini b/tox.ini index 4b08065f..dc7470e0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,12 @@ [tox] envlist = - py{27,34,35,36}-django111-drf{36}, + py{27,34,35,36}-django111-drf{36,37}, [testenv] deps = django111: Django>=1.11,<1.12 drf36: djangorestframework>=3.6.3,<3.7 + drf37: djangorestframework>=3.7.0,<3.8 setenv = PYTHONPATH = {toxinidir} From ed2043d116b0ab4b810c31a39fee6d8deb419a16 Mon Sep 17 00:00:00 2001 From: Matt Layman Date: Wed, 24 Jan 2018 21:57:55 -0500 Subject: [PATCH 003/488] Set 2.4.0 release date. --- CHANGELOG.md | 2 +- README.rst | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3daf4a5..dc2b441f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -v2.4.0 +v2.4.0 - Released January 25, 2018 * Add support for Django REST Framework 3.7.x. * Add support for Django 2.0. diff --git a/README.rst b/README.rst index 3e6b1713..9f0dd9e2 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,6 @@ -==================================== +================================== JSON API and Django Rest Framework -==================================== +================================== .. image:: https://travis-ci.org/django-json-api/django-rest-framework-json-api.svg?branch=develop :target: https://travis-ci.org/django-json-api/django-rest-framework-json-api From 2ef94c73b389ba0b39cdefb67749a322108309c0 Mon Sep 17 00:00:00 2001 From: Matt Layman Date: Wed, 24 Jan 2018 22:10:17 -0500 Subject: [PATCH 004/488] Update the MANIFEST to exclude __pycache__ for the sdist. --- MANIFEST.in | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index ca8b73ea..44e3d86c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,6 @@ include LICENSE include README.rst recursive-include example * recursive-exclude example *.pyc *.pyo + +global-exclude __pycache__ +global-exclude *.py[co] From baea52e895acc89b348b161a5e54af50229eb1c8 Mon Sep 17 00:00:00 2001 From: Matt Layman Date: Thu, 1 Feb 2018 09:42:04 -0500 Subject: [PATCH 005/488] Remove factory-boy version constraint. (#408) * Remove factory-boy version constraint. * Remove constraint on pytest version. It appears that pytest-factoryboy now works with a new version of pytest. * Missed a place where pytest version was constrained. * Force use of a newer pytest. --- .travis.yml | 3 ++- requirements-development.txt | 7 +++---- setup.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index fce5e4bd..2a30c0c5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -33,7 +33,8 @@ matrix: before_install: # Force an upgrade of py & pytest to avoid VersionConflict - pip install --upgrade py - - pip install "pytest>=2.8,<3" + # Faker requires a newer pytest + - pip install "pytest>3.3" - pip install codecov flake8 isort install: - pip install Django${DJANGO} djangorestframework${DRF} diff --git a/requirements-development.txt b/requirements-development.txt index 467dc587..2a46de82 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -3,14 +3,13 @@ django-polymorphic>=2.0 Faker isort mock -pytest>=2.9.0,<3.0 +pytest pytest-django -# factory_boy is currently broken at 2.9 and above. See: https://github.com/pytest-dev/pytest-factoryboy/issues/47 -factory-boy<2.9.0 +factory-boy pytest-factoryboy recommonmark Sphinx sphinx_rtd_theme tox django-debug-toolbar -packaging==16.8 \ No newline at end of file +packaging==16.8 diff --git a/setup.py b/setup.py index 90dc4e6c..c99639a0 100755 --- a/setup.py +++ b/setup.py @@ -105,9 +105,9 @@ def get_package_data(package): setup_requires=pytest_runner + sphinx + wheel, tests_require=[ 'pytest-factoryboy', - 'factory-boy<2.9.0', + 'factory-boy', 'pytest-django', - 'pytest>=2.8,<3', + 'pytest', 'django-polymorphic>=2.0', 'packaging', 'django-debug-toolbar' From c5d34e2ea67bab8a4e9570f54779911570f24c83 Mon Sep 17 00:00:00 2001 From: Erick Navarro Date: Thu, 1 Feb 2018 06:43:09 -0800 Subject: [PATCH 006/488] Fix typo in usage.md (#407) --- docs/usage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage.md b/docs/usage.md index d9f0092a..7b0b6cc0 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -421,7 +421,7 @@ url( The `related_field` kwarg specifies which relationship to use, so if we are interested in the relationship represented by the related model field `Order.line_items` on the Order with pk 3, the url would be -`/order/3/relationships/line_items`. On `HyperlinkedModelSerializer`, the +`/orders/3/relationships/line_items`. On `HyperlinkedModelSerializer`, the `ResourceRelatedField` will construct the url based on the provided `self_link_view_name` keyword argument, which should match the `name=` provided in the urlconf, and will use the name of the field for the From 4ef5bb4e93edbf9d43bd3d0238556409942bd945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Wr=C3=B3bel?= Date: Sun, 18 Feb 2018 14:42:03 +0100 Subject: [PATCH 007/488] Speed up JSONRenderer.extract_included (#412) --- rest_framework_json_api/exceptions.py | 5 +- rest_framework_json_api/renderers.py | 112 ++++++++++++-------------- 2 files changed, 56 insertions(+), 61 deletions(-) diff --git a/rest_framework_json_api/exceptions.py b/rest_framework_json_api/exceptions.py index 7ffaf256..f6b21ad6 100644 --- a/rest_framework_json_api/exceptions.py +++ b/rest_framework_json_api/exceptions.py @@ -2,12 +2,13 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework import exceptions, status -from rest_framework_json_api import renderers, utils +from rest_framework_json_api import utils def rendered_with_json_api(view): + from rest_framework_json_api.renderers import JSONRenderer for renderer_class in getattr(view, 'renderer_classes', []): - if issubclass(renderer_class, renderers.JSONRenderer): + if issubclass(renderer_class, JSONRenderer): return True return False diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 1c74ffdf..6059d2a2 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -2,7 +2,7 @@ Renderers """ import copy -from collections import OrderedDict +from collections import OrderedDict, defaultdict import inflection from django.db.models import Manager @@ -13,6 +13,7 @@ import rest_framework_json_api from rest_framework_json_api import utils +from rest_framework_json_api.relations import ResourceRelatedField class JSONRenderer(renderers.JSONRenderer): @@ -313,12 +314,12 @@ def extract_relation_instance(cls, field_name, field, resource_instance, seriali return relation_instance @classmethod - def extract_included(cls, fields, resource, resource_instance, included_resources): + def extract_included(cls, fields, resource, resource_instance, included_resources, + included_cache): # this function may be called with an empty record (example: Browsable Interface) if not resource_instance: return - included_data = list() current_serializer = fields.serializer context = current_serializer.context included_serializers = utils.get_included_serializers(current_serializer) @@ -350,9 +351,6 @@ def extract_included(cls, fields, resource, resource_instance, included_resource if isinstance(relation_instance, Manager): relation_instance = relation_instance.all() - new_included_resources = [key.replace('%s.' % field_name, '', 1) - for key in included_resources - if field_name == key.split('.')[0]] serializer_data = resource.get(field_name) if isinstance(field, relations.ManyRelatedField): @@ -365,10 +363,22 @@ def extract_included(cls, fields, resource, resource_instance, included_resource continue many = field._kwargs.get('child_relation', None) is not None + + if isinstance(field, ResourceRelatedField) and not many: + already_included = serializer_data['type'] in included_cache and \ + serializer_data['id'] in included_cache[serializer_data['type']] + + if already_included: + continue + serializer_class = included_serializers[field_name] field = serializer_class(relation_instance, many=many, context=context) serializer_data = field.data + new_included_resources = [key.replace('%s.' % field_name, '', 1) + for key in included_resources + if field_name == key.split('.')[0]] + if isinstance(field, ListSerializer): serializer = field.child relation_type = utils.get_resource_type_from_serializer(serializer) @@ -387,48 +397,45 @@ def extract_included(cls, fields, resource, resource_instance, included_resource nested_resource_instance, context=serializer.context ) ) - included_data.append( - cls.build_json_resource_obj( - serializer_fields, - serializer_resource, - nested_resource_instance, - resource_type, - getattr(serializer, '_poly_force_type_resolution', False) - ) + new_item = cls.build_json_resource_obj( + serializer_fields, + serializer_resource, + nested_resource_instance, + resource_type, + getattr(serializer, '_poly_force_type_resolution', False) ) - included_data.extend( - cls.extract_included( - serializer_fields, - serializer_resource, - nested_resource_instance, - new_included_resources - ) + included_cache[new_item['type']][new_item['id']] = \ + utils.format_keys(new_item) + cls.extract_included( + serializer_fields, + serializer_resource, + nested_resource_instance, + new_included_resources, + included_cache, ) if isinstance(field, Serializer): - relation_type = utils.get_resource_type_from_serializer(field) # Get the serializer fields serializer_fields = utils.get_serializer_fields(field) if serializer_data: - included_data.append( - cls.build_json_resource_obj( - serializer_fields, serializer_data, - relation_instance, relation_type, - getattr(field, '_poly_force_type_resolution', False)) + new_item = cls.build_json_resource_obj( + serializer_fields, + serializer_data, + relation_instance, + relation_type, + getattr(field, '_poly_force_type_resolution', False) ) - included_data.extend( - cls.extract_included( - serializer_fields, - serializer_data, - relation_instance, - new_included_resources - ) + included_cache[new_item['type']][new_item['id']] = utils.format_keys(new_item) + cls.extract_included( + serializer_fields, + serializer_data, + relation_instance, + new_included_resources, + included_cache, ) - return utils.format_keys(included_data) - @classmethod def extract_meta(cls, serializer, resource): if hasattr(serializer, 'child'): @@ -529,9 +536,9 @@ def render(self, data, accepted_media_type=None, renderer_context=None): ) json_api_data = data - json_api_included = list() # initialize json_api_meta with pagination meta or an empty dict json_api_meta = data.get('meta', {}) if isinstance(data, dict) else {} + included_cache = defaultdict(dict) if data and 'results' in data: serializer_data = data["results"] @@ -573,11 +580,9 @@ def render(self, data, accepted_media_type=None, renderer_context=None): json_resource_obj.update({'meta': utils.format_keys(meta)}) json_api_data.append(json_resource_obj) - included = self.extract_included( - fields, resource, resource_instance, included_resources + self.extract_included( + fields, resource, resource_instance, included_resources, included_cache ) - if included: - json_api_included.extend(included) else: fields = utils.get_serializer_fields(serializer) force_type_resolution = getattr(serializer, '_poly_force_type_resolution', False) @@ -591,11 +596,9 @@ def render(self, data, accepted_media_type=None, renderer_context=None): if meta: json_api_data.update({'meta': utils.format_keys(meta)}) - included = self.extract_included( - fields, serializer_data, resource_instance, included_resources + self.extract_included( + fields, serializer_data, resource_instance, included_resources, included_cache ) - if included: - json_api_included.extend(included) # Make sure we render data in a specific order render_data = OrderedDict() @@ -610,20 +613,11 @@ def render(self, data, accepted_media_type=None, renderer_context=None): else: render_data['data'] = json_api_data - if len(json_api_included) > 0: - # Iterate through compound documents to remove duplicates - seen = set() - unique_compound_documents = list() - for included_dict in json_api_included: - type_tuple = tuple((included_dict['type'], included_dict['id'])) - if type_tuple not in seen: - seen.add(type_tuple) - unique_compound_documents.append(included_dict) - - # Sort the items by type then by id - render_data['included'] = sorted( - unique_compound_documents, key=lambda item: (item['type'], item['id']) - ) + if included_cache: + render_data['included'] = list() + for included_type in sorted(included_cache.keys()): + for included_id in sorted(included_cache[included_type].keys()): + render_data['included'].append(included_cache[included_type][included_id]) if json_api_meta: render_data['meta'] = utils.format_keys(json_api_meta) From e501b0445da047f6672d7596486ceb9b027a66c7 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 18 Feb 2018 13:39:03 -0500 Subject: [PATCH 008/488] Issue 410 (#411) * Make documented example runserver work Resolves #410: docs/getting-started.md example doesn't work. * xGet the steps right in the documentation to run the example app. * First attempt at for issue #410 didn't have includes sorted properly. - travis caught it. This time I've done the isort locally to confirm the order. - MIDDLEWARE_CLASSES was replaced by MIDDLEWARE as of Django 1.10 and, according to tox.ini, this is only testing >=1.11. * missed a line which flake8 caught * add back install of this package * add myself as an author as requested --- AUTHORS | 1 + docs/getting-started.md | 11 ++++++++--- example/requirements.txt | 11 +++++++++++ example/settings/dev.py | 2 +- example/urls.py | 22 ++++++++++++++++++++++ 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/AUTHORS b/AUTHORS index a7efe523..6a90ff25 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,5 +1,6 @@ Adam Wróbel Adam Ziolkowski +Alan Crosswell Christian Zosel Greg Aker Jamie Bliss diff --git a/docs/getting-started.md b/docs/getting-started.md index 4afcf2df..4b564418 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -70,13 +70,18 @@ From Source git clone https://github.com/django-json-api/django-rest-framework-json-api.git cd django-rest-framework-json-api - pip install -e . + python -m venv env + source env/bin/activate pip install -r example/requirements.txt - django-admin.py runserver + pip install -e . + django-admin.py startproject example . + python manage.py migrate + python manage.py runserver Browse to http://localhost:8000 ## Running Tests - python runtests.py + pip install tox + tox diff --git a/example/requirements.txt b/example/requirements.txt index a9660ae7..0fa77009 100644 --- a/example/requirements.txt +++ b/example/requirements.txt @@ -1,2 +1,13 @@ # Requirements specifically for the example app packaging +Django>=1.11 +django-debug-toolbar +django-polymorphic>=2.0 +djangorestframework +inflection +pluggy +py +pyparsing +pytz +six +sqlparse diff --git a/example/settings/dev.py b/example/settings/dev.py index d8b45738..61dfa443 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -59,7 +59,7 @@ PASSWORD_HASHERS = ('django.contrib.auth.hashers.UnsaltedMD5PasswordHasher', ) -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = ( 'debug_toolbar.middleware.DebugToolbarMiddleware', ) diff --git a/example/urls.py b/example/urls.py index d6b58f3d..688ce70e 100644 --- a/example/urls.py +++ b/example/urls.py @@ -3,11 +3,16 @@ from rest_framework import routers from example.views import ( + AuthorRelationshipView, AuthorViewSet, + BlogRelationshipView, BlogViewSet, + CommentRelationshipView, CommentViewSet, CompanyViewset, + EntryRelationshipView, EntryViewSet, + NonPaginatedEntryViewSet, ProjectViewset ) @@ -15,6 +20,7 @@ router.register(r'blogs', BlogViewSet) router.register(r'entries', EntryViewSet) +router.register(r'nopage-entries', NonPaginatedEntryViewSet, 'nopage-entry') router.register(r'authors', AuthorViewSet) router.register(r'comments', CommentViewSet) router.register(r'companies', CompanyViewset) @@ -22,6 +28,22 @@ urlpatterns = [ url(r'^', include(router.urls)), + url(r'^entries/(?P[^/.]+)/suggested/', + EntryViewSet.as_view({'get': 'list'}), + name='entry-suggested' + ), + url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)', + EntryRelationshipView.as_view(), + name='entry-relationships'), + url(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)', + BlogRelationshipView.as_view(), + name='blog-relationships'), + url(r'^comments/(?P[^/.]+)/relationships/(?P\w+)', + CommentRelationshipView.as_view(), + name='comment-relationships'), + url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)', + AuthorRelationshipView.as_view(), + name='author-relationships'), ] From 7252be3be41521141ae1928731003dccfd2da947 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 16 Apr 2018 12:10:05 +0200 Subject: [PATCH 009/488] Add json api settings module This is inspired by api_settings of REST framework. Advantages: * Centralizing all json api settings and defaults in one module * Simplifying refactoring and deprecation of settings in the future * Properly use override_settings in tests instead of monkey patching --- example/tests/test_generic_viewset.py | 12 +----- example/tests/test_model_viewsets.py | 9 +---- example/tests/unit/test_settings.py | 17 ++++++++ example/tests/unit/test_utils.py | 10 ++--- rest_framework_json_api/exceptions.py | 5 ++- rest_framework_json_api/parsers.py | 6 +-- rest_framework_json_api/settings.py | 57 +++++++++++++++++++++++++++ rest_framework_json_api/utils.py | 14 ++++--- 8 files changed, 97 insertions(+), 33 deletions(-) create mode 100644 example/tests/unit/test_settings.py create mode 100644 rest_framework_json_api/settings.py diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index 9d32da06..bfde51ea 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -1,23 +1,15 @@ -from django.conf import settings +from django.test import override_settings from django.urls import reverse from example.tests import TestBase +@override_settings(JSON_API_FORMAT_KEYS='dasherize') class GenericViewSet(TestBase): """ Test expected responses coming from a Generic ViewSet """ - def setUp(self): - super(GenericViewSet, self).setUp() - - setattr(settings, 'JSON_API_FORMAT_KEYS', 'dasherize') - - def tearDown(self): - - setattr(settings, 'JSON_API_FORMAT_KEYS', 'camelize') - def test_default_rest_framework_behavior(self): """ This is more of an example really, showing default behavior diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index f84c4ae4..21c6d41a 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -1,12 +1,13 @@ import pytest -from django.conf import settings from django.contrib.auth import get_user_model +from django.test import override_settings from django.urls import reverse from django.utils import encoding from example.tests import TestBase +@override_settings(JSON_API_FORMAT_KEYS='dasherize') class ModelViewSetTests(TestBase): """ Test usage with ModelViewSets, also tests pluralization, camelization, @@ -21,12 +22,6 @@ def setUp(self): super(ModelViewSetTests, self).setUp() self.detail_url = reverse('user-detail', kwargs={'pk': self.miles.pk}) - setattr(settings, 'JSON_API_FORMAT_KEYS', 'dasherize') - - def tearDown(self): - - setattr(settings, 'JSON_API_FORMAT_KEYS', 'camelize') - def test_key_in_list_result(self): """ Ensure the result has a 'user' key since that is the name of the model diff --git a/example/tests/unit/test_settings.py b/example/tests/unit/test_settings.py new file mode 100644 index 00000000..516e76ec --- /dev/null +++ b/example/tests/unit/test_settings.py @@ -0,0 +1,17 @@ +import pytest + +from rest_framework_json_api.settings import json_api_settings + + +def test_settings_invalid(): + with pytest.raises(AttributeError): + json_api_settings.INVALID_SETTING + + +def test_settings_default(): + assert json_api_settings.UNIFORM_EXCEPTIONS is False + + +def test_settings_override(settings): + settings.JSON_API_FORMAT_KEYS = 'dasherize' + assert json_api_settings.FORMAT_KEYS == 'dasherize' diff --git a/example/tests/unit/test_utils.py b/example/tests/unit/test_utils.py index 4e690f5b..46315772 100644 --- a/example/tests/unit/test_utils.py +++ b/example/tests/unit/test_utils.py @@ -1,6 +1,6 @@ import pytest -from django.conf import settings from django.contrib.auth import get_user_model +from django.test import override_settings from django.utils import six from rest_framework import serializers from rest_framework.generics import GenericAPIView @@ -29,12 +29,12 @@ class Meta: def test_get_resource_name(): view = APIView() context = {'view': view} - setattr(settings, 'JSON_API_FORMAT_TYPES', None) - assert 'APIViews' == utils.get_resource_name(context), 'not formatted' + with override_settings(JSON_API_FORMAT_TYPES=None): + assert 'APIViews' == utils.get_resource_name(context), 'not formatted' context = {'view': view} - setattr(settings, 'JSON_API_FORMAT_TYPES', 'dasherize') - assert 'api-views' == utils.get_resource_name(context), 'derived from view' + with override_settings(JSON_API_FORMAT_TYPES='dasherize'): + assert 'api-views' == utils.get_resource_name(context), 'derived from view' view.model = get_user_model() assert 'users' == utils.get_resource_name(context), 'derived from view model' diff --git a/rest_framework_json_api/exceptions.py b/rest_framework_json_api/exceptions.py index f6b21ad6..38ff527b 100644 --- a/rest_framework_json_api/exceptions.py +++ b/rest_framework_json_api/exceptions.py @@ -1,9 +1,10 @@ -from django.conf import settings from django.utils.translation import ugettext_lazy as _ from rest_framework import exceptions, status from rest_framework_json_api import utils +from .settings import json_api_settings + def rendered_with_json_api(view): from rest_framework_json_api.renderers import JSONRenderer @@ -29,7 +30,7 @@ def exception_handler(exc, context): # Use regular DRF format if not rendered by DRF JSON API and not uniform is_json_api_view = rendered_with_json_api(context['view']) - is_uniform = getattr(settings, 'JSON_API_UNIFORM_EXCEPTIONS', False) + is_uniform = json_api_settings.UNIFORM_EXCEPTIONS if not is_json_api_view and not is_uniform: return response diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 86a61e74..98873d2e 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -1,12 +1,12 @@ """ Parsers """ -from django.conf import settings from django.utils import six from rest_framework import parsers from rest_framework.exceptions import ParseError from . import exceptions, renderers, serializers, utils +from .settings import json_api_settings class JSONParser(parsers.JSONParser): @@ -32,7 +32,7 @@ class JSONParser(parsers.JSONParser): @staticmethod def parse_attributes(data): attributes = data.get('attributes') - uses_format_translation = getattr(settings, 'JSON_API_FORMAT_KEYS', False) + uses_format_translation = json_api_settings.FORMAT_KEYS if not attributes: return dict() @@ -44,7 +44,7 @@ def parse_attributes(data): @staticmethod def parse_relationships(data): - uses_format_translation = getattr(settings, 'JSON_API_FORMAT_KEYS', False) + uses_format_translation = json_api_settings.FORMAT_KEYS relationships = data.get('relationships') if not relationships: diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py new file mode 100644 index 00000000..40c5a96b --- /dev/null +++ b/rest_framework_json_api/settings.py @@ -0,0 +1,57 @@ +""" +This module provides the `json_api_settings` object that is used to access +JSON API REST framework settings, checking for user settings first, then falling back to +the defaults. +""" + +from django.conf import settings +from django.core.signals import setting_changed + +JSON_API_SETTINGS_PREFIX = 'JSON_API_' + +DEFAULTS = { + 'FORMAT_KEYS': False, + 'FORMAT_RELATION_KEYS': None, + 'FORMAT_TYPES': False, + 'PLURALIZE_RELATION_TYPE': None, + 'PLURALIZE_TYPES': False, + 'UNIFORM_EXCEPTIONS': False, +} + + +class JSONAPISettings(object): + """ + A settings object that allows json api settings to be access as + properties. + """ + + def __init__(self, user_settings=settings, defaults=DEFAULTS): + self.defaults = defaults + self.user_settings = user_settings + + def __getattr__(self, attr): + if attr not in self.defaults: + raise AttributeError("Invalid JSON API setting: '%s'" % attr) + + value = getattr(self.user_settings, JSON_API_SETTINGS_PREFIX + attr, self.defaults[attr]) + + # Cache the result + setattr(self, attr, value) + return value + + +json_api_settings = JSONAPISettings() + + +def reload_json_api_settings(*args, **kwargs): + django_setting = kwargs['setting'] + setting = django_setting.replace(JSON_API_SETTINGS_PREFIX, '') + value = kwargs['value'] + if setting in DEFAULTS.keys(): + if value is not None: + setattr(json_api_settings, setting, value) + elif hasattr(json_api_settings, setting): + delattr(json_api_settings, setting) + + +setting_changed.connect(reload_json_api_settings) diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 2d991050..a7220084 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -18,6 +18,8 @@ from rest_framework.exceptions import APIException from rest_framework.serializers import ManyRelatedField # noqa: F401 +from .settings import json_api_settings + try: from rest_framework_nested.relations import HyperlinkedRouterField except ImportError: @@ -104,7 +106,7 @@ def format_keys(obj, format_type=None): :format_type: Either 'dasherize', 'camelize' or 'underscore' """ if format_type is None: - format_type = getattr(settings, 'JSON_API_FORMAT_KEYS', False) + format_type = json_api_settings.FORMAT_KEYS if format_type in ('dasherize', 'camelize', 'underscore', 'capitalize'): @@ -136,7 +138,7 @@ def format_keys(obj, format_type=None): def format_value(value, format_type=None): if format_type is None: - format_type = getattr(settings, 'JSON_API_FORMAT_KEYS', False) + format_type = json_api_settings.FORMAT_KEYS if format_type == 'dasherize': # inflection can't dasherize camelCase value = inflection.underscore(value) @@ -156,17 +158,17 @@ def format_relation_name(value, format_type=None): "settings are now 'JSON_API_FORMAT_TYPES' and 'JSON_API_PLURALIZE_TYPES'" ) if format_type is None: - format_type = getattr(settings, 'JSON_API_FORMAT_RELATION_KEYS', None) - pluralize = getattr(settings, 'JSON_API_PLURALIZE_RELATION_TYPE', None) + format_type = json_api_settings.FORMAT_RELATION_KEYS + pluralize = json_api_settings.PLURALIZE_RELATION_TYPE return format_resource_type(value, format_type, pluralize) def format_resource_type(value, format_type=None, pluralize=None): if format_type is None: - format_type = getattr(settings, 'JSON_API_FORMAT_TYPES', False) + format_type = json_api_settings.FORMAT_TYPES if pluralize is None: - pluralize = getattr(settings, 'JSON_API_PLURALIZE_TYPES', False) + pluralize = json_api_settings.PLURALIZE_TYPES if format_type: # format_type will never be None here so we can use format_value From f3dfa29869fa9dd19a71858e92539b1b1cff6b08 Mon Sep 17 00:00:00 2001 From: Matt Layman Date: Thu, 26 Apr 2018 11:08:47 -0400 Subject: [PATCH 010/488] Add a CONTRIBUTING.md file. (#425) This branch adds a skeleton of contribution guidelines and cleans up little bits that I noticed along the way. For #424 --- LICENSE | 2 +- docs/CONTRIBUTING.md | 30 ++++++++++++++++++++++++++++++ docs/_static/.gitkeep | 0 docs/conf.py | 8 +++++--- docs/index.rst | 1 + requirements-development.txt | 7 ++++--- 6 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 docs/CONTRIBUTING.md create mode 100644 docs/_static/.gitkeep diff --git a/LICENSE b/LICENSE index ed0db152..8c11d568 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2014 nGen Works Company and individual contributors. +Copyright (c) 2018 nGen Works Company and individual contributors. All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 00000000..5ab82991 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,30 @@ +Contributing +============ + +DJA should be easy to contribute to. +If anything is unclear about how to contribute, +please submit an issue on GitHub so that we can fix it! + +How +--- + +Before writing any code, +have a conversation on a GitHub issue +to see if the proposed change makes sense +for the project. + +Fork DJA on [GitHub](https://github.com/django-json-api/django-rest-framework-json-api) and +[submit a Pull Request](https://help.github.com/articles/creating-a-pull-request/) +when you're ready. + +For maintainers +--------------- + +To upload a release (using version 1.2.3 as the example): + +```bash +(venv)$ python setup.py sdist bdist_wheel +(venv)$ twine upload dist/* +(venv)$ git tag -a v1.2.3 -m 'Release 1.2.3' +(venv)$ git push --tags +``` diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/docs/conf.py b/docs/conf.py index 76c5f14c..7a61ea8a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,6 +13,7 @@ # All configuration values have a default; values that are commented out # serve to show the default. +import datetime import sys import os import shlex @@ -52,8 +53,9 @@ # General information about the project. project = 'Django REST Framework JSON API' -copyright = '2015, Jerel Unruh and contributors' -author = 'Jerel Unruh' +year = datetime.date.today().year +copyright = '{}, Django REST Framework JSON API contributors'.format(year) +author = 'Django REST Framework JSON API contributors' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -235,7 +237,7 @@ # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'DjangoRESTFrameworkJSONAPI.tex', 'Django REST Framework JSON API Documentation', - 'Jerel Unruh', 'manual'), + 'Django REST Framework JSON API contributors', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of diff --git a/docs/index.rst b/docs/index.rst index a9d4a7fc..b18b8b6e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,6 +14,7 @@ Contents: getting-started usage api + CONTRIBUTING Indices and tables ================== diff --git a/requirements-development.txt b/requirements-development.txt index 2a46de82..f5c7cacb 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -1,15 +1,16 @@ -e . +django-debug-toolbar django-polymorphic>=2.0 +factory-boy Faker isort mock +packaging==16.8 pytest pytest-django -factory-boy pytest-factoryboy recommonmark Sphinx sphinx_rtd_theme tox -django-debug-toolbar -packaging==16.8 +twine From 925ecad57e43c9aba70e167c2c40c2b78f9ecf05 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 1 May 2018 15:19:41 +0200 Subject: [PATCH 011/488] Add pull request template --- docs/pull_request_template.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 docs/pull_request_template.md diff --git a/docs/pull_request_template.md b/docs/pull_request_template.md new file mode 100644 index 00000000..a862bcbb --- /dev/null +++ b/docs/pull_request_template.md @@ -0,0 +1,11 @@ +Fixes # + +## Description of the Change + +## Checklist + +- [ ] PR only contains one change (considered splitting up PR) +- [ ] unit-test added +- [ ] documentation updated +- [ ] changelog entry added to `CHANGELOG.md` +- [ ] author name in `AUTHORS` From ad3dd293ac40f08d3ad5dfb85914977f98186ab0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 6 May 2018 16:39:09 +0200 Subject: [PATCH 012/488] Allow overwriting of get_queryset() of related field (#415) * Allow overwriting of get_queryset() of related field * Always use get_queryset to retrieve queryset This allows proper overwriting of derived classes. --- docs/usage.md | 2 +- example/tests/test_relations.py | 7 ++++++- rest_framework_json_api/mixins.py | 5 +++-- rest_framework_json_api/relations.py | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 7b0b6cc0..b61d6548 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -373,7 +373,7 @@ class LineItemViewSet(viewsets.ModelViewSet): serializer_class = LineItemSerializer def get_queryset(self): - queryset = self.queryset + queryset = super(LineItemViewSet, self).get_queryset() # if this viewset is accessed via the 'order-lineitems-list' route, # it wll have been passed the `order_pk` kwarg and the queryset diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py index b4f40446..e7d27b76 100644 --- a/example/tests/test_relations.py +++ b/example/tests/test_relations.py @@ -129,8 +129,13 @@ def test_invalid_resource_id_object(self): } +class BlogResourceRelatedField(ResourceRelatedField): + def get_queryset(self): + return Blog.objects + + class BlogFKSerializer(serializers.Serializer): - blog = ResourceRelatedField(queryset=Blog.objects) + blog = BlogResourceRelatedField() class EntryFKSerializer(serializers.Serializer): diff --git a/rest_framework_json_api/mixins.py b/rest_framework_json_api/mixins.py index 7b98d62e..1fa8741d 100644 --- a/rest_framework_json_api/mixins.py +++ b/rest_framework_json_api/mixins.py @@ -12,10 +12,11 @@ def get_queryset(self): """ Override :meth:``get_queryset`` """ + queryset = super(MultipleIDMixin, self).get_queryset() if hasattr(self.request, 'query_params'): ids = dict(self.request.query_params).get('ids[]') else: ids = dict(self.request.QUERY_PARAMS).get('ids[]') if ids: - self.queryset = self.queryset.filter(id__in=ids) - return self.queryset + queryset = queryset.filter(id__in=ids) + return queryset diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index a6d99c5e..27644919 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -150,7 +150,7 @@ def to_internal_value(self, data): if not isinstance(data, dict): self.fail('incorrect_type', data_type=type(data).__name__) - expected_relation_type = get_resource_type_from_queryset(self.queryset) + expected_relation_type = get_resource_type_from_queryset(self.get_queryset()) serializer_resource_type = self.get_resource_type_from_included_serializer() if serializer_resource_type is not None: From 409fb659f8ff2af00581524bc8ff5a2d8318d625 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 17 May 2018 09:22:53 -0400 Subject: [PATCH 013/488] Issue 430: pagination enhancement (#434) * Add configurable pagination query parameter names. --- CHANGELOG.md | 5 +++ docs/usage.md | 55 +++++++++++++++++++++++---- example/tests/unit/test_pagination.py | 29 ++++++++++++-- rest_framework_json_api/pagination.py | 41 ++++++++++++++++++-- 4 files changed, 116 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2b441f..21513fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v2.5.0 - [unreleased] +* Add new pagination classes based on JSON:API query parameter *recommendations*: + * JsonApiPageNumberPagination and JsonApiLimitOffsetPagination. See [usage docs](docs/usage.md#pagination). + * Deprecates PageNumberPagination and LimitOffsetPagination. + v2.4.0 - Released January 25, 2018 * Add support for Django REST Framework 3.7.x. diff --git a/docs/usage.md b/docs/usage.md index b61d6548..c0d8d21e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -15,7 +15,7 @@ REST_FRAMEWORK = { 'PAGE_SIZE': 10, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', 'DEFAULT_PAGINATION_CLASS': - 'rest_framework_json_api.pagination.PageNumberPagination', + 'rest_framework_json_api.pagination.JsonApiPageNumberPagination', 'DEFAULT_PARSER_CLASSES': ( 'rest_framework_json_api.parsers.JSONParser', 'rest_framework.parsers.FormParser', @@ -34,14 +34,55 @@ REST_FRAMEWORK = { } ``` -If `PAGE_SIZE` is set the renderer will return a `meta` object with +### Pagination + +DJA pagination is based on [DRF pagination](http://www.django-rest-framework.org/api-guide/pagination/). + +When pagination is enabled, the renderer will return a `meta` object with record count and a `links` object with the next, previous, first, and last links. -Pages can be selected with the `page` GET parameter. The query parameter used to -retrieve the page can be customized by subclassing `PageNumberPagination` and -overriding the `page_query_param`. Page size can be controlled per request via -the `PAGINATE_BY_PARAM` query parameter (`page_size` by default). -#### Performance Testing +#### Configuring the Pagination Style + +Pagination style can be set on a particular viewset with the `pagination_class` attribute or by default for all viewsets +by setting `REST_FRAMEWORK['DEFAULT_PAGINATION_CLASS']` and by setting `REST_FRAMEWORK['PAGE_SIZE']`. + +You can configure fixed values for the page size or limit -- or allow the client to choose the size or limit +via query parameters. + +Two pagination classes are available: +- `JsonApiPageNumberPagination` breaks a response up into pages that start at a given page number with a given size + (number of items per page). It can be configured with the following attributes: + - `page_query_param` (default `page[number]`) + - `page_size_query_param` (default `page[size]`) Set this to `None` if you don't want to allow the client + to specify the size. + - `max_page_size` (default `100`) enforces an upper bound on the `page_size_query_param`. + Set it to `None` if you don't want to enforce an upper bound. +- `JsonApiLimitOffsetPagination` breaks a response up into pages that start from an item's offset in the viewset for + a given number of items (the limit). + It can be configured with the following attributes: + - `offset_query_param` (default `page[offset]`). + - `limit_query_param` (default `page[limit]`). + - `max_limit` (default `100`) enforces an upper bound on the limit. + Set it to `None` if you don't want to enforce an upper bound. + + +These examples show how to configure the parameters to use non-standard names and different limits: + +```python +from rest_framework_json_api.pagination import JsonApiPageNumberPagination, JsonApiLimitOffsetPagination + +class MyPagePagination(JsonApiPageNumberPagination): + page_query_param = 'page_number' + page_size_query_param = 'page_size' + max_page_size = 1000 + +class MyLimitPagination(JsonApiLimitOffsetPagination): + offset_query_param = 'offset' + limit_query_param = 'limit' + max_limit = None +``` + +### Performance Testing If you are trying to see if your viewsets are configured properly to optimize performance, it is preferable to use `example.utils.BrowsableAPIRendererWithoutForms` instead of the default `BrowsableAPIRenderer` diff --git a/example/tests/unit/test_pagination.py b/example/tests/unit/test_pagination.py index 2a2c5fd1..f6e95db0 100644 --- a/example/tests/unit/test_pagination.py +++ b/example/tests/unit/test_pagination.py @@ -1,21 +1,23 @@ +import sys from collections import OrderedDict +import pytest from rest_framework.request import Request from rest_framework.test import APIRequestFactory from rest_framework.utils.urls import replace_query_param -from rest_framework_json_api.pagination import LimitOffsetPagination +from rest_framework_json_api import pagination factory = APIRequestFactory() class TestLimitOffset: """ - Unit tests for `pagination.LimitOffsetPagination`. + Unit tests for `pagination.JsonApiLimitOffsetPagination`. """ def setup(self): - class ExamplePagination(LimitOffsetPagination): + class ExamplePagination(pagination.JsonApiLimitOffsetPagination): default_limit = 10 max_limit = 15 @@ -76,3 +78,24 @@ def test_valid_offset_limit(self): assert queryset == list(range(offset + 1, next_offset + 1)) assert content == expected_content + + def test_limit_offset_deprecation(self): + with pytest.warns(DeprecationWarning) as record: + pagination.LimitOffsetPagination() + assert len(record) == 1 + assert 'LimitOffsetPagination' in str(record[0].message) + + +# TODO: This test fails under py27 but it's not clear why so just leave it out for now. +@pytest.mark.xfail((sys.version_info.major, sys.version_info.minor) == (2, 7), + reason="python2.7 fails for unknown reason") +class TestPageNumber: + """ + Unit tests for `pagination.JsonApiPageNumberPagination`. + TODO: add unit tests for changing query parameter names, limits, etc. + """ + def test_page_number_deprecation(self): + with pytest.warns(DeprecationWarning) as record: + pagination.PageNumberPagination() + assert len(record) == 1 + assert 'PageNumberPagination' in str(record[0].message) diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index f36cdfe6..13258760 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -1,6 +1,7 @@ """ Pagination fields """ +import warnings from collections import OrderedDict from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination @@ -8,12 +9,12 @@ from rest_framework.views import Response -class PageNumberPagination(PageNumberPagination): +class JsonApiPageNumberPagination(PageNumberPagination): """ A json-api compatible pagination format """ - - page_size_query_param = 'page_size' + page_query_param = 'page[number]' + page_size_query_param = 'page[size]' max_page_size = 100 def build_link(self, index): @@ -49,7 +50,7 @@ def get_paginated_response(self, data): }) -class LimitOffsetPagination(LimitOffsetPagination): +class JsonApiLimitOffsetPagination(LimitOffsetPagination): """ A limit/offset based style. For example: http://api.example.org/accounts/?page[limit]=100 @@ -57,6 +58,7 @@ class LimitOffsetPagination(LimitOffsetPagination): """ limit_query_param = 'page[limit]' offset_query_param = 'page[offset]' + max_limit = 100 def get_last_link(self): if self.count == 0: @@ -96,3 +98,34 @@ def get_paginated_response(self, data): ('prev', self.get_previous_link()) ]) }) + + +class PageNumberPagination(JsonApiPageNumberPagination): + """ + Deprecated paginator that uses different query parameters + """ + page_query_param = 'page' + page_size_query_param = 'page_size' + + def __init__(self): + warnings.warn( + 'PageNumberPagination is deprecated. Use JsonApiPageNumberPagination ' + 'or create custom pagination. See ' + 'http://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', + DeprecationWarning) + super(PageNumberPagination, self).__init__() + + +class LimitOffsetPagination(JsonApiLimitOffsetPagination): + """ + Deprecated paginator that uses a different max_limit + """ + max_limit = None + + def __init__(self): + warnings.warn( + 'LimitOffsetPagination is deprecated. Use JsonApiLimitOffsetPagination ' + 'or create custom pagination. See ' + 'http://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', + DeprecationWarning) + super(LimitOffsetPagination, self).__init__() From 9246279d4b365801e457cd4ccf020ec39a2952e9 Mon Sep 17 00:00:00 2001 From: lcary Date: Mon, 21 May 2018 14:40:42 -0400 Subject: [PATCH 014/488] Extend ReadOnlyModelViewSet with prefetch mixins This change creates a ReadOnlyModelViewSet with the required prefetch mixins. --- rest_framework_json_api/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 0156bccc..f43fec8e 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -102,6 +102,12 @@ class ModelViewSet(AutoPrefetchMixin, PrefetchForIncludesHelperMixin, viewsets.M pass +class ReadOnlyModelViewSet(AutoPrefetchMixin, + PrefetchForIncludesHelperMixin, + viewsets.ReadOnlyModelViewSet): + pass + + class RelationshipView(generics.GenericAPIView): serializer_class = ResourceIdentifierObjectSerializer self_link_view_name = None From d8d4f5dc91089155b47638f7a3389d00c7993ba2 Mon Sep 17 00:00:00 2001 From: Luc Cary Date: Tue, 22 May 2018 10:17:33 -0400 Subject: [PATCH 015/488] Add documentation and unit tests for ReadOnlyModelViewSet --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/usage.md | 28 +++++++++++++++++++--------- example/tests/unit/test_renderers.py | 27 ++++++++++++++++++++++----- rest_framework_json_api/views.py | 2 +- 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/AUTHORS b/AUTHORS index 6a90ff25..b34f17c6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -6,6 +6,7 @@ Greg Aker Jamie Bliss Jerel Unruh Léo S. +Luc Cary Matt Layman Ola Tarkowska Oliver Sauder diff --git a/CHANGELOG.md b/CHANGELOG.md index 21513fda..61d85b52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ v2.5.0 - [unreleased] * Add new pagination classes based on JSON:API query parameter *recommendations*: * JsonApiPageNumberPagination and JsonApiLimitOffsetPagination. See [usage docs](docs/usage.md#pagination). * Deprecates PageNumberPagination and LimitOffsetPagination. +* Add ReadOnlyModelViewSet extension with prefetch mixins. v2.4.0 - Released January 25, 2018 diff --git a/docs/usage.md b/docs/usage.md index c0d8d21e..990638f9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -612,19 +612,29 @@ class QuestSerializer(serializers.ModelSerializer): #### Performance improvements -Be aware that using included resources without any form of prefetching **WILL HURT PERFORMANCE** as it will introduce m*(n+1) queries. +Be aware that using included resources without any form of prefetching **WILL HURT PERFORMANCE** as it will introduce m\*(n+1) queries. A viewset helper was designed to allow for greater flexibility and it is automatically available when subclassing -`views.ModelViewSet` -``` - # When MyViewSet is called with ?include=author it will dynamically prefetch author and author.bio - class MyViewSet(viewsets.ModelViewSet): +`rest_framework_json_api.views.ModelViewSet`: +```python +from rest_framework_json_api import views + +# When MyViewSet is called with ?include=author it will dynamically prefetch author and author.bio +class MyViewSet(views.ModelViewSet): queryset = Book.objects.all() prefetch_for_includes = { - '__all__': [], - 'author': ['author', 'author__bio'] - 'category.section': ['category'] -} + '__all__': [], + 'author': ['author', 'author__bio'], + 'category.section': ['category'] + } +``` + +An additional convenience DJA class exists for read-only views, just as it does in DRF. +```python +from rest_framework_json_api import views + +class MyReadOnlyViewSet(views.ReadOnlyModelViewSet): + # ... ``` The special keyword `__all__` can be used to specify a prefetch which should be done regardless of the include, similar to making the prefetch yourself on the QuerySet. diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index 495bf99f..de40afac 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -33,14 +33,31 @@ class DummyTestViewSet(views.ModelViewSet): serializer_class = DummyTestSerializer +class ReadOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): + queryset = Entry.objects.all() + serializer_class = DummyTestSerializer + + +def render_dummy_test_serialized_view(view_class): + serializer = DummyTestSerializer(instance=Entry()) + renderer = JSONRenderer() + return renderer.render( + serializer.data, + renderer_context={'view': view_class()}) + + def test_simple_reverse_relation_included_renderer(): ''' Test renderer when a single reverse fk relation is passed. ''' - serializer = DummyTestSerializer(instance=Entry()) - renderer = JSONRenderer() - rendered = renderer.render( - serializer.data, - renderer_context={'view': DummyTestViewSet()}) + rendered = render_dummy_test_serialized_view( + DummyTestViewSet) + + assert rendered + + +def test_simple_reverse_relation_included_read_only_viewset(): + rendered = render_dummy_test_serialized_view( + ReadOnlyDummyTestViewSet) assert rendered diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index f43fec8e..64e5d12e 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -38,7 +38,7 @@ class MyViewSet(viewsets.ModelViewSet): queryset = Book.objects.all() prefetch_for_includes = { '__all__': [], - 'author': ['author', 'author__authorbio'] + 'author': ['author', 'author__authorbio'], 'category.section': ['category'] } """ From fbd235b8af42bfe3df8025b2f7c5e3f1f53b6aef Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 12 Jun 2018 17:22:42 +0200 Subject: [PATCH 016/488] Use tox to run test in travis This way we can harmonize tox and travis configuration --- .gitignore | 2 +- .travis.yml | 46 +++++++++++++++++++--------------------------- setup.cfg | 6 +++++- tox.ini | 9 ++++++++- 4 files changed, 33 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index ac41fdd8..5ab2be51 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,7 @@ pip-delete-this-directory.txt .idea/ # PyTest cache -.cache/ +.pytest_cache/ # Tox .tox/ diff --git a/.travis.yml b/.travis.yml index 2a30c0c5..09cd5729 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,3 @@ ---- language: python sudo: false cache: pip @@ -6,45 +5,38 @@ cache: pip matrix: include: - python: 2.7 - env: DJANGO=">=1.11,<2.0" DRF=">=3.6.3,<3.7" + env: TOXENV=py27-django111-drf36 - python: 2.7 - env: DJANGO=">=1.11,<2.0" DRF=">=3.7.0,<3.8" + env: TOXENV=py27-django111-drf37 - python: 3.4 - env: DJANGO=">=1.11,<2.0" DRF=">=3.6.3,<3.7" + env: TOXENV=py34-django111-drf36 - python: 3.4 - env: DJANGO=">=1.11,<2.0" DRF=">=3.7.0,<3.8" + env: TOXENV=py34-django111-drf37 - python: 3.4 - env: DJANGO=">=2.0,<2.1" DRF=">=3.7.0,<3.8" + env: TOXENV=py34-django20-drf37 - python: 3.5 - env: DJANGO=">=1.11,<2.0" DRF=">=3.6.3,<3.7" + env: TOXENV=py35-django111-drf36 - python: 3.5 - env: DJANGO=">=1.11,<2.0" DRF=">=3.7.0,<3.8" + env: TOXENV=py35-django111-drf37 - python: 3.5 - env: DJANGO=">=2.0,<2.1" DRF=">=3.7.0,<3.8" + env: TOXENV=py35-django20-drf37 - python: 3.6 - env: DJANGO=">=1.11,<2.0" DRF=">=3.6.3,<3.7" + env: TOXENV=py36-django111-drf36 - python: 3.6 - env: DJANGO=">=1.11,<2.0" DRF=">=3.7.0,<3.8" + env: TOXENV=py36-django111-drf37 - python: 3.6 - env: DJANGO=">=2.0,<2.1" DRF=">=3.7.0,<3.8" -before_install: - # Force an upgrade of py & pytest to avoid VersionConflict - - pip install --upgrade py - # Faker requires a newer pytest - - pip install "pytest>3.3" - - pip install codecov flake8 isort + env: TOXENV=py36-django20-drf37 + - python: 3.6 + env: TOXENV=flake8 + - python: 3.6 + env: TOXENV=isort install: - - pip install Django${DJANGO} djangorestframework${DRF} - - python setup.py install + - pip install tox script: - - flake8 - - isort --check-only --verbose --recursive --diff rest_framework_json_api - # example has extra dependencies that are installed in a dev environment - # but are not installed in CI. Explicitly set those packages. - - isort --check-only --verbose --recursive --diff --thirdparty pytest --thirdparty polymorphic --thirdparty pytest_factoryboy --thirdparty packaging example - - coverage run setup.py -v test + - tox after_success: - - codecov + - pip install codecov + - codecov -e TOXENV diff --git a/setup.cfg b/setup.cfg index 10bd79e4..6b206f6f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,7 +7,11 @@ universal = 1 [flake8] ignore = F405 max-line-length = 100 -exclude = docs/conf.py,build,migrations +exclude = + docs/conf.py, + build, + migrations, + .tox, [isort] indent = 4 diff --git a/tox.ini b/tox.ini index dc7470e0..cd161419 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,12 @@ [tox] envlist = py{27,34,35,36}-django111-drf{36,37}, + py{34,35,36}-django20-drf{37}, [testenv] deps = django111: Django>=1.11,<1.12 + django20: Django>=2.0,<2.1 drf36: djangorestframework>=3.6.3,<3.7 drf37: djangorestframework>=3.7.0,<3.8 @@ -15,6 +17,11 @@ setenv = commands = python setup.py test {posargs} +[testenv:flake8] +deps = flake8 +commands = flake8 +skip_install = true + [testenv:isort] deps = isort @@ -22,4 +29,4 @@ commands = isort --check-only --verbose --recursive --diff rest_framework_json_api # example has extra dependencies that are installed in a dev environment # but are not installed in CI. Explicitly set those packages. - isort --check-only --verbose --recursive --diff --thirdparty pytest --thirdparty polymorphic --thirdparty pytest_factoryboy example + isort --check-only --verbose --recursive --diff --thirdparty pytest --thirdparty polymorphic --thirdparty pytest_factoryboy --thirdparty packaging example From 0f01240b5814b2d83ace72e1b61c552eaaccfaae Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 13 Jun 2018 10:38:29 +0200 Subject: [PATCH 017/488] Integrate coverage with pytest-cov --- .gitignore | 3 +++ setup.cfg | 6 ++++++ setup.py | 1 + tox.ini | 2 +- 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5ab2be51..1207cc48 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ pip-delete-this-directory.txt # PyTest cache .pytest_cache/ +# Coverage +.coverage + # Tox .tox/ .cache/ diff --git a/setup.cfg b/setup.cfg index 6b206f6f..0e89958c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,3 +22,9 @@ known_standard_library = mock line_length = 100 multi_line_output = 3 skip_glob=*migrations* + +[coverage:report] +omit= + .tox/* + .eggs/* +show_missing = True diff --git a/setup.py b/setup.py index c99639a0..7ff381e2 100755 --- a/setup.py +++ b/setup.py @@ -108,6 +108,7 @@ def get_package_data(package): 'factory-boy', 'pytest-django', 'pytest', + 'pytest-cov', 'django-polymorphic>=2.0', 'packaging', 'django-debug-toolbar' diff --git a/tox.ini b/tox.ini index cd161419..c5c341fb 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ setenv = DJANGO_SETTINGS_MODULE=example.settings.test commands = - python setup.py test {posargs} + python setup.py test --addopts '--cov --no-cov-on-fail' {posargs} [testenv:flake8] deps = flake8 From b5a1fd1787691572580364c7f06a74a8cf608af6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 13 Jun 2018 16:56:50 +0200 Subject: [PATCH 018/488] Add support for Django REST Framework 3.8.x No code changes needed. --- .travis.yml | 15 +++++++++++++++ CHANGELOG.md | 1 + README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 5 +++-- 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 09cd5729..9c102172 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,27 +8,42 @@ matrix: env: TOXENV=py27-django111-drf36 - python: 2.7 env: TOXENV=py27-django111-drf37 + - python: 2.7 + env: TOXENV=py27-django111-drf38 - python: 3.4 env: TOXENV=py34-django111-drf36 - python: 3.4 env: TOXENV=py34-django111-drf37 + - python: 3.4 + env: TOXENV=py34-django111-drf38 - python: 3.4 env: TOXENV=py34-django20-drf37 + - python: 3.4 + env: TOXENV=py34-django20-drf38 - python: 3.5 env: TOXENV=py35-django111-drf36 - python: 3.5 env: TOXENV=py35-django111-drf37 + - python: 3.5 + env: TOXENV=py35-django111-drf38 - python: 3.5 env: TOXENV=py35-django20-drf37 + - python: 3.5 + env: TOXENV=py35-django20-drf38 - python: 3.6 env: TOXENV=py36-django111-drf36 - python: 3.6 env: TOXENV=py36-django111-drf37 + - python: 3.6 + env: TOXENV=py36-django111-drf38 - python: 3.6 env: TOXENV=py36-django20-drf37 + - python: 3.6 + env: TOXENV=py36-django20-drf38 + - python: 3.6 env: TOXENV=flake8 - python: 3.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d85b52..04069795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ v2.5.0 - [unreleased] * JsonApiPageNumberPagination and JsonApiLimitOffsetPagination. See [usage docs](docs/usage.md#pagination). * Deprecates PageNumberPagination and LimitOffsetPagination. * Add ReadOnlyModelViewSet extension with prefetch mixins. +* Add support for Django REST Framework 3.8.x v2.4.0 - Released January 25, 2018 diff --git a/README.rst b/README.rst index 9f0dd9e2..1c8fb465 100644 --- a/README.rst +++ b/README.rst @@ -68,7 +68,7 @@ Requirements 1. Python (2.7, 3.4, 3.5, 3.6) 2. Django (1.11, 2.0) -3. Django REST Framework (3.6, 3.7) +3. Django REST Framework (3.6, 3.7, 3.8) ------------ Installation diff --git a/docs/getting-started.md b/docs/getting-started.md index 4b564418..d68bbdd3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,7 +53,7 @@ like the following: 1. Python (2.7, 3.4, 3.5, 3.6) 2. Django (1.11, 2.0) -3. Django REST Framework (3.6, 3.7) +3. Django REST Framework (3.6, 3.7, 3.8) ## Installation diff --git a/tox.ini b/tox.ini index c5c341fb..d5cca046 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = - py{27,34,35,36}-django111-drf{36,37}, - py{34,35,36}-django20-drf{37}, + py{27,34,35,36}-django111-drf{36,37,38}, + py{34,35,36}-django20-drf{37,38}, [testenv] deps = @@ -9,6 +9,7 @@ deps = django20: Django>=2.0,<2.1 drf36: djangorestframework>=3.6.3,<3.7 drf37: djangorestframework>=3.7.0,<3.8 + drf38: djangorestframework>=3.8.0,<3.9 setenv = PYTHONPATH = {toxinidir} From 79e773817564e2b05a5835159ed422343146965c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 18 Jun 2018 16:05:09 +0200 Subject: [PATCH 019/488] Preserve values from being formatted (#420) Introduce `JSON_API_FORMAT_FIELD_NAMES` which preserves keys of values. `JSON_API_FORMAT_KEYS` still exists but is deprecated. --- CHANGELOG.md | 9 ++++-- docs/usage.md | 6 ++-- example/api/resources/identity.py | 2 +- example/settings/dev.py | 2 +- example/settings/test.py | 2 +- example/tests/test_generic_viewset.py | 2 +- example/tests/test_model_viewsets.py | 2 +- example/tests/test_parsers.py | 21 +++++++++++-- example/tests/unit/test_renderers.py | 28 ++++++++++++++++- example/tests/unit/test_settings.py | 4 +-- example/tests/unit/test_utils.py | 16 +++++++++- rest_framework_json_api/parsers.py | 8 ++--- rest_framework_json_api/renderers.py | 16 +++++----- rest_framework_json_api/settings.py | 16 ++++++++-- rest_framework_json_api/utils.py | 45 +++++++++++++++++++++++++-- 15 files changed, 144 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04069795..3dd15afa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ v2.5.0 - [unreleased] * Add new pagination classes based on JSON:API query parameter *recommendations*: - * JsonApiPageNumberPagination and JsonApiLimitOffsetPagination. See [usage docs](docs/usage.md#pagination). - * Deprecates PageNumberPagination and LimitOffsetPagination. -* Add ReadOnlyModelViewSet extension with prefetch mixins. + * `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination`. See [usage docs](docs/usage.md#pagination). + * Deprecates `PageNumberPagination` and `LimitOffsetPagination`. +* Add `ReadOnlyModelViewSet` extension with prefetch mixins. * Add support for Django REST Framework 3.8.x +* Introduce `JSON_API_FORMAT_FIELD_NAMES` option replacing `JSON_API_FORMAT_KEYS` but in comparision preserving + values from being formatted as attributes can contain any [json value](http://jsonapi.org/format/#document-resource-object-attributes). + * `JSON_API_FORMAT_KEYS` still works as before (formating all json value keys also nested) but is marked as deprecated. v2.4.0 - Released January 25, 2018 diff --git a/docs/usage.md b/docs/usage.md index 990638f9..c8727f1b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -149,13 +149,13 @@ multiple endpoints. Setting the `resource_name` on views may result in a differe ### Inflecting object and relation keys -This package includes the ability (off by default) to automatically convert json -requests and responses from the python/rest_framework's preferred underscore to +This package includes the ability (off by default) to automatically convert [json +api field names](http://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the python/rest_framework's preferred underscore to a format of your choice. To hook this up include the following setting in your project settings: ``` python -JSON_API_FORMAT_KEYS = 'dasherize' +JSON_API_FORMAT_FIELD_NAMES = 'dasherize' ``` Possible values: diff --git a/example/api/resources/identity.py b/example/api/resources/identity.py index 470bd79c..a00def74 100644 --- a/example/api/resources/identity.py +++ b/example/api/resources/identity.py @@ -37,7 +37,7 @@ def posts(self, request): encoding.force_text('identities'): IdentitySerializer(identities, many=True).data, encoding.force_text('posts'): PostSerializer(posts, many=True).data, } - return Response(utils.format_keys(data, format_type='camelize')) + return Response(utils.format_field_names(data, format_type='camelize')) @detail_route() def manual_resource_name(self, request, *args, **kwargs): diff --git a/example/settings/dev.py b/example/settings/dev.py index 61dfa443..01e2fef1 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -65,7 +65,7 @@ INTERNAL_IPS = ('127.0.0.1', ) -JSON_API_FORMAT_KEYS = 'camelize' +JSON_API_FORMAT_FIELD_NAMES = 'camelize' JSON_API_FORMAT_TYPES = 'camelize' REST_FRAMEWORK = { 'PAGE_SIZE': 5, diff --git a/example/settings/test.py b/example/settings/test.py index 1f0e959d..bbf6e400 100644 --- a/example/settings/test.py +++ b/example/settings/test.py @@ -9,7 +9,7 @@ ROOT_URLCONF = 'example.urls_test' -JSON_API_FORMAT_KEYS = 'camelize' +JSON_API_FIELD_NAMES = 'camelize' JSON_API_FORMAT_TYPES = 'camelize' JSON_API_PLURALIZE_TYPES = True REST_FRAMEWORK.update({ diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index bfde51ea..15d9bd1e 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -4,7 +4,7 @@ from example.tests import TestBase -@override_settings(JSON_API_FORMAT_KEYS='dasherize') +@override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize') class GenericViewSet(TestBase): """ Test expected responses coming from a Generic ViewSet diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index 21c6d41a..ee3e4ba0 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -7,7 +7,7 @@ from example.tests import TestBase -@override_settings(JSON_API_FORMAT_KEYS='dasherize') +@override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize') class ModelViewSetTests(TestBase): """ Test usage with ModelViewSets, also tests pluralization, camelization, diff --git a/example/tests/test_parsers.py b/example/tests/test_parsers.py index 3c7a102c..aec80a12 100644 --- a/example/tests/test_parsers.py +++ b/example/tests/test_parsers.py @@ -1,7 +1,7 @@ import json from io import BytesIO -from django.test import TestCase +from django.test import TestCase, override_settings from rest_framework.exceptions import ParseError from rest_framework_json_api.parsers import JSONParser @@ -22,7 +22,10 @@ def __init__(self): data = { 'data': { 'id': 123, - 'type': 'Blog' + 'type': 'Blog', + 'attributes': { + 'json-value': {'JsonKey': 'JsonValue'} + }, }, 'meta': { 'random_key': 'random_value' @@ -31,13 +34,25 @@ def __init__(self): self.string = json.dumps(data) - def test_parse_include_metadata(self): + @override_settings(JSON_API_FORMAT_KEYS='camelize') + def test_parse_include_metadata_format_keys(self): parser = JSONParser() stream = BytesIO(self.string.encode('utf-8')) data = parser.parse(stream, None, self.parser_context) self.assertEqual(data['_meta'], {'random_key': 'random_value'}) + self.assertEqual(data['json_value'], {'json_key': 'JsonValue'}) + + @override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize') + def test_parse_include_metadata_format_field_names(self): + parser = JSONParser() + + stream = BytesIO(self.string.encode('utf-8')) + data = parser.parse(stream, None, self.parser_context) + + self.assertEqual(data['_meta'], {'random_key': 'random_value'}) + self.assertEqual(data['json_value'], {'JsonKey': 'JsonValue'}) def test_parse_invalid_data(self): parser = JSONParser() diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index de40afac..42a45a94 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -1,3 +1,5 @@ +import json + from rest_framework_json_api import serializers, views from rest_framework_json_api.renderers import JSONRenderer @@ -19,9 +21,14 @@ class DummyTestSerializer(serializers.ModelSerializer): related_models = RelatedModelSerializer( source='comments', many=True, read_only=True) + json_field = serializers.SerializerMethodField() + + def get_json_field(self, entry): + return {'JsonKey': 'JsonValue'} + class Meta: model = Entry - fields = ('related_models',) + fields = ('related_models', 'json_field') class JSONAPIMeta: included_resources = ('related_models',) @@ -61,3 +68,22 @@ def test_simple_reverse_relation_included_read_only_viewset(): ReadOnlyDummyTestViewSet) assert rendered + + +def test_render_format_field_names(settings): + """Test that json field is kept untouched.""" + settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' + rendered = render_dummy_test_serialized_view(DummyTestViewSet) + + result = json.loads(rendered.decode()) + assert result['data']['attributes']['json-field'] == {'JsonKey': 'JsonValue'} + + +def test_render_format_keys(settings): + """Test that json field value keys are formated.""" + delattr(settings, 'JSON_API_FORMAT_FILED_NAMES') + settings.JSON_API_FORMAT_KEYS = 'dasherize' + rendered = render_dummy_test_serialized_view(DummyTestViewSet) + + result = json.loads(rendered.decode()) + assert result['data']['attributes']['json-field'] == {'json-key': 'JsonValue'} diff --git a/example/tests/unit/test_settings.py b/example/tests/unit/test_settings.py index 516e76ec..e6b82a24 100644 --- a/example/tests/unit/test_settings.py +++ b/example/tests/unit/test_settings.py @@ -13,5 +13,5 @@ def test_settings_default(): def test_settings_override(settings): - settings.JSON_API_FORMAT_KEYS = 'dasherize' - assert json_api_settings.FORMAT_KEYS == 'dasherize' + settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' + assert json_api_settings.FORMAT_FIELD_NAMES == 'dasherize' diff --git a/example/tests/unit/test_utils.py b/example/tests/unit/test_utils.py index 46315772..a1414050 100644 --- a/example/tests/unit/test_utils.py +++ b/example/tests/unit/test_utils.py @@ -69,7 +69,8 @@ def test_format_keys(): } output = {'firstName': 'a', 'lastName': 'b'} - assert utils.format_keys(underscored, 'camelize') == output + result = pytest.deprecated_call(utils.format_keys, underscored, 'camelize') + assert result == output output = {'FirstName': 'a', 'LastName': 'b'} assert utils.format_keys(underscored, 'capitalize') == output @@ -84,6 +85,19 @@ def test_format_keys(): assert utils.format_keys([underscored], 'dasherize') == output +@pytest.mark.parametrize("format_type,output", [ + ('camelize', {'fullName': {'last-name': 'a', 'first-name': 'b'}}), + ('capitalize', {'FullName': {'last-name': 'a', 'first-name': 'b'}}), + ('dasherize', {'full-name': {'last-name': 'a', 'first-name': 'b'}}), + ('underscore', {'full_name': {'last-name': 'a', 'first-name': 'b'}}), +]) +def test_format_field_names(settings, format_type, output): + settings.JSON_API_FORMAT_FIELD_NAMES = format_type + + value = {'full_name': {'last-name': 'a', 'first-name': 'b'}} + assert utils.format_field_names(value, format_type) == output + + def test_format_value(): assert utils.format_value('first_name', 'camelize') == 'firstName' assert utils.format_value('first_name', 'capitalize') == 'FirstName' diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 98873d2e..8459f3ba 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -32,26 +32,26 @@ class JSONParser(parsers.JSONParser): @staticmethod def parse_attributes(data): attributes = data.get('attributes') - uses_format_translation = json_api_settings.FORMAT_KEYS + uses_format_translation = json_api_settings.format_type if not attributes: return dict() elif uses_format_translation: # convert back to python/rest_framework's preferred underscore format - return utils.format_keys(attributes, 'underscore') + return utils._format_object(attributes, 'underscore') else: return attributes @staticmethod def parse_relationships(data): - uses_format_translation = json_api_settings.FORMAT_KEYS + uses_format_translation = json_api_settings.format_type relationships = data.get('relationships') if not relationships: relationships = dict() elif uses_format_translation: # convert back to python/rest_framework's preferred underscore format - relationships = utils.format_keys(relationships, 'underscore') + relationships = utils._format_object(relationships, 'underscore') # Parse the relationships parsed_relationships = dict() diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 6059d2a2..ba6424ee 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -68,7 +68,7 @@ def extract_attributes(cls, fields, resource): field_name: resource.get(field_name) }) - return utils.format_keys(data) + return utils._format_object(data) @classmethod def extract_relationships(cls, fields, resource, resource_instance): @@ -281,7 +281,7 @@ def extract_relationships(cls, fields, resource, resource_instance): }) continue - return utils.format_keys(data) + return utils._format_object(data) @classmethod def extract_relation_instance(cls, field_name, field, resource_instance, serializer): @@ -405,7 +405,7 @@ def extract_included(cls, fields, resource, resource_instance, included_resource getattr(serializer, '_poly_force_type_resolution', False) ) included_cache[new_item['type']][new_item['id']] = \ - utils.format_keys(new_item) + utils._format_object(new_item) cls.extract_included( serializer_fields, serializer_resource, @@ -427,7 +427,9 @@ def extract_included(cls, fields, resource, resource_instance, included_resource relation_type, getattr(field, '_poly_force_type_resolution', False) ) - included_cache[new_item['type']][new_item['id']] = utils.format_keys(new_item) + included_cache[new_item['type']][new_item['id']] = utils._format_object( + new_item + ) cls.extract_included( serializer_fields, serializer_data, @@ -577,7 +579,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): ) meta = self.extract_meta(serializer, resource) if meta: - json_resource_obj.update({'meta': utils.format_keys(meta)}) + json_resource_obj.update({'meta': utils._format_object(meta)}) json_api_data.append(json_resource_obj) self.extract_included( @@ -594,7 +596,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): meta = self.extract_meta(serializer, serializer_data) if meta: - json_api_data.update({'meta': utils.format_keys(meta)}) + json_api_data.update({'meta': utils._format_object(meta)}) self.extract_included( fields, serializer_data, resource_instance, included_resources, included_cache @@ -620,7 +622,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): render_data['included'].append(included_cache[included_type][included_id]) if json_api_meta: - render_data['meta'] = utils.format_keys(json_api_meta) + render_data['meta'] = utils._format_object(json_api_meta) return super(JSONRenderer, self).render( render_data, accepted_media_type, renderer_context diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 40c5a96b..6c7eeffe 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -10,12 +10,15 @@ JSON_API_SETTINGS_PREFIX = 'JSON_API_' DEFAULTS = { - 'FORMAT_KEYS': False, - 'FORMAT_RELATION_KEYS': None, + 'FORMAT_FIELD_NAMES': False, 'FORMAT_TYPES': False, - 'PLURALIZE_RELATION_TYPE': None, 'PLURALIZE_TYPES': False, 'UNIFORM_EXCEPTIONS': False, + + # deprecated settings to be removed in the future + 'FORMAT_KEYS': None, + 'FORMAT_RELATION_KEYS': None, + 'PLURALIZE_RELATION_TYPE': None, } @@ -39,6 +42,13 @@ def __getattr__(self, attr): setattr(self, attr, value) return value + @property + def format_type(self): + if self.FORMAT_KEYS is not None: + return self.FORMAT_KEYS + + return self.FORMAT_FIELD_NAMES + json_api_settings = JSONAPISettings() diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index a7220084..39000216 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -98,13 +98,50 @@ def get_serializer_fields(serializer): return fields +def format_field_names(obj, format_type=None): + """ + Takes a dict and returns it with formatted keys as set in `format_type` + or `JSON_API_FORMAT_FIELD_NAMES` + + :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' + """ + if format_type is None: + format_type = json_api_settings.FORMAT_FIELD_NAMES + + if isinstance(obj, dict): + formatted = OrderedDict() + for key, value in obj.items(): + key = format_value(key, format_type) + formatted[key] = value + return formatted + + return obj + + +def _format_object(obj, format_type=None): + """Depending on settings calls either `format_keys` or `format_field_names`""" + + if json_api_settings.FORMAT_KEYS is not None: + return format_keys(obj, format_type) + + return format_field_names(obj, format_type) + + def format_keys(obj, format_type=None): """ Takes either a dict or list and returns it with camelized keys only if JSON_API_FORMAT_KEYS is set. - :format_type: Either 'dasherize', 'camelize' or 'underscore' + :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' """ + warnings.warn( + "`format_keys` function and `JSON_API_FORMAT_KEYS` setting are deprecated and will be " + "removed in the future. " + "Use `format_field_names` and `JSON_API_FIELD_NAMES` instead. Be aware that " + "`format_field_names` only formats keys and preserves value.", + DeprecationWarning + ) + if format_type is None: format_type = json_api_settings.FORMAT_KEYS @@ -138,7 +175,7 @@ def format_keys(obj, format_type=None): def format_value(value, format_type=None): if format_type is None: - format_type = json_api_settings.FORMAT_KEYS + format_type = json_api_settings.format_type if format_type == 'dasherize': # inflection can't dasherize camelCase value = inflection.underscore(value) @@ -155,7 +192,9 @@ def format_value(value, format_type=None): def format_relation_name(value, format_type=None): warnings.warn( "The 'format_relation_name' function has been renamed 'format_resource_type' and the " - "settings are now 'JSON_API_FORMAT_TYPES' and 'JSON_API_PLURALIZE_TYPES'" + "settings are now 'JSON_API_FORMAT_TYPES' and 'JSON_API_PLURALIZE_TYPES' instead of " + "'JSON_API_FORMAT_RELATION_KEYS' and 'JSON_API_PLURALIZE_RELATION_TYPE'", + DeprecationWarning ) if format_type is None: format_type = json_api_settings.FORMAT_RELATION_KEYS From 08466249c2383ac8a47ac34fc067494f17fd559c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 10 Jul 2018 10:08:50 +0200 Subject: [PATCH 020/488] Preparing release 2.5.0 --- CHANGELOG.md | 13 ++++++++----- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd15afa..f9937c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,15 @@ -v2.5.0 - [unreleased] +v2.5.0 - Released July 11, 2018 + * Add new pagination classes based on JSON:API query parameter *recommendations*: * `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination`. See [usage docs](docs/usage.md#pagination). - * Deprecates `PageNumberPagination` and `LimitOffsetPagination`. -* Add `ReadOnlyModelViewSet` extension with prefetch mixins. + * Deprecates `PageNumberPagination` and `LimitOffsetPagination` +* Add `ReadOnlyModelViewSet` extension with prefetch mixins * Add support for Django REST Framework 3.8.x -* Introduce `JSON_API_FORMAT_FIELD_NAMES` option replacing `JSON_API_FORMAT_KEYS` but in comparision preserving +* Introduce `JSON_API_FORMAT_FIELD_NAMES` option replacing `JSON_API_FORMAT_KEYS` but in comparison preserving values from being formatted as attributes can contain any [json value](http://jsonapi.org/format/#document-resource-object-attributes). - * `JSON_API_FORMAT_KEYS` still works as before (formating all json value keys also nested) but is marked as deprecated. + * `JSON_API_FORMAT_KEYS` still works as before (formatting all json value keys also nested) but is marked as deprecated +* Performance improvement when rendering included data +* Allow overwriting of `get_queryset()` in custom `ResourceRelatedField` v2.4.0 - Released January 25, 2018 diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index b9631b00..e30504ed 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = 'djangorestframework-jsonapi' -__version__ = '2.4.0' +__version__ = '2.5.0' __author__ = '' __license__ = 'MIT' __copyright__ = '' From e33df0a9ced47f35038f41b4f726aac999b2304b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 12 Jul 2018 18:57:27 +0200 Subject: [PATCH 021/488] Improve REST_FRAMEWORK settings recommendation (#444) This includes: * Testing configuration as explained in http://www.django-rest-framework.org/api-guide/testing/#configuration * Sorting configuration according to http://jsonapi.org/format/#fetching-sorting --- CHANGELOG.md | 6 ++++++ README.rst | 14 +++++++++----- docs/usage.md | 8 ++++++++ example/settings/dev.py | 4 ++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9937c64..61c4512b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +[unreleased] + +* Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](http://www.django-rest-framework.org/api-guide/testing/#configuration) +* Add sorting configuration to `REST_FRAMEWORK` as defined in [json api spec](http://jsonapi.org/format/#fetching-sorting) + + v2.5.0 - Released July 11, 2018 * Add new pagination classes based on JSON:API query parameter *recommendations*: diff --git a/README.rst b/README.rst index 1c8fb465..f4cfdf21 100644 --- a/README.rst +++ b/README.rst @@ -140,7 +140,7 @@ override ``settings.REST_FRAMEWORK`` 'PAGE_SIZE': 10, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', 'DEFAULT_PAGINATION_CLASS': - 'rest_framework_json_api.pagination.PageNumberPagination', + 'rest_framework_json_api.pagination.JsonApiPageNumberPagination', 'DEFAULT_PARSER_CLASSES': ( 'rest_framework_json_api.parsers.JSONParser', 'rest_framework.parsers.FormParser', @@ -151,10 +151,14 @@ override ``settings.REST_FRAMEWORK`` 'rest_framework.renderers.BrowsableAPIRenderer', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', + 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework.filters.OrderingFilter', + ), + 'ORDERING_PARAM': 'sort', + 'TEST_REQUEST_RENDERER_CLASSES': ( + 'rest_framework_json_api.renderers.JSONRenderer', + ), + 'TEST_REQUEST_DEFAULT_FORMAT': 'vnd.api+json' } -If ``PAGINATE_BY`` is set the renderer will return a ``meta`` object with -record count and a ``links`` object with the next and previous links. Pages -can be specified with the ``page`` GET parameter. - This package provides much more including automatic inflection of JSON keys, extra top level data (using nested serializers), relationships, links, and handy shortcuts like MultipleIDMixin. Read more at http://django-rest-framework-json-api.readthedocs.org/ diff --git a/docs/usage.md b/docs/usage.md index c8727f1b..714137f4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -31,6 +31,14 @@ REST_FRAMEWORK = { 'rest_framework.renderers.BrowsableAPIRenderer' ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', + 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework.filters.OrderingFilter', + ), + 'ORDERING_PARAM': 'sort', + 'TEST_REQUEST_RENDERER_CLASSES': ( + 'rest_framework_json_api.renderers.JSONRenderer', + ), + 'TEST_REQUEST_DEFAULT_FORMAT': 'vnd.api+json' } ``` diff --git a/example/settings/dev.py b/example/settings/dev.py index 01e2fef1..5f938f78 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -88,6 +88,10 @@ 'rest_framework.renderers.BrowsableAPIRenderer', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', + 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework.filters.OrderingFilter', + ), + 'ORDERING_PARAM': 'sort', 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', ), From cb7f830acc3828b6e7ab8eb4a2aec8fdb4d88bf1 Mon Sep 17 00:00:00 2001 From: Anton Shutik Date: Thu, 19 Jul 2018 16:06:57 +0300 Subject: [PATCH 022/488] Add HyperlinkedRelatedField and SerializerMethodHyperlinkedRelatedField fields (#445) --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/usage.md | 8 + example/serializers.py | 46 ++++- .../test_non_paginated_responses.py | 50 ++++++ example/tests/integration/test_pagination.py | 25 +++ example/tests/test_relations.py | 164 +++++++++++++++++- example/tests/test_views.py | 124 +++++++++++++ example/urls.py | 13 ++ example/urls_test.py | 15 ++ example/views.py | 22 +++ rest_framework_json_api/relations.py | 143 ++++++++++----- rest_framework_json_api/renderers.py | 48 ++--- 13 files changed, 592 insertions(+), 68 deletions(-) diff --git a/AUTHORS b/AUTHORS index b34f17c6..103d327b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,6 +1,7 @@ Adam Wróbel Adam Ziolkowski Alan Crosswell +Anton Shutik Christian Zosel Greg Aker Jamie Bliss diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c4512b..7bad2f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](http://www.django-rest-framework.org/api-guide/testing/#configuration) * Add sorting configuration to `REST_FRAMEWORK` as defined in [json api spec](http://jsonapi.org/format/#fetching-sorting) +* Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields) v2.5.0 - Released July 11, 2018 diff --git a/docs/usage.md b/docs/usage.md index 714137f4..57479c96 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -324,6 +324,8 @@ When set to pluralize: ### Related fields +#### ResourceRelatedField + Because of the additional structure needed to represent relationships in JSON API, this package provides the `ResourceRelatedField` for serializers, which works similarly to `PrimaryKeyRelatedField`. By default, @@ -435,6 +437,12 @@ class LineItemViewSet(viewsets.ModelViewSet): return queryset ``` +#### HyperlinkedRelatedField + +`HyperlinkedRelatedField` has same functionality as `ResourceRelatedField` but does +not render `data`. Use this in case you only need links of relationships and want to lower payload +and increase performance. + ### RelationshipView `rest_framework_json_api.views.RelationshipView` is used to build relationship views (see the diff --git a/example/serializers.py b/example/serializers.py index da491e7f..f43accac 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -67,19 +67,58 @@ def __init__(self, *args, **kwargs): } body_format = serializers.SerializerMethodField() + # single related from model + blog_hyperlinked = relations.HyperlinkedRelatedField( + related_link_view_name='entry-blog', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + read_only=True, + source='blog' + ) # many related from model comments = relations.ResourceRelatedField( many=True, read_only=True) + # many related hyperlinked from model + comments_hyperlinked = relations.HyperlinkedRelatedField( + related_link_view_name='entry-comments', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + many=True, + read_only=True, + source='comments' + ) # many related from serializer suggested = relations.SerializerMethodResourceRelatedField( - source='get_suggested', model=Entry, many=True, read_only=True, related_link_view_name='entry-suggested', related_link_url_kwarg='entry_pk', self_link_view_name='entry-relationships', + source='get_suggested', + model=Entry, + many=True, + read_only=True + ) + # many related hyperlinked from serializer + suggested_hyperlinked = relations.SerializerMethodHyperlinkedRelatedField( + related_link_view_name='entry-suggested', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + source='get_suggested', + model=Entry, + many=True, + read_only=True ) # single related from serializer featured = relations.SerializerMethodResourceRelatedField( source='get_featured', model=Entry, read_only=True) + # single related hyperlinked from serializer + featured_hyperlinked = relations.SerializerMethodHyperlinkedRelatedField( + related_link_view_name='entry-featured', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + source='get_featured', + model=Entry, + read_only=True + ) tags = TaggedItemSerializer(many=True, read_only=True) def get_suggested(self, obj): @@ -93,8 +132,9 @@ def get_body_format(self, obj): class Meta: model = Entry - fields = ('blog', 'headline', 'body_text', 'pub_date', 'mod_date', - 'authors', 'comments', 'featured', 'suggested', 'tags') + fields = ('blog', 'blog_hyperlinked', 'headline', 'body_text', 'pub_date', 'mod_date', + 'authors', 'comments', 'comments_hyperlinked', 'featured', 'suggested', + 'suggested_hyperlinked', 'tags', 'featured_hyperlinked') read_only_fields = ('tags',) meta_fields = ('body_format',) diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 5769f6da..dab32d94 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -35,6 +35,12 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "blog": { "data": {"type": "blogs", "id": "1"} }, + "blogHyperlinked": { + "links": { + "related": "http://testserver/entries/1/blog", + "self": "http://testserver/entries/1/relationships/blog_hyperlinked" + } + }, "authors": { "meta": {"count": 1}, "data": [{"type": "authors", "id": "1"}] @@ -43,6 +49,12 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "meta": {"count": 1}, "data": [{"type": "comments", "id": "1"}] }, + "commentsHyperlinked": { + "links": { + "related": "http://testserver/entries/1/comments", + "self": "http://testserver/entries/1/relationships/comments_hyperlinked" + } + }, "suggested": { "data": [{"type": "entries", "id": "2"}], "links": { @@ -50,6 +62,19 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "self": "http://testserver/entries/1/relationships/suggested" } }, + "suggestedHyperlinked": { + "links": { + "related": "http://testserver/entries/1/suggested/", + "self": "http://testserver/entries/1" + "/relationships/suggested_hyperlinked" + } + }, + "featuredHyperlinked": { + "links": { + "related": "http://testserver/entries/1/featured", + "self": "http://testserver/entries/1/relationships/featured_hyperlinked" + } + }, "tags": { "data": [] } @@ -73,6 +98,12 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "blog": { "data": {"type": "blogs", "id": "2"} }, + "blogHyperlinked": { + "links": { + "related": "http://testserver/entries/2/blog", + "self": "http://testserver/entries/2/relationships/blog_hyperlinked", + } + }, "authors": { "meta": {"count": 1}, "data": [{"type": "authors", "id": "2"}] @@ -81,6 +112,12 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "meta": {"count": 1}, "data": [{"type": "comments", "id": "2"}] }, + "commentsHyperlinked": { + "links": { + "related": "http://testserver/entries/2/comments", + "self": "http://testserver/entries/2/relationships/comments_hyperlinked" + } + }, "suggested": { "data": [{"type": "entries", "id": "1"}], "links": { @@ -88,6 +125,19 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "self": "http://testserver/entries/2/relationships/suggested" } }, + "suggestedHyperlinked": { + "links": { + "related": "http://testserver/entries/2/suggested/", + "self": "http://testserver/entries/2" + "/relationships/suggested_hyperlinked" + } + }, + "featuredHyperlinked": { + "links": { + "related": "http://testserver/entries/2/featured", + "self": "http://testserver/entries/2/relationships/featured_hyperlinked" + } + }, "tags": { "data": [] } diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index cff9d9af..18306e3e 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -36,6 +36,12 @@ def test_pagination_with_single_entry(single_entry, client): "blog": { "data": {"type": "blogs", "id": "1"} }, + "blogHyperlinked": { + "links": { + "related": "http://testserver/entries/1/blog", + "self": "http://testserver/entries/1/relationships/blog_hyperlinked", + } + }, "authors": { "meta": {"count": 1}, "data": [{"type": "authors", "id": "1"}] @@ -44,6 +50,12 @@ def test_pagination_with_single_entry(single_entry, client): "meta": {"count": 1}, "data": [{"type": "comments", "id": "1"}] }, + "commentsHyperlinked": { + "links": { + "related": "http://testserver/entries/1/comments", + "self": "http://testserver/entries/1/relationships/comments_hyperlinked" + } + }, "suggested": { "data": [], "links": { @@ -51,6 +63,19 @@ def test_pagination_with_single_entry(single_entry, client): "self": "http://testserver/entries/1/relationships/suggested" } }, + "suggestedHyperlinked": { + "links": { + "related": "http://testserver/entries/1/suggested/", + "self": "http://testserver/entries/1" + "/relationships/suggested_hyperlinked" + } + }, + "featuredHyperlinked": { + "links": { + "related": "http://testserver/entries/1/featured", + "self": "http://testserver/entries/1/relationships/featured_hyperlinked" + } + }, "tags": { "data": [ { diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py index e7d27b76..94db188a 100644 --- a/example/tests/test_relations.py +++ b/example/tests/test_relations.py @@ -1,15 +1,23 @@ from __future__ import absolute_import +from django.test.client import RequestFactory from django.utils import timezone from rest_framework import serializers +from rest_framework.fields import SkipField +from rest_framework.reverse import reverse from rest_framework_json_api.exceptions import Conflict -from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.relations import ( + HyperlinkedRelatedField, + ResourceRelatedField, + SerializerMethodHyperlinkedRelatedField +) from rest_framework_json_api.utils import format_resource_type from . import TestBase from example.models import Author, Blog, Comment, Entry from example.serializers import CommentSerializer +from example.views import EntryViewSet class TestResourceRelatedField(TestBase): @@ -129,6 +137,131 @@ def test_invalid_resource_id_object(self): } +class TestHyperlinkedFieldBase(TestBase): + + def setUp(self): + super(TestHyperlinkedFieldBase, self).setUp() + self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.entry = Entry.objects.create( + blog=self.blog, + headline='headline', + body_text='body_text', + pub_date=timezone.now(), + mod_date=timezone.now(), + n_comments=0, + n_pingbacks=0, + rating=3 + ) + self.comment = Comment.objects.create( + entry=self.entry, + body='testing one two three', + ) + + self.request = RequestFactory().get(reverse('entry-detail', kwargs={'pk': self.entry.pk})) + self.view = EntryViewSet(request=self.request, kwargs={'entry_pk': self.entry.id}) + + +class TestHyperlinkedRelatedField(TestHyperlinkedFieldBase): + + def test_single_hyperlinked_related_field(self): + field = HyperlinkedRelatedField( + related_link_view_name='entry-blog', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + read_only=True, + ) + field._context = {'request': self.request, 'view': self.view} + field.field_name = 'blog' + + self.assertRaises(NotImplementedError, field.to_representation, self.entry) + self.assertRaises(SkipField, field.get_attribute, self.entry) + + links_expected = { + 'self': 'http://testserver/entries/{}/relationships/blog'.format(self.entry.pk), + 'related': 'http://testserver/entries/{}/blog'.format(self.entry.pk) + } + got = field.get_links(self.entry) + self.assertEqual(got, links_expected) + + def test_many_hyperlinked_related_field(self): + field = HyperlinkedRelatedField( + related_link_view_name='entry-comments', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + read_only=True, + many=True + ) + field._context = {'request': self.request, 'view': self.view} + field.field_name = 'comments' + + self.assertRaises(NotImplementedError, field.to_representation, self.entry.comments.all()) + self.assertRaises(SkipField, field.get_attribute, self.entry) + + links_expected = { + 'self': 'http://testserver/entries/{}/relationships/comments'.format(self.entry.pk), + 'related': 'http://testserver/entries/{}/comments'.format(self.entry.pk) + } + got = field.child_relation.get_links(self.entry) + self.assertEqual(got, links_expected) + + +class TestSerializerMethodHyperlinkedRelatedField(TestHyperlinkedFieldBase): + + def test_single_serializer_method_hyperlinked_related_field(self): + serializer = EntryModelSerializerWithHyperLinks( + instance=self.entry, + context={ + 'request': self.request, + 'view': self.view + } + ) + field = serializer.fields['blog'] + + self.assertRaises(NotImplementedError, field.to_representation, self.entry) + self.assertRaises(SkipField, field.get_attribute, self.entry) + + expected = { + 'self': 'http://testserver/entries/{}/relationships/blog'.format(self.entry.pk), + 'related': 'http://testserver/entries/{}/blog'.format(self.entry.pk) + } + got = field.get_links(self.entry) + self.assertEqual(got, expected) + + def test_many_serializer_method_hyperlinked_related_field(self): + serializer = EntryModelSerializerWithHyperLinks( + instance=self.entry, + context={ + 'request': self.request, + 'view': self.view + } + ) + field = serializer.fields['comments'] + + self.assertRaises(NotImplementedError, field.to_representation, self.entry) + self.assertRaises(SkipField, field.get_attribute, self.entry) + + expected = { + 'self': 'http://testserver/entries/{}/relationships/comments'.format(self.entry.pk), + 'related': 'http://testserver/entries/{}/comments'.format(self.entry.pk) + } + got = field.get_links(self.entry) + self.assertEqual(got, expected) + + def test_get_blog(self): + serializer = EntryModelSerializerWithHyperLinks(instance=self.entry) + got = serializer.get_blog(self.entry) + expected = self.entry.blog + + self.assertEqual(got, expected) + + def test_get_comments(self): + serializer = EntryModelSerializerWithHyperLinks(instance=self.entry) + got = serializer.get_comments(self.entry) + expected = self.entry.comments.all() + + self.assertListEqual(list(got), list(expected)) + + class BlogResourceRelatedField(ResourceRelatedField): def get_queryset(self): return Blog.objects @@ -149,3 +282,32 @@ class EntryModelSerializer(serializers.ModelSerializer): class Meta: model = Entry fields = ('authors', 'comments') + + +class EntryModelSerializerWithHyperLinks(serializers.ModelSerializer): + blog = SerializerMethodHyperlinkedRelatedField( + related_link_view_name='entry-blog', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + many=True, + read_only=True, + source='get_blog' + ) + comments = SerializerMethodHyperlinkedRelatedField( + related_link_view_name='entry-comments', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + many=True, + read_only=True, + source='get_comments' + ) + + class Meta: + model = Entry + fields = ('blog', 'comments',) + + def get_blog(self, obj): + return obj.blog + + def get_comments(self, obj): + return obj.comments.all() diff --git a/example/tests/test_views.py b/example/tests/test_views.py index db3a3407..9cccf2f9 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -280,3 +280,127 @@ def test_no_content_response(self): response = self.client.delete(url) assert response.status_code == 204, response.rendered_content.decode() assert len(response.rendered_content) == 0, response.rendered_content.decode() + + +class TestBlogViewSet(APITestCase): + + def setUp(self): + self.blog = Blog.objects.create( + name='Some Blog', + tagline="It's a blog" + ) + self.entry = Entry.objects.create( + blog=self.blog, + headline='headline one', + body_text='body_text two', + ) + + def test_get_object_gives_correct_blog(self): + url = reverse('entry-blog', kwargs={'entry_pk': self.entry.id}) + resp = self.client.get(url) + expected = { + 'data': { + 'attributes': {'name': self.blog.name}, + 'id': '{}'.format(self.blog.id), + 'links': {'self': 'http://testserver/blogs/{}'.format(self.blog.id)}, + 'meta': {'copyright': 2018}, + 'relationships': {'tags': {'data': []}}, + 'type': 'blogs' + }, + 'meta': {'apiDocs': '/docs/api/blogs'} + } + got = resp.json() + self.assertEqual(got, expected) + + +class TestEntryViewSet(APITestCase): + + def setUp(self): + self.blog = Blog.objects.create( + name='Some Blog', + tagline="It's a blog" + ) + self.first_entry = Entry.objects.create( + blog=self.blog, + headline='headline two', + body_text='body_text two', + ) + self.second_entry = Entry.objects.create( + blog=self.blog, + headline='headline two', + body_text='body_text two', + ) + self.maxDiff = None + + def test_get_object_gives_correct_entry(self): + url = reverse('entry-featured', kwargs={'entry_pk': self.first_entry.id}) + resp = self.client.get(url) + expected = { + 'data': { + 'attributes': { + 'bodyText': self.second_entry.body_text, + 'headline': self.second_entry.headline, + 'modDate': self.second_entry.mod_date, + 'pubDate': self.second_entry.pub_date + }, + 'id': '{}'.format(self.second_entry.id), + 'meta': {'bodyFormat': 'text'}, + 'relationships': { + 'authors': {'data': [], 'meta': {'count': 0}}, + 'blog': { + 'data': { + 'id': '{}'.format(self.second_entry.blog_id), + 'type': 'blogs' + } + }, + 'blogHyperlinked': { + 'links': { + 'related': 'http://testserver/entries/{}' + '/blog'.format(self.second_entry.id), + 'self': 'http://testserver/entries/{}' + '/relationships/blog_hyperlinked'.format(self.second_entry.id) + } + }, + 'comments': { + 'data': [], + 'meta': {'count': 0} + }, + 'commentsHyperlinked': { + 'links': { + 'related': 'http://testserver/entries/{}' + '/comments'.format(self.second_entry.id), + 'self': 'http://testserver/entries/{}/relationships' + '/comments_hyperlinked'.format(self.second_entry.id) + } + }, + 'featuredHyperlinked': { + 'links': { + 'related': 'http://testserver/entries/{}' + '/featured'.format(self.second_entry.id), + 'self': 'http://testserver/entries/{}/relationships' + '/featured_hyperlinked'.format(self.second_entry.id) + } + }, + 'suggested': { + 'data': [{'id': '1', 'type': 'entries'}], + 'links': { + 'related': 'http://testserver/entries/{}' + '/suggested/'.format(self.second_entry.id), + 'self': 'http://testserver/entries/{}' + '/relationships/suggested'.format(self.second_entry.id) + } + }, + 'suggestedHyperlinked': { + 'links': { + 'related': 'http://testserver/entries/{}' + '/suggested/'.format(self.second_entry.id), + 'self': 'http://testserver/entries/{}/relationships' + '/suggested_hyperlinked'.format(self.second_entry.id) + } + }, + 'tags': {'data': []}}, + 'type': 'posts' + } + } + got = resp.json() + self.assertEqual(got, expected) diff --git a/example/urls.py b/example/urls.py index 688ce70e..469ce53a 100644 --- a/example/urls.py +++ b/example/urls.py @@ -32,6 +32,19 @@ EntryViewSet.as_view({'get': 'list'}), name='entry-suggested' ), + url(r'entries/(?P[^/.]+)/blog', + BlogViewSet.as_view({'get': 'retrieve'}), + name='entry-blog'), + url(r'entries/(?P[^/.]+)/comments', + CommentViewSet.as_view({'get': 'list'}), + name='entry-comments'), + url(r'entries/(?P[^/.]+)/authors', + AuthorViewSet.as_view({'get': 'list'}), + name='entry-authors'), + url(r'entries/(?P[^/.]+)/featured', + EntryViewSet.as_view({'get': 'retrieve'}), + name='entry-featured'), + url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)', EntryRelationshipView.as_view(), name='entry-relationships'), diff --git a/example/urls_test.py b/example/urls_test.py index 486ce418..3ec07380 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -37,10 +37,25 @@ GenericIdentity.as_view(), name='user-default'), + url(r'^entries/(?P[^/.]+)/blog', + BlogViewSet.as_view({'get': 'retrieve'}), + name='entry-blog' + ), + url(r'^entries/(?P[^/.]+)/comments', + CommentViewSet.as_view({'get': 'list'}), + name='entry-comments' + ), url(r'^entries/(?P[^/.]+)/suggested/', EntryViewSet.as_view({'get': 'list'}), name='entry-suggested' ), + url(r'entries/(?P[^/.]+)/authors', + AuthorViewSet.as_view({'get': 'list'}), + name='entry-authors'), + url(r'entries/(?P[^/.]+)/featured', + EntryViewSet.as_view({'get': 'retrieve'}), + name='entry-featured'), + url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)', EntryRelationshipView.as_view(), name='entry-relationships'), diff --git a/example/views.py b/example/views.py index 6182c6df..5dfc3341 100644 --- a/example/views.py +++ b/example/views.py @@ -26,6 +26,13 @@ class BlogViewSet(ModelViewSet): queryset = Blog.objects.all() serializer_class = BlogSerializer + def get_object(self): + entry_pk = self.kwargs.get('entry_pk', None) + if entry_pk is not None: + return Entry.objects.get(id=entry_pk).blog + + return super(BlogViewSet, self).get_object() + class JsonApiViewSet(ModelViewSet): """ @@ -68,6 +75,14 @@ class EntryViewSet(ModelViewSet): def get_serializer_class(self): return EntrySerializer + def get_object(self): + # Handle featured + entry_pk = self.kwargs.get('entry_pk', None) + if entry_pk is not None: + return Entry.objects.exclude(pk=entry_pk).first() + + return super(EntryViewSet, self).get_object() + class NoPagination(PageNumberPagination): page_size = None @@ -90,6 +105,13 @@ class CommentViewSet(ModelViewSet): 'author': ['author__bio', 'author__entries'], } + def get_queryset(self, *args, **kwargs): + entry_pk = self.kwargs.get('entry_pk', None) + if entry_pk is not None: + return self.queryset.filter(entry_id=entry_pk) + + return super(CommentViewSet, self).get_queryset() + class CompanyViewset(ModelViewSet): queryset = Company.objects.all() diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 27644919..1c51d2da 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -7,8 +7,10 @@ from django.core.exceptions import ImproperlyConfigured from django.urls import NoReverseMatch from django.utils.translation import ugettext_lazy as _ -from rest_framework.fields import MISSING_ERROR_MESSAGE -from rest_framework.relations import MANY_RELATION_KWARGS, PrimaryKeyRelatedField +from rest_framework.fields import MISSING_ERROR_MESSAGE, SkipField +from rest_framework.relations import MANY_RELATION_KWARGS +from rest_framework.relations import ManyRelatedField as DRFManyRelatedField +from rest_framework.relations import PrimaryKeyRelatedField, RelatedField from rest_framework.reverse import reverse from rest_framework.serializers import Serializer @@ -29,26 +31,31 @@ ] -class ResourceRelatedField(PrimaryKeyRelatedField): - _skip_polymorphic_optimization = True +class SkipDataMixin(object): + """ + This workaround skips "data" rendering for relationships + in order to save some sql queries and improve performance + """ + + def __init__(self, *args, **kwargs): + super(SkipDataMixin, self).__init__(*args, **kwargs) + + def get_attribute(self, instance): + raise SkipField + + def to_representation(self, *args): + raise NotImplementedError + + +class ManyRelatedFieldWithNoData(SkipDataMixin, DRFManyRelatedField): + pass + + +class HyperlinkedMixin(object): self_link_view_name = None related_link_view_name = None related_link_lookup_field = 'pk' - default_error_messages = { - 'required': _('This field is required.'), - 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), - 'incorrect_type': _( - 'Incorrect type. Expected resource identifier object, received {data_type}.' - ), - 'incorrect_relation_type': _( - 'Incorrect relation type. Expected {relation_type}, received {received_type}.' - ), - 'missing_type': _('Invalid resource identifier object: missing \'type\' attribute'), - 'missing_id': _('Invalid resource identifier object: missing \'id\' attribute'), - 'no_match': _('Invalid hyperlink - No URL match.'), - } - def __init__(self, self_link_view_name=None, related_link_view_name=None, **kwargs): if self_link_view_name is not None: self.self_link_view_name = self_link_view_name @@ -62,34 +69,12 @@ def __init__(self, self_link_view_name=None, related_link_view_name=None, **kwar 'related_link_url_kwarg', self.related_link_lookup_field ) - # check for a model class that was passed in for the relation type - model = kwargs.pop('model', None) - if model: - self.model = model - # We include this simply for dependency injection in tests. # We can't add it as a class attributes or it would expect an # implicit `self` argument to be passed. self.reverse = reverse - super(ResourceRelatedField, self).__init__(**kwargs) - - def use_pk_only_optimization(self): - # We need the real object to determine its type... - return self.get_resource_type_from_included_serializer() is not None - - def conflict(self, key, **kwargs): - """ - A helper method that simply raises a validation error. - """ - try: - msg = self.error_messages[key] - except KeyError: - class_name = self.__class__.__name__ - msg = MISSING_ERROR_MESSAGE.format(class_name=class_name, key=key) - raise AssertionError(msg) - message_string = msg.format(**kwargs) - raise Conflict(message_string) + super(HyperlinkedMixin, self).__init__(**kwargs) def get_url(self, name, view_name, kwargs, request): """ @@ -140,6 +125,78 @@ def get_links(self, obj=None, lookup_field='pk'): return_data.update({'related': related_link}) return return_data + +class HyperlinkedRelatedField(HyperlinkedMixin, SkipDataMixin, RelatedField): + + @classmethod + def many_init(cls, *args, **kwargs): + """ + This method handles creating a parent `ManyRelatedField` instance + when the `many=True` keyword argument is passed. + + Typically you won't need to override this method. + + Note that we're over-cautious in passing most arguments to both parent + and child classes in order to try to cover the general case. If you're + overriding this method you'll probably want something much simpler, eg: + + @classmethod + def many_init(cls, *args, **kwargs): + kwargs['child'] = cls() + return CustomManyRelatedField(*args, **kwargs) + """ + list_kwargs = {'child_relation': cls(*args, **kwargs)} + for key in kwargs: + if key in MANY_RELATION_KWARGS: + list_kwargs[key] = kwargs[key] + return ManyRelatedFieldWithNoData(**list_kwargs) + + +class ResourceRelatedField(HyperlinkedMixin, PrimaryKeyRelatedField): + _skip_polymorphic_optimization = True + self_link_view_name = None + related_link_view_name = None + related_link_lookup_field = 'pk' + + default_error_messages = { + 'required': _('This field is required.'), + 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), + 'incorrect_type': _( + 'Incorrect type. Expected resource identifier object, received {data_type}.' + ), + 'incorrect_relation_type': _( + 'Incorrect relation type. Expected {relation_type}, received {received_type}.' + ), + 'missing_type': _('Invalid resource identifier object: missing \'type\' attribute'), + 'missing_id': _('Invalid resource identifier object: missing \'id\' attribute'), + 'no_match': _('Invalid hyperlink - No URL match.'), + } + + def __init__(self, **kwargs): + # check for a model class that was passed in for the relation type + model = kwargs.pop('model', None) + if model: + self.model = model + + super(ResourceRelatedField, self).__init__(**kwargs) + + def use_pk_only_optimization(self): + # We need the real object to determine its type... + return self.get_resource_type_from_included_serializer() is not None + + def conflict(self, key, **kwargs): + """ + A helper method that simply raises a validation error. + """ + try: + msg = self.error_messages[key] + except KeyError: + class_name = self.__class__.__name__ + msg = MISSING_ERROR_MESSAGE.format(class_name=class_name, key=key) + raise AssertionError(msg) + message_string = msg.format(**kwargs) + raise Conflict(message_string) + def to_internal_value(self, data): if isinstance(data, six.text_type): try: @@ -323,3 +380,7 @@ def to_representation(self, value): base = super(SerializerMethodResourceRelatedField, self) return [base.to_representation(x) for x in value] return super(SerializerMethodResourceRelatedField, self).to_representation(value) + + +class SerializerMethodHyperlinkedRelatedField(SkipDataMixin, SerializerMethodResourceRelatedField): + pass diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index ba6424ee..60836e97 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -2,7 +2,7 @@ Renderers """ import copy -from collections import OrderedDict, defaultdict +from collections import Iterable, OrderedDict, defaultdict import inflection from django.db.models import Manager @@ -13,7 +13,7 @@ import rest_framework_json_api from rest_framework_json_api import utils -from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.relations import HyperlinkedMixin, ResourceRelatedField, SkipDataMixin class JSONRenderer(renderers.JSONRenderer): @@ -126,7 +126,13 @@ def extract_relationships(cls, fields, resource, resource_instance): }}) continue - if isinstance(field, ResourceRelatedField): + relation_data = {} + if isinstance(field, HyperlinkedMixin): + field_links = field.get_links(resource_instance, field.related_link_lookup_field) + relation_data.update({'links': field_links} if field_links else dict()) + data.update({field_name: relation_data}) + + if isinstance(field, (ResourceRelatedField, )): relation_instance_id = getattr(resource_instance, source + "_id", None) if not relation_instance_id: resolved, relation_instance = utils.get_relation_instance(resource_instance, @@ -134,17 +140,9 @@ def extract_relationships(cls, fields, resource, resource_instance): if not resolved: continue - # special case for ResourceRelatedField - relation_data = { - 'data': resource.get(field_name) - } + if not isinstance(field, SkipDataMixin): + relation_data.update({'data': resource.get(field_name)}) - field_links = field.get_links( - resource_instance, field.related_link_lookup_field) - relation_data.update( - {'links': field_links} - if field_links else dict() - ) data.update({field_name: relation_data}) continue @@ -186,12 +184,22 @@ def extract_relationships(cls, fields, resource, resource_instance): if not resolved: continue + relation_data = {} + + if isinstance(resource.get(field_name), Iterable): + relation_data.update( + { + 'meta': {'count': len(resource.get(field_name))} + } + ) + if isinstance(field.child_relation, ResourceRelatedField): # special case for ResourceRelatedField - relation_data = { - 'data': resource.get(field_name) - } + relation_data.update( + {'data': resource.get(field_name)} + ) + if isinstance(field.child_relation, HyperlinkedMixin): field_links = field.child_relation.get_links( resource_instance, field.child_relation.related_link_lookup_field @@ -200,13 +208,7 @@ def extract_relationships(cls, fields, resource, resource_instance): {'links': field_links} if field_links else dict() ) - relation_data.update( - { - 'meta': { - 'count': len(resource.get(field_name)) - } - } - ) + data.update({field_name: relation_data}) continue From 1e54ced739529db08c6e94eec5f5abedcdf0bd4d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 24 Jul 2018 18:59:51 +0200 Subject: [PATCH 023/488] Document goals of Django REST Framework (#447) See also https://github.com/django-json-api/django-rest-framework-json-api/issues/155#issuecomment-301172555 --- README.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.rst b/README.rst index f4cfdf21..9ab99836 100644 --- a/README.rst +++ b/README.rst @@ -62,6 +62,20 @@ like the following:: } +----- +Goals +----- + +As a Django REST Framework JSON API (short DJA) we are trying to address following goals: + +1. Support the [JSON API](http://jsonapi.org/) spec to compliance +2. Be as compatible with Django REST Framework as possible + e.g. issues in Django REST Framework should be fixed upstream and not worked around in DJA +3. Have sane defaults to be as easy to pick up as possible +4. Be solid and tested with good coverage +5. Be performant + + ------------ Requirements ------------ From 3bfff93deb003907fa7640378833c48d1326f531 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 25 Jul 2018 16:39:31 +0200 Subject: [PATCH 024/488] Update documentation hyperlinks (#448) * Add additional hyperlinks * Convert http links to https where applicable --- CHANGELOG.md | 2 +- README.rst | 15 +++++++++++---- docs/usage.md | 2 +- rest_framework_json_api/pagination.py | 4 ++-- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bad2f7c..e47c5da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ [unreleased] -* Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](http://www.django-rest-framework.org/api-guide/testing/#configuration) +* Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](https://www.django-rest-framework.org/api-guide/testing/#configuration) * Add sorting configuration to `REST_FRAMEWORK` as defined in [json api spec](http://jsonapi.org/format/#fetching-sorting) * Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields) diff --git a/README.rst b/README.rst index 9ab99836..46813188 100644 --- a/README.rst +++ b/README.rst @@ -7,7 +7,7 @@ JSON API and Django Rest Framework .. image:: https://readthedocs.org/projects/django-rest-framework-json-api/badge/?version=latest :alt: Read the docs - :target: http://django-rest-framework-json-api.readthedocs.org/ + :target: https://django-rest-framework-json-api.readthedocs.org/ .. image:: https://badges.gitter.im/Join%20Chat.svg :alt: Join the chat at https://gitter.im/django-json-api/django-rest-framework-json-api @@ -19,7 +19,7 @@ Overview **JSON API support for Django REST Framework** -* Documentation: http://django-rest-framework-json-api.readthedocs.org/ +* Documentation: https://django-rest-framework-json-api.readthedocs.org/ * Format specification: http://jsonapi.org/format/ @@ -68,13 +68,20 @@ Goals As a Django REST Framework JSON API (short DJA) we are trying to address following goals: -1. Support the [JSON API](http://jsonapi.org/) spec to compliance -2. Be as compatible with Django REST Framework as possible +1. Support the `JSON API`_ spec to compliance + +2. Be as compatible with `Django REST Framework`_ as possible + e.g. issues in Django REST Framework should be fixed upstream and not worked around in DJA + 3. Have sane defaults to be as easy to pick up as possible + 4. Be solid and tested with good coverage + 5. Be performant +.. _JSON API: http://jsonapi.org +.. _Django REST Framework: https://www.django-rest-framework.org/ ------------ Requirements diff --git a/docs/usage.md b/docs/usage.md index 57479c96..d0fee78e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -44,7 +44,7 @@ REST_FRAMEWORK = { ### Pagination -DJA pagination is based on [DRF pagination](http://www.django-rest-framework.org/api-guide/pagination/). +DJA pagination is based on [DRF pagination](https://www.django-rest-framework.org/api-guide/pagination/). When pagination is enabled, the renderer will return a `meta` object with record count and a `links` object with the next, previous, first, and last links. diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index 13258760..00873c99 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -111,7 +111,7 @@ def __init__(self): warnings.warn( 'PageNumberPagination is deprecated. Use JsonApiPageNumberPagination ' 'or create custom pagination. See ' - 'http://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', + 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', DeprecationWarning) super(PageNumberPagination, self).__init__() @@ -126,6 +126,6 @@ def __init__(self): warnings.warn( 'LimitOffsetPagination is deprecated. Use JsonApiLimitOffsetPagination ' 'or create custom pagination. See ' - 'http://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', + 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', DeprecationWarning) super(LimitOffsetPagination, self).__init__() From 96c533bad51c77590d15e178f15ca541f020ff73 Mon Sep 17 00:00:00 2001 From: Anton Shutik Date: Fri, 17 Aug 2018 16:33:29 +0300 Subject: [PATCH 025/488] Add support for related links using parent view and its permissions (#451) Add RelatedMixin. This introduces new approach in handling related urls/entities. RelatedMixin will handle all related entities, configured in related_serializers dict with no related views required. Also it will check permissions for parent object for any related entity. --- CHANGELOG.md | 1 + docs/usage.md | 47 ++++++++++++++ example/serializers.py | 28 +++++++- example/tests/test_views.py | 97 +++++++++++++++++++++++++++- example/urls.py | 4 ++ example/urls_test.py | 4 ++ rest_framework_json_api/relations.py | 14 +++- rest_framework_json_api/views.py | 78 +++++++++++++++++++++- 8 files changed, 269 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e47c5da0..fc160067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](https://www.django-rest-framework.org/api-guide/testing/#configuration) * Add sorting configuration to `REST_FRAMEWORK` as defined in [json api spec](http://jsonapi.org/format/#fetching-sorting) * Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields) +* Add related urls support. See [usage docs](docs/usage.md#related-urls) v2.5.0 - Released July 11, 2018 diff --git a/docs/usage.md b/docs/usage.md index d0fee78e..25bb7310 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -443,6 +443,53 @@ class LineItemViewSet(viewsets.ModelViewSet): not render `data`. Use this in case you only need links of relationships and want to lower payload and increase performance. +#### Related urls + +There is a nice way to handle "related" urls like `/orders/3/lineitems/` or `/orders/3/customer/`. +All you need is just add to `urls.py`: +```python +url(r'^orders/(?P[^/.]+)/$', + OrderViewSet.as_view({'get': 'retrieve'}), + name='order-detail'), +url(r'^orders/(?P[^/.]+)/(?P\w+)/$', + OrderViewSet.as_view({'get': 'retrieve_related'}), + name='order-related'), +``` +Make sure that RelatedField declaration has `related_link_url_kwarg='pk'` or simply skipped (will be set by default): +```python + line_items = ResourceRelatedField( + queryset=LineItem.objects, + many=True, + related_link_view_name='order-related', + related_link_url_kwarg='pk', + self_link_view_name='order-relationships' + ) + + customer = ResourceRelatedField( + queryset=Customer.objects, + related_link_view_name='order-related', + self_link_view_name='order-relationships' + ) +``` +And, the most important part - declare serializer for each related entity: +```python +class OrderSerializer(serializers.HyperlinkedModelSerializer): + ... + related_serializers = { + 'customer': 'example.serializers.CustomerSerializer', + 'line_items': 'example.serializers.LineItemSerializer' + } +``` +Or, if you already have `included_serializers` declared and your `related_serializers` look the same, just skip it: +```python +class OrderSerializer(serializers.HyperlinkedModelSerializer): + ... + included_serializers = { + 'customer': 'example.serializers.CustomerSerializer', + 'line_items': 'example.serializers.LineItemSerializer' + } +``` + ### RelationshipView `rest_framework_json_api.views.RelationshipView` is used to build relationship views (see the diff --git a/example/serializers.py b/example/serializers.py index f43accac..d96a917d 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -155,14 +155,40 @@ class Meta: class AuthorSerializer(serializers.ModelSerializer): + bio = relations.ResourceRelatedField( + related_link_view_name='author-related', + self_link_view_name='author-relationships', + queryset=AuthorBio.objects, + ) + entries = relations.ResourceRelatedField( + related_link_view_name='author-related', + self_link_view_name='author-relationships', + queryset=Entry.objects, + many=True + ) + first_entry = relations.SerializerMethodResourceRelatedField( + related_link_view_name='author-related', + self_link_view_name='author-relationships', + model=Entry, + read_only=True, + source='get_first_entry' + ) included_serializers = { 'bio': AuthorBioSerializer, 'type': AuthorTypeSerializer } + related_serializers = { + 'bio': 'example.serializers.AuthorBioSerializer', + 'entries': 'example.serializers.EntrySerializer', + 'first_entry': 'example.serializers.EntrySerializer' + } class Meta: model = Author - fields = ('name', 'email', 'bio', 'entries', 'type') + fields = ('name', 'email', 'bio', 'entries', 'first_entry', 'type') + + def get_first_entry(self, obj): + return obj.entries.first() class WriterSerializer(serializers.ModelSerializer): diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 9cccf2f9..48e1bfa6 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -2,14 +2,19 @@ from django.test import RequestFactory from django.utils import timezone +from rest_framework.exceptions import NotFound +from rest_framework.request import Request from rest_framework.reverse import reverse -from rest_framework.test import APITestCase, force_authenticate +from rest_framework.test import APIRequestFactory, APITestCase, force_authenticate from rest_framework_json_api.utils import format_resource_type from . import TestBase from .. import views +from example.factories import AuthorFactory, EntryFactory from example.models import Author, Blog, Comment, Entry +from example.serializers import AuthorBioSerializer, AuthorTypeSerializer, EntrySerializer +from example.views import AuthorViewSet class TestRelationshipView(APITestCase): @@ -225,6 +230,96 @@ def test_delete_to_many_relationship_with_change(self): assert response.status_code == 200, response.content.decode() +class TestRelatedMixin(APITestCase): + + def setUp(self): + self.author = AuthorFactory() + + def _get_view(self, kwargs): + factory = APIRequestFactory() + request = Request(factory.get('', content_type='application/vnd.api+json')) + return AuthorViewSet(request=request, kwargs=kwargs) + + def test_get_related_field_name(self): + kwargs = {'pk': self.author.id, 'related_field': 'bio'} + view = self._get_view(kwargs) + got = view.get_related_field_name() + self.assertEqual(got, kwargs['related_field']) + + def test_get_related_instance_serializer_field(self): + kwargs = {'pk': self.author.id, 'related_field': 'bio'} + view = self._get_view(kwargs) + got = view.get_related_instance() + self.assertEqual(got, self.author.bio) + + def test_get_related_instance_model_field(self): + kwargs = {'pk': self.author.id, 'related_field': 'id'} + view = self._get_view(kwargs) + got = view.get_related_instance() + self.assertEqual(got, self.author.id) + + def test_get_serializer_class(self): + kwargs = {'pk': self.author.id, 'related_field': 'bio'} + view = self._get_view(kwargs) + got = view.get_serializer_class() + self.assertEqual(got, AuthorBioSerializer) + + def test_get_serializer_class_many(self): + kwargs = {'pk': self.author.id, 'related_field': 'entries'} + view = self._get_view(kwargs) + got = view.get_serializer_class() + self.assertEqual(got, EntrySerializer) + + def test_get_serializer_comes_from_included_serializers(self): + kwargs = {'pk': self.author.id, 'related_field': 'type'} + view = self._get_view(kwargs) + related_serializers = view.serializer_class.related_serializers + delattr(view.serializer_class, 'related_serializers') + got = view.get_serializer_class() + self.assertEqual(got, AuthorTypeSerializer) + + view.serializer_class.related_serializers = related_serializers + + def test_get_serializer_class_raises_error(self): + kwargs = {'pk': self.author.id, 'related_field': 'type'} + view = self._get_view(kwargs) + self.assertRaises(NotFound, view.get_serializer_class) + + def test_retrieve_related_single(self): + url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'bio'}) + resp = self.client.get(url) + expected = { + 'data': { + 'type': 'authorBios', 'id': str(self.author.bio.id), + 'relationships': { + 'author': {'data': {'type': 'authors', 'id': str(self.author.id)}}}, + 'attributes': { + 'body': str(self.author.bio.body) + }, + } + } + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), expected) + + def test_retrieve_related_many(self): + entry = EntryFactory(authors=self.author) + url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'entries'}) + resp = self.client.get(url) + + self.assertEqual(resp.status_code, 200) + self.assertTrue(isinstance(resp.json()['data'], list)) + self.assertEqual(len(resp.json()['data']), 1) + self.assertEqual(resp.json()['data'][0]['id'], str(entry.id)) + + def test_retrieve_related_None(self): + kwargs = {'pk': self.author.pk, 'related_field': 'first_entry'} + url = reverse('author-related', kwargs=kwargs) + resp = self.client.get(url) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), {'data': None}) + + class TestValidationErrorResponses(TestBase): def test_if_returns_error_on_empty_post(self): view = views.BlogViewSet.as_view({'post': 'create'}) diff --git a/example/urls.py b/example/urls.py index 469ce53a..fa06499f 100644 --- a/example/urls.py +++ b/example/urls.py @@ -45,6 +45,10 @@ EntryViewSet.as_view({'get': 'retrieve'}), name='entry-featured'), + url(r'^authors/(?P[^/.]+)/(?P\w+)/$', + AuthorViewSet.as_view({'get': 'retrieve_related'}), + name='author-related'), + url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)', EntryRelationshipView.as_view(), name='entry-relationships'), diff --git a/example/urls_test.py b/example/urls_test.py index 3ec07380..e7a27ce4 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -56,6 +56,10 @@ EntryViewSet.as_view({'get': 'retrieve'}), name='entry-featured'), + url(r'^authors/(?P[^/.]+)/(?P\w+)/$', + AuthorViewSet.as_view({'get': 'retrieve_related'}), + name='author-related'), + url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)', EntryRelationshipView.as_view(), name='entry-relationships'), diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 1c51d2da..040a84f1 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -116,7 +116,19 @@ def get_links(self, obj=None, lookup_field='pk'): }) self_link = self.get_url('self', self.self_link_view_name, self_kwargs, request) - related_kwargs = {self.related_link_url_kwarg: kwargs[self.related_link_lookup_field]} + """ + Assuming RelatedField will be declared in two ways: + 1. url(r'^authors/(?P[^/.]+)/(?P\w+)/$', + AuthorViewSet.as_view({'get': 'retrieve_related'})) + 2. url(r'^authors/(?P[^/.]+)/bio/$', + AuthorBioViewSet.as_view({'get': 'retrieve'})) + So, if related_link_url_kwarg == 'pk' it will add 'related_field' parameter to reverse() + """ + if self.related_link_url_kwarg == 'pk': + related_kwargs = self_kwargs + else: + related_kwargs = {self.related_link_url_kwarg: kwargs[self.related_link_lookup_field]} + related_link = self.get_url('related', self.related_link_view_name, related_kwargs, request) if self_link: diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 64e5d12e..b77e6a99 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -1,3 +1,5 @@ +from collections import Iterable + from django.core.exceptions import ImproperlyConfigured from django.db.models import Model from django.db.models.fields.related_descriptors import ( @@ -9,6 +11,7 @@ from django.db.models.manager import Manager from django.db.models.query import QuerySet from django.urls import NoReverseMatch +from django.utils.module_loading import import_string as import_class_from_dotted_path from rest_framework import generics, viewsets from rest_framework.exceptions import MethodNotAllowed, NotFound from rest_framework.response import Response @@ -98,12 +101,85 @@ def get_queryset(self, *args, **kwargs): return qs -class ModelViewSet(AutoPrefetchMixin, PrefetchForIncludesHelperMixin, viewsets.ModelViewSet): +class RelatedMixin(object): + """ + This mixin handles all related entities, whose Serializers are declared in "related_serializers" + """ + + def retrieve_related(self, request, *args, **kwargs): + serializer_kwargs = {} + instance = self.get_related_instance() + + if hasattr(instance, 'all'): + instance = instance.all() + + if callable(instance): + instance = instance() + + if instance is None: + return Response(data=None) + + if isinstance(instance, Iterable): + serializer_kwargs['many'] = True + + serializer = self.get_serializer(instance, **serializer_kwargs) + return Response(serializer.data) + + def get_serializer_class(self): + parent_serializer_class = super(RelatedMixin, self).get_serializer_class() + + if 'related_field' in self.kwargs: + field_name = self.kwargs['related_field'] + + # Try get the class from related_serializers + if hasattr(parent_serializer_class, 'related_serializers'): + _class = parent_serializer_class.related_serializers.get(field_name, None) + if _class is None: + raise NotFound + + elif hasattr(parent_serializer_class, 'included_serializers'): + _class = parent_serializer_class.included_serializers.get(field_name, None) + if _class is None: + raise NotFound + + else: + assert False, \ + 'Either "included_serializers" or "related_serializers" should be configured' + + if not isinstance(_class, type): + return import_class_from_dotted_path(_class) + return _class + + return parent_serializer_class + + def get_related_field_name(self): + return self.kwargs['related_field'] + + def get_related_instance(self): + parent_obj = self.get_object() + parent_serializer = self.serializer_class(parent_obj) + field_name = self.get_related_field_name() + field = parent_serializer.fields.get(field_name, None) + + if field is not None: + return field.get_attribute(parent_obj) + else: + try: + return getattr(parent_obj, field_name) + except AttributeError: + raise NotFound + + +class ModelViewSet(AutoPrefetchMixin, + PrefetchForIncludesHelperMixin, + RelatedMixin, + viewsets.ModelViewSet): pass class ReadOnlyModelViewSet(AutoPrefetchMixin, PrefetchForIncludesHelperMixin, + RelatedMixin, viewsets.ReadOnlyModelViewSet): pass From 8821b9908b8fc0d4c1be401f147765b185b0eef5 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 21 Aug 2018 07:59:42 -0400 Subject: [PATCH 026/488] correct running example app documentation (#457) --- docs/getting-started.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index d68bbdd3..49bd64b7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -68,15 +68,15 @@ From Source ## Running the example app - git clone https://github.com/django-json-api/django-rest-framework-json-api.git - cd django-rest-framework-json-api - python -m venv env - source env/bin/activate - pip install -r example/requirements.txt + git clone https://github.com/django-json-api/django-rest-framework-json-api.git + cd django-rest-framework-json-api + python3 -m venv env + source env/bin/activate + pip install -r example/requirements.txt pip install -e . - django-admin.py startproject example . - python manage.py migrate - python manage.py runserver + django-admin migrate --settings=example.settings + django-admin runserver --settings=example.settings + Browse to http://localhost:8000 From 95e6d8db1e072a93122a0df7e5c5f26a8a0a1cdf Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 21 Aug 2018 15:48:25 -0400 Subject: [PATCH 027/488] Drf example fixtures (#462) * replace binary drf_example sqlite3 db file with fixture --- .gitignore | 3 + CHANGELOG.md | 1 + docs/getting-started.md | 1 + drf_example | Bin 80896 -> 0 bytes example/fixtures/drf_example.json | 124 ++++++++++++++++++++++++++++++ example/requirements.txt | 1 + 6 files changed, 130 insertions(+) delete mode 100644 drf_example create mode 100644 example/fixtures/drf_example.json diff --git a/.gitignore b/.gitignore index 1207cc48..6b952e8f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ pip-delete-this-directory.txt *.sw* manage.py .DS_Store + +# example database +drf_example diff --git a/CHANGELOG.md b/CHANGELOG.md index fc160067..c591958f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Add sorting configuration to `REST_FRAMEWORK` as defined in [json api spec](http://jsonapi.org/format/#fetching-sorting) * Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields) * Add related urls support. See [usage docs](docs/usage.md#related-urls) +* Replaced binary `drf_example` sqlite3 db with a [fixture](example/fixtures/drf_example.yaml). See [usage docs](docs/usage.md#running-the-example-app). v2.5.0 - Released July 11, 2018 diff --git a/docs/getting-started.md b/docs/getting-started.md index 49bd64b7..26117e0b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -75,6 +75,7 @@ From Source pip install -r example/requirements.txt pip install -e . django-admin migrate --settings=example.settings + django-admin loaddata drf_example --settings=example.settings django-admin runserver --settings=example.settings diff --git a/drf_example b/drf_example deleted file mode 100644 index 2b54190f8873c12784558b18f8aac186b0c613d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80896 zcmeHQdvF}dS)ZO>X|=ZAvb<-@vPY6-c_T~Onc1g(m%Fts`(#`4*=OIK3}iFAJCax4 zhi-S}vz((YD_>pl2agI>yo#!XS5YCns|Zj$0wh$06z@Ptpm>u40s)dh5(+5b>zgSv=_4i^M#CG9dBRuEvf8#IXFXFG^f53l%KZpMo{}KK@{3-mK zMszDNoX!#82zVoK_A=0)B@OlI^AISr5XdPALJZ${I6KXKJl~SuizT~ zxcDXU!%sdAJco}v0{w${vO~Y66;}&-I$JKTYNhpkeK?LxnqI0^*3;Tr?RvRV75jSe zgfmLS8$!Dv^zX6BS*?^;^lWXg2hTK^4GEciS=@v16w0k=rR8#3%N6sbbfLWL>+i-P z7qb{_!>Y-dEthIwOS-ncs`m}F<4Hs$rdRb!F<-6b%Ow?e_TxhVN?oh!75x3eKr{OI z9|?`TsI9BJ6ORR$K~?Cr8$1I8pu3y(K=Gmsr| z7l`i@KO*4Q@z3J#z(0&XFa8ETfUn{kct-ppJ|%t*3*txcuj9{HcJN;ufrpR4Ufr_ces@7Z_K80x;J?FvjDHRP4E`bf?f89o1z*7@@iZP1|4;m9=nMQhu<(l` z@aPa2?DGjjBkVnAwg16h8`*0A{XIY)V%)4f_jlW9to`rr0@@_wbG84zPKevb;*4h8 zzuPH-wEx~+KnpT9+KTrCfO5!@5c(569Y7sqRMP(Y{5~N#>}db}?LJ{}gy^!`e`g!e z1__O{|1KZg1<4(4{{z@33=g^5f0qb?2OI5w2>FEZF?`7D`Kvh zBft@OU7@J4^YA513E zoo8!Wrl6CjX_`JxZNs#xH+A=^%d@E~v(lB+$qTd6s5jH7baXVI8nu90a8 zL0ao=k-j?tRsvH>NbP73o*h9#zLe8%RbMSY&ym!VGQFoYhG8UInGV!@{r=#{2&&(3 zA!+e+v_foKD3!FLJ}TWL-E^&TG!YJ2;9gyryL5Fn^n%zPoSa09)F76Rs{ZPlUdrmO zTkJ5+6?v4XItD32^+n_l&di_u0*3&&Y=Q0VR6So z(n4N`3YpVtdM#hn8=!?q`*(cRdtBNmw}T_V5qRJTkoiB}{vSBZTmeVmu|%K??S|&R zM-cXie3*K3{r;M5elGh}t@$lQa`|1&zqw)<{pZ-@5$Y|-25?9lXg*Oeu# zW+(RTbfZI1J}dWJMhLzs-Sbu7*XIwOPonw>+PSv+%J%U`I;U(*{ZcWrnglEcQZcZs%{y-{ZSw9|KD@# z{EtVrR+aU}--rsK)D8cT+|A@z78wVckE z^0mBH@Jtbp$0MrR)g}x>3ic@!RBoOq5|?9PrL#>K8c(s0GFzsR!)i=E4{Ig|zbQrD zb&w#=@^w=yTL95otq7?wVv{vv0FPcoo5|QURK*m{9 zM#L_I2qLpf>GEpLGb7P>G$BV%`9Z?GDM7VM(4oW&>7sTkUC>L*wd-D4iAL41tUlik zLS8n7m{rF1nxlqnAxVix!|~_a+Jy15DLae=MZmXMzS)46F49R8p?%azsZ`7C*G z#;3xuDLE<0%JHyrJPL0*IjYENv=0(a+7c>hYjuNHGBuKjCSzxM;R*4g`GiQ=pSO)C zw&OwBq+Sij67bd}dA0BA*+$-Z5{>{zU}qvg+P{e3E8w4kfL|N|j=+6I;DXpLq*4Qu zw$XtcJ|0dUkH)8E6|PvTm_+zwIg=@8o@1laX;`xO0!%ljQo)&~G?B!#0;6Ch(JCep zPZjc69Y(2H-7b;8|L<#Yb735T9fbh-{;!Ik7V!Vzuj8-4&iuc}e~teP_5%DK{(0C9 z@DcnY_=EU+@o(Xe<6ptwiN6)Uh2M{F!_L5O#4q6&@Hu=MzZ)m8f{)=6zK%1nU+|yA z&*LLl!u#JZLzZ5?wej1D7|A=4PQP6N>cO(LB7@=O3h6)X35_W}YI77o}6825e z@E8pzN!ar)8ir_il!V}t#MaWy=xD)EGB z|5yW^eJdswo|BgKnq+<#l9npvqC`99l2*z|RULxbb-gIn%F-#VQZ9(^p*{Yq0hhAy zaYdbuD6l|8Z4ncRsdLiGTD2y}m^UYgxl)X|_n z`0Ny_kGV2O9cEOW&O)euBVu1G%w6f+))~<**C4_k!-f%W8=7l?A?2wpY%tTcwt)c( zWjru!$Vp6k2tSWyeH58BwF*Y&h#GS%WLMMR{%}Yik?rK4?PqgZ+l5ms!hR zD&FCxC85UEj2yQ)R3Vnb)DJBAGtWLjEo-gHEcFUc7>>y9|Mg$ZW5)+X5NC7u5x^Z#vR%3l(`h<;rB zC|-9KLw#<Mek+eSo>|&Uac2b+?gTT(gYIbuw*zANHIL0Jh&@y_#YVxwH$rsDH zmeHa5)1&@iRz-E=OdAiw(+w5rcr24sb83dpAbYU-SRS2Ttvwi9$#fKw8=Hn5qwv%) zG8NkJkNL^THg{$%Pu5n;rVkD4cFb7>uGnqCZ@1C;5HycBvOmN;kYHNLgD?x_LxR@P1DsfKsYd@c>2r7~(fs#zZcEarC4Vsyc^xkTI3 z&yq{>>ITf7?@--0?hif#d6@FdgV~f_mh>@m?M~D!rHu#2U7JMD|MB6c1@QOrL0o_z z{Nf031nvrfX)Msu15|KFg0S5jc<(KKEfA18=-hNdE zhse{ShlWn)j(^jaSVW1)VdSZyIR*6>mPm$^ZIDy4{}--{F#pH*0N+)~b0&_!4o4sv zh782^k^a9=_^2R!6#b<52Y3JvJhXg0vu#cFwP}Aa6he2#X-}9rNMxs)I`4X%{a|P0 z_PW6p9Zpe%GgNCH1979bo?n8~7F#knd$v*MoCS53Z;fx1Xv8|_F|;Ag(1R%Nls)nd zr@1j!t^3_^ZdM#hOF6DGBM+TM>(#q49Sb2LAA7gmWk1A9+W@pX5|nt-p#$=3lJ8;hA~$xFW+T z8*)r`ow5;W#KaR_PFTK+%|2^jaj-vX56BFpI zF~>X+EjM$bh~6Cao++Ynk7laqI%{fk(Fn7cr1hoARFOGh1Ph&jyt5hR`-67Yc23 zMf|~d9NjV2vpC9$O(0i2iSOE^5Y2?!Dok1p?mjp6T0>S+Y&LD`g@(c3m@hTP|6dXC zf8np(WBups9088Njzgeb^a*e#i0DH;ArLUe|KR_dfBWBYCCLru2y71lvh@e{{|E?1 zd+_}qeLz4T5dGrs<7K?Or9|}JEm=6jwh^es{K4b|st?j-q0X*oEE0((mQ=+-onReP zwj|UMmo`%v3ql=nqzhq*tQ=@Hs=d4t!DDW_kPV>1z@>E(QmEhE4uBoKXFac@Tn3y@l1R0 zVyM+V9H-@J*?Av%{oVfHi4dwAeukZq>6{*oEW!MplN@zUk2xb7X^eR>XC$3Xv}Gn# zSD*6-$HvgclmqiTZ2F=ro6@k(Mvr@=4KYNX=O!i_x>D-um5Xx=AY*=Z;R;y{0n4PU zLUHX0%ax1Z?%0ZUZ0(VZDb`Rl)gGK1+X`^7&q5n+5_JAwfNlRzqkj;kK;~|8uV>Jw5nCBu#yb6hnaO(RuYaiD+R4uBYuepR%DTi zQ6r6oEBR`=x(4gdh!2)gSX)*uH0mnAzm%^)QfDEkww3`bRQRSGn{C|#0mOouwzSl& z9b$-kpPNtv+=h{Cq*uUSi4OU;@?75?} zEGAMAa5qZ#|B2{LLHsxb{CXq^Y@86==HF~f6(dXQx7Ax&?bVy{gOFJB5TzC5R1Jbmf4h10XIEu6h7Upu`RU07Kt%&%MxFRm2w*G``+ zUORnBo>ykog_YI#Tp@Wq_sZq+E9y(Puf1|^NjrPywsvYRHn(ykJb!i`(%x8KxH5NZ z;dG&}a0TR@eJMYG`^K$>mBsaID|3;BE7|C^m4)KM>0EyC_I&=rsdGzQP!ZDi;qJq*wdkj{l$j-`? zHCV?6fOUMvPc+*3h}H&^)DKK!lWj~j8B^QTl3S_I;9wd}4yKLV?(YST;qf-2$c!dR z$nVh}V#Jh>$)|@s*%Z+G(Qbo>X=v~;74-f<7w{aK@DUvi2BL)C9P5PGnRq+XV)>f1 zsj`U2b{pJGpA|v4nNo{*WEXIU#{XXCr9v7(=7EM*a?`hm7 z1mqJ;Jo6J;TVJ(nVN{8br$x%ol)3nsPCNfm1pJdxqT0nwEUjRP?H@ zRkGJtD`j$ohpqo)#z-tGK4T9_GQ<%}X71-`M#9-`RBM6|NoBNJ3oH`54jfmN-vZp$^J964g}F^x7p zrjb#kajLPKXtXgAji#i-RMKusBk{)J)JP~t#tc47qseD!Bot|sYTV5>`>P3`7sTnLG%wQ}F=PVhm)#rHCzO zY;X(2$zg+;sdF$hl@{~(0brhx14OTbljyeC4i6EwnNEiRP1+CdH@F;{3712y(f|KG z0e_!Yljbyz07u}FAfTg8VLlb;>goyvQbA2uGV)SZ1J8fSNK%jHU=378Q8EcVmyAZ^ zQFHz;tk^z}Dv@w9Zi```{*>vkszhRm6}_TU|9^=1|NHtQ$t73H5#R_sd;~ghFhJWs zdH?%_&j`Y2(6@+xic1gQln0=(9zFvHeZd;5#a3&q*c52vh^Tl*T7ZC-En?k(3_ zwUV{j+ACL@(a^@i96j92wb~B4(xhW+n#FEUdatu%v5#hv-Q7F&cORK(X3pr~GV6(J zE85%Mre~~Bz5kp)cnQweiaP25!-#dSL3mn^B(u>>wz1TXaZGxbd+YSh;u zP`&58KX?(0G7kPTb8ZSXGn`IDaa!Q<9+i!#V~>hfsj*pavxj9-PI>p8 zEe6s7WvSDvwH60b+4pjJd0AxwY@Jz908X#1eJJ+rMWOmD^RVOX1ll<3$cOpy!j%~p z`LuVATwD(*TXa(uxSlTC&S7_EZE3+cwHQ0H=E@Sgo%PO&6V2x1!R)2s-dR^GujaGf zu)`2zwOm*)mMg2*^VxKkth8|sH{3%NwgJ#SdNYd{whdl14kd=YdO7`;S@W3fX)jIb zjGmR%Y$EJpPI>q9?@E-j&u>G{%K+a0-&xN)ZY@XP{vyEp|MwS9E{!9wlMo>Ne-UQ{ z{4)^nizC1hxX%b&5dCnd(3H^FqhoxdhfTQA=yW8ZsA0wSjh>vP)QC}&XDTpgG!Oq7 z85t255RoRM#+IIjG&MRM2Co5O*IEFw+1H3kL@tzX>c*i#WHg#B=p)qsH=qB%&qdC~ zaRhb<0_6SA+y4$JEN&)8;65Y3+y8wAl#AmC>>vbq``YvY_@)i3G|8Si^J6Z%J>&gNo5ixv$M^P4+vV^xY;*Vt-?X!9 zl?p8IH1`2Izi4~8t27O^eJm!6RYUcuOR$U@R;xOfQ8UZa&3e*GOw+QNuxd(Vtj~FW z&%O&m&ac@v%$k+M8fy76xYj(6?ksv0OL}Rowgw)HSuPu={F+v{qMzTi*xbUlRg9)) zPjA6qt4eJ7Cj~^-FBqF^w_nEm{r{vsqBtLIUD-LdZ59Nf8vmFDmhMr`TCQaF*&B8_vX zTO_%#`RMsB6nKuJy@s&1;bdrI_lq6D`oMX#aq6xzW?By!v&5uhnOHQec(5Yza!o6FDUkmECh9ZurmLTPb@e z*iJ6_{$F=<=JyooQ`dPBOB&|zJQFFk2xDzr1XaCKX74E zsu6bF?L)(;;S}4~=R;#iWa-JldTj=Ur6iPgO0j$E_V4kb5W;3E<21w$gJ>o+h=Cp- zI)p6W=3V_hG>&{MCG~LLZID4HM`*le=jw%gC!;2@Ph`0 zB_oulNc-Q9{!kEA@tmkZ`+rmX9`VP-kBNUIeqQ`f+=-8fd+{lJ6|dko@CWdZiU|CH z{vrNl{0I0S&1%8_K1K-8s@2Pi*xJ>s8gy0}EV@GLZ7=mW+1NELAMl|hYT?K=yw``O zP{5Q#4|M=Xm%|3xRBVzD(gtXG(mX_~_%4>3`k|2w5=%)*BebI0J$;T+J9o2m(EBBB zk;V)nOUa0bdwpmG*-o6;H{e4PsMBHaeuL()c4(gusmRNj?6iRgli=D|Jwt7^`#~M2 zW_4S(lCQ!?45DQ#Ax_Y=cCW2RDO(Rq%LX(dgUHe{;=3r(a6X3&`g zi#|oi|9D-%pT!@BKm6hda0DI(0#hou=bcG5KdJ4Gd@D86ee;xJu(OtJJ;3cAeJ%D0 S8Q4Rr>$y&NsViZN@&5r)Myl}u diff --git a/example/fixtures/drf_example.json b/example/fixtures/drf_example.json new file mode 100644 index 00000000..498c0d1c --- /dev/null +++ b/example/fixtures/drf_example.json @@ -0,0 +1,124 @@ +[ +{ + "model": "example.blog", + "pk": 1, + "fields": { + "created_at": "2016-05-02T08:27:16.889", + "modified_at": "2016-05-02T08:27:16.889", + "name": "Personal", + "tagline": "" + } +}, +{ + "model": "example.blog", + "pk": 2, + "fields": { + "created_at": "2016-05-02T08:27:23.871", + "modified_at": "2016-05-02T08:27:23.871", + "name": "Work", + "tagline": "" + } +}, +{ + "model": "example.author", + "pk": 1, + "fields": { + "created_at": "2016-05-02T10:09:48.277", + "modified_at": "2016-05-02T10:09:48.277", + "name": "Alice", + "email": "alice@example.com", + "type": null + } +}, +{ + "model": "example.author", + "pk": 2, + "fields": { + "created_at": "2016-05-02T10:09:57.133", + "modified_at": "2016-05-02T10:09:57.133", + "name": "Bob", + "email": "bob@example.com", + "type": null + } +}, +{ + "model": "example.authorbio", + "pk": 1, + "fields": { + "created_at": "2016-05-02T10:10:23.429", + "modified_at": "2016-05-02T10:10:23.429", + "author": 1, + "body": "I just want to send messages to Bob." + } +}, +{ + "model": "example.authorbio", + "pk": 2, + "fields": { + "created_at": "2016-05-02T10:11:30.327", + "modified_at": "2016-05-02T10:11:30.327", + "author": 2, + "body": "I get messages from Alice and send them to Carol" + } +}, +{ + "model": "example.entry", + "pk": 1, + "fields": { + "created_at": "2016-05-02T10:43:21.271", + "modified_at": "2016-05-02T10:43:21.271", + "blog": 1, + "headline": "This is a test, this is only a test", + "body_text": "And this is the body text for the blog entry. To see comments included in this payload visit: /entries/1?include=comments", + "pub_date": "2015-01-01", + "mod_date": "2015-04-05", + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [ + 1 + ] + } +}, +{ + "model": "example.entry", + "pk": 2, + "fields": { + "created_at": "2016-05-02T10:44:14.376", + "modified_at": "2016-05-02T10:49:30.150", + "blog": 1, + "headline": "Django, the framework for perfectionists with deadlines", + "body_text": "And this is the body text. Try out includes by using this uri: /entries/2?include=comments,authors,authors.bio", + "pub_date": "2015-05-01", + "mod_date": "2015-09-03", + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [ + 2 + ] + } +}, +{ + "model": "example.comment", + "pk": 1, + "fields": { + "created_at": "2016-05-02T10:44:35.093", + "modified_at": "2016-05-02T10:44:35.093", + "entry": 1, + "body": "Love this article!", + "author": 2 + } +}, +{ + "model": "example.comment", + "pk": 2, + "fields": { + "created_at": "2016-05-02T10:44:55.482", + "modified_at": "2016-05-02T10:44:55.482", + "entry": 2, + "body": "Frist comment!!!", + "author": null + } +} +] diff --git a/example/requirements.txt b/example/requirements.txt index 0fa77009..fe28eddc 100644 --- a/example/requirements.txt +++ b/example/requirements.txt @@ -11,3 +11,4 @@ pyparsing pytz six sqlparse + From 81d2236eadd998d2ed47012f1740337bfbaa0e9b Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 21 Aug 2018 21:21:27 -0400 Subject: [PATCH 028/488] JSONAPIOrderingFilter (#459) JSONAPIOrderingFilter includes @sliverc review requested changes - use json instead of yaml fixture to remove dependency on PyYAML - use reverse() - request.data dict instead of string concat to the url - response.json() instead of json.loads(response.content.decode(...) --- CHANGELOG.md | 3 +- README.rst | 3 +- docs/usage.md | 37 +++- example/fixtures/blogentry.json | 280 ++++++++++++++++++++++++++++ example/settings/dev.py | 2 +- example/tests/test_backends.py | 54 ++++++ requirements-development.txt | 1 + rest_framework_json_api/backends.py | 36 ++++ 8 files changed, 407 insertions(+), 9 deletions(-) create mode 100644 example/fixtures/blogentry.json create mode 100644 example/tests/test_backends.py create mode 100644 rest_framework_json_api/backends.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c591958f..6063d671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,10 @@ [unreleased] * Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](https://www.django-rest-framework.org/api-guide/testing/#configuration) -* Add sorting configuration to `REST_FRAMEWORK` as defined in [json api spec](http://jsonapi.org/format/#fetching-sorting) * Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields) * Add related urls support. See [usage docs](docs/usage.md#related-urls) * Replaced binary `drf_example` sqlite3 db with a [fixture](example/fixtures/drf_example.yaml). See [usage docs](docs/usage.md#running-the-example-app). - +* Add optional [jsonapi-style](http://jsonapi.org/format/) sort filter backend. See [usage docs](docs/usage.md#filter-backends) v2.5.0 - Released July 11, 2018 diff --git a/README.rst b/README.rst index 46813188..6fa4aee5 100644 --- a/README.rst +++ b/README.rst @@ -173,9 +173,8 @@ override ``settings.REST_FRAMEWORK`` ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework.filters.OrderingFilter', + 'rest_framework_json_api.backends.JSONAPIOrderingFilter', ), - 'ORDERING_PARAM': 'sort', 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', ), diff --git a/docs/usage.md b/docs/usage.md index 25bb7310..75fbad7d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,10 +1,11 @@ # Usage -The DJA package implements a custom renderer, parser, exception handler, and +The DJA package implements a custom renderer, parser, exception handler, query filter backends, and pagination. To get started enable the pieces in `settings.py` that you want to use. -Many features of the JSON:API format standard have been implemented using Mixin classes in `serializers.py`. +Many features of the [JSON:API](http://jsonapi.org/format) format standard have been implemented using +Mixin classes in `serializers.py`. The easiest way to make use of those features is to import ModelSerializer variants from `rest_framework_json_api` instead of the usual `rest_framework` @@ -32,9 +33,8 @@ REST_FRAMEWORK = { ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework.filters.OrderingFilter', + 'rest_framework_json_api.backends.JSONAPIOrderingFilter', ), - 'ORDERING_PARAM': 'sort', 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', ), @@ -90,6 +90,35 @@ class MyLimitPagination(JsonApiLimitOffsetPagination): max_limit = None ``` +### Filter Backends + +_This is the first of several anticipated JSON:API-specific filter backends._ + +#### `JSONAPIOrderingFilter` +`JSONAPIOrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses +DRF's [ordering filter](http://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#orderingfilter). + +Per the JSON:API, "If the server does not support sorting as specified in the query parameter `sort`, +it **MUST** return `400 Bad Request`." For example, for `?sort=`abc,foo,def` where `foo` is a valid +field name and the other two are not valid: +```json +{ + "errors": [ + { + "detail": "invalid sort parameters: abc,def", + "source": { + "pointer": "/data" + }, + "status": "400" + } + ] +} +``` + +If you want to silently ignore bad sort fields, just use `rest_framework.filters.OrderingFilter` and set +`ordering_param` to `sort`. + + ### Performance Testing If you are trying to see if your viewsets are configured properly to optimize performance, diff --git a/example/fixtures/blogentry.json b/example/fixtures/blogentry.json new file mode 100644 index 00000000..15ceded9 --- /dev/null +++ b/example/fixtures/blogentry.json @@ -0,0 +1,280 @@ +[ +{ + "model": "example.blog", + "pk": 1, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "ANTB", + "tagline": "ANTHROPOLOGY (BARNARD)" + } +}, +{ + "model": "example.blog", + "pk": 2, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "CLSB", + "tagline": "CLASSICS (BARNARD)" + } +}, +{ + "model": "example.blog", + "pk": 3, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "AMSB", + "tagline": "AMERICAN STUDIES (BARNARD)" + } +}, +{ + "model": "example.blog", + "pk": 4, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "CHMB", + "tagline": "CHEMISTRY (BARNARD)" + } +}, +{ + "model": "example.blog", + "pk": 5, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "ARHB", + "tagline": "ART HISTORY (BARNARD)" + } +}, +{ + "model": "example.blog", + "pk": 6, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "ITLB", + "tagline": "ITALIAN (BARNARD)" + } +}, +{ + "model": "example.blog", + "pk": 7, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "BIOB", + "tagline": "BIOLOGICAL SCIENCES (BARNARD)" + } +}, +{ + "model": "example.entry", + "pk": 1, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 1, + "headline": "ANTH1009V", + "body_text": "INTRO TO LANGUAGE & CULTURE", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [ + 1 + ] + } +}, +{ + "model": "example.entry", + "pk": 2, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 2, + "headline": "CLCV2442V", + "body_text": "EGYPT IN CLASSICAL WORLD-DISC", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [ + 2 + ] + } +}, +{ + "model": "example.entry", + "pk": 3, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 3, + "headline": "AMST3704X", + "body_text": "SENIOR RESEARCH ESSAY SEMINAR", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 4, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 1, + "headline": "ANTH3976V", + "body_text": "ANTHROPOLOGY OF SCIENCE", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 5, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 4, + "headline": "CHEM3271X", + "body_text": "INORGANIC CHEMISTRY", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 6, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 5, + "headline": "AHIS3915X", + "body_text": "ISLAM AND MEDIEVAL WEST", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 7, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 1, + "headline": "ANTH3868X", + "body_text": "ETHNOGRAPHIC FIELD RESEARCH IN NYC", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 8, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 6, + "headline": "CLIA3660V", + "body_text": "MAFIA MOVIES", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 9, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 5, + "headline": "AHIS3999X", + "body_text": "INDEPENDENT RESEARCH", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 10, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 7, + "headline": "BIOL3594X", + "body_text": "SENIOR THESIS SEMINAR", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 11, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 7, + "headline": "BIOL9999X", + "body_text": null, + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 12, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 7, + "headline": "BIOL0000X", + "body_text": "", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +} +] diff --git a/example/settings/dev.py b/example/settings/dev.py index 5f938f78..e8ed4094 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -89,7 +89,7 @@ ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework.filters.OrderingFilter', + 'rest_framework_json_api.backends.JSONAPIOrderingFilter', ), 'ORDERING_PARAM': 'sort', 'TEST_REQUEST_RENDERER_CLASSES': ( diff --git a/example/tests/test_backends.py b/example/tests/test_backends.py new file mode 100644 index 00000000..0721f780 --- /dev/null +++ b/example/tests/test_backends.py @@ -0,0 +1,54 @@ +from rest_framework.reverse import reverse +from rest_framework.test import APITestCase + +from ..models import Blog, Entry + + +class DJATestParameters(APITestCase): + """ + tests of JSON:API backends + """ + fixtures = ('blogentry',) + + def setUp(self): + self.entries = Entry.objects.all() + self.blogs = Blog.objects.all() + self.url = reverse('nopage-entry-list') + + def test_sort(self): + """ + test sort + """ + response = self.client.get(self.url, data={'sort': 'headline'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + headlines = [c['attributes']['headline'] for c in dja_response['data']] + sorted_headlines = [c['attributes']['headline'] for c in dja_response['data']] + sorted_headlines.sort() + self.assertEqual(headlines, sorted_headlines) + + def test_sort_reverse(self): + """ + confirm switching the sort order actually works + """ + response = self.client.get(self.url, data={'sort': '-headline'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + headlines = [c['attributes']['headline'] for c in dja_response['data']] + sorted_headlines = [c['attributes']['headline'] for c in dja_response['data']] + sorted_headlines.sort() + self.assertNotEqual(headlines, sorted_headlines) + + def test_sort_invalid(self): + """ + test sort of invalid field + """ + response = self.client.get(self.url, + data={'sort': 'nonesuch,headline,-not_a_field'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid sort parameters: nonesuch,-not_a_field") diff --git a/requirements-development.txt b/requirements-development.txt index f5c7cacb..e2e8aae3 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -14,3 +14,4 @@ Sphinx sphinx_rtd_theme tox twine + diff --git a/rest_framework_json_api/backends.py b/rest_framework_json_api/backends.py new file mode 100644 index 00000000..e6fda16b --- /dev/null +++ b/rest_framework_json_api/backends.py @@ -0,0 +1,36 @@ +from rest_framework.exceptions import ValidationError +from rest_framework.filters import OrderingFilter + +from rest_framework_json_api.utils import format_value + + +class JSONAPIOrderingFilter(OrderingFilter): + """ + This implements http://jsonapi.org/format/#fetching-sorting and raises 400 + if any sort field is invalid. If you prefer *not* to report 400 errors for + invalid sort fields, just use OrderingFilter with `ordering_param='sort'` + + TODO: Add sorting based upon relationships (sort=relname.fieldname) + """ + ordering_param = 'sort' + + def remove_invalid_fields(self, queryset, fields, view, request): + """ + overrides remove_invalid_fields to raise a 400 exception instead of + silently removing them. set `ignore_bad_sort_fields = True` to not + do this validation. + """ + valid_fields = [ + item[0] for item in self.get_valid_fields(queryset, view, + {'request': request}) + ] + bad_terms = [ + term for term in fields + if format_value(term.lstrip('-'), "underscore") not in valid_fields + ] + if bad_terms: + raise ValidationError('invalid sort parameter{}: {}'.format( + ('s' if len(bad_terms) > 1 else ''), ','.join(bad_terms))) + + return super(JSONAPIOrderingFilter, self).remove_invalid_fields( + queryset, fields, view, request) From e6290af5c34bf1760cb8c79fb8648b58e9336685 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 22 Aug 2018 03:10:31 -0400 Subject: [PATCH 029/488] deprecated JsonApi paginators class prefix to JSONAPI prefix for consistency (#463) --- CHANGELOG.md | 2 ++ README.rst | 2 +- docs/usage.md | 16 +++++----- example/tests/unit/test_pagination.py | 16 ++++++++-- example/views.py | 6 ++-- rest_framework_json_api/pagination.py | 43 +++++++++++++++++++++++---- 6 files changed, 64 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6063d671..354c1b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ * Add related urls support. See [usage docs](docs/usage.md#related-urls) * Replaced binary `drf_example` sqlite3 db with a [fixture](example/fixtures/drf_example.yaml). See [usage docs](docs/usage.md#running-the-example-app). * Add optional [jsonapi-style](http://jsonapi.org/format/) sort filter backend. See [usage docs](docs/usage.md#filter-backends) +* For naming consistency, renamed new `JsonApi`-prefix pagination classes to `JSONAPI`-prefix. + * Deprecates `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination` v2.5.0 - Released July 11, 2018 diff --git a/README.rst b/README.rst index 6fa4aee5..7d345185 100644 --- a/README.rst +++ b/README.rst @@ -161,7 +161,7 @@ override ``settings.REST_FRAMEWORK`` 'PAGE_SIZE': 10, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', 'DEFAULT_PAGINATION_CLASS': - 'rest_framework_json_api.pagination.JsonApiPageNumberPagination', + 'rest_framework_json_api.pagination.JSONAPIPageNumberPagination', 'DEFAULT_PARSER_CLASSES': ( 'rest_framework_json_api.parsers.JSONParser', 'rest_framework.parsers.FormParser', diff --git a/docs/usage.md b/docs/usage.md index 75fbad7d..e172df47 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -16,7 +16,7 @@ REST_FRAMEWORK = { 'PAGE_SIZE': 10, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', 'DEFAULT_PAGINATION_CLASS': - 'rest_framework_json_api.pagination.JsonApiPageNumberPagination', + 'rest_framework_json_api.pagination.JSONAPIPageNumberPagination', 'DEFAULT_PARSER_CLASSES': ( 'rest_framework_json_api.parsers.JSONParser', 'rest_framework.parsers.FormParser', @@ -58,15 +58,15 @@ You can configure fixed values for the page size or limit -- or allow the client via query parameters. Two pagination classes are available: -- `JsonApiPageNumberPagination` breaks a response up into pages that start at a given page number with a given size - (number of items per page). It can be configured with the following attributes: +- `JSONAPIPageNumberPagination` breaks a response up into pages that start at a given page number + with a given size (number of items per page). It can be configured with the following attributes: - `page_query_param` (default `page[number]`) - `page_size_query_param` (default `page[size]`) Set this to `None` if you don't want to allow the client to specify the size. - `max_page_size` (default `100`) enforces an upper bound on the `page_size_query_param`. Set it to `None` if you don't want to enforce an upper bound. -- `JsonApiLimitOffsetPagination` breaks a response up into pages that start from an item's offset in the viewset for - a given number of items (the limit). +- `JSONAPILimitOffsetPagination` breaks a response up into pages that start from an item's offset + in the viewset for a given number of items (the limit). It can be configured with the following attributes: - `offset_query_param` (default `page[offset]`). - `limit_query_param` (default `page[limit]`). @@ -77,14 +77,14 @@ Two pagination classes are available: These examples show how to configure the parameters to use non-standard names and different limits: ```python -from rest_framework_json_api.pagination import JsonApiPageNumberPagination, JsonApiLimitOffsetPagination +from rest_framework_json_api.pagination import JSONAPIPageNumberPagination, JSONAPILimitOffsetPagination -class MyPagePagination(JsonApiPageNumberPagination): +class MyPagePagination(JSONAPIPageNumberPagination): page_query_param = 'page_number' page_size_query_param = 'page_size' max_page_size = 1000 -class MyLimitPagination(JsonApiLimitOffsetPagination): +class MyLimitPagination(JSONAPILimitOffsetPagination): offset_query_param = 'offset' limit_query_param = 'limit' max_limit = None diff --git a/example/tests/unit/test_pagination.py b/example/tests/unit/test_pagination.py index f6e95db0..5fdcade6 100644 --- a/example/tests/unit/test_pagination.py +++ b/example/tests/unit/test_pagination.py @@ -13,11 +13,11 @@ class TestLimitOffset: """ - Unit tests for `pagination.JsonApiLimitOffsetPagination`. + Unit tests for `pagination.JSONAPILimitOffsetPagination`. """ def setup(self): - class ExamplePagination(pagination.JsonApiLimitOffsetPagination): + class ExamplePagination(pagination.JSONAPILimitOffsetPagination): default_limit = 10 max_limit = 15 @@ -85,13 +85,18 @@ def test_limit_offset_deprecation(self): assert len(record) == 1 assert 'LimitOffsetPagination' in str(record[0].message) + with pytest.warns(DeprecationWarning) as record: + pagination.JsonApiLimitOffsetPagination() + assert len(record) == 1 + assert 'JsonApiLimitOffsetPagination' in str(record[0].message) + # TODO: This test fails under py27 but it's not clear why so just leave it out for now. @pytest.mark.xfail((sys.version_info.major, sys.version_info.minor) == (2, 7), reason="python2.7 fails for unknown reason") class TestPageNumber: """ - Unit tests for `pagination.JsonApiPageNumberPagination`. + Unit tests for `pagination.JSONAPIPageNumberPagination`. TODO: add unit tests for changing query parameter names, limits, etc. """ def test_page_number_deprecation(self): @@ -99,3 +104,8 @@ def test_page_number_deprecation(self): pagination.PageNumberPagination() assert len(record) == 1 assert 'PageNumberPagination' in str(record[0].message) + + with pytest.warns(DeprecationWarning) as record: + pagination.JsonApiPageNumberPagination() + assert len(record) == 1 + assert 'JsonApiPageNumberPagination' in str(record[0].message) diff --git a/example/views.py b/example/views.py index 5dfc3341..a42a80ae 100644 --- a/example/views.py +++ b/example/views.py @@ -34,7 +34,7 @@ def get_object(self): return super(BlogViewSet, self).get_object() -class JsonApiViewSet(ModelViewSet): +class JSONAPIViewSet(ModelViewSet): """ This is an example on how to configure DRF-jsonapi from within a class. It allows using DRF-jsonapi alongside @@ -58,12 +58,12 @@ def handle_exception(self, exc): exc.status_code = HTTP_422_UNPROCESSABLE_ENTITY # exception handler can't be set on class so you have to # override the error response in this method - response = super(JsonApiViewSet, self).handle_exception(exc) + response = super(JSONAPIViewSet, self).handle_exception(exc) context = self.get_exception_handler_context() return format_drf_errors(response, context, exc) -class BlogCustomViewSet(JsonApiViewSet): +class BlogCustomViewSet(JSONAPIViewSet): queryset = Blog.objects.all() serializer_class = BlogSerializer diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index 00873c99..b150aa83 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -9,7 +9,7 @@ from rest_framework.views import Response -class JsonApiPageNumberPagination(PageNumberPagination): +class JSONAPIPageNumberPagination(PageNumberPagination): """ A json-api compatible pagination format """ @@ -50,7 +50,7 @@ def get_paginated_response(self, data): }) -class JsonApiLimitOffsetPagination(LimitOffsetPagination): +class JSONAPILimitOffsetPagination(LimitOffsetPagination): """ A limit/offset based style. For example: http://api.example.org/accounts/?page[limit]=100 @@ -100,7 +100,23 @@ def get_paginated_response(self, data): }) -class PageNumberPagination(JsonApiPageNumberPagination): +class JsonApiPageNumberPagination(JSONAPIPageNumberPagination): + """ + Deprecated due to desire to use `JSONAPI` prefix for all classes. + """ + page_query_param = 'page' + page_size_query_param = 'page_size' + + def __init__(self): + warnings.warn( + 'JsonApiPageNumberPagination is deprecated. Use JSONAPIPageNumberPagination ' + 'or create custom pagination. See ' + 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', + DeprecationWarning) + super(JsonApiPageNumberPagination, self).__init__() + + +class PageNumberPagination(JSONAPIPageNumberPagination): """ Deprecated paginator that uses different query parameters """ @@ -109,14 +125,29 @@ class PageNumberPagination(JsonApiPageNumberPagination): def __init__(self): warnings.warn( - 'PageNumberPagination is deprecated. Use JsonApiPageNumberPagination ' + 'PageNumberPagination is deprecated. Use JSONAPIPageNumberPagination ' 'or create custom pagination. See ' 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', DeprecationWarning) super(PageNumberPagination, self).__init__() -class LimitOffsetPagination(JsonApiLimitOffsetPagination): +class JsonApiLimitOffsetPagination(JSONAPILimitOffsetPagination): + """ + Deprecated due to desire to use `JSONAPI` prefix for all classes. + """ + max_limit = None + + def __init__(self): + warnings.warn( + 'JsonApiLimitOffsetPagination is deprecated. Use JSONAPILimitOffsetPagination ' + 'or create custom pagination. See ' + 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', + DeprecationWarning) + super(JsonApiLimitOffsetPagination, self).__init__() + + +class LimitOffsetPagination(JSONAPILimitOffsetPagination): """ Deprecated paginator that uses a different max_limit """ @@ -124,7 +155,7 @@ class LimitOffsetPagination(JsonApiLimitOffsetPagination): def __init__(self): warnings.warn( - 'LimitOffsetPagination is deprecated. Use JsonApiLimitOffsetPagination ' + 'LimitOffsetPagination is deprecated. Use JSONAPILimitOffsetPagination ' 'or create custom pagination. See ' 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', DeprecationWarning) From 0d7afccd87493e3240c26ed88c8ac7386f78c6e5 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 22 Aug 2018 10:48:12 -0400 Subject: [PATCH 030/488] rename `backends` to `filters` --- docs/usage.md | 18 ++++++++++++++++-- example/settings/dev.py | 3 +-- .../{test_backends.py => test_filters.py} | 0 .../{backends.py => filters.py} | 0 4 files changed, 17 insertions(+), 4 deletions(-) rename example/tests/{test_backends.py => test_filters.py} (100%) rename rest_framework_json_api/{backends.py => filters.py} (100%) diff --git a/docs/usage.md b/docs/usage.md index e172df47..b9d89ecf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -33,7 +33,7 @@ REST_FRAMEWORK = { ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.backends.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.JSONAPIOrderingFilter', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', @@ -98,7 +98,7 @@ _This is the first of several anticipated JSON:API-specific filter backends._ `JSONAPIOrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses DRF's [ordering filter](http://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#orderingfilter). -Per the JSON:API, "If the server does not support sorting as specified in the query parameter `sort`, +Per the JSON:API specification, "If the server does not support sorting as specified in the query parameter `sort`, it **MUST** return `400 Bad Request`." For example, for `?sort=`abc,foo,def` where `foo` is a valid field name and the other two are not valid: ```json @@ -118,6 +118,20 @@ field name and the other two are not valid: If you want to silently ignore bad sort fields, just use `rest_framework.filters.OrderingFilter` and set `ordering_param` to `sort`. +#### Configuring Filter Backends + +You can configure the filter backends either by setting the `REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']` as shown +in the [preceding](#configuration) example or individually add them as `.filter_backends` View attributes: + + ```python +from rest_framework_json_api import filters + +class MyViewset(ModelViewSet): + queryset = MyModel.objects.all() + serializer_class = MyModelSerializer + filter_backends = (filters.JSONAPIOrderingFilter,) +``` + ### Performance Testing diff --git a/example/settings/dev.py b/example/settings/dev.py index e8ed4094..6856a91b 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -89,9 +89,8 @@ ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.backends.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.JSONAPIOrderingFilter', ), - 'ORDERING_PARAM': 'sort', 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', ), diff --git a/example/tests/test_backends.py b/example/tests/test_filters.py similarity index 100% rename from example/tests/test_backends.py rename to example/tests/test_filters.py diff --git a/rest_framework_json_api/backends.py b/rest_framework_json_api/filters.py similarity index 100% rename from rest_framework_json_api/backends.py rename to rest_framework_json_api/filters.py From d77b17a2c3c462ba8a00cf30d1aaf561a56fc810 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 22 Aug 2018 17:18:46 -0400 Subject: [PATCH 031/488] bugfix: camelcase, etc. sort parameters were being ignored. Also added more test_cases and suppor for sorting via relationship paths. --- example/tests/test_filters.py | 59 ++++++++++++++++++++++++++++-- example/views.py | 1 + rest_framework_json_api/filters.py | 24 ++++++++---- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 0721f780..2b18b5f3 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -24,8 +24,7 @@ def test_sort(self): msg=response.content.decode("utf-8")) dja_response = response.json() headlines = [c['attributes']['headline'] for c in dja_response['data']] - sorted_headlines = [c['attributes']['headline'] for c in dja_response['data']] - sorted_headlines.sort() + sorted_headlines = sorted(headlines) self.assertEqual(headlines, sorted_headlines) def test_sort_reverse(self): @@ -37,8 +36,19 @@ def test_sort_reverse(self): msg=response.content.decode("utf-8")) dja_response = response.json() headlines = [c['attributes']['headline'] for c in dja_response['data']] - sorted_headlines = [c['attributes']['headline'] for c in dja_response['data']] - sorted_headlines.sort() + sorted_headlines = sorted(headlines) + self.assertNotEqual(headlines, sorted_headlines) + + def test_sort_double_negative(self): + """ + what if they provide multiple `-`'s? It's OK. + """ + response = self.client.get(self.url, data={'sort': '--headline'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + headlines = [c['attributes']['headline'] for c in dja_response['data']] + sorted_headlines = sorted(headlines) self.assertNotEqual(headlines, sorted_headlines) def test_sort_invalid(self): @@ -52,3 +62,44 @@ def test_sort_invalid(self): dja_response = response.json() self.assertEqual(dja_response['errors'][0]['detail'], "invalid sort parameters: nonesuch,-not_a_field") + + def test_sort_camelcase(self): + """ + test sort of camelcase field name + """ + response = self.client.get(self.url, data={'sort': 'bodyText'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + blog_ids = [(c['attributes']['bodyText'] or '') for c in dja_response['data']] + sorted_blog_ids = sorted(blog_ids) + self.assertEqual(blog_ids, sorted_blog_ids) + + def test_sort_underscore(self): + """ + test sort of underscore field name + Do we allow this notation in a search even if camelcase is in effect? + "Be conservative in what you send, be liberal in what you accept" + -- https://en.wikipedia.org/wiki/Robustness_principle + """ + response = self.client.get(self.url, data={'sort': 'body_text'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + blog_ids = [(c['attributes']['bodyText'] or '') for c in dja_response['data']] + sorted_blog_ids = sorted(blog_ids) + self.assertEqual(blog_ids, sorted_blog_ids) + + def test_sort_related(self): + """ + test sort via related field using jsonapi path `.` and django orm `__` notation. + ORM relations must be predefined in the View's .ordering_fields attr + """ + for datum in ('blog__id', 'blog.id'): + response = self.client.get(self.url, data={'sort': datum}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + blog_ids = [c['relationships']['blog']['data']['id'] for c in dja_response['data']] + sorted_blog_ids = sorted(blog_ids) + self.assertEqual(blog_ids, sorted_blog_ids) diff --git a/example/views.py b/example/views.py index a42a80ae..36026b17 100644 --- a/example/views.py +++ b/example/views.py @@ -90,6 +90,7 @@ class NoPagination(PageNumberPagination): class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination + ordering_fields = ('headline', 'body_text', 'blog__name', 'blog__id') class AuthorViewSet(ModelViewSet): diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index e6fda16b..748b18bf 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -10,27 +10,35 @@ class JSONAPIOrderingFilter(OrderingFilter): if any sort field is invalid. If you prefer *not* to report 400 errors for invalid sort fields, just use OrderingFilter with `ordering_param='sort'` - TODO: Add sorting based upon relationships (sort=relname.fieldname) + Also applies DJA format_value() to convert (e.g. camelcase) to underscore. + (See JSON_API_FORMAT_FIELD_NAMES in docs/usage.md) """ ordering_param = 'sort' def remove_invalid_fields(self, queryset, fields, view, request): - """ - overrides remove_invalid_fields to raise a 400 exception instead of - silently removing them. set `ignore_bad_sort_fields = True` to not - do this validation. - """ valid_fields = [ item[0] for item in self.get_valid_fields(queryset, view, {'request': request}) ] bad_terms = [ term for term in fields - if format_value(term.lstrip('-'), "underscore") not in valid_fields + if format_value(term.replace(".", "__").lstrip('-'), "underscore") not in valid_fields ] if bad_terms: raise ValidationError('invalid sort parameter{}: {}'.format( ('s' if len(bad_terms) > 1 else ''), ','.join(bad_terms))) + # this looks like it duplicates code above, but we want the ValidationError to report + # the actual parameter supplied while we want the fields passed to the super() to + # be correctly rewritten. + # The leading `-` has to be stripped to prevent format_value from turning it into `_`. + underscore_fields = [] + for item in fields: + item_rewritten = item.replace(".", "__") + if item_rewritten.startswith('-'): + underscore_fields.append( + '-' + format_value(item_rewritten.lstrip('-'), "underscore")) + else: + underscore_fields.append(format_value(item_rewritten, "underscore")) return super(JSONAPIOrderingFilter, self).remove_invalid_fields( - queryset, fields, view, request) + queryset, underscore_fields, view, request) From a3205367c8050c978c636cb6b7867b5351c3b2d5 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Thu, 23 Aug 2018 08:28:37 -0400 Subject: [PATCH 032/488] Missing contribution (#465) --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 103d327b..b28cc99c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -6,6 +6,7 @@ Christian Zosel Greg Aker Jamie Bliss Jerel Unruh +Jonathan Senecal Léo S. Luc Cary Matt Layman From 22c4587c98cd4f05c0a4378fd6f5627ab420c532 Mon Sep 17 00:00:00 2001 From: Tim Selman Date: Tue, 4 Sep 2018 09:55:17 +0200 Subject: [PATCH 033/488] Performance improvement when rendering relationships with `ModelSerializer` (#461) --- AUTHORS | 1 + CHANGELOG.md | 2 + example/tests/test_serializers.py | 76 ++++++++++++++++++++++++-- rest_framework_json_api/serializers.py | 54 ++++++++++-------- 4 files changed, 105 insertions(+), 28 deletions(-) diff --git a/AUTHORS b/AUTHORS index b28cc99c..f2525a0a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,4 +15,5 @@ Oliver Sauder Raphael Cohen Roberto Barreda santiavenda +Tim Selman Yaniv Peer diff --git a/CHANGELOG.md b/CHANGELOG.md index 354c1b5c..cba4a852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ * Add optional [jsonapi-style](http://jsonapi.org/format/) sort filter backend. See [usage docs](docs/usage.md#filter-backends) * For naming consistency, renamed new `JsonApi`-prefix pagination classes to `JSONAPI`-prefix. * Deprecates `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination` +* Performance improvement when rendering relationships with `ModelSerializer` + v2.5.0 - Released July 11, 2018 diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index 66860b61..5dc5ce81 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -2,24 +2,39 @@ from django.test import TestCase from django.urls import reverse from django.utils import timezone - -from rest_framework_json_api.serializers import ResourceIdentifierObjectSerializer +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory + +from rest_framework_json_api.serializers import ( + DateField, + ModelSerializer, + ResourceIdentifierObjectSerializer +) from rest_framework_json_api.utils import format_resource_type from example.models import Author, Blog, Entry +from example.serializers import BlogSerializer + +try: + from unittest import mock +except ImportError: + import mock +request_factory = APIRequestFactory() pytestmark = pytest.mark.django_db class TestResourceIdentifierObjectSerializer(TestCase): def setUp(self): self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + now = timezone.now() + self.entry = Entry.objects.create( blog=self.blog, headline='headline', body_text='body_text', - pub_date=timezone.now(), - mod_date=timezone.now(), + pub_date=now.date(), + mod_date=now.date(), n_comments=0, n_pingbacks=0, rating=3 @@ -30,6 +45,59 @@ def setUp(self): Author.objects.create(name=name, email='{}@example.org'.format(name)) ) + def test_forward_relationship_not_loaded_when_not_included(self): + to_representation_method = 'example.serializers.BlogSerializer.to_representation' + with mock.patch(to_representation_method) as mocked_serializer: + class EntrySerializer(ModelSerializer): + blog = BlogSerializer() + + class Meta: + model = Entry + fields = '__all__' + + request_without_includes = Request(request_factory.get('/')) + serializer = EntrySerializer(context={'request': request_without_includes}) + serializer.to_representation(self.entry) + + mocked_serializer.assert_not_called() + + def test_forward_relationship_optimization_correct_representation(self): + class EntrySerializer(ModelSerializer): + blog = BlogSerializer() + + class Meta: + model = Entry + fields = '__all__' + + request_without_includes = Request(request_factory.get('/')) + serializer = EntrySerializer(context={'request': request_without_includes}) + result = serializer.to_representation(self.entry) + + # Remove non deterministic fields + result.pop('created_at') + result.pop('modified_at') + + expected = dict( + [ + ('id', 1), + ('blog', dict([('type', 'blogs'), ('id', 1)])), + ('headline', 'headline'), + ('body_text', 'body_text'), + ('pub_date', DateField().to_representation(self.entry.pub_date)), + ('mod_date', DateField().to_representation(self.entry.mod_date)), + ('n_comments', 0), + ('n_pingbacks', 0), + ('rating', 3), + ('authors', + [ + dict([('type', 'authors'), ('id', '1')]), + dict([('type', 'authors'), ('id', '2')]), + dict([('type', 'authors'), ('id', '3')]), + dict([('type', 'authors'), ('id', '4')]), + dict([('type', 'authors'), ('id', '5')])])]) + + self.assertDictEqual(expected, result) + def test_data_in_correct_format_when_instantiated_with_blog_object(self): serializer = ResourceIdentifierObjectSerializer(instance=self.blog) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index ffb77800..d525af15 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -182,35 +182,41 @@ def to_representation(self, instance): for field in readable_fields: try: - - if isinstance(field, ModelSerializer) and hasattr(field, field.source + "_id"): - attribute = getattr(instance, field.source + "_id") - if attribute is None: - ret[field.field_name] = None - continue - resource_type = get_resource_type_from_instance(field) - if resource_type: - ret[field.field_name] = OrderedDict([("type", resource_type), - ("id", attribute)]) - continue - - attribute = field.get_attribute(instance) + field_representation = self._get_field_representation(field, instance) + ret[field.field_name] = field_representation except SkipField: continue - # We skip `to_representation` for `None` values so that fields do - # not have to explicitly deal with that case. - # - # For related fields with `use_pk_only_optimization` we need to - # resolve the pk value. - check_for_none = attribute.pk if isinstance(attribute, PKOnlyObject) else attribute - if check_for_none is None: - ret[field.field_name] = None - else: - ret[field.field_name] = field.to_representation(attribute) - return ret + def _get_field_representation(self, field, instance): + request = self.context.get('request') + is_included = field.source in get_included_resources(request) + if not is_included and \ + isinstance(field, ModelSerializer) and \ + hasattr(instance, field.source + '_id'): + attribute = getattr(instance, field.source + '_id') + + if attribute is None: + return None + + resource_type = get_resource_type_from_serializer(field) + if resource_type: + return OrderedDict([('type', resource_type), ('id', attribute)]) + + attribute = field.get_attribute(instance) + + # We skip `to_representation` for `None` values so that fields do + # not have to explicitly deal with that case. + # + # For related fields with `use_pk_only_optimization` we need to + # resolve the pk value. + check_for_none = attribute.pk if isinstance(attribute, PKOnlyObject) else attribute + if check_for_none is None: + return None + else: + return field.to_representation(attribute) + class PolymorphicSerializerMetaclass(SerializerMetaclass): """ From 5de570cf3d25a3c19a26b694caa0eda0983ad26c Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 6 Sep 2018 02:55:15 -0400 Subject: [PATCH 034/488] Add json api DjangoFilterBackend (#466) Implements django_filters.DjangoFilterBackend a Django ORM-style JSON:API filter[] implementation. See docs/usage.md for details. --- .travis.yml | 36 +-- CHANGELOG.md | 5 +- README.rst | 3 +- docs/usage.md | 56 +++- example/requirements.txt | 2 +- example/settings/dev.py | 2 + example/tests/test_filters.py | 239 +++++++++++++++++- example/urls_test.py | 5 + example/views.py | 46 +++- requirements-development.txt | 2 +- .../django_filters/__init__.py | 1 + .../django_filters/backends.py | 121 +++++++++ rest_framework_json_api/filters/__init__.py | 1 + .../{filters.py => filters/sort.py} | 0 tox.ini | 7 +- 15 files changed, 493 insertions(+), 33 deletions(-) create mode 100644 rest_framework_json_api/django_filters/__init__.py create mode 100644 rest_framework_json_api/django_filters/backends.py create mode 100644 rest_framework_json_api/filters/__init__.py rename rest_framework_json_api/{filters.py => filters/sort.py} (100%) diff --git a/.travis.yml b/.travis.yml index 9c102172..8b073020 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,44 +5,44 @@ cache: pip matrix: include: - python: 2.7 - env: TOXENV=py27-django111-drf36 + env: TOXENV=py27-df11-django111-drf36 - python: 2.7 - env: TOXENV=py27-django111-drf37 + env: TOXENV=py27-df11-django111-drf37 - python: 2.7 - env: TOXENV=py27-django111-drf38 + env: TOXENV=py27-df11-django111-drf38 - python: 3.4 - env: TOXENV=py34-django111-drf36 + env: TOXENV=py34-df20-django111-drf36 - python: 3.4 - env: TOXENV=py34-django111-drf37 + env: TOXENV=py34-df20-django111-drf37 - python: 3.4 - env: TOXENV=py34-django111-drf38 + env: TOXENV=py34-df20-django111-drf38 - python: 3.4 - env: TOXENV=py34-django20-drf37 + env: TOXENV=py34-df20-django20-drf37 - python: 3.4 - env: TOXENV=py34-django20-drf38 + env: TOXENV=py34-df20-django20-drf38 - python: 3.5 - env: TOXENV=py35-django111-drf36 + env: TOXENV=py35-df20-django111-drf36 - python: 3.5 - env: TOXENV=py35-django111-drf37 + env: TOXENV=py35-df20-django111-drf37 - python: 3.5 - env: TOXENV=py35-django111-drf38 + env: TOXENV=py35-df20-django111-drf38 - python: 3.5 - env: TOXENV=py35-django20-drf37 + env: TOXENV=py35-df20-django20-drf37 - python: 3.5 - env: TOXENV=py35-django20-drf38 + env: TOXENV=py35-df20-django20-drf38 - python: 3.6 - env: TOXENV=py36-django111-drf36 + env: TOXENV=py36-df20-django111-drf36 - python: 3.6 - env: TOXENV=py36-django111-drf37 + env: TOXENV=py36-df20-django111-drf37 - python: 3.6 - env: TOXENV=py36-django111-drf38 + env: TOXENV=py36-df20-django111-drf38 - python: 3.6 - env: TOXENV=py36-django20-drf37 + env: TOXENV=py36-df20-django20-drf37 - python: 3.6 - env: TOXENV=py36-django20-drf38 + env: TOXENV=py36-df20-django20-drf38 - python: 3.6 env: TOXENV=flake8 diff --git a/CHANGELOG.md b/CHANGELOG.md index cba4a852..be7dda6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,11 @@ * Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](https://www.django-rest-framework.org/api-guide/testing/#configuration) * Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields) * Add related urls support. See [usage docs](docs/usage.md#related-urls) -* Replaced binary `drf_example` sqlite3 db with a [fixture](example/fixtures/drf_example.yaml). See [usage docs](docs/usage.md#running-the-example-app). -* Add optional [jsonapi-style](http://jsonapi.org/format/) sort filter backend. See [usage docs](docs/usage.md#filter-backends) +* Replaced binary `drf_example` sqlite3 db with a [fixture](example/fixtures/drf_example.yaml). See [getting started](docs/getting-started.md#running-the-example-app). * For naming consistency, renamed new `JsonApi`-prefix pagination classes to `JSONAPI`-prefix. * Deprecates `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination` * Performance improvement when rendering relationships with `ModelSerializer` - +* Add optional [jsonapi-style](http://jsonapi.org/format/) filter backends. See [usage docs](docs/usage.md#filter-backends) v2.5.0 - Released July 11, 2018 diff --git a/README.rst b/README.rst index 7d345185..d690bb3f 100644 --- a/README.rst +++ b/README.rst @@ -173,7 +173,8 @@ override ``settings.REST_FRAMEWORK`` ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.backends.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.JSONAPIOrderingFilter', + 'rest_framework_json_api.django_filters.DjangoFilterBackend', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', diff --git a/docs/usage.md b/docs/usage.md index b9d89ecf..51576d06 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -34,6 +34,7 @@ REST_FRAMEWORK = { 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.JSONAPIOrderingFilter', + 'rest_framework_json_api.django_filters.DjangoFilterBackend', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', @@ -92,14 +93,14 @@ class MyLimitPagination(JSONAPILimitOffsetPagination): ### Filter Backends -_This is the first of several anticipated JSON:API-specific filter backends._ +_There are several anticipated JSON:API-specific filter backends in development. The first two are described below._ #### `JSONAPIOrderingFilter` `JSONAPIOrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses DRF's [ordering filter](http://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#orderingfilter). Per the JSON:API specification, "If the server does not support sorting as specified in the query parameter `sort`, -it **MUST** return `400 Bad Request`." For example, for `?sort=`abc,foo,def` where `foo` is a valid +it **MUST** return `400 Bad Request`." For example, for `?sort=abc,foo,def` where `foo` is a valid field name and the other two are not valid: ```json { @@ -118,18 +119,65 @@ field name and the other two are not valid: If you want to silently ignore bad sort fields, just use `rest_framework.filters.OrderingFilter` and set `ordering_param` to `sort`. +#### `DjangoFilterBackend` +`DjangoFilterBackend` implements a Django ORM-style [JSON:API `filter`](http://jsonapi.org/format/#fetching-filtering) +using the [django-filter](https://django-filter.readthedocs.io/) package. + +This filter is not part of the JSON:API standard per-se, other than the requirement +to use the `filter` keyword: It is an optional implementation of a style of +filtering in which each filter is an ORM expression as implemented by +`DjangoFilterBackend` and seems to be in alignment with an interpretation of the +[JSON:API _recommendations_](http://jsonapi.org/recommendations/#filtering), including relationship +chaining. + +Filters can be: +- A resource field equality test: + `?filter[qty]=123` +- Apply other [field lookup](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups) operators: + `?filter[name.icontains]=bar` or `?filter[name.isnull]=true` +- Membership in a list of values: + `?filter[name.in]=abc,123,zzz (name in ['abc','123','zzz'])` +- Filters can be combined for intersection (AND): + `?filter[qty]=123&filter[name.in]=abc,123,zzz&filter[...]` +- A related resource path can be used: + `?filter[inventory.item.partNum]=123456` (where `inventory.item` is the relationship path) + +If you are also using [`rest_framework.filters.SearchFilter`](https://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#searchfilter) +(which performs single parameter searchs across multiple fields) you'll want to customize the name of the query +parameter for searching to make sure it doesn't conflict with a field name defined in the filterset. +The recommended value is: `search_param="filter[search]"` but just make sure it's +`filter[_something_]` to comply with the jsonapi spec requirement to use the filter +keyword. The default is "search" unless overriden. + +The filter returns a `400 Bad Request` error for invalid filter query parameters as in this example +for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`: +```json +{ + "errors": [ + { + "detail": "invalid filter[bad]", + "source": { + "pointer": "/data" + }, + "status": "400" + } + ] +} +``` + #### Configuring Filter Backends You can configure the filter backends either by setting the `REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']` as shown -in the [preceding](#configuration) example or individually add them as `.filter_backends` View attributes: +in the [example settings](#configuration) or individually add them as `.filter_backends` View attributes: ```python from rest_framework_json_api import filters +from rest_framework_json_api import django_filters class MyViewset(ModelViewSet): queryset = MyModel.objects.all() serializer_class = MyModelSerializer - filter_backends = (filters.JSONAPIOrderingFilter,) + filter_backends = (filters.JSONAPIOrderingFilter, django_filters.DjangoFilterBackend,) ``` diff --git a/example/requirements.txt b/example/requirements.txt index fe28eddc..58ce43b2 100644 --- a/example/requirements.txt +++ b/example/requirements.txt @@ -11,4 +11,4 @@ pyparsing pytz six sqlparse - +django-filter>=2.0 diff --git a/example/settings/dev.py b/example/settings/dev.py index 6856a91b..5356146f 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -26,6 +26,7 @@ 'polymorphic', 'example', 'debug_toolbar', + 'django_filters', ] TEMPLATES = [ @@ -90,6 +91,7 @@ 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.JSONAPIOrderingFilter', + 'rest_framework_json_api.django_filters.DjangoFilterBackend', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 2b18b5f3..273fe9ac 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -4,9 +4,9 @@ from ..models import Blog, Entry -class DJATestParameters(APITestCase): +class DJATestFilters(APITestCase): """ - tests of JSON:API backends + tests of JSON:API filter backends """ fixtures = ('blogentry',) @@ -14,6 +14,8 @@ def setUp(self): self.entries = Entry.objects.all() self.blogs = Blog.objects.all() self.url = reverse('nopage-entry-list') + self.fs_url = reverse('filterset-entry-list') + self.no_fs_url = reverse('nofilterset-entry-list') def test_sort(self): """ @@ -103,3 +105,236 @@ def test_sort_related(self): blog_ids = [c['relationships']['blog']['data']['id'] for c in dja_response['data']] sorted_blog_ids = sorted(blog_ids) self.assertEqual(blog_ids, sorted_blog_ids) + + def test_filter_exact(self): + """ + filter for an exact match + """ + response = self.client.get(self.url, data={'filter[headline]': 'CHEM3271X'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(len(dja_response['data']), 1) + + def test_filter_exact_fail(self): + """ + failed search for an exact match + """ + response = self.client.get(self.url, data={'filter[headline]': 'XXXXX'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(len(dja_response['data']), 0) + + def test_filter_isnull(self): + """ + search for null value + """ + response = self.client.get(self.url, data={'filter[bodyText.isnull]': 'true'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual( + len(dja_response['data']), + len([k for k in self.entries if k.body_text is None]) + ) + + def test_filter_not_null(self): + """ + search for not null + """ + response = self.client.get(self.url, data={'filter[bodyText.isnull]': 'false'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual( + len(dja_response['data']), + len([k for k in self.entries if k.body_text is not None]) + ) + + def test_filter_isempty(self): + """ + search for an empty value (different from null!) + the easiest way to do this is search for r'^$' + """ + response = self.client.get(self.url, data={'filter[bodyText.regex]': '^$'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(len(dja_response['data']), + len([k for k in self.entries + if k.body_text is not None and + len(k.body_text) == 0])) + + def test_filter_related(self): + """ + filter via a relationship chain + """ + response = self.client.get(self.url, data={'filter[blog.name]': 'ANTB'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(len(dja_response['data']), + len([k for k in self.entries + if k.blog.name == 'ANTB'])) + + def test_filter_related_fieldset_class(self): + """ + filter via a FilterSet class instead of filterset_fields shortcut + This tests a shortcut for a longer ORM path: `bname` is a shortcut + name for `blog.name`. + """ + response = self.client.get(self.fs_url, data={'filter[bname]': 'ANTB'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(len(dja_response['data']), + len([k for k in self.entries + if k.blog.name == 'ANTB'])) + + def test_filter_related_missing_fieldset_class(self): + """ + filter via with neither filterset_fields nor filterset_class + This should return an error for any filter[] + """ + response = self.client.get(self.no_fs_url, data={'filter[bname]': 'ANTB'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter[bname]") + + def test_filter_fields_union_list(self): + """ + test field for a list of values(ORed): ?filter[field.in]': 'val1,val2,val3 + """ + response = self.client.get(self.url, + data={'filter[headline.in]': 'CLCV2442V,XXX,BIOL3594X'}) + dja_response = response.json() + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + self.assertEqual( + len(dja_response['data']), + len([k for k in self.entries if k.headline == 'CLCV2442V']) + + len([k for k in self.entries if k.headline == 'XXX']) + + len([k for k in self.entries if k.headline == 'BIOL3594X']), + msg="filter field list (union)") + + def test_filter_fields_intersection(self): + """ + test fields (ANDed): ?filter[field1]': 'val1&filter[field2]'='val2 + """ + # + response = self.client.get(self.url, + data={'filter[headline.regex]': '^A', + 'filter[body_text.icontains]': 'in'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertGreater(len(dja_response['data']), 1) + self.assertEqual( + len(dja_response['data']), + len([k for k in self.entries if k.headline.startswith('A') and + 'in' in k.body_text.lower()])) + + def test_filter_invalid_association_name(self): + """ + test for filter with invalid filter association name + """ + response = self.client.get(self.url, data={'filter[nonesuch]': 'CHEM3271X'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter[nonesuch]") + + def test_filter_empty_association_name(self): + """ + test for filter with missing association name + """ + response = self.client.get(self.url, data={'filter[]': 'foobar'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter[]") + + def test_filter_no_brackets(self): + """ + test for `filter=foobar` with missing filter[association] name + """ + response = self.client.get(self.url, data={'filter': 'foobar'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter") + + def test_filter_no_brackets_rvalue(self): + """ + test for `filter=` with missing filter[association] and value + """ + response = self.client.get(self.url + '?filter=') + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter") + + def test_filter_no_brackets_equal(self): + """ + test for `filter` with missing filter[association] name and =value + """ + response = self.client.get(self.url + '?filter') + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter") + + def test_filter_malformed_left_bracket(self): + """ + test for filter with invalid filter syntax + """ + response = self.client.get(self.url, data={'filter[': 'foobar'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter[") + + def test_filter_missing_right_bracket(self): + """ + test for filter missing right bracket + """ + response = self.client.get(self.url, data={'filter[headline': 'foobar'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter[headline") + + def test_filter_missing_rvalue(self): + """ + test for filter with missing value to test against + this should probably be an error rather than ignoring the filter: + https://django-filter.readthedocs.io/en/latest/guide/tips.html#filtering-by-an-empty-string + """ + response = self.client.get(self.url, data={'filter[headline]': ''}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "missing filter[headline] test value") + + def test_filter_missing_rvalue_equal(self): + """ + test for filter with missing value to test against + this should probably be an error rather than ignoring the filter: + """ + response = self.client.get(self.url + '?filter[headline]') + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "missing filter[headline] test value") diff --git a/example/urls_test.py b/example/urls_test.py index e7a27ce4..e2b8ef27 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -12,6 +12,8 @@ CompanyViewset, EntryRelationshipView, EntryViewSet, + FiltersetEntryViewSet, + NoFiltersetEntryViewSet, NonPaginatedEntryViewSet, ProjectViewset ) @@ -20,7 +22,10 @@ router.register(r'blogs', BlogViewSet) router.register(r'entries', EntryViewSet) +# these "flavors" of entries are used for various tests: router.register(r'nopage-entries', NonPaginatedEntryViewSet, 'nopage-entry') +router.register(r'filterset-entries', FiltersetEntryViewSet, 'filterset-entry') +router.register(r'nofilterset-entries', NoFiltersetEntryViewSet, 'nofilterset-entry') router.register(r'authors', AuthorViewSet) router.register(r'comments', CommentViewSet) router.register(r'companies', CompanyViewset) diff --git a/example/views.py b/example/views.py index 36026b17..f3b9ccec 100644 --- a/example/views.py +++ b/example/views.py @@ -1,10 +1,11 @@ +import rest_framework.exceptions as exceptions import rest_framework.parsers import rest_framework.renderers -from rest_framework import exceptions import rest_framework_json_api.metadata import rest_framework_json_api.parsers import rest_framework_json_api.renderers +from django_filters import rest_framework as filters from rest_framework_json_api.pagination import PageNumberPagination from rest_framework_json_api.utils import format_drf_errors from rest_framework_json_api.views import ModelViewSet, RelationshipView @@ -91,6 +92,49 @@ class NoPagination(PageNumberPagination): class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination ordering_fields = ('headline', 'body_text', 'blog__name', 'blog__id') + rels = ('exact', 'iexact', + 'contains', 'icontains', + 'gt', 'gte', 'lt', 'lte', + 'in', 'regex', 'isnull',) + filterset_fields = { + 'id': ('exact', 'in'), + 'headline': rels, + 'body_text': rels, + 'blog__name': rels, + 'blog__tagline': rels, + } + filter_fields = filterset_fields # django-filter<=1.1 (required for py27) + + +class EntryFilter(filters.FilterSet): + bname = filters.CharFilter(field_name="blog__name", + lookup_expr="exact") + + class Meta: + model = Entry + fields = ['id', 'headline', 'body_text'] + + +class FiltersetEntryViewSet(EntryViewSet): + """ + like above but use filterset_class instead of filterset_fields + """ + pagination_class = NoPagination + filterset_fields = None + filterset_class = EntryFilter + filter_fields = filterset_fields # django-filter<=1.1 + filter_class = filterset_class + + +class NoFiltersetEntryViewSet(EntryViewSet): + """ + like above but no filtersets + """ + pagination_class = NoPagination + filterset_fields = None + filterset_class = None + filter_fields = filterset_fields # django-filter<=1.1 + filter_class = filterset_class class AuthorViewSet(ModelViewSet): diff --git a/requirements-development.txt b/requirements-development.txt index e2e8aae3..d578ffb7 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -14,4 +14,4 @@ Sphinx sphinx_rtd_theme tox twine - +django-filter>=2.0 diff --git a/rest_framework_json_api/django_filters/__init__.py b/rest_framework_json_api/django_filters/__init__.py new file mode 100644 index 00000000..16c56714 --- /dev/null +++ b/rest_framework_json_api/django_filters/__init__.py @@ -0,0 +1 @@ +from .backends import DjangoFilterBackend # noqa: F401 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py new file mode 100644 index 00000000..31001b08 --- /dev/null +++ b/rest_framework_json_api/django_filters/backends.py @@ -0,0 +1,121 @@ +import re + +from rest_framework.exceptions import ValidationError +from rest_framework.settings import api_settings + +from django_filters import VERSION +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework_json_api.utils import format_value + + +class DjangoFilterBackend(DjangoFilterBackend): + """ + A Django-style ORM filter implementation, using `django-filter`. + + This is not part of the jsonapi standard per-se, other than the requirement + to use the `filter` keyword: This is an optional implementation of style of + filtering in which each filter is an ORM expression as implemented by + DjangoFilterBackend and seems to be in alignment with an interpretation of + http://jsonapi.org/recommendations/#filtering, including relationship + chaining. It also returns a 400 error for invalid filters. + + Filters can be: + - A resource field equality test: + `?filter[qty]=123` + - Apply other [field lookup](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups) # noqa: E501 + operators: + `?filter[name.icontains]=bar` or `?filter[name.isnull]=true...` + - Membership in a list of values: + `?filter[name.in]=abc,123,zzz (name in ['abc','123','zzz'])` + - Filters can be combined for intersection (AND): + `?filter[qty]=123&filter[name.in]=abc,123,zzz&filter[...]` + - A related resource path can be used: + `?filter[inventory.item.partNum]=123456 (where `inventory.item` is the relationship path)` + + If you are also using rest_framework.filters.SearchFilter you'll want to customize + the name of the query parameter for searching to make sure it doesn't conflict + with a field name defined in the filterset. + The recommended value is: `search_param="filter[search]"` but just make sure it's + `filter[]` to comply with the jsonapi spec requirement to use the filter + keyword. The default is "search" unless overriden but it's used here just to make sure + we don't complain about it being an invalid filter. + """ + search_param = api_settings.SEARCH_PARAM + + # Make this regex check for 'filter' as well as 'filter[...]' + # See http://jsonapi.org/format/#document-member-names for allowed characters + # and http://jsonapi.org/format/#document-member-names-reserved-characters for reserved + # characters (for use in paths, lists or as delimiters). + # regex `\w` matches [a-zA-Z0-9_]. + # TODO: U+0080 and above allowed but not recommended. Leave them out for now.e + # Also, ' ' (space) is allowed within a member name but not recommended. + filter_regex = re.compile(r'^filter(?P\[?)(?P[\w\.\-]*)(?P\]?$)') + + def _validate_filter(self, keys, filterset_class): + for k in keys: + if ((not filterset_class) or (k not in filterset_class.base_filters)): + raise ValidationError("invalid filter[{}]".format(k)) + + def get_filterset(self, request, queryset, view): + """ + Sometimes there's no filterset_class defined yet the client still + requests a filter. Make sure they see an error too. This means + we have to get_filterset_kwargs() even if there's no filterset_class. + + TODO: .base_filters vs. .filters attr (not always present) + """ + filterset_class = self.get_filterset_class(view, queryset) + kwargs = self.get_filterset_kwargs(request, queryset, view) + self._validate_filter(kwargs.pop('filter_keys'), filterset_class) + if filterset_class is None: + return None + return filterset_class(**kwargs) + + def get_filterset_kwargs(self, request, queryset, view): + """ + Turns filter[]= into = which is what + DjangoFilterBackend expects + """ + filter_keys = [] + # rewrite filter[field] query params to make DjangoFilterBackend work. + data = request.query_params.copy() + for qp, val in data.items(): + m = self.filter_regex.match(qp) + if m and (not m.groupdict()['assoc'] or + m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'): + raise ValidationError("invalid filter: {}".format(qp)) + if m and qp != self.search_param: + if not val: + raise ValidationError("missing {} test value".format(qp)) + # convert jsonapi relationship path to Django ORM's __ notation + key = m.groupdict()['assoc'].replace('.', '__') + # undo JSON_API_FORMAT_FIELD_NAMES conversion: + key = format_value(key, 'underscore') + data[key] = val + filter_keys.append(key) + del data[qp] + return { + 'data': data, + 'queryset': queryset, + 'request': request, + 'filter_keys': filter_keys, + } + + def filter_queryset(self, request, queryset, view): + """ + Backwards compatibility to 1.1 (required for Python 2.7) + In 1.1 filter_queryset does not call get_filterset or get_filterset_kwargs. + """ + # TODO: remove when Python 2.7 support is deprecated + if VERSION >= (2, 0, 0): + return super(DjangoFilterBackend, self).filter_queryset(request, queryset, view) + + filter_class = self.get_filter_class(view, queryset) + + kwargs = self.get_filterset_kwargs(request, queryset, view) + self._validate_filter(kwargs.pop('filter_keys'), filter_class) + + if filter_class: + return filter_class(kwargs['data'], queryset=queryset, request=request).qs + + return queryset diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py new file mode 100644 index 00000000..0bd022fb --- /dev/null +++ b/rest_framework_json_api/filters/__init__.py @@ -0,0 +1 @@ +from .sort import JSONAPIOrderingFilter # noqa: F401 diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters/sort.py similarity index 100% rename from rest_framework_json_api/filters.py rename to rest_framework_json_api/filters/sort.py diff --git a/tox.ini b/tox.ini index d5cca046..e242ba2a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,8 @@ [tox] envlist = - py{27,34,35,36}-django111-drf{36,37,38}, - py{34,35,36}-django20-drf{37,38}, + py27-df11-django111-drf{36,37,38} + py{34,35,36}-df20-django111-drf{36,37,38}, + py{34,35,36}-df20-django20-drf{37,38}, [testenv] deps = @@ -10,6 +11,8 @@ deps = drf36: djangorestframework>=3.6.3,<3.7 drf37: djangorestframework>=3.7.0,<3.8 drf38: djangorestframework>=3.8.0,<3.9 + df11: django-filter<=1.1 + df20: django-filter>=2.0 setenv = PYTHONPATH = {toxinidir} From e8a735ffbbf989a25e35ca3bef41e684dabb3257 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 11 Sep 2018 14:24:57 +0200 Subject: [PATCH 035/488] Move to keep a changelog format (#474) * Move to keep a changelog format Fixes #470 Includes documentation of versioning and deprecation policy * Remove deprecation warning for JSONAPI prefix We will remove this before new version will be released. --- CHANGELOG.md | 136 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 96 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be7dda6c..4a14f730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,94 +1,150 @@ -[unreleased] +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), +any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. + +## [Unreleased] + +### Added * Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](https://www.django-rest-framework.org/api-guide/testing/#configuration) * Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields) * Add related urls support. See [usage docs](docs/usage.md#related-urls) -* Replaced binary `drf_example` sqlite3 db with a [fixture](example/fixtures/drf_example.yaml). See [getting started](docs/getting-started.md#running-the-example-app). -* For naming consistency, renamed new `JsonApi`-prefix pagination classes to `JSONAPI`-prefix. - * Deprecates `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination` -* Performance improvement when rendering relationships with `ModelSerializer` * Add optional [jsonapi-style](http://jsonapi.org/format/) filter backends. See [usage docs](docs/usage.md#filter-backends) -v2.5.0 - Released July 11, 2018 +### Changed + +* Replaced binary `drf_example` sqlite3 db with a [fixture](example/fixtures/drf_example.json). See [getting started](docs/getting-started.md#running-the-example-app). + +### Fixed + +* Performance improvement when rendering relationships with `ModelSerializer` + +## [2.5.0] - 2018-07-11 + +### Added * Add new pagination classes based on JSON:API query parameter *recommendations*: * `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination`. See [usage docs](docs/usage.md#pagination). - * Deprecates `PageNumberPagination` and `LimitOffsetPagination` * Add `ReadOnlyModelViewSet` extension with prefetch mixins * Add support for Django REST Framework 3.8.x * Introduce `JSON_API_FORMAT_FIELD_NAMES` option replacing `JSON_API_FORMAT_KEYS` but in comparison preserving values from being formatted as attributes can contain any [json value](http://jsonapi.org/format/#document-resource-object-attributes). - * `JSON_API_FORMAT_KEYS` still works as before (formatting all json value keys also nested) but is marked as deprecated -* Performance improvement when rendering included data * Allow overwriting of `get_queryset()` in custom `ResourceRelatedField` -v2.4.0 - Released January 25, 2018 +### Deprecated + +* Deprecate `PageNumberPagination` and `LimitOffsetPagination`. Use `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination` instead. +* Deprecate `JSON_API_FORMAT_KEYS`, use `JSON_API_FORMAT_FIELD_NAMES`. + +### Fixed + +* Performance improvement when rendering included data + +## [2.4.0] - 2018-01-25 + +### Added * Add support for Django REST Framework 3.7.x. * Add support for Django 2.0. + +### Removed + * Drop support for Django 1.8 - 1.10 (EOL) * Drop support for Django REST Framework < 3.6.3 (3.6.3 is the first to support Django 1.11) * Drop support for Python 3.3 (EOL) -v2.3.0 - Released November 28, 2017 -* Added support for polymorphic models +## [2.3.0] - 2017-11-28 + +### Added + +* Add support for polymorphic models +* Add nested included serializer support for remapped relations + +### Changed + +* Enforcing flake8 linting + +### Fixed * When `JSON_API_FORMAT_KEYS` is False (the default) do not translate request attributes and relations to snake\_case format. This conversion was unexpected and there was no way to turn it off. * Fix for apps that don't use `django.contrib.contenttypes`. * Fix `resource_name` support for POST requests and nested serializers -* Enforcing flake8 linting -* Added nested included serializer support for remapped relations -v2.2.0 +## [2.2.0] - 2017-04-22 + +### Added * Add support for Django REST Framework 3.5 and 3.6 * Add support for Django 1.11 * Add support for Python 3.6 -v2.1.1 +## [2.1.1] - 2016-09-26 + +### Added -* Avoid setting `id` to `None` in the parser simply because it's missing -* Fixed out of scope `relation_instance` variable in renderer * Allow default DRF serializers to operate even when mixed with DRF-JA serializers -* Fixed wrong resource type for reverse foreign keys -* Fixed documentation typos -v2.1.0 +### Fixed + +* Avoid setting `id` to `None` in the parser simply because it's missing +* Fix out of scope `relation_instance` variable in renderer +* Fix wrong resource type for reverse foreign keys +* Fix documentation typos + +## [2.1.0] - 2016-08.18 + +### Added * Parse `meta` in JSONParser -* Added code coverage reporting and updated Django versions tested against -* Fixed Django 1.10 compatibility -* Added support for regular non-ModelSerializers -* Added performance enhancements to reduce the number of queries in related payloads -* Fixed bug where related `SerializerMethodRelatedField` fields were not included even if in `include` -* Convert `include` field names back to snake_case +* Add code coverage reporting and updated Django versions tested against +* Add support for regular non-ModelSerializers + +### Changed + * Documented built in `url` field for generating a `self` link in the `links` key -* Fixed bug that prevented `fields = ()` in a serializer from being valid -* Fixed stale data returned in PATCH to-one relation +* Convert `include` field names back to snake_case * Raise a `ParseError` if an `id` is not included in a PATCH request -v2.0.1 +### Fixed -* Fixed naming error that caused ModelSerializer relationships to fail +* Fix Django 1.10 compatibility +* Performance enhancements to reduce the number of queries in related payloads +* Fix issue where related `SerializerMethodRelatedField` fields were not included even if in `include` +* Fix bug that prevented `fields = ()` in a serializer from being valid +* Fix stale data returned in PATCH to-one relation -v2.0.0 +## [2.0.1] - 2016-05-02 -* Fixed bug where write_only fields still had their keys rendered -* Exception handler can now easily be used on DRF-JA views alongside regular DRF views -* Added `get_related_field_name` for views subclassing RelationshipView to override -* Renamed `JSON_API_FORMAT_RELATION_KEYS` to `JSON_API_FORMAT_TYPES` to match what it was actually doing -* Renamed `JSON_API_PLURALIZE_RELATION_TYPE` to `JSON_API_PLURALIZE_TYPES` -* Documented ResourceRelatedField and RelationshipView +### Fixed + +* Fixes naming error that caused ModelSerializer relationships to fail + +## [2.0.0] - 2016-04-29 + +### Added + +* Add `get_related_field_name` for views subclassing RelationshipView to override * Added LimitOffsetPagination * Support deeply nested `?includes=foo.bar.baz` without returning intermediate models (bar) * Allow a view's serializer_class to be fetched at runtime via `get_serializer_class` * Added support for `get_root_meta` on list serializers +### Changed -v2.0.0-beta.2 +* Exception handler can now easily be used on DRF-JA views alongside regular DRF views +* Rename `JSON_API_FORMAT_RELATION_KEYS` to `JSON_API_FORMAT_TYPES` to match what it was actually doing +* Rename `JSON_API_PLURALIZE_RELATION_TYPE` to `JSON_API_PLURALIZE_TYPES` +* Documented ResourceRelatedField and RelationshipView -* Added JSONAPIMeta class option to models for overriding `resource_name`. #197 +### Fixed +* Fixes bug where write_only fields still had their keys rendered From 7fa46c2ce8502f326fe9fbe68ee5a8595587eb5d Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 13 Sep 2018 02:46:15 -0400 Subject: [PATCH 036/488] revert JSONAPI prefix to JsonApi for paginators. (#469) Clarify deprecation warning of pagination classes --- CHANGELOG.md | 12 ++++ README.rst | 2 +- docs/usage.md | 35 +++++++----- example/settings/test.py | 1 + example/tests/unit/test_pagination.py | 64 ++++++++++++++++----- example/views.py | 6 +- rest_framework_json_api/pagination.py | 80 ++++++++++++--------------- 7 files changed, 124 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a14f730..0230d7e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Performance improvement when rendering relationships with `ModelSerializer` +* Do not show deprecation warning when user has implemented custom pagination class overwriting default values. + ## [2.5.0] - 2018-07-11 @@ -40,6 +42,16 @@ any parts of the framework not mentioned in the documentation should generally b ### Deprecated * Deprecate `PageNumberPagination` and `LimitOffsetPagination`. Use `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination` instead. + * To retain deprecated values for `PageNumberPagination` and `LimitOffsetPagination` create new custom class like the following in your code base: + ```python + class CustomPageNumberPagination(PageNumberPagination): + page_query_param = "page" + page_size_query_param = "page_size" + + class CustomLimitOffsetPagination(LimitOffsetPagination): + max_limit = None + ``` + and adjust `REST_FRAMEWORK['DEFAULT_PAGINATION_CLASS']` setting accordingly. * Deprecate `JSON_API_FORMAT_KEYS`, use `JSON_API_FORMAT_FIELD_NAMES`. ### Fixed diff --git a/README.rst b/README.rst index d690bb3f..cb0647fa 100644 --- a/README.rst +++ b/README.rst @@ -161,7 +161,7 @@ override ``settings.REST_FRAMEWORK`` 'PAGE_SIZE': 10, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', 'DEFAULT_PAGINATION_CLASS': - 'rest_framework_json_api.pagination.JSONAPIPageNumberPagination', + 'rest_framework_json_api.pagination.JsonApiPageNumberPagination', 'DEFAULT_PARSER_CLASSES': ( 'rest_framework_json_api.parsers.JSONParser', 'rest_framework.parsers.FormParser', diff --git a/docs/usage.md b/docs/usage.md index 51576d06..7d329a3c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -16,7 +16,7 @@ REST_FRAMEWORK = { 'PAGE_SIZE': 10, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', 'DEFAULT_PAGINATION_CLASS': - 'rest_framework_json_api.pagination.JSONAPIPageNumberPagination', + 'rest_framework_json_api.pagination.JsonApiPageNumberPagination', 'DEFAULT_PARSER_CLASSES': ( 'rest_framework_json_api.parsers.JSONParser', 'rest_framework.parsers.FormParser', @@ -50,6 +50,8 @@ DJA pagination is based on [DRF pagination](https://www.django-rest-framework.or When pagination is enabled, the renderer will return a `meta` object with record count and a `links` object with the next, previous, first, and last links. +Optional query parameters can also be provided to customize the page size or offset limit. + #### Configuring the Pagination Style Pagination style can be set on a particular viewset with the `pagination_class` attribute or by default for all viewsets @@ -59,35 +61,42 @@ You can configure fixed values for the page size or limit -- or allow the client via query parameters. Two pagination classes are available: -- `JSONAPIPageNumberPagination` breaks a response up into pages that start at a given page number - with a given size (number of items per page). It can be configured with the following attributes: +- `JsonApiPageNumberPagination` breaks a response up into pages that start at a given page number with a given size + (number of items per page). It can be configured with the following attributes: - `page_query_param` (default `page[number]`) - `page_size_query_param` (default `page[size]`) Set this to `None` if you don't want to allow the client to specify the size. + - `page_size` (default `REST_FRAMEWORK['PAGE_SIZE']`) default number of items per page unless overridden by + `page_size_query_param`. - `max_page_size` (default `100`) enforces an upper bound on the `page_size_query_param`. Set it to `None` if you don't want to enforce an upper bound. -- `JSONAPILimitOffsetPagination` breaks a response up into pages that start from an item's offset - in the viewset for a given number of items (the limit). + +- `JsonApiLimitOffsetPagination` breaks a response up into pages that start from an item's offset in the viewset for + a given number of items (the limit). It can be configured with the following attributes: - `offset_query_param` (default `page[offset]`). - `limit_query_param` (default `page[limit]`). + - `default_limit` (default `REST_FRAMEWORK['PAGE_SIZE']`) is the default number of items per page unless + overridden by `limit_query_param`. - `max_limit` (default `100`) enforces an upper bound on the limit. Set it to `None` if you don't want to enforce an upper bound. - +##### Examples These examples show how to configure the parameters to use non-standard names and different limits: ```python -from rest_framework_json_api.pagination import JSONAPIPageNumberPagination, JSONAPILimitOffsetPagination +from rest_framework_json_api.pagination import JsonApiPageNumberPagination, JsonApiLimitOffsetPagination -class MyPagePagination(JSONAPIPageNumberPagination): +class MyPagePagination(JsonApiPageNumberPagination): page_query_param = 'page_number' - page_size_query_param = 'page_size' + page_size_query_param = 'page_length' + page_size = 3 max_page_size = 1000 -class MyLimitPagination(JSONAPILimitOffsetPagination): +class MyLimitPagination(JsonApiLimitOffsetPagination): offset_query_param = 'offset' limit_query_param = 'limit' + default_limit = 3 max_limit = None ``` @@ -146,7 +155,7 @@ If you are also using [`rest_framework.filters.SearchFilter`](https://django-res (which performs single parameter searchs across multiple fields) you'll want to customize the name of the query parameter for searching to make sure it doesn't conflict with a field name defined in the filterset. The recommended value is: `search_param="filter[search]"` but just make sure it's -`filter[_something_]` to comply with the jsonapi spec requirement to use the filter +`filter[_something_]` to comply with the JSON:API spec requirement to use the filter keyword. The default is "search" unless overriden. The filter returns a `400 Bad Request` error for invalid filter query parameters as in this example @@ -446,7 +455,7 @@ class OrderSerializer(serializers.ModelSerializer): ``` -In the [JSON API spec](http://jsonapi.org/format/#document-resource-objects), +In the [JSON:API spec](http://jsonapi.org/format/#document-resource-objects), relationship objects contain links to related objects. To make this work on a serializer we need to tell the `ResourceRelatedField` about the corresponding view. Use the `HyperlinkedModelSerializer` and instantiate @@ -584,7 +593,7 @@ class OrderSerializer(serializers.HyperlinkedModelSerializer): ### RelationshipView `rest_framework_json_api.views.RelationshipView` is used to build relationship views (see the -[JSON API spec](http://jsonapi.org/format/#fetching-relationships)). +[JSON:API spec](http://jsonapi.org/format/#fetching-relationships)). The `self` link on a relationship object should point to the corresponding relationship view. diff --git a/example/settings/test.py b/example/settings/test.py index bbf6e400..c165e187 100644 --- a/example/settings/test.py +++ b/example/settings/test.py @@ -12,6 +12,7 @@ JSON_API_FIELD_NAMES = 'camelize' JSON_API_FORMAT_TYPES = 'camelize' JSON_API_PLURALIZE_TYPES = True + REST_FRAMEWORK.update({ 'PAGE_SIZE': 1, }) diff --git a/example/tests/unit/test_pagination.py b/example/tests/unit/test_pagination.py index 5fdcade6..9da8675f 100644 --- a/example/tests/unit/test_pagination.py +++ b/example/tests/unit/test_pagination.py @@ -13,11 +13,11 @@ class TestLimitOffset: """ - Unit tests for `pagination.JSONAPILimitOffsetPagination`. + Unit tests for `pagination.JsonApiLimitOffsetPagination`. """ def setup(self): - class ExamplePagination(pagination.JSONAPILimitOffsetPagination): + class ExamplePagination(pagination.JsonApiLimitOffsetPagination): default_limit = 10 max_limit = 15 @@ -79,33 +79,71 @@ def test_valid_offset_limit(self): assert queryset == list(range(offset + 1, next_offset + 1)) assert content == expected_content + @pytest.mark.xfail((sys.version_info.major, sys.version_info.minor) == (2, 7), + reason="python2.7 fails to generate DeprecationWarrning for unknown reason") def test_limit_offset_deprecation(self): with pytest.warns(DeprecationWarning) as record: pagination.LimitOffsetPagination() assert len(record) == 1 - assert 'LimitOffsetPagination' in str(record[0].message) + assert 'LimitOffsetPagination is deprecated' in str(record[0].message) + class MyInheritedLimitOffsetPagination(pagination.LimitOffsetPagination): + """ + Inherit the default values + """ + pass + + class MyOverridenLimitOffsetPagination(pagination.LimitOffsetPagination): + """ + Explicitly set max_limit to the "old" values. + """ + max_limit = None + + def test_my_limit_offset_deprecation(self): with pytest.warns(DeprecationWarning) as record: - pagination.JsonApiLimitOffsetPagination() + self.MyInheritedLimitOffsetPagination() assert len(record) == 1 - assert 'JsonApiLimitOffsetPagination' in str(record[0].message) + assert 'LimitOffsetPagination is deprecated' in str(record[0].message) + + with pytest.warns(None) as record: + self.MyOverridenLimitOffsetPagination() + assert len(record) == 0 -# TODO: This test fails under py27 but it's not clear why so just leave it out for now. -@pytest.mark.xfail((sys.version_info.major, sys.version_info.minor) == (2, 7), - reason="python2.7 fails for unknown reason") class TestPageNumber: """ - Unit tests for `pagination.JSONAPIPageNumberPagination`. - TODO: add unit tests for changing query parameter names, limits, etc. + Unit tests for `pagination.JsonApiPageNumberPagination`. """ + + @pytest.mark.xfail((sys.version_info.major, sys.version_info.minor) == (2, 7), + reason="python2.7 fails to generate DeprecationWarrning for unknown reason") def test_page_number_deprecation(self): with pytest.warns(DeprecationWarning) as record: pagination.PageNumberPagination() assert len(record) == 1 - assert 'PageNumberPagination' in str(record[0].message) + assert 'PageNumberPagination is deprecated' in str(record[0].message) + class MyInheritedPageNumberPagination(pagination.PageNumberPagination): + """ + Inherit the default values + """ + pass + + class MyOverridenPageNumberPagination(pagination.PageNumberPagination): + """ + Explicitly set page_query_param and page_size_query_param to the "old" values. + """ + page_query_param = "page" + page_size_query_param = "page_size" + + @pytest.mark.xfail((sys.version_info.major, sys.version_info.minor) == (2, 7), + reason="python2.7 fails to generate DeprecationWarrning for unknown reason") + def test_my_page_number_deprecation(self): with pytest.warns(DeprecationWarning) as record: - pagination.JsonApiPageNumberPagination() + self.MyInheritedPageNumberPagination() assert len(record) == 1 - assert 'JsonApiPageNumberPagination' in str(record[0].message) + assert 'PageNumberPagination is deprecated' in str(record[0].message) + + with pytest.warns(None) as record: + self.MyOverridenPageNumberPagination() + assert len(record) == 0 diff --git a/example/views.py b/example/views.py index f3b9ccec..98907902 100644 --- a/example/views.py +++ b/example/views.py @@ -35,7 +35,7 @@ def get_object(self): return super(BlogViewSet, self).get_object() -class JSONAPIViewSet(ModelViewSet): +class JsonApiViewSet(ModelViewSet): """ This is an example on how to configure DRF-jsonapi from within a class. It allows using DRF-jsonapi alongside @@ -59,12 +59,12 @@ def handle_exception(self, exc): exc.status_code = HTTP_422_UNPROCESSABLE_ENTITY # exception handler can't be set on class so you have to # override the error response in this method - response = super(JSONAPIViewSet, self).handle_exception(exc) + response = super(JsonApiViewSet, self).handle_exception(exc) context = self.get_exception_handler_context() return format_drf_errors(response, context, exc) -class BlogCustomViewSet(JSONAPIViewSet): +class BlogCustomViewSet(JsonApiViewSet): queryset = Blog.objects.all() serializer_class = BlogSerializer diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index b150aa83..281c78f2 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -9,9 +9,10 @@ from rest_framework.views import Response -class JSONAPIPageNumberPagination(PageNumberPagination): +class JsonApiPageNumberPagination(PageNumberPagination): """ - A json-api compatible pagination format + A json-api compatible pagination format. + Use a private name for the implementation because the public name is pending deprecation. """ page_query_param = 'page[number]' page_size_query_param = 'page[size]' @@ -50,11 +51,13 @@ def get_paginated_response(self, data): }) -class JSONAPILimitOffsetPagination(LimitOffsetPagination): +class JsonApiLimitOffsetPagination(LimitOffsetPagination): """ A limit/offset based style. For example: http://api.example.org/accounts/?page[limit]=100 http://api.example.org/accounts/?page[offset]=400&page[limit]=100 + + Use a private name for the implementation because the public name is pending deprecation. """ limit_query_param = 'page[limit]' offset_query_param = 'page[offset]' @@ -100,63 +103,48 @@ def get_paginated_response(self, data): }) -class JsonApiPageNumberPagination(JSONAPIPageNumberPagination): +class PageNumberPagination(JsonApiPageNumberPagination): """ - Deprecated due to desire to use `JSONAPI` prefix for all classes. + A soon-to-be-changed paginator that uses non-JSON:API query parameters (default: + 'page' and 'page_size' instead of 'page[number]' and 'page[size]'). """ page_query_param = 'page' page_size_query_param = 'page_size' def __init__(self): - warnings.warn( - 'JsonApiPageNumberPagination is deprecated. Use JSONAPIPageNumberPagination ' - 'or create custom pagination. See ' - 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', - DeprecationWarning) - super(JsonApiPageNumberPagination, self).__init__() - - -class PageNumberPagination(JSONAPIPageNumberPagination): - """ - Deprecated paginator that uses different query parameters - """ - page_query_param = 'page' - page_size_query_param = 'page_size' + if type(self) == PageNumberPagination: + warn = self.page_query_param == 'page' or self.page_size_query_param == 'page_size' + else: # inherited class doesn't override the attributes? + warn = ('page_query_param' not in type(self).__dict__ or + 'page_size_query_param' not in type(self).__dict__) + if warn: + warnings.warn( + 'PageNumberPagination is deprecated use JsonApiPageNumberPagination instead. ' + 'If you want to retain current defaults you will need to implement custom ' + 'pagination class explicitly setting `page_query_param = "page"` and ' + '`page_size_query_param = "page_size"`. ' + 'See changelog for more details.', + DeprecationWarning) - def __init__(self): - warnings.warn( - 'PageNumberPagination is deprecated. Use JSONAPIPageNumberPagination ' - 'or create custom pagination. See ' - 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', - DeprecationWarning) super(PageNumberPagination, self).__init__() -class JsonApiLimitOffsetPagination(JSONAPILimitOffsetPagination): - """ - Deprecated due to desire to use `JSONAPI` prefix for all classes. - """ - max_limit = None - - def __init__(self): - warnings.warn( - 'JsonApiLimitOffsetPagination is deprecated. Use JSONAPILimitOffsetPagination ' - 'or create custom pagination. See ' - 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', - DeprecationWarning) - super(JsonApiLimitOffsetPagination, self).__init__() - - -class LimitOffsetPagination(JSONAPILimitOffsetPagination): +class LimitOffsetPagination(JsonApiLimitOffsetPagination): """ Deprecated paginator that uses a different max_limit """ max_limit = None def __init__(self): - warnings.warn( - 'LimitOffsetPagination is deprecated. Use JSONAPILimitOffsetPagination ' - 'or create custom pagination. See ' - 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', - DeprecationWarning) + if type(self) == LimitOffsetPagination: + warn = self.max_limit is None + else: + warn = 'max_limit' not in type(self).__dict__ + if warn: + warnings.warn( + 'LimitOffsetPagination is deprecated use JsonApiLimitOffsetPagination instead. ' + 'If you want to retain current defaults you will need to implement custom ' + 'pagination class explicitly setting `max_limit = None`. ' + 'See changelog for more details.', + DeprecationWarning) super(LimitOffsetPagination, self).__init__() From 59c439ddf5e05f44b37c1f29973af14545cc65bf Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 14 Sep 2018 02:17:47 -0400 Subject: [PATCH 037/488] Rename JSONAPIOrderingFilter back to OrderingFilter (#477) --- README.rst | 2 +- docs/usage.md | 8 ++++---- example/settings/dev.py | 2 +- rest_framework_json_api/filters/__init__.py | 2 +- rest_framework_json_api/filters/sort.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index cb0647fa..426c9492 100644 --- a/README.rst +++ b/README.rst @@ -173,7 +173,7 @@ override ``settings.REST_FRAMEWORK`` ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.filters.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', ), 'TEST_REQUEST_RENDERER_CLASSES': ( diff --git a/docs/usage.md b/docs/usage.md index 7d329a3c..7591c2ab 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -33,7 +33,7 @@ REST_FRAMEWORK = { ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.filters.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', ), 'TEST_REQUEST_RENDERER_CLASSES': ( @@ -104,8 +104,8 @@ class MyLimitPagination(JsonApiLimitOffsetPagination): _There are several anticipated JSON:API-specific filter backends in development. The first two are described below._ -#### `JSONAPIOrderingFilter` -`JSONAPIOrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses +#### `OrderingFilter` +`OrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses DRF's [ordering filter](http://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#orderingfilter). Per the JSON:API specification, "If the server does not support sorting as specified in the query parameter `sort`, @@ -186,7 +186,7 @@ from rest_framework_json_api import django_filters class MyViewset(ModelViewSet): queryset = MyModel.objects.all() serializer_class = MyModelSerializer - filter_backends = (filters.JSONAPIOrderingFilter, django_filters.DjangoFilterBackend,) + filter_backends = (filters.OrderingFilter, django_filters.DjangoFilterBackend,) ``` diff --git a/example/settings/dev.py b/example/settings/dev.py index 5356146f..e0a3e726 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -90,7 +90,7 @@ ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.filters.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', ), 'TEST_REQUEST_RENDERER_CLASSES': ( diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py index 0bd022fb..8ac4b976 100644 --- a/rest_framework_json_api/filters/__init__.py +++ b/rest_framework_json_api/filters/__init__.py @@ -1 +1 @@ -from .sort import JSONAPIOrderingFilter # noqa: F401 +from .sort import OrderingFilter # noqa: F401 diff --git a/rest_framework_json_api/filters/sort.py b/rest_framework_json_api/filters/sort.py index 748b18bf..f74b2c1c 100644 --- a/rest_framework_json_api/filters/sort.py +++ b/rest_framework_json_api/filters/sort.py @@ -4,7 +4,7 @@ from rest_framework_json_api.utils import format_value -class JSONAPIOrderingFilter(OrderingFilter): +class OrderingFilter(OrderingFilter): """ This implements http://jsonapi.org/format/#fetching-sorting and raises 400 if any sort field is invalid. If you prefer *not* to report 400 errors for @@ -40,5 +40,5 @@ def remove_invalid_fields(self, queryset, fields, view, request): else: underscore_fields.append(format_value(item_rewritten, "underscore")) - return super(JSONAPIOrderingFilter, self).remove_invalid_fields( + return super(OrderingFilter, self).remove_invalid_fields( queryset, underscore_fields, view, request) From 5d98c0bbd8eb50c9338412cb3d6e0578cbec78c0 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Mon, 17 Sep 2018 04:05:12 -0400 Subject: [PATCH 038/488] Document how to use `rest_framework.filters.SearchFilter` (#476) --- README.rst | 2 + docs/usage.md | 28 ++++++- example/settings/dev.py | 2 + example/tests/test_filters.py | 140 ++++++++++++++++++++++++++++++++++ example/views.py | 1 + 5 files changed, 169 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 426c9492..c06dbbd5 100644 --- a/README.rst +++ b/README.rst @@ -175,7 +175,9 @@ override ``settings.REST_FRAMEWORK`` 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', ), + 'SEARCH_PARAM': 'filter[search]', 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', ), diff --git a/docs/usage.md b/docs/usage.md index 7591c2ab..39575202 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -35,7 +35,9 @@ REST_FRAMEWORK = { 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', ), + 'SEARCH_PARAM': 'filter[search]', 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', ), @@ -102,7 +104,8 @@ class MyLimitPagination(JsonApiLimitOffsetPagination): ### Filter Backends -_There are several anticipated JSON:API-specific filter backends in development. The first two are described below._ +Following are descriptions for two JSON:API-specific filter backends and documentation on suggested usage +for a standard DRF keyword-search filter backend that makes it consistent with JSON:API. #### `OrderingFilter` `OrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses @@ -151,12 +154,12 @@ Filters can be: - A related resource path can be used: `?filter[inventory.item.partNum]=123456` (where `inventory.item` is the relationship path) -If you are also using [`rest_framework.filters.SearchFilter`](https://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#searchfilter) -(which performs single parameter searchs across multiple fields) you'll want to customize the name of the query +If you are also using [`SearchFilter`](#searchfilter) +(which performs single parameter searches across multiple fields) you'll want to customize the name of the query parameter for searching to make sure it doesn't conflict with a field name defined in the filterset. The recommended value is: `search_param="filter[search]"` but just make sure it's `filter[_something_]` to comply with the JSON:API spec requirement to use the filter -keyword. The default is "search" unless overriden. +keyword. The default is `REST_FRAMEWORK['SEARCH_PARAM']` unless overriden. The filter returns a `400 Bad Request` error for invalid filter query parameters as in this example for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`: @@ -173,6 +176,15 @@ for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`: ] } ``` +#### `SearchFilter` + +To comply with JSON:API query parameter naming standards, DRF's +[SearchFilter](https://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#searchfilter) should +be configured to use a `filter[_something_]` query parameter. This can be done by default by adding the +SearchFilter to `REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']` and setting `REST_FRAMEWORK['SEARCH_PARAM']` or +adding the `.search_param` attribute to a custom class derived from `SearchFilter`. If you do this and also +use [`DjangoFilterBackend`](#djangofilterbackend), make sure you set the same values for both classes. + #### Configuring Filter Backends @@ -182,11 +194,19 @@ in the [example settings](#configuration) or individually add them as `.filter_b ```python from rest_framework_json_api import filters from rest_framework_json_api import django_filters +from rest_framework import SearchFilter +from models import MyModel class MyViewset(ModelViewSet): queryset = MyModel.objects.all() serializer_class = MyModelSerializer filter_backends = (filters.OrderingFilter, django_filters.DjangoFilterBackend,) + filterset_fields = { + 'id': ('exact', 'lt', 'gt', 'gte', 'lte', 'in'), + 'descriptuon': ('icontains', 'iexact', 'contains'), + 'tagline': ('icontains', 'iexact', 'contains'), + } + search_fields = ('id', 'description', 'tagline',) ``` diff --git a/example/settings/dev.py b/example/settings/dev.py index e0a3e726..9e70aeb5 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -92,7 +92,9 @@ 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', ), + 'SEARCH_PARAM': 'filter[search]', 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', ), diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 273fe9ac..befba5e2 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -338,3 +338,143 @@ def test_filter_missing_rvalue_equal(self): dja_response = response.json() self.assertEqual(dja_response['errors'][0]['detail'], "missing filter[headline] test value") + + def test_search_keywords(self): + """ + test for `filter[search]="keywords"` where some of the keywords are in the entry and + others are in the related blog. + """ + response = self.client.get(self.url, data={'filter[search]': 'barnard field research'}) + expected_result = { + 'data': [ + { + 'type': 'posts', + 'id': '7', + 'attributes': { + 'headline': 'ANTH3868X', + 'bodyText': 'ETHNOGRAPHIC FIELD RESEARCH IN NYC', + 'pubDate': None, + 'modDate': None}, + 'relationships': { + 'blog': { + 'data': { + 'type': 'blogs', + 'id': '1' + } + }, + 'blogHyperlinked': { + 'links': { + 'self': 'http://testserver/entries/7/relationships/blog_hyperlinked', # noqa: E501 + 'related': 'http://testserver/entries/7/blog'} + }, + 'authors': { + 'meta': { + 'count': 0 + }, + 'data': [] + }, + 'comments': { + 'meta': { + 'count': 0 + }, + 'data': [] + }, + 'commentsHyperlinked': { + 'links': { + 'self': 'http://testserver/entries/7/relationships/comments_hyperlinked', # noqa: E501 + 'related': 'http://testserver/entries/7/comments' + } + }, + 'suggested': { + 'links': { + 'self': 'http://testserver/entries/7/relationships/suggested', + 'related': 'http://testserver/entries/7/suggested/' + }, + 'data': [ + {'type': 'entries', 'id': '1'}, + {'type': 'entries', 'id': '2'}, + {'type': 'entries', 'id': '3'}, + {'type': 'entries', 'id': '4'}, + {'type': 'entries', 'id': '5'}, + {'type': 'entries', 'id': '6'}, + {'type': 'entries', 'id': '8'}, + {'type': 'entries', 'id': '9'}, + {'type': 'entries', 'id': '10'}, + {'type': 'entries', 'id': '11'}, + {'type': 'entries', 'id': '12'} + ] + }, + 'suggestedHyperlinked': { + 'links': { + 'self': 'http://testserver/entries/7/relationships/suggested_hyperlinked', # noqa: E501 + 'related': 'http://testserver/entries/7/suggested/'} + }, + 'tags': { + 'data': [] + }, + 'featuredHyperlinked': { + 'links': { + 'self': 'http://testserver/entries/7/relationships/featured_hyperlinked', # noqa: E501 + 'related': 'http://testserver/entries/7/featured' + } + } + }, + 'meta': { + 'bodyFormat': 'text' + } + } + ] + } + assert response.json() == expected_result + + def test_search_multiple_keywords(self): + """ + test for `filter[search]=keyword1...` (keyword1 [AND keyword2...]) + + See the four search_fields defined in views.py which demonstrate both searching + direct fields (entry) and following ORM links to related fields (blog): + `search_fields = ('headline', 'body_text', 'blog__name', 'blog__tagline')` + + SearchFilter searches for items that match all whitespace separated keywords across + the many fields. + + This code tests that functionality by comparing the result of the GET request + with the equivalent results used by filtering the test data via the model manager. + To do so, iterate over the list of given searches: + 1. For each keyword, search the 4 search_fields for a match and then get the result + set which is the union of all results for the given keyword. + 2. Intersect those results sets such that *all* keywords are represented. + See `example/fixtures/blogentry.json` for the test content that the searches are based on. + The searches test for both direct entries and related blogs across multiple fields. + """ + for searches in ("research", "chemistry", "nonesuch", + "research seminar", "research nonesuch", + "barnard classic", "barnard ethnographic field research"): + response = self.client.get(self.url, data={'filter[search]': searches}) + self.assertEqual(response.status_code, 200, msg=response.content.decode("utf-8")) + dja_response = response.json() + keys = searches.split() + # dicts keyed by the search keys for the 4 search_fields: + headline = {} # list of entry ids where key is in entry__headline + body_text = {} # list of entry ids where key is in entry__body_text + blog_name = {} # list of entry ids where key is in entry__blog__name + blog_tagline = {} # list of entry ids where key is in entry__blog__tagline + for key in keys: + headline[key] = [str(k.id) for k in + self.entries.filter(headline__icontains=key)] + body_text[key] = [str(k.id) for k in + self.entries.filter(body_text__icontains=key)] + blog_name[key] = [str(k.id) for k in + self.entries.filter(blog__name__icontains=key)] + blog_tagline[key] = [str(k.id) for k in + self.entries.filter(blog__tagline__icontains=key)] + union = [] # each list item is a set of entry ids matching the given key + for key in keys: + union.append(set(headline[key] + body_text[key] + + blog_name[key] + blog_tagline[key])) + # all keywords must be present: intersect the keyword sets + expected_ids = set.intersection(*union) + expected_len = len(expected_ids) + self.assertEqual(len(dja_response['data']), expected_len) + returned_ids = set([k['id'] for k in dja_response['data']]) + self.assertEqual(returned_ids, expected_ids) diff --git a/example/views.py b/example/views.py index 98907902..d0980f01 100644 --- a/example/views.py +++ b/example/views.py @@ -104,6 +104,7 @@ class NonPaginatedEntryViewSet(EntryViewSet): 'blog__tagline': rels, } filter_fields = filterset_fields # django-filter<=1.1 (required for py27) + search_fields = ('headline', 'body_text', 'blog__name', 'blog__tagline') class EntryFilter(filters.FilterSet): From 760845abf603a55d319db7b45b7afb4731e5f9ab Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 19 Sep 2018 02:42:28 -0400 Subject: [PATCH 039/488] Deprecate MultipleIDMixin (#482) --- CHANGELOG.md | 6 ++++++ README.rst | 4 +++- rest_framework_json_api/mixins.py | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0230d7e8..a626fff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ any parts of the framework not mentioned in the documentation should generally b * Add related urls support. See [usage docs](docs/usage.md#related-urls) * Add optional [jsonapi-style](http://jsonapi.org/format/) filter backends. See [usage docs](docs/usage.md#filter-backends) +### Deprecated + +* Deprecate `MultipleIDMixin` because it doesn't comply with the JSON:API 1.0 spec. Replace it with + `DjangoFilterBackend` and **change clients** to use `filter[id.in]` query parameter instead of `ids[]`. + See [usage docs](docs/usage.md#djangofilterbackend). + ### Changed * Replaced binary `drf_example` sqlite3 db with a [fixture](example/fixtures/drf_example.json). See [getting started](docs/getting-started.md#running-the-example-app). diff --git a/README.rst b/README.rst index c06dbbd5..2ad66b09 100644 --- a/README.rst +++ b/README.rst @@ -184,4 +184,6 @@ override ``settings.REST_FRAMEWORK`` 'TEST_REQUEST_DEFAULT_FORMAT': 'vnd.api+json' } -This package provides much more including automatic inflection of JSON keys, extra top level data (using nested serializers), relationships, links, and handy shortcuts like MultipleIDMixin. Read more at http://django-rest-framework-json-api.readthedocs.org/ +This package provides much more including automatic inflection of JSON keys, extra top level data (using nested +serializers), relationships, links, paginators, filters, and handy shortcuts. +Read more at http://django-rest-framework-json-api.readthedocs.org/ diff --git a/rest_framework_json_api/mixins.py b/rest_framework_json_api/mixins.py index 1fa8741d..fd4219ca 100644 --- a/rest_framework_json_api/mixins.py +++ b/rest_framework_json_api/mixins.py @@ -1,12 +1,28 @@ """ Class Mixins. """ +import warnings class MultipleIDMixin(object): """ Override get_queryset for multiple id support + + .. warning:: + + MultipleIDMixin is deprecated because it does not comply with http://jsonapi.org/format. + Instead add :py:class:`django_filters.DjangoFilterBackend` to your + list of `filter_backends` and change client usage from: + ``?ids[]=id1,id2,...,idN`` to ``'?filter[id.in]=id1,id2,...,idN`` + """ + def __init__(self, *args, **kwargs): + warnings.warn("MultipleIDMixin is deprecated. " + "Instead add django_filters.DjangoFilterBackend to your " + "list of 'filter_backends' and change client usage from: " + "'?ids[]=id1,id2,...,idN' to '?filter[id.in]=id1,id2,...,idN'", + DeprecationWarning) + super(MultipleIDMixin, self).__init__(*args, **kwargs) def get_queryset(self): """ From 16f3c97f24722139cea508e7110a6402dd4a579d Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 19 Sep 2018 03:01:59 -0400 Subject: [PATCH 040/488] Add query parameter validation filter (#481) --- README.rst | 1 + docs/usage.md | 32 +++++++++- example/tests/test_filters.py | 63 +++++++++++++------ example/views.py | 7 +++ .../django_filters/backends.py | 11 +++- rest_framework_json_api/filters/__init__.py | 1 + .../filters/queryvalidation.py | 37 +++++++++++ 7 files changed, 130 insertions(+), 22 deletions(-) create mode 100644 rest_framework_json_api/filters/queryvalidation.py diff --git a/README.rst b/README.rst index 2ad66b09..7940e45f 100644 --- a/README.rst +++ b/README.rst @@ -173,6 +173,7 @@ override ``settings.REST_FRAMEWORK`` ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework_json_api.filters.QueryParameterValidationFilter', 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', 'rest_framework.filters.SearchFilter', diff --git a/docs/usage.md b/docs/usage.md index 39575202..345356be 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -33,6 +33,7 @@ REST_FRAMEWORK = { ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework_json_api.filters.QueryParameterValidationFilter', 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', 'rest_framework.filters.SearchFilter', @@ -104,9 +105,32 @@ class MyLimitPagination(JsonApiLimitOffsetPagination): ### Filter Backends -Following are descriptions for two JSON:API-specific filter backends and documentation on suggested usage +Following are descriptions of JSON:API-specific filter backends and documentation on suggested usage for a standard DRF keyword-search filter backend that makes it consistent with JSON:API. +#### `QueryParameterValidationFilter` +`QueryParameterValidationFilter` validates query parameters to be one of the defined JSON:API query parameters +(sort, include, filter, fields, page) and returns a `400 Bad Request` if a non-matching query parameter +is used. This can help the client identify misspelled query parameters, for example. + +If you want to change the list of valid query parameters, override the `.query_regex` attribute: +```python +# compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters +# `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s +query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') +``` +For example: +```python +import re +from rest_framework_json_api.filters import QueryValidationFilter + +class MyQPValidator(QueryValidationFilter): + query_regex = re.compile(r'^(sort|include|page|page_size)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') +``` + +If you don't care if non-JSON:API query parameters are allowed (and potentially silently ignored), +simply don't use this filter backend. + #### `OrderingFilter` `OrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses DRF's [ordering filter](http://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#orderingfilter). @@ -176,6 +200,7 @@ for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`: ] } ``` + #### `SearchFilter` To comply with JSON:API query parameter naming standards, DRF's @@ -186,6 +211,7 @@ adding the `.search_param` attribute to a custom class derived from `SearchFilte use [`DjangoFilterBackend`](#djangofilterbackend), make sure you set the same values for both classes. + #### Configuring Filter Backends You can configure the filter backends either by setting the `REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']` as shown @@ -200,13 +226,15 @@ from models import MyModel class MyViewset(ModelViewSet): queryset = MyModel.objects.all() serializer_class = MyModelSerializer - filter_backends = (filters.OrderingFilter, django_filters.DjangoFilterBackend,) + filter_backends = (filters.QueryParameterValidationFilter, filters.OrderingFilter, + django_filters.DjangoFilterBackend, SearchFilter) filterset_fields = { 'id': ('exact', 'lt', 'gt', 'gte', 'lte', 'in'), 'descriptuon': ('icontains', 'iexact', 'contains'), 'tagline': ('icontains', 'iexact', 'contains'), } search_fields = ('id', 'description', 'tagline',) + ``` diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index befba5e2..f0243457 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -251,13 +251,13 @@ def test_filter_invalid_association_name(self): def test_filter_empty_association_name(self): """ test for filter with missing association name + error texts are different depending on whether QueryParameterValidationFilter is in use. """ response = self.client.get(self.url, data={'filter[]': 'foobar'}) self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter[]") + self.assertEqual(dja_response['errors'][0]['detail'], "invalid query parameter: filter[]") def test_filter_no_brackets(self): """ @@ -268,7 +268,17 @@ def test_filter_no_brackets(self): msg=response.content.decode("utf-8")) dja_response = response.json() self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter") + "invalid query parameter: filter") + + def test_filter_missing_right_bracket(self): + """ + test for filter missing right bracket + """ + response = self.client.get(self.url, data={'filter[headline': 'foobar'}) + self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid query parameter: filter[headline") def test_filter_no_brackets_rvalue(self): """ @@ -279,7 +289,7 @@ def test_filter_no_brackets_rvalue(self): msg=response.content.decode("utf-8")) dja_response = response.json() self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter") + "invalid query parameter: filter") def test_filter_no_brackets_equal(self): """ @@ -290,7 +300,7 @@ def test_filter_no_brackets_equal(self): msg=response.content.decode("utf-8")) dja_response = response.json() self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter") + "invalid query parameter: filter") def test_filter_malformed_left_bracket(self): """ @@ -300,19 +310,7 @@ def test_filter_malformed_left_bracket(self): self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter[") - - def test_filter_missing_right_bracket(self): - """ - test for filter missing right bracket - """ - response = self.client.get(self.url, data={'filter[headline': 'foobar'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) - dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter[headline") + self.assertEqual(dja_response['errors'][0]['detail'], "invalid query parameter: filter[") def test_filter_missing_rvalue(self): """ @@ -331,7 +329,7 @@ def test_filter_missing_rvalue_equal(self): """ test for filter with missing value to test against this should probably be an error rather than ignoring the filter: - """ + """ response = self.client.get(self.url + '?filter[headline]') self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) @@ -478,3 +476,30 @@ def test_search_multiple_keywords(self): self.assertEqual(len(dja_response['data']), expected_len) returned_ids = set([k['id'] for k in dja_response['data']]) self.assertEqual(returned_ids, expected_ids) + + def test_param_invalid(self): + """ + Test a "wrong" query parameter + """ + response = self.client.get(self.url, data={'garbage': 'foo'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid query parameter: garbage") + + def test_param_duplicate(self): + """ + Test a duplicated query parameter: + `?sort=headline&page[size]=3&sort=bodyText` is not allowed. + This is not so obvious when using a data dict.... + """ + response = self.client.get(self.url, + data={'sort': ['headline', 'bodyText'], + 'page[size]': 3} + ) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "repeated query parameter not allowed: sort") diff --git a/example/views.py b/example/views.py index d0980f01..2307925a 100644 --- a/example/views.py +++ b/example/views.py @@ -1,11 +1,14 @@ import rest_framework.exceptions as exceptions import rest_framework.parsers import rest_framework.renderers +from rest_framework.filters import SearchFilter import rest_framework_json_api.metadata import rest_framework_json_api.parsers import rest_framework_json_api.renderers from django_filters import rest_framework as filters +from rest_framework_json_api.django_filters import DjangoFilterBackend +from rest_framework_json_api.filters import OrderingFilter, QueryParameterValidationFilter from rest_framework_json_api.pagination import PageNumberPagination from rest_framework_json_api.utils import format_drf_errors from rest_framework_json_api.views import ModelViewSet, RelationshipView @@ -91,6 +94,10 @@ class NoPagination(PageNumberPagination): class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination + # override the default filter backends in order to test QueryParameterValidationFilter without + # breaking older usage of non-standard query params like `page_size`. + filter_backends = (QueryParameterValidationFilter, OrderingFilter, + DjangoFilterBackend, SearchFilter) ordering_fields = ('headline', 'body_text', 'blog__name', 'blog__id') rels = ('exact', 'iexact', 'contains', 'icontains', diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 31001b08..61148266 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -52,6 +52,13 @@ class DjangoFilterBackend(DjangoFilterBackend): filter_regex = re.compile(r'^filter(?P\[?)(?P[\w\.\-]*)(?P\]?$)') def _validate_filter(self, keys, filterset_class): + """ + Check that all the filter[key] are valid. + + :param keys: list of FilterSet keys + :param filterset_class: :py:class:`django_filters.rest_framework.FilterSet` + :raises ValidationError: if key not in FilterSet keys or no FilterSet. + """ for k in keys: if ((not filterset_class) or (k not in filterset_class.base_filters)): raise ValidationError("invalid filter[{}]".format(k)) @@ -75,6 +82,8 @@ def get_filterset_kwargs(self, request, queryset, view): """ Turns filter[]= into = which is what DjangoFilterBackend expects + + :raises ValidationError: for bad filter syntax """ filter_keys = [] # rewrite filter[field] query params to make DjangoFilterBackend work. @@ -83,7 +92,7 @@ def get_filterset_kwargs(self, request, queryset, view): m = self.filter_regex.match(qp) if m and (not m.groupdict()['assoc'] or m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'): - raise ValidationError("invalid filter: {}".format(qp)) + raise ValidationError("invalid query parameter: {}".format(qp)) if m and qp != self.search_param: if not val: raise ValidationError("missing {} test value".format(qp)) diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py index 8ac4b976..37c21e2e 100644 --- a/rest_framework_json_api/filters/__init__.py +++ b/rest_framework_json_api/filters/__init__.py @@ -1 +1,2 @@ from .sort import OrderingFilter # noqa: F401 +from .queryvalidation import QueryParameterValidationFilter # noqa: F401 diff --git a/rest_framework_json_api/filters/queryvalidation.py b/rest_framework_json_api/filters/queryvalidation.py new file mode 100644 index 00000000..746ab787 --- /dev/null +++ b/rest_framework_json_api/filters/queryvalidation.py @@ -0,0 +1,37 @@ +import re + +from rest_framework.exceptions import ValidationError +from rest_framework.filters import BaseFilterBackend + + +class QueryParameterValidationFilter(BaseFilterBackend): + """ + A backend filter that performs strict validation of query parameters for + jsonapi spec conformance and raises a 400 error if non-conforming usage is + found. + + If you want to add some additional non-standard query parameters, + override :py:attr:`query_regex` adding the new parameters. Make sure to comply with + the rules at http://jsonapi.org/format/#query-parameters. + """ + #: compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters + #: `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s + query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') + + def validate_query_params(self, request): + """ + Validate that query params are in the list of valid query keywords + Raises ValidationError if not. + """ + # TODO: For jsonapi error object conformance, must set jsonapi errors "parameter" for + # the ValidationError. This requires extending DRF/DJA Exceptions. + for qp in request.query_params.keys(): + if not self.query_regex.match(qp): + raise ValidationError('invalid query parameter: {}'.format(qp)) + if len(request.query_params.getlist(qp)) > 1: + raise ValidationError( + 'repeated query parameter not allowed: {}'.format(qp)) + + def filter_queryset(self, request, queryset, view): + self.validate_query_params(request) + return queryset From 4eef958fd9d51570d8a60535f50fdfa0a1a198a4 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 20 Sep 2018 02:40:21 -0400 Subject: [PATCH 041/488] autogenerate API documentation (#479) --- CHANGELOG.md | 1 + docs/.gitignore | 1 + docs/api.md | 60 ------------ docs/api.rst | 9 ++ docs/conf.py | 16 ++- .../django_filters/backends.py | 46 ++++++--- rest_framework_json_api/filters.py | 97 +++++++++++++++++++ rest_framework_json_api/filters/__init__.py | 2 - .../filters/queryvalidation.py | 37 ------- rest_framework_json_api/filters/sort.py | 44 --------- rest_framework_json_api/pagination.py | 32 ++++-- rest_framework_json_api/parsers.py | 11 +++ rest_framework_json_api/relations.py | 10 +- rest_framework_json_api/renderers.py | 57 ++++++++--- rest_framework_json_api/serializers.py | 2 + rest_framework_json_api/utils.py | 14 +++ rest_framework_json_api/views.py | 22 +++-- tox.ini | 6 ++ 18 files changed, 268 insertions(+), 199 deletions(-) delete mode 100644 docs/api.md create mode 100644 docs/api.rst create mode 100644 rest_framework_json_api/filters.py delete mode 100644 rest_framework_json_api/filters/__init__.py delete mode 100644 rest_framework_json_api/filters/queryvalidation.py delete mode 100644 rest_framework_json_api/filters/sort.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a626fff7..348fe53e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Changed * Replaced binary `drf_example` sqlite3 db with a [fixture](example/fixtures/drf_example.json). See [getting started](docs/getting-started.md#running-the-example-app). +* Replaced unmaintained [API doc](docs/api.md) with [auto-generated API reference](docs/api.rst). ### Fixed diff --git a/docs/.gitignore b/docs/.gitignore index e35d8850..70a87b2c 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1 +1,2 @@ _build +apidoc diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 86116d5c..00000000 --- a/docs/api.md +++ /dev/null @@ -1,60 +0,0 @@ - -# API - -## mixins -### MultipleIDMixin - -Add this mixin to a view to override `get_queryset` to automatically filter -records by `ids[]=1&ids[]=2` in URL query params. - -## rest_framework_json_api.renderers.JSONRenderer - -The `JSONRenderer` exposes a number of methods that you may override if you need -highly custom rendering control. - -#### extract_attributes - -`extract_attributes(fields, resource)` - -Builds the `attributes` object of the JSON API resource object. - -#### extract_relationships - -`extract_relationships(fields, resource, resource_instance)` - -Builds the `relationships` top level object based on related serializers. - -#### extract_included - -`extract_included(fields, resource, resource_instance, included_resources)` - -Adds related data to the top level `included` key when the request includes `?include=example,example_field2` - -#### extract_meta - -`extract_meta(serializer, resource)` - -Gathers the data from serializer fields specified in `meta_fields` and adds it to the `meta` object. - -#### extract_root_meta - -`extract_root_meta(serializer, resource)` - -Calls a `get_root_meta` function on a serializer, if it exists. - -#### build_json_resource_obj - -`build_json_resource_obj(fields, resource, resource_instance, resource_name)` - -Builds the resource object (type, id, attributes) and extracts relationships. - -## rest_framework_json_api.parsers.JSONParser - -Similar to `JSONRenderer`, the `JSONParser` you may override the following methods if you need -highly custom parsing control. - -#### parse_metadata - -`parse_metadata(result)` - -Returns a dictionary which will be merged into parsed data of the request. By default, it reads the `meta` content in the request body and returns it in a dictionary with a `_meta` top level key. diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..0f633cd8 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,9 @@ +API Reference +============= + +This API reference is autogenerated from the Python docstrings -- which need to be improved! + +.. toctree:: + :maxdepth: 4 + + apidoc/rest_framework_json_api diff --git a/docs/conf.py b/docs/conf.py index 7a61ea8a..5b2f42de 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,11 +17,18 @@ import sys import os import shlex +import django # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('..')) +os.environ['DJANGO_SETTINGS_MODULE'] = 'example.settings' +django.setup() + +# Auto-generate API documentation. +from sphinx.apidoc import main +main(['sphinx-apidoc', '-e', '-T', '-M', '-f', '-o', 'apidoc', '../rest_framework_json_api']) # -- General configuration ------------------------------------------------ @@ -31,7 +38,9 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [] +extensions = ['sphinx.ext.autodoc'] +autodoc_member_order = 'bysource' +autodoc_inherit_docstrings = False # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -62,9 +71,10 @@ # built documents. # # The short X.Y version. -version = '2.0' +from rest_framework_json_api import VERSION +version = VERSION # The full version, including alpha/beta/rc tags. -release = '2.0.0-alpha.1' +release = VERSION # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 61148266..c9268eed 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -20,17 +20,32 @@ class DjangoFilterBackend(DjangoFilterBackend): chaining. It also returns a 400 error for invalid filters. Filters can be: - - A resource field equality test: - `?filter[qty]=123` - - Apply other [field lookup](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups) # noqa: E501 + + - A resource field + equality test: + + ``?filter[qty]=123`` + + - Apply other + https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups operators: - `?filter[name.icontains]=bar` or `?filter[name.isnull]=true...` - - Membership in a list of values: - `?filter[name.in]=abc,123,zzz (name in ['abc','123','zzz'])` - - Filters can be combined for intersection (AND): - `?filter[qty]=123&filter[name.in]=abc,123,zzz&filter[...]` - - A related resource path can be used: - `?filter[inventory.item.partNum]=123456 (where `inventory.item` is the relationship path)` + + ``?filter[name.icontains]=bar`` or ``?filter[name.isnull]=true...`` + + - Membership in + a list of values: + + ``?filter[name.in]=abc,123,zzz`` (name in ['abc','123','zzz']) + + - Filters can be combined + for intersection (AND): + + ``?filter[qty]=123&filter[name.in]=abc,123,zzz&filter[...]`` + + - A related resource path + can be used: + + ``?filter[inventory.item.partNum]=123456`` (where `inventory.item` is the relationship path) If you are also using rest_framework.filters.SearchFilter you'll want to customize the name of the query parameter for searching to make sure it doesn't conflict @@ -65,12 +80,11 @@ def _validate_filter(self, keys, filterset_class): def get_filterset(self, request, queryset, view): """ - Sometimes there's no filterset_class defined yet the client still + Sometimes there's no `filterset_class` defined yet the client still requests a filter. Make sure they see an error too. This means - we have to get_filterset_kwargs() even if there's no filterset_class. - - TODO: .base_filters vs. .filters attr (not always present) + we have to `get_filterset_kwargs()` even if there's no `filterset_class`. """ + # TODO: .base_filters vs. .filters attr (not always present) filterset_class = self.get_filterset_class(view, queryset) kwargs = self.get_filterset_kwargs(request, queryset, view) self._validate_filter(kwargs.pop('filter_keys'), filterset_class) @@ -112,8 +126,8 @@ def get_filterset_kwargs(self, request, queryset, view): def filter_queryset(self, request, queryset, view): """ - Backwards compatibility to 1.1 (required for Python 2.7) - In 1.1 filter_queryset does not call get_filterset or get_filterset_kwargs. + This is backwards compatibility to django-filter 1.1 (required for Python 2.7). + In 1.1 `filter_queryset` does not call `get_filterset` or `get_filterset_kwargs`. """ # TODO: remove when Python 2.7 support is deprecated if VERSION >= (2, 0, 0): diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py new file mode 100644 index 00000000..f0b95a35 --- /dev/null +++ b/rest_framework_json_api/filters.py @@ -0,0 +1,97 @@ +import re + +from rest_framework.exceptions import ValidationError +from rest_framework.filters import BaseFilterBackend, OrderingFilter + +from rest_framework_json_api.utils import format_value + + +class OrderingFilter(OrderingFilter): + """ + A backend filter that implements http://jsonapi.org/format/#fetching-sorting and + raises a 400 error if any sort field is invalid. + + If you prefer *not* to report 400 errors for invalid sort fields, just use + :py:class:`rest_framework.filters.OrderingFilter` with + :py:attr:`~rest_framework.filters.OrderingFilter.ordering_param` = "sort" + + Also applies DJA format_value() to convert (e.g. camelcase) to underscore. + (See JSON_API_FORMAT_FIELD_NAMES in docs/usage.md) + """ + #: override :py:attr:`rest_framework.filters.OrderingFilter.ordering_param` + #: with JSON:API-compliant query parameter name. + ordering_param = 'sort' + + def remove_invalid_fields(self, queryset, fields, view, request): + """ + Extend :py:meth:`rest_framework.filters.OrderingFilter.remove_invalid_fields` to + validate that all provided sort fields exist (as contrasted with the super's behavior + which is to silently remove invalid fields). + + :raises ValidationError: if a sort field is invalid. + """ + valid_fields = [ + item[0] for item in self.get_valid_fields(queryset, view, + {'request': request}) + ] + bad_terms = [ + term for term in fields + if format_value(term.replace(".", "__").lstrip('-'), "underscore") not in valid_fields + ] + if bad_terms: + raise ValidationError('invalid sort parameter{}: {}'.format( + ('s' if len(bad_terms) > 1 else ''), ','.join(bad_terms))) + # this looks like it duplicates code above, but we want the ValidationError to report + # the actual parameter supplied while we want the fields passed to the super() to + # be correctly rewritten. + # The leading `-` has to be stripped to prevent format_value from turning it into `_`. + underscore_fields = [] + for item in fields: + item_rewritten = item.replace(".", "__") + if item_rewritten.startswith('-'): + underscore_fields.append( + '-' + format_value(item_rewritten.lstrip('-'), "underscore")) + else: + underscore_fields.append(format_value(item_rewritten, "underscore")) + + return super(OrderingFilter, self).remove_invalid_fields( + queryset, underscore_fields, view, request) + + +class QueryParameterValidationFilter(BaseFilterBackend): + """ + A backend filter that performs strict validation of query parameters for + JSON:API spec conformance and raises a 400 error if non-conforming usage is + found. + + If you want to add some additional non-standard query parameters, + override :py:attr:`query_regex` adding the new parameters. Make sure to comply with + the rules at http://jsonapi.org/format/#query-parameters. + """ + #: compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters: + #: `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s + query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') + + def validate_query_params(self, request): + """ + Validate that query params are in the list of valid query keywords in + :py:attr:`query_regex` + + :raises ValidationError: if not. + """ + # TODO: For jsonapi error object conformance, must set jsonapi errors "parameter" for + # the ValidationError. This requires extending DRF/DJA Exceptions. + for qp in request.query_params.keys(): + if not self.query_regex.match(qp): + raise ValidationError('invalid query parameter: {}'.format(qp)) + if len(request.query_params.getlist(qp)) > 1: + raise ValidationError( + 'repeated query parameter not allowed: {}'.format(qp)) + + def filter_queryset(self, request, queryset, view): + """ + Overrides :py:meth:`BaseFilterBackend.filter_queryset` by first validating the + query params with :py:meth:`validate_query_params` + """ + self.validate_query_params(request) + return queryset diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py deleted file mode 100644 index 37c21e2e..00000000 --- a/rest_framework_json_api/filters/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .sort import OrderingFilter # noqa: F401 -from .queryvalidation import QueryParameterValidationFilter # noqa: F401 diff --git a/rest_framework_json_api/filters/queryvalidation.py b/rest_framework_json_api/filters/queryvalidation.py deleted file mode 100644 index 746ab787..00000000 --- a/rest_framework_json_api/filters/queryvalidation.py +++ /dev/null @@ -1,37 +0,0 @@ -import re - -from rest_framework.exceptions import ValidationError -from rest_framework.filters import BaseFilterBackend - - -class QueryParameterValidationFilter(BaseFilterBackend): - """ - A backend filter that performs strict validation of query parameters for - jsonapi spec conformance and raises a 400 error if non-conforming usage is - found. - - If you want to add some additional non-standard query parameters, - override :py:attr:`query_regex` adding the new parameters. Make sure to comply with - the rules at http://jsonapi.org/format/#query-parameters. - """ - #: compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters - #: `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s - query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') - - def validate_query_params(self, request): - """ - Validate that query params are in the list of valid query keywords - Raises ValidationError if not. - """ - # TODO: For jsonapi error object conformance, must set jsonapi errors "parameter" for - # the ValidationError. This requires extending DRF/DJA Exceptions. - for qp in request.query_params.keys(): - if not self.query_regex.match(qp): - raise ValidationError('invalid query parameter: {}'.format(qp)) - if len(request.query_params.getlist(qp)) > 1: - raise ValidationError( - 'repeated query parameter not allowed: {}'.format(qp)) - - def filter_queryset(self, request, queryset, view): - self.validate_query_params(request) - return queryset diff --git a/rest_framework_json_api/filters/sort.py b/rest_framework_json_api/filters/sort.py deleted file mode 100644 index f74b2c1c..00000000 --- a/rest_framework_json_api/filters/sort.py +++ /dev/null @@ -1,44 +0,0 @@ -from rest_framework.exceptions import ValidationError -from rest_framework.filters import OrderingFilter - -from rest_framework_json_api.utils import format_value - - -class OrderingFilter(OrderingFilter): - """ - This implements http://jsonapi.org/format/#fetching-sorting and raises 400 - if any sort field is invalid. If you prefer *not* to report 400 errors for - invalid sort fields, just use OrderingFilter with `ordering_param='sort'` - - Also applies DJA format_value() to convert (e.g. camelcase) to underscore. - (See JSON_API_FORMAT_FIELD_NAMES in docs/usage.md) - """ - ordering_param = 'sort' - - def remove_invalid_fields(self, queryset, fields, view, request): - valid_fields = [ - item[0] for item in self.get_valid_fields(queryset, view, - {'request': request}) - ] - bad_terms = [ - term for term in fields - if format_value(term.replace(".", "__").lstrip('-'), "underscore") not in valid_fields - ] - if bad_terms: - raise ValidationError('invalid sort parameter{}: {}'.format( - ('s' if len(bad_terms) > 1 else ''), ','.join(bad_terms))) - # this looks like it duplicates code above, but we want the ValidationError to report - # the actual parameter supplied while we want the fields passed to the super() to - # be correctly rewritten. - # The leading `-` has to be stripped to prevent format_value from turning it into `_`. - underscore_fields = [] - for item in fields: - item_rewritten = item.replace(".", "__") - if item_rewritten.startswith('-'): - underscore_fields.append( - '-' + format_value(item_rewritten.lstrip('-'), "underscore")) - else: - underscore_fields.append(format_value(item_rewritten, "underscore")) - - return super(OrderingFilter, self).remove_invalid_fields( - queryset, underscore_fields, view, request) diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index 281c78f2..db09bb7d 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -12,7 +12,6 @@ class JsonApiPageNumberPagination(PageNumberPagination): """ A json-api compatible pagination format. - Use a private name for the implementation because the public name is pending deprecation. """ page_query_param = 'page[number]' page_size_query_param = 'page[size]' @@ -54,10 +53,12 @@ def get_paginated_response(self, data): class JsonApiLimitOffsetPagination(LimitOffsetPagination): """ A limit/offset based style. For example: - http://api.example.org/accounts/?page[limit]=100 - http://api.example.org/accounts/?page[offset]=400&page[limit]=100 - Use a private name for the implementation because the public name is pending deprecation. + .. code:: + + http://api.example.org/accounts/?page[limit]=100 + http://api.example.org/accounts/?page[offset]=400&page[limit]=100 + """ limit_query_param = 'page[limit]' offset_query_param = 'page[offset]' @@ -105,7 +106,15 @@ def get_paginated_response(self, data): class PageNumberPagination(JsonApiPageNumberPagination): """ - A soon-to-be-changed paginator that uses non-JSON:API query parameters (default: + .. warning:: + + PageNumberPagination is deprecated. Use JsonApiPageNumberPagination instead. + If you want to retain current defaults you will need to implement custom + pagination class explicitly setting `page_query_param = "page"` and + `page_size_query_param = "page_size"`. + See changelog for more details. + + A paginator that uses non-JSON:API query parameters (default: 'page' and 'page_size' instead of 'page[number]' and 'page[size]'). """ page_query_param = 'page' @@ -119,7 +128,7 @@ def __init__(self): 'page_size_query_param' not in type(self).__dict__) if warn: warnings.warn( - 'PageNumberPagination is deprecated use JsonApiPageNumberPagination instead. ' + 'PageNumberPagination is deprecated. Use JsonApiPageNumberPagination instead. ' 'If you want to retain current defaults you will need to implement custom ' 'pagination class explicitly setting `page_query_param = "page"` and ' '`page_size_query_param = "page_size"`. ' @@ -131,7 +140,14 @@ def __init__(self): class LimitOffsetPagination(JsonApiLimitOffsetPagination): """ - Deprecated paginator that uses a different max_limit + .. warning:: + + LimitOffsetPagination is deprecated. Use JsonApiLimitOffsetPagination instead. + If you want to retain current defaults you will need to implement custom + pagination class explicitly setting `max_limit = None`. + See changelog for more details. + + A paginator that uses a different max_limit from `JsonApiLimitOffsetPagination`. """ max_limit = None @@ -142,7 +158,7 @@ def __init__(self): warn = 'max_limit' not in type(self).__dict__ if warn: warnings.warn( - 'LimitOffsetPagination is deprecated use JsonApiLimitOffsetPagination instead. ' + 'LimitOffsetPagination is deprecated. Use JsonApiLimitOffsetPagination instead. ' 'If you want to retain current defaults you will need to implement custom ' 'pagination class explicitly setting `max_limit = None`. ' 'See changelog for more details.', diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 8459f3ba..3ce39bbb 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -11,8 +11,14 @@ class JSONParser(parsers.JSONParser): """ + Similar to `JSONRenderer`, the `JSONParser` you may override the following methods if you + need highly custom parsing control. + A JSON API client will send a payload that looks like this: + .. code:: json + + { "data": { "type": "identities", @@ -65,6 +71,11 @@ def parse_relationships(data): @staticmethod def parse_metadata(result): + """ + Returns a dictionary which will be merged into parsed data of the request. By default, + it reads the `meta` content in the request body and returns it in a dictionary with + a `_meta` top level key. + """ metadata = result.get('meta') if metadata: return {'_meta': metadata} diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 040a84f1..044b6f9f 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -152,10 +152,12 @@ def many_init(cls, *args, **kwargs): and child classes in order to try to cover the general case. If you're overriding this method you'll probably want something much simpler, eg: - @classmethod - def many_init(cls, *args, **kwargs): - kwargs['child'] = cls() - return CustomManyRelatedField(*args, **kwargs) + .. code:: python + + @classmethod + def many_init(cls, *args, **kwargs): + kwargs['child'] = cls() + return CustomManyRelatedField(*args, **kwargs) """ list_kwargs = {'child_relation': cls(*args, **kwargs)} for key in kwargs: diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 60836e97..9fee611c 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -18,22 +18,29 @@ class JSONRenderer(renderers.JSONRenderer): """ + The `JSONRenderer` exposes a number of methods that you may override if you need highly + custom rendering control. + Render a JSON response per the JSON API spec: - { - "data": [{ - "type": "companies", - "id": 1, - "attributes": { - "name": "Mozilla", - "slug": "mozilla", - "date-created": "2014-03-13 16:33:37" - } - }, { - "type": "companies", - "id": 2, - ... - }] - } + + .. code:: json + + { + "data": [{ + "type": "companies", + "id": 1, + "attributes": { + "name": "Mozilla", + "slug": "mozilla", + "date-created": "2014-03-13 16:33:37" + } + }, { + "type": "companies", + "id": 2, + ... + }] + } + """ media_type = 'application/vnd.api+json' @@ -41,6 +48,9 @@ class JSONRenderer(renderers.JSONRenderer): @classmethod def extract_attributes(cls, fields, resource): + """ + Builds the `attributes` object of the JSON API resource object. + """ data = OrderedDict() for field_name, field in six.iteritems(fields): # ID is always provided in the root of JSON API so remove it from attributes @@ -72,6 +82,9 @@ def extract_attributes(cls, fields, resource): @classmethod def extract_relationships(cls, fields, resource, resource_instance): + """ + Builds the relationships top level object based on related serializers. + """ # Avoid circular deps from rest_framework_json_api.relations import ResourceRelatedField @@ -318,6 +331,10 @@ def extract_relation_instance(cls, field_name, field, resource_instance, seriali @classmethod def extract_included(cls, fields, resource, resource_instance, included_resources, included_cache): + """ + Adds related data to the top level included key when the request includes + ?include=example,example_field2 + """ # this function may be called with an empty record (example: Browsable Interface) if not resource_instance: return @@ -442,6 +459,10 @@ def extract_included(cls, fields, resource, resource_instance, included_resource @classmethod def extract_meta(cls, serializer, resource): + """ + Gathers the data from serializer fields specified in meta_fields and adds it to + the meta object. + """ if hasattr(serializer, 'child'): meta = getattr(serializer.child, 'Meta', None) else: @@ -456,6 +477,9 @@ def extract_meta(cls, serializer, resource): @classmethod def extract_root_meta(cls, serializer, resource): + """ + Calls a `get_root_meta` function on a serializer, if it exists. + """ many = False if hasattr(serializer, 'child'): many = True @@ -471,6 +495,9 @@ def extract_root_meta(cls, serializer, resource): @classmethod def build_json_resource_obj(cls, fields, resource, resource_instance, resource_name, force_type_resolution=False): + """ + Builds the resource object (type, id, attributes) and extracts relationships. + """ # Determine type from the instance if the underlying model is polymorphic if force_type_resolution: resource_name = utils.get_resource_type_from_instance(resource_instance) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index d525af15..5085eeec 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -127,6 +127,7 @@ class HyperlinkedModelSerializer( * Relationships to other instances are hyperlinks, instead of primary keys. Included Mixins: + * A mixin class to enable sparse fieldsets is included * A mixin class to enable validation of included resources is included """ @@ -150,6 +151,7 @@ class ModelSerializer(IncludedResourcesValidationMixin, SparseFieldsetsMixin, Mo Included Mixins: + * A mixin class to enable sparse fieldsets is included * A mixin class to enable validation of included resources is included """ diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 39000216..033a5730 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -129,6 +129,13 @@ def _format_object(obj, format_type=None): def format_keys(obj, format_type=None): """ + .. warning:: + + `format_keys` function and `JSON_API_FORMAT_KEYS` setting are deprecated and will be + removed in the future. + Use `format_field_names` and `JSON_API_FIELD_NAMES` instead. Be aware that + `format_field_names` only formats keys and preserves value. + Takes either a dict or list and returns it with camelized keys only if JSON_API_FORMAT_KEYS is set. @@ -190,6 +197,13 @@ def format_value(value, format_type=None): def format_relation_name(value, format_type=None): + """ + .. warning:: + + The 'format_relation_name' function has been renamed 'format_resource_type' and the + settings are now 'JSON_API_FORMAT_TYPES' and 'JSON_API_PLURALIZE_TYPES' instead of + 'JSON_API_FORMAT_RELATION_KEYS' and 'JSON_API_PLURALIZE_RELATION_TYPE' + """ warnings.warn( "The 'format_relation_name' function has been renamed 'format_resource_type' and the " "settings are now 'JSON_API_FORMAT_TYPES' and 'JSON_API_PLURALIZE_TYPES' instead of " diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index b77e6a99..f8766bc4 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -30,20 +30,22 @@ class PrefetchForIncludesHelperMixin(object): def get_queryset(self): - """This viewset provides a helper attribute to prefetch related models + """ + This viewset provides a helper attribute to prefetch related models based on the include specified in the URL. __all__ can be used to specify a prefetch which should be done regardless of the include - @example - # When MyViewSet is called with ?include=author it will prefetch author and authorbio - class MyViewSet(viewsets.ModelViewSet): - queryset = Book.objects.all() - prefetch_for_includes = { - '__all__': [], - 'author': ['author', 'author__authorbio'], - 'category.section': ['category'] - } + .. code:: python + + # When MyViewSet is called with ?include=author it will prefetch author and authorbio + class MyViewSet(viewsets.ModelViewSet): + queryset = Book.objects.all() + prefetch_for_includes = { + '__all__': [], + 'author': ['author', 'author__authorbio'], + 'category.section': ['category'] + } """ qs = super(PrefetchForIncludesHelperMixin, self).get_queryset() if not hasattr(self, 'prefetch_for_includes'): diff --git a/tox.ini b/tox.ini index e242ba2a..e8cf36c2 100644 --- a/tox.ini +++ b/tox.ini @@ -34,3 +34,9 @@ commands = # example has extra dependencies that are installed in a dev environment # but are not installed in CI. Explicitly set those packages. isort --check-only --verbose --recursive --diff --thirdparty pytest --thirdparty polymorphic --thirdparty pytest_factoryboy --thirdparty packaging example + +[testenv:sphinx] +deps = + -rrequirements-development.txt +commands = + sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html From 364db35dfda9df31f7c68939ee5213db2039158f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 20 Sep 2018 15:35:54 +0200 Subject: [PATCH 042/488] Release 2.6.0 (#483) --- CHANGELOG.md | 4 ++-- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 348fe53e..749ed888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [2.6.0] - 2018-09-20 ### Added @@ -21,7 +21,7 @@ any parts of the framework not mentioned in the documentation should generally b * Deprecate `MultipleIDMixin` because it doesn't comply with the JSON:API 1.0 spec. Replace it with `DjangoFilterBackend` and **change clients** to use `filter[id.in]` query parameter instead of `ids[]`. - See [usage docs](docs/usage.md#djangofilterbackend). + See [usage docs](docs/usage.md#djangofilterbackend). You also have the option to copy the mixin into your code. ### Changed diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index e30504ed..5a34935c 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = 'djangorestframework-jsonapi' -__version__ = '2.5.0' +__version__ = '2.6.0' __author__ = '' __license__ = 'MIT' __copyright__ = '' From 7a9cca63ce7cb11de76616eae29848d6050a4311 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 20 Sep 2018 20:58:23 +0200 Subject: [PATCH 043/488] Fix invalid license setup.py classifier (#484) --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 7ff381e2..da9c581a 100755 --- a/setup.py +++ b/setup.py @@ -71,7 +71,7 @@ def get_package_data(package): name='djangorestframework-jsonapi', version=get_version('rest_framework_json_api'), url='https://github.com/django-json-api/django-rest-framework-json-api', - license='MIT', + license='BSD', description='A Django REST framework API adapter for the JSON API spec.', long_description=read('README.rst'), author='Jerel Unruh', @@ -83,7 +83,7 @@ def get_package_data(package): 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', + 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 2', From ac931458864b34aeb558b4163ca8ccfb239df48d Mon Sep 17 00:00:00 2001 From: Michael Haselton Date: Mon, 24 Sep 2018 03:14:49 -0400 Subject: [PATCH 044/488] Support polymorphic nested serializer url fields (#485) --- AUTHORS | 1 + CHANGELOG.md | 14 ++++++ example/factories.py | 10 +++++ example/migrations/0005_auto_20180922_1508.py | 43 +++++++++++++++++++ example/models.py | 12 ++++++ example/serializers.py | 20 ++++++++- .../tests/integration/test_polymorphism.py | 27 ++++++++++++ example/urls.py | 2 + example/urls_test.py | 2 + example/views.py | 10 ++++- rest_framework_json_api/renderers.py | 4 +- 11 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 example/migrations/0005_auto_20180922_1508.py diff --git a/AUTHORS b/AUTHORS index f2525a0a..f6feed96 100644 --- a/AUTHORS +++ b/AUTHORS @@ -10,6 +10,7 @@ Jonathan Senecal Léo S. Luc Cary Matt Layman +Michael Haselton Ola Tarkowska Oliver Sauder Raphael Cohen diff --git a/CHANGELOG.md b/CHANGELOG.md index 749ed888..d4db4bbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. + +## [Unreleased] + +### Added + +### Deprecated + +### Changed + +### Fixed + +* Pass context from `PolymorphicModelSerializer` to child serializers to support fields which require a `request` context such as `url`. + + ## [2.6.0] - 2018-09-20 ### Added diff --git a/example/factories.py b/example/factories.py index 563ba30b..dbdd9838 100644 --- a/example/factories.py +++ b/example/factories.py @@ -12,6 +12,7 @@ Comment, Company, Entry, + ProjectType, ResearchProject, TaggedItem ) @@ -89,12 +90,20 @@ class Meta: tag = factory.LazyAttribute(lambda x: faker.word()) +class ProjectTypeFactory(factory.django.DjangoModelFactory): + class Meta: + model = ProjectType + + name = factory.LazyAttribute(lambda x: faker.name()) + + class ArtProjectFactory(factory.django.DjangoModelFactory): class Meta: model = ArtProject topic = factory.LazyAttribute(lambda x: faker.catch_phrase()) artist = factory.LazyAttribute(lambda x: faker.name()) + project_type = factory.SubFactory(ProjectTypeFactory) class ResearchProjectFactory(factory.django.DjangoModelFactory): @@ -103,6 +112,7 @@ class Meta: topic = factory.LazyAttribute(lambda x: faker.catch_phrase()) supervisor = factory.LazyAttribute(lambda x: faker.name()) + project_type = factory.SubFactory(ProjectTypeFactory) class CompanyFactory(factory.django.DjangoModelFactory): diff --git a/example/migrations/0005_auto_20180922_1508.py b/example/migrations/0005_auto_20180922_1508.py new file mode 100644 index 00000000..99d397f6 --- /dev/null +++ b/example/migrations/0005_auto_20180922_1508.py @@ -0,0 +1,43 @@ +# Generated by Django 2.1.1 on 2018-09-22 15:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('example', '0004_auto_20171011_0631'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=50)), + ], + options={ + 'ordering': ('id',), + }, + ), + migrations.AlterModelOptions( + name='artproject', + options={'base_manager_name': 'objects'}, + ), + migrations.AlterModelOptions( + name='project', + options={'base_manager_name': 'objects'}, + ), + migrations.AlterModelOptions( + name='researchproject', + options={'base_manager_name': 'objects'}, + ), + migrations.AddField( + model_name='project', + name='project_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='example.ProjectType'), + ), + ] diff --git a/example/models.py b/example/models.py index d94219f3..f183391e 100644 --- a/example/models.py +++ b/example/models.py @@ -119,8 +119,20 @@ class Meta: ordering = ('id',) +@python_2_unicode_compatible +class ProjectType(BaseModel): + name = models.CharField(max_length=50) + + def __str__(self): + return self.name + + class Meta: + ordering = ('id',) + + class Project(PolymorphicModel): topic = models.CharField(max_length=30) + project_type = models.ForeignKey(ProjectType, null=True, on_delete=models.CASCADE) class ArtProject(Project): diff --git a/example/serializers.py b/example/serializers.py index d96a917d..0cecad5d 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -15,6 +15,7 @@ Company, Entry, Project, + ProjectType, ResearchProject, TaggedItem ) @@ -218,19 +219,34 @@ class Meta: # fields = ('entry', 'body', 'author',) -class ArtProjectSerializer(serializers.ModelSerializer): +class ProjectTypeSerializer(serializers.ModelSerializer): + class Meta: + model = ProjectType + fields = ('name', 'url',) + + +class BaseProjectSerializer(serializers.ModelSerializer): + included_serializers = { + 'project_type': ProjectTypeSerializer, + } + + +class ArtProjectSerializer(BaseProjectSerializer): class Meta: model = ArtProject exclude = ('polymorphic_ctype',) -class ResearchProjectSerializer(serializers.ModelSerializer): +class ResearchProjectSerializer(BaseProjectSerializer): class Meta: model = ResearchProject exclude = ('polymorphic_ctype',) class ProjectSerializer(serializers.PolymorphicModelSerializer): + included_serializers = { + 'project_type': ProjectTypeSerializer, + } polymorphic_serializers = [ArtProjectSerializer, ResearchProjectSerializer] class Meta: diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index cfaad5fd..9bfac112 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -3,6 +3,8 @@ import pytest from django.urls import reverse +from example.factories import ArtProjectFactory, ProjectTypeFactory + pytestmark = pytest.mark.django_db @@ -57,6 +59,7 @@ def test_polymorphism_on_polymorphic_model_detail_patch(single_art_project, clie def test_polymorphism_on_polymorphic_model_list_post(client): test_topic = 'New test topic {}'.format(random.randint(0, 999999)) test_artist = 'test-{}'.format(random.randint(0, 999999)) + test_project_type = ProjectTypeFactory() url = reverse('project-list') data = { 'data': { @@ -64,6 +67,14 @@ def test_polymorphism_on_polymorphic_model_list_post(client): 'attributes': { 'topic': test_topic, 'artist': test_artist + }, + 'relationships': { + 'projectType': { + 'data': { + 'type': 'projectTypes', + 'id': test_project_type.pk + } + } } } } @@ -73,6 +84,22 @@ def test_polymorphism_on_polymorphic_model_list_post(client): assert content['data']['type'] == "artProjects" assert content['data']['attributes']['topic'] == test_topic assert content['data']['attributes']['artist'] == test_artist + assert content['data']['relationships']['projectType']['data']['id'] == \ + str(test_project_type.pk) + + +def test_polymorphism_on_polymorphic_model_w_included_serializers(client): + test_project = ArtProjectFactory() + query = '?include=projectType' + url = reverse('project-list') + response = client.get(url + query) + content = response.json() + assert content['data'][0]['id'] == str(test_project.pk) + assert content['data'][0]['type'] == 'artProjects' + assert content['data'][0]['relationships']['projectType']['data']['id'] == \ + str(test_project.project_type.pk) + assert content['included'][0]['type'] == 'projectTypes' + assert content['included'][0]['id'] == str(test_project.project_type.pk) def test_polymorphic_model_without_any_instance(client): diff --git a/example/urls.py b/example/urls.py index fa06499f..79d3b1c1 100644 --- a/example/urls.py +++ b/example/urls.py @@ -13,6 +13,7 @@ EntryRelationshipView, EntryViewSet, NonPaginatedEntryViewSet, + ProjectTypeViewset, ProjectViewset ) @@ -25,6 +26,7 @@ router.register(r'comments', CommentViewSet) router.register(r'companies', CompanyViewset) router.register(r'projects', ProjectViewset) +router.register(r'project-types', ProjectTypeViewset) urlpatterns = [ url(r'^', include(router.urls)), diff --git a/example/urls_test.py b/example/urls_test.py index e2b8ef27..94568ce4 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -15,6 +15,7 @@ FiltersetEntryViewSet, NoFiltersetEntryViewSet, NonPaginatedEntryViewSet, + ProjectTypeViewset, ProjectViewset ) @@ -30,6 +31,7 @@ router.register(r'comments', CommentViewSet) router.register(r'companies', CompanyViewset) router.register(r'projects', ProjectViewset) +router.register(r'project-types', ProjectTypeViewset) # for the old tests router.register(r'identities', Identity) diff --git a/example/views.py b/example/views.py index 2307925a..aa0d67e7 100644 --- a/example/views.py +++ b/example/views.py @@ -13,14 +13,15 @@ from rest_framework_json_api.utils import format_drf_errors from rest_framework_json_api.views import ModelViewSet, RelationshipView -from example.models import Author, Blog, Comment, Company, Entry, Project +from example.models import Author, Blog, Comment, Company, Entry, Project, ProjectType from example.serializers import ( AuthorSerializer, BlogSerializer, CommentSerializer, CompanySerializer, EntrySerializer, - ProjectSerializer + ProjectSerializer, + ProjectTypeSerializer ) HTTP_422_UNPROCESSABLE_ENTITY = 422 @@ -176,6 +177,11 @@ class ProjectViewset(ModelViewSet): serializer_class = ProjectSerializer +class ProjectTypeViewset(ModelViewSet): + queryset = ProjectType.objects.all() + serializer_class = ProjectTypeSerializer + + class EntryRelationshipView(RelationshipView): queryset = Entry.objects.all() diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 9fee611c..8fae9483 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -595,7 +595,9 @@ def render(self, data, accepted_media_type=None, renderer_context=None): if isinstance(serializer.child, rest_framework_json_api. serializers.PolymorphicModelSerializer): resource_serializer_class = serializer.child.\ - get_polymorphic_serializer_for_instance(resource_instance)() + get_polymorphic_serializer_for_instance(resource_instance)( + context=serializer.child.context + ) else: resource_serializer_class = serializer.child From 70134c92e38a1df388aed31cdaf7c50a867520c8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 13 Oct 2018 15:47:26 +0200 Subject: [PATCH 045/488] Remove obsolete packaging requirements (#494) DRF 3.3 is not supported anymore since DJA version 2.4.0 --- example/requirements.txt | 1 - example/serializers.py | 6 +----- requirements-development.txt | 1 - setup.py | 1 - 4 files changed, 1 insertion(+), 8 deletions(-) diff --git a/example/requirements.txt b/example/requirements.txt index 58ce43b2..aeb20f90 100644 --- a/example/requirements.txt +++ b/example/requirements.txt @@ -1,5 +1,4 @@ # Requirements specifically for the example app -packaging Django>=1.11 django-debug-toolbar django-polymorphic>=2.0 diff --git a/example/serializers.py b/example/serializers.py index 0cecad5d..44a61796 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -1,8 +1,5 @@ from datetime import datetime -import rest_framework -from packaging import version - from rest_framework_json_api import relations, serializers from example.models import ( @@ -267,5 +264,4 @@ class CompanySerializer(serializers.ModelSerializer): class Meta: model = Company - if version.parse(rest_framework.VERSION) >= version.parse('3.3'): - fields = '__all__' + fields = '__all__' diff --git a/requirements-development.txt b/requirements-development.txt index d578ffb7..2d82ff2e 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -5,7 +5,6 @@ factory-boy Faker isort mock -packaging==16.8 pytest pytest-django pytest-factoryboy diff --git a/setup.py b/setup.py index da9c581a..18dd868b 100755 --- a/setup.py +++ b/setup.py @@ -110,7 +110,6 @@ def get_package_data(package): 'pytest', 'pytest-cov', 'django-polymorphic>=2.0', - 'packaging', 'django-debug-toolbar' ] + mock, zip_safe=False, From e4aefb773f9a3082db28df58d07f6fed1eb02c53 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 15 Oct 2018 01:11:28 +0200 Subject: [PATCH 046/488] Use flake8-isort to check isort (#493) This combines flak8 and isort environment and simplifies flake8 and isort tests for developers. --- .travis.yml | 8 +++----- README.rst | 7 +++---- example/views.py | 2 +- requirements-development.txt | 5 +++-- rest_framework_json_api/django_filters/backends.py | 4 ++-- setup.cfg | 2 +- tox.ini | 13 ++----------- 7 files changed, 15 insertions(+), 26 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8b073020..ae4cb4de 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,9 @@ cache: pip # Favor explicit over implicit and use an explicit build matrix. matrix: include: + - python: 3.6 + env: TOXENV=flake8 + - python: 2.7 env: TOXENV=py27-df11-django111-drf36 - python: 2.7 @@ -43,11 +46,6 @@ matrix: env: TOXENV=py36-df20-django20-drf37 - python: 3.6 env: TOXENV=py36-df20-django20-drf38 - - - python: 3.6 - env: TOXENV=flake8 - - python: 3.6 - env: TOXENV=isort install: - pip install tox script: diff --git a/README.rst b/README.rst index 7940e45f..13579a24 100644 --- a/README.rst +++ b/README.rst @@ -126,19 +126,18 @@ Running the example app Browse to http://localhost:8000 -Running Tests -^^^^^^^^^^^^^ +Running Tests and linting +^^^^^^^^^^^^^^^^^^^^^^^^^ It is recommended to create a virtualenv for testing. Assuming it is already installed and activated: :: - $ pip install -e . $ pip install -r requirements-development.txt + $ flake8 $ py.test - ----- Usage ----- diff --git a/example/views.py b/example/views.py index aa0d67e7..f2777136 100644 --- a/example/views.py +++ b/example/views.py @@ -1,12 +1,12 @@ import rest_framework.exceptions as exceptions import rest_framework.parsers import rest_framework.renderers +from django_filters import rest_framework as filters from rest_framework.filters import SearchFilter import rest_framework_json_api.metadata import rest_framework_json_api.parsers import rest_framework_json_api.renderers -from django_filters import rest_framework as filters from rest_framework_json_api.django_filters import DjangoFilterBackend from rest_framework_json_api.filters import OrderingFilter, QueryParameterValidationFilter from rest_framework_json_api.pagination import PageNumberPagination diff --git a/requirements-development.txt b/requirements-development.txt index 2d82ff2e..834dc094 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -1,8 +1,11 @@ -e . django-debug-toolbar +django-filter>=2.0 django-polymorphic>=2.0 factory-boy Faker +flake8 +flake8-isort isort mock pytest @@ -11,6 +14,4 @@ pytest-factoryboy recommonmark Sphinx sphinx_rtd_theme -tox twine -django-filter>=2.0 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index c9268eed..073315bc 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -1,10 +1,10 @@ import re +from django_filters import VERSION +from django_filters.rest_framework import DjangoFilterBackend from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings -from django_filters import VERSION -from django_filters.rest_framework import DjangoFilterBackend from rest_framework_json_api.utils import format_value diff --git a/setup.cfg b/setup.cfg index 0e89958c..dd743ab2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,7 +21,7 @@ known_localfolder = example known_standard_library = mock line_length = 100 multi_line_output = 3 -skip_glob=*migrations* +skip=migrations,.tox,docs/conf.py [coverage:report] omit= diff --git a/tox.ini b/tox.ini index e8cf36c2..c4812e0b 100644 --- a/tox.ini +++ b/tox.ini @@ -22,18 +22,9 @@ commands = python setup.py test --addopts '--cov --no-cov-on-fail' {posargs} [testenv:flake8] -deps = flake8 -commands = flake8 -skip_install = true - -[testenv:isort] deps = - isort -commands = - isort --check-only --verbose --recursive --diff rest_framework_json_api - # example has extra dependencies that are installed in a dev environment - # but are not installed in CI. Explicitly set those packages. - isort --check-only --verbose --recursive --diff --thirdparty pytest --thirdparty polymorphic --thirdparty pytest_factoryboy --thirdparty packaging example + -rrequirements-development.txt +commands = flake8 [testenv:sphinx] deps = From bda8f63d9c5122a7ce3b588aa489cc29ac7af52c Mon Sep 17 00:00:00 2001 From: Mohammed Ali Zubair Date: Mon, 15 Oct 2018 16:20:42 +0600 Subject: [PATCH 047/488] Add tests using default DRF classes (#490) --- AUTHORS | 1 + example/serializers.py | 33 ++++ .../unit/test_default_drf_serializers.py | 186 ++++++++++++++++++ example/urls_test.py | 3 + example/views.py | 15 ++ 5 files changed, 238 insertions(+) create mode 100644 example/tests/unit/test_default_drf_serializers.py diff --git a/AUTHORS b/AUTHORS index f6feed96..f24fc6a6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -18,3 +18,4 @@ Roberto Barreda santiavenda Tim Selman Yaniv Peer +Mohammed Ali Zubair diff --git a/example/serializers.py b/example/serializers.py index 44a61796..1fee79c4 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -1,5 +1,7 @@ from datetime import datetime +from rest_framework import serializers as drf_serilazers + from rest_framework_json_api import relations, serializers from example.models import ( @@ -24,6 +26,15 @@ class Meta: fields = ('tag',) +class TaggedItemDRFSerializer(drf_serilazers.ModelSerializer): + """ + DRF default serializer to test default DRF functionalities + """ + class Meta: + model = TaggedItem + fields = ('tag',) + + class BlogSerializer(serializers.ModelSerializer): copyright = serializers.SerializerMethodField() tags = TaggedItemSerializer(many=True, read_only=True) @@ -47,6 +58,28 @@ class Meta: meta_fields = ('copyright',) +class BlogDRFSerializer(drf_serilazers.ModelSerializer): + """ + DRF default serializer to test default DRF functionalities + """ + copyright = serializers.SerializerMethodField() + tags = TaggedItemSerializer(many=True, read_only=True) + + def get_copyright(self, resource): + return datetime.now().year + + def get_root_meta(self, resource, many): + return { + 'api_docs': '/docs/api/blogs' + } + + class Meta: + model = Blog + fields = ('name', 'url', 'tags', 'copyright') + read_only_fields = ('tags',) + meta_fields = ('copyright',) + + class EntrySerializer(serializers.ModelSerializer): def __init__(self, *args, **kwargs): super(EntrySerializer, self).__init__(*args, **kwargs) diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py new file mode 100644 index 00000000..d09d293f --- /dev/null +++ b/example/tests/unit/test_default_drf_serializers.py @@ -0,0 +1,186 @@ +import json + +import pytest +from django.urls import reverse +from rest_framework import viewsets +from rest_framework.serializers import ModelSerializer, SerializerMethodField + +from rest_framework_json_api.renderers import JSONRenderer + +from example.models import Blog, Comment, Entry + + +# serializers +class RelatedModelSerializer(ModelSerializer): + class Meta: + model = Comment + fields = ('id',) + + +class DummyTestSerializer(ModelSerializer): + """ + This serializer is a simple compound document serializer which includes only + a single embedded relation + """ + related_models = RelatedModelSerializer(source='comments', many=True, read_only=True) + + json_field = SerializerMethodField() + + def get_json_field(self, entry): + return {'JsonKey': 'JsonValue'} + + class Meta: + model = Entry + fields = ('related_models', 'json_field') + + +# views +class DummyTestViewSet(viewsets.ModelViewSet): + queryset = Entry.objects.all() + serializer_class = DummyTestSerializer + + +def render_dummy_test_serialized_view(view_class): + serializer = DummyTestSerializer(instance=Entry()) + renderer = JSONRenderer() + return renderer.render( + serializer.data, + renderer_context={'view': view_class()}) + + +# tests +def test_simple_reverse_relation_included_renderer(): + """ + Test renderer when a single reverse fk relation is passed. + """ + rendered = render_dummy_test_serialized_view( + DummyTestViewSet) + + assert rendered + + +def test_render_format_field_names(settings): + """Test that json field is kept untouched.""" + settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' + rendered = render_dummy_test_serialized_view(DummyTestViewSet) + + result = json.loads(rendered.decode()) + assert result['data']['attributes']['json-field'] == {'JsonKey': 'JsonValue'} + + +def test_render_format_keys(settings): + """Test that json field value keys are formated.""" + delattr(settings, 'JSON_API_FORMAT_FILED_NAMES') + settings.JSON_API_FORMAT_KEYS = 'dasherize' + rendered = render_dummy_test_serialized_view(DummyTestViewSet) + + result = json.loads(rendered.decode()) + assert result['data']['attributes']['json-field'] == {'json-key': 'JsonValue'} + + +@pytest.mark.django_db +def test_blog_create(client): + + url = reverse('drf-entry-blog-list') + name = "Dummy Name" + + request_data = { + 'data': { + 'attributes': {'name': name}, + 'type': 'blogs' + }, + } + + resp = client.post(url, request_data) + + # look for created blog in database + blog = Blog.objects.filter(name=name) + + # check if blog exists in database + assert blog.count() == 1 + + # get created blog from database + blog = blog.first() + + expected = { + 'data': { + 'attributes': {'name': blog.name}, + 'id': '{}'.format(blog.id), + 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, + 'meta': {'copyright': 2018}, + 'relationships': {'tags': {'data': []}}, + 'type': 'blogs' + }, + 'meta': {'apiDocs': '/docs/api/blogs'} + } + + assert resp.status_code == 201 + assert resp.json() == expected + + +@pytest.mark.django_db +def test_get_object_gives_correct_blog(client, blog, entry): + + url = reverse('drf-entry-blog-detail', kwargs={'entry_pk': entry.id}) + resp = client.get(url) + expected = { + 'data': { + 'attributes': {'name': blog.name}, + 'id': '{}'.format(blog.id), + 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, + 'meta': {'copyright': 2018}, + 'relationships': {'tags': {'data': []}}, + 'type': 'blogs' + }, + 'meta': {'apiDocs': '/docs/api/blogs'} + } + got = resp.json() + assert got == expected + + +@pytest.mark.django_db +def test_get_object_patches_correct_blog(client, blog, entry): + + url = reverse('drf-entry-blog-detail', kwargs={'entry_pk': entry.id}) + new_name = blog.name + " update" + assert not new_name == blog.name + + request_data = { + 'data': { + 'attributes': {'name': new_name}, + 'id': '{}'.format(blog.id), + 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, + 'meta': {'copyright': 2018}, + 'relationships': {'tags': {'data': []}}, + 'type': 'blogs' + }, + 'meta': {'apiDocs': '/docs/api/blogs'} + } + + resp = client.patch(url, data=request_data) + + assert resp.status_code == 200 + + expected = { + 'data': { + 'attributes': {'name': new_name}, + 'id': '{}'.format(blog.id), + 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, + 'meta': {'copyright': 2018}, + 'relationships': {'tags': {'data': []}}, + 'type': 'blogs' + }, + 'meta': {'apiDocs': '/docs/api/blogs'} + } + got = resp.json() + assert got == expected + + +@pytest.mark.django_db +def test_get_object_deletes_correct_blog(client, entry): + + url = reverse('drf-entry-blog-detail', kwargs={'entry_pk': entry.id}) + + resp = client.delete(url) + + assert resp.status_code == 204 diff --git a/example/urls_test.py b/example/urls_test.py index 94568ce4..2e7d2d64 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -10,6 +10,7 @@ CommentRelationshipView, CommentViewSet, CompanyViewset, + DRFBlogViewSet, EntryRelationshipView, EntryViewSet, FiltersetEntryViewSet, @@ -22,6 +23,8 @@ router = routers.DefaultRouter(trailing_slash=False) router.register(r'blogs', BlogViewSet) +# router to test default DRF functionalities +router.register(r'drf-blogs', DRFBlogViewSet, 'drf-entry-blog') router.register(r'entries', EntryViewSet) # these "flavors" of entries are used for various tests: router.register(r'nopage-entries', NonPaginatedEntryViewSet, 'nopage-entry') diff --git a/example/views.py b/example/views.py index f2777136..78cdc6ad 100644 --- a/example/views.py +++ b/example/views.py @@ -2,6 +2,7 @@ import rest_framework.parsers import rest_framework.renderers from django_filters import rest_framework as filters +from rest_framework import viewsets from rest_framework.filters import SearchFilter import rest_framework_json_api.metadata @@ -16,6 +17,7 @@ from example.models import Author, Blog, Comment, Company, Entry, Project, ProjectType from example.serializers import ( AuthorSerializer, + BlogDRFSerializer, BlogSerializer, CommentSerializer, CompanySerializer, @@ -39,6 +41,19 @@ def get_object(self): return super(BlogViewSet, self).get_object() +class DRFBlogViewSet(viewsets.ModelViewSet): + queryset = Blog.objects.all() + serializer_class = BlogDRFSerializer + lookup_url_kwarg = 'entry_pk' + + def get_object(self): + entry_pk = self.kwargs.get(self.lookup_url_kwarg, None) + if entry_pk is not None: + return Entry.objects.get(id=entry_pk).blog + + return super(DRFBlogViewSet, self).get_object() + + class JsonApiViewSet(ModelViewSet): """ This is an example on how to configure DRF-jsonapi from From d0debda7964dab3fe098f10e0c46eb941ea6ba4f Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 23 Oct 2018 11:16:51 -0400 Subject: [PATCH 048/488] add drf39 and drfmaster tests (#498) --- .travis.yml | 50 ++++++++++++++++++++++++++++++++++++++++- CHANGELOG.md | 3 +++ README.rst | 16 ++++++++----- docs/getting-started.md | 6 ++--- tox.ini | 11 ++++++--- 5 files changed, 74 insertions(+), 12 deletions(-) diff --git a/.travis.yml b/.travis.yml index ae4cb4de..c76e079e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,17 @@ language: python -sudo: false +sudo: required cache: pip # Favor explicit over implicit and use an explicit build matrix. matrix: + allow_failures: + - env: TOXENV=py34-df20-django20-drfmaster + - env: TOXENV=py35-df20-django20-drfmaster + - env: TOXENV=py36-df20-django20-drfmaster + - env: TOXENV=py37-df20-django20-drfmaster + - env: TOXENV=py35-df20-django21-drfmaster + - env: TOXENV=py36-df20-django21-drfmaster + - env: TOXENV=py37-df20-django21-drfmaster + include: - python: 3.6 env: TOXENV=flake8 @@ -13,6 +22,8 @@ matrix: env: TOXENV=py27-df11-django111-drf37 - python: 2.7 env: TOXENV=py27-df11-django111-drf38 + - python: 2.7 + env: TOXENV=py27-df11-django111-drf39 - python: 3.4 env: TOXENV=py34-df20-django111-drf36 @@ -24,6 +35,10 @@ matrix: env: TOXENV=py34-df20-django20-drf37 - python: 3.4 env: TOXENV=py34-df20-django20-drf38 + - python: 3.4 + env: TOXENV=py34-df20-django20-drf39 + - python: 3.4 + env: TOXENV=py34-df20-django20-drfmaster - python: 3.5 env: TOXENV=py35-df20-django111-drf36 @@ -35,6 +50,14 @@ matrix: env: TOXENV=py35-df20-django20-drf37 - python: 3.5 env: TOXENV=py35-df20-django20-drf38 + - python: 3.5 + env: TOXENV=py35-df20-django20-drf39 + - python: 3.5 + env: TOXENV=py35-df20-django20-drfmaster + - python: 3.5 + env: TOXENV=py35-df20-django21-drf39 + - python: 3.5 + env: TOXENV=py35-df20-django21-drfmaster - python: 3.6 env: TOXENV=py36-df20-django111-drf36 @@ -46,6 +69,31 @@ matrix: env: TOXENV=py36-df20-django20-drf37 - python: 3.6 env: TOXENV=py36-df20-django20-drf38 + - python: 3.6 + env: TOXENV=py36-df20-django20-drf39 + - python: 3.6 + env: TOXENV=py36-df20-django20-drfmaster + - python: 3.6 + env: TOXENV=py36-df20-django21-drf39 + - python: 3.6 + env: TOXENV=py36-df20-django21-drfmaster + + - python: 3.7 + dist: xenial + sudo: required + env: TOXENV=py37-df20-django20-drf39 + - python: 3.7 + dist: xenial + sudo: required + env: TOXENV=py37-df20-django20-drfmaster + - python: 3.7 + dist: xenial + sudo: required + env: TOXENV=py37-df20-django21-drf39 + - python: 3.7 + dist: xenial + sudo: required + env: TOXENV=py37-df20-django21-drfmaster install: - pip install tox script: diff --git a/CHANGELOG.md b/CHANGELOG.md index d4db4bbc..49114a36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ any parts of the framework not mentioned in the documentation should generally b ### Added +* Add support for Django 2.1, DRF 3.9 and Python 3.7. Please note: + - Django >= 2.1 is not supported with Python < 3.5. + ### Deprecated ### Changed diff --git a/README.rst b/README.rst index 13579a24..e5b9f930 100644 --- a/README.rst +++ b/README.rst @@ -87,9 +87,9 @@ As a Django REST Framework JSON API (short DJA) we are trying to address followi Requirements ------------ -1. Python (2.7, 3.4, 3.5, 3.6) -2. Django (1.11, 2.0) -3. Django REST Framework (3.6, 3.7, 3.8) +1. Python (2.7, 3.4, 3.5, 3.6, 3.7) +2. Django (1.11, 2.0, 2.1) +3. Django REST Framework (3.6, 3.7, 3.8, 3.9) ------------ Installation @@ -116,12 +116,18 @@ From Source Running the example app ^^^^^^^^^^^^^^^^^^^^^^^ +It is recommended to create a virtualenv for testing. Assuming it is already +installed and activated: + :: $ git clone https://github.com/django-json-api/django-rest-framework-json-api.git $ cd django-rest-framework-json-api + $ pip install -r example/requirements.txt $ pip install -e . - $ django-admin.py runserver --settings=example.settings + $ django-admin migrate --settings=example.settings + $ django-admin loaddata drf_example --settings=example.settings + $ django-admin runserver --settings=example.settings Browse to http://localhost:8000 @@ -136,7 +142,7 @@ installed and activated: $ pip install -r requirements-development.txt $ flake8 - $ py.test + $ DJANGO_SETTINGS_MODULE=example.settings.test py.test ----- Usage diff --git a/docs/getting-started.md b/docs/getting-started.md index 26117e0b..baa53189 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,9 +51,9 @@ like the following: ## Requirements -1. Python (2.7, 3.4, 3.5, 3.6) -2. Django (1.11, 2.0) -3. Django REST Framework (3.6, 3.7, 3.8) +1. Python (2.7, 3.4, 3.5, 3.6, 3.7) +2. Django (1.11, 2.0, 2.1) +3. Django REST Framework (3.6, 3.7, 3.8, 3.9) ## Installation diff --git a/tox.ini b/tox.ini index c4812e0b..30f6b86f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,21 @@ [tox] envlist = - py27-df11-django111-drf{36,37,38} - py{34,35,36}-df20-django111-drf{36,37,38}, - py{34,35,36}-df20-django20-drf{37,38}, + py27-df11-django111-drf{36,37,38,39} + py{34,35,36}-df20-django111-drf{36,37,38,39,master}, + py{34,35,36}-df20-django20-drf{37,38,39,master}, + py37-df20-django20-drf{39,master}, + py{35,36,37}-df20-django21-drf{39,master}, [testenv] deps = django111: Django>=1.11,<1.12 django20: Django>=2.0,<2.1 + django21: Django>=2.1,<2.2 drf36: djangorestframework>=3.6.3,<3.7 drf37: djangorestframework>=3.7.0,<3.8 drf38: djangorestframework>=3.8.0,<3.9 + drf39: djangorestframework>=3.9.0,<3.10 + drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip df11: django-filter<=1.1 df20: django-filter>=2.0 From 00fb5dcbfdc7dc48998373a1204e40a9c6e474d5 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 24 Oct 2018 19:55:57 +0200 Subject: [PATCH 049/488] Adjust to new flake8 3.6.0 version (#501) --- requirements-development.txt | 2 +- rest_framework_json_api/relations.py | 14 ++++++-------- setup.cfg | 12 +++++++++--- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/requirements-development.txt b/requirements-development.txt index 834dc094..807c78a1 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -4,7 +4,7 @@ django-filter>=2.0 django-polymorphic>=2.0 factory-boy Faker -flake8 +flake8==3.6.0 flake8-isort isort mock diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 044b6f9f..82b94cd5 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -116,14 +116,12 @@ def get_links(self, obj=None, lookup_field='pk'): }) self_link = self.get_url('self', self.self_link_view_name, self_kwargs, request) - """ - Assuming RelatedField will be declared in two ways: - 1. url(r'^authors/(?P[^/.]+)/(?P\w+)/$', - AuthorViewSet.as_view({'get': 'retrieve_related'})) - 2. url(r'^authors/(?P[^/.]+)/bio/$', - AuthorBioViewSet.as_view({'get': 'retrieve'})) - So, if related_link_url_kwarg == 'pk' it will add 'related_field' parameter to reverse() - """ + # Assuming RelatedField will be declared in two ways: + # 1. url(r'^authors/(?P[^/.]+)/(?P\w+)/$', + # AuthorViewSet.as_view({'get': 'retrieve_related'})) + # 2. url(r'^authors/(?P[^/.]+)/bio/$', + # AuthorBioViewSet.as_view({'get': 'retrieve'})) + # So, if related_link_url_kwarg == 'pk' it will add 'related_field' parameter to reverse() if self.related_link_url_kwarg == 'pk': related_kwargs = self_kwargs else: diff --git a/setup.cfg b/setup.cfg index dd743ab2..effb04ad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,12 +5,13 @@ test = pytest universal = 1 [flake8] -ignore = F405 +ignore = F405,W504 max-line-length = 100 exclude = - docs/conf.py, build, + docs/conf.py, migrations, + .eggs .tox, [isort] @@ -21,7 +22,12 @@ known_localfolder = example known_standard_library = mock line_length = 100 multi_line_output = 3 -skip=migrations,.tox,docs/conf.py +skip= + build, + docs/conf.py, + migrations, + .eggs + .tox, [coverage:report] omit= From b3eed32c50dedf4cc8d405f7e4fd92a2659a70dd Mon Sep 17 00:00:00 2001 From: Mohammed Ali Zubair Date: Fri, 26 Oct 2018 00:22:59 +0600 Subject: [PATCH 050/488] Avoid patch on `RelationshipView` deleting relationship instance when constraint would allow null (#499) --- CHANGELOG.md | 1 + example/tests/test_views.py | 46 +++++++++++++++++++++++++++++++- rest_framework_json_api/views.py | 23 +++++++++++++++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49114a36..c60329ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Pass context from `PolymorphicModelSerializer` to child serializers to support fields which require a `request` context such as `url`. +* Avoid patch on `RelationshipView` deleting relationship instance when constraint would allow null ([#242](https://github.com/django-json-api/django-rest-framework-json-api/issues/242)) ## [2.6.0] - 2018-09-20 diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 48e1bfa6..5b778b90 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -11,7 +11,7 @@ from . import TestBase from .. import views -from example.factories import AuthorFactory, EntryFactory +from example.factories import AuthorFactory, CommentFactory, EntryFactory from example.models import Author, Blog, Comment, Entry from example.serializers import AuthorBioSerializer, AuthorTypeSerializer, EntrySerializer from example.views import AuthorViewSet @@ -229,6 +229,50 @@ def test_delete_to_many_relationship_with_change(self): response = self.client.delete(url, data=request_data) assert response.status_code == 200, response.content.decode() + def test_new_comment_data_patch_to_many_relationship(self): + entry = EntryFactory(blog=self.blog, authors=(self.author,)) + comment = CommentFactory(entry=entry) + + url = '/authors/{}/relationships/comment_set'.format(self.author.id) + request_data = { + 'data': [{'type': format_resource_type('Comment'), 'id': str(comment.id)}, ] + } + previous_response = { + 'data': [ + {'type': 'comments', + 'id': str(self.second_comment.id) + } + ], + 'links': { + 'self': 'http://testserver/authors/{}/relationships/comment_set'.format( + self.author.id + ) + } + } + + response = self.client.get(url) + assert response.status_code == 200 + assert response.json() == previous_response + + new_patched_response = { + 'data': [ + {'type': 'comments', + 'id': str(comment.id) + } + ], + 'links': { + 'self': 'http://testserver/authors/{}/relationships/comment_set'.format( + self.author.id + ) + } + } + + response = self.client.patch(url, data=request_data) + assert response.status_code == 200 + assert response.json() == new_patched_response + + assert Comment.objects.filter(id=self.second_comment.id).exists() + class TestRelatedMixin(APITestCase): diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index f8766bc4..9f3178a1 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -251,6 +251,18 @@ def get(self, request, *args, **kwargs): serializer_instance = self._instantiate_serializer(related_instance) return Response(serializer_instance.data) + def remove_relationships(self, instance_manager, field): + field_object = getattr(instance_manager, field) + + if field_object.null: + for obj in instance_manager.all(): + setattr(obj, field_object.name, None) + obj.save() + else: + instance_manager.all().delete() + + return instance_manager + def patch(self, request, *args, **kwargs): parent_obj = self.get_object() related_instance_or_manager = self.get_related_instance() @@ -261,7 +273,16 @@ def patch(self, request, *args, **kwargs): data=request.data, model_class=related_model_class, many=True ) serializer.is_valid(raise_exception=True) - related_instance_or_manager.all().delete() + + # for to one + if hasattr(related_instance_or_manager, "field"): + related_instance_or_manager = self.remove_relationships( + instance_manager=related_instance_or_manager, field="field") + # for to many + else: + related_instance_or_manager = self.remove_relationships( + instance_manager=related_instance_or_manager, field="target_field") + # have to set bulk to False since data isn't saved yet class_name = related_instance_or_manager.__class__.__name__ if class_name != 'ManyRelatedManager': From 860a0961143f10a4888a97a7937582c8854bac9b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 25 Oct 2018 21:21:56 +0200 Subject: [PATCH 051/488] Use pytest instead of py.test (#502) py.test is the old way of running pytest. Also remove DJANGO_SETTINGS_MODULE env when running tests This is already defined in pytest.ini and py.test will take it from as it should also take parameters we might add in pytest.ini in the future. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index e5b9f930..61ea29c2 100644 --- a/README.rst +++ b/README.rst @@ -142,7 +142,7 @@ installed and activated: $ pip install -r requirements-development.txt $ flake8 - $ DJANGO_SETTINGS_MODULE=example.settings.test py.test + $ pytest ----- Usage From a4d063f61c022e0923a83f2500a39d5cd97e7702 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 25 Oct 2018 21:51:33 +0200 Subject: [PATCH 052/488] Use pyupio to manage development dependencies (#503) This way CI doesn't suddently break when a dependency has updated but we still keep up-to-date. pyupio will open a PR with the updated dependencies. --- .pyup.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .pyup.yml diff --git a/.pyup.yml b/.pyup.yml new file mode 100644 index 00000000..02f8ed99 --- /dev/null +++ b/.pyup.yml @@ -0,0 +1,5 @@ +search: False +requirements: + - requirements-development.txt: + update: all + pin: True From 56b5fd733ee60d94a155d3508cfadea62ac99093 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 26 Oct 2018 01:16:01 -0700 Subject: [PATCH 053/488] Initial Update (#506) --- requirements-development.txt | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/requirements-development.txt b/requirements-development.txt index 807c78a1..c4a90dee 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -1,17 +1,17 @@ -e . -django-debug-toolbar -django-filter>=2.0 -django-polymorphic>=2.0 -factory-boy -Faker +django-debug-toolbar==1.10.1 +django-filter==2.0.0 +django-polymorphic==2.0.3 +factory-boy==2.11.1 +Faker==0.9.2 flake8==3.6.0 -flake8-isort -isort -mock -pytest -pytest-django -pytest-factoryboy -recommonmark -Sphinx -sphinx_rtd_theme -twine +flake8-isort==2.5 +isort==4.3.4 +mock==2.0.0 +pytest==3.9.2 +pytest-django==3.4.3 +pytest-factoryboy==2.0.1 +recommonmark==0.4.0 +Sphinx==1.8.1 +sphinx_rtd_theme==0.4.2 +twine==1.12.1 From 1ce84f83dd22ac180515671be0cbe5919e1b21a1 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 26 Oct 2018 15:55:10 +0200 Subject: [PATCH 054/488] Adjust depreaction warning to state to move to JSON_API_FORMAT_FIELD_NAMES (#507) Changelog entry was correct so not adding a new one to add only noise. --- example/settings/test.py | 2 +- rest_framework_json_api/utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/example/settings/test.py b/example/settings/test.py index c165e187..c32aa95f 100644 --- a/example/settings/test.py +++ b/example/settings/test.py @@ -9,7 +9,7 @@ ROOT_URLCONF = 'example.urls_test' -JSON_API_FIELD_NAMES = 'camelize' +JSON_API_FORMAT_FIELD_NAMES = 'camelize' JSON_API_FORMAT_TYPES = 'camelize' JSON_API_PLURALIZE_TYPES = True diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 033a5730..19233132 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -133,7 +133,7 @@ def format_keys(obj, format_type=None): `format_keys` function and `JSON_API_FORMAT_KEYS` setting are deprecated and will be removed in the future. - Use `format_field_names` and `JSON_API_FIELD_NAMES` instead. Be aware that + Use `format_field_names` and `JSON_API_FORMAT_FIELD_NAMES` instead. Be aware that `format_field_names` only formats keys and preserves value. Takes either a dict or list and returns it with camelized keys only if @@ -144,7 +144,7 @@ def format_keys(obj, format_type=None): warnings.warn( "`format_keys` function and `JSON_API_FORMAT_KEYS` setting are deprecated and will be " "removed in the future. " - "Use `format_field_names` and `JSON_API_FIELD_NAMES` instead. Be aware that " + "Use `format_field_names` and `JSON_API_FORMAT_FIELD_NAMES` instead. Be aware that " "`format_field_names` only formats keys and preserves value.", DeprecationWarning ) From 7d1453750213becfc2c4d06a4bd4dabc5f77846d Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 29 Oct 2018 07:23:15 -0700 Subject: [PATCH 055/488] Update pytest from 3.9.2 to 3.9.3 (#508) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index c4a90dee..053a7e21 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.6.0 flake8-isort==2.5 isort==4.3.4 mock==2.0.0 -pytest==3.9.2 +pytest==3.9.3 pytest-django==3.4.3 pytest-factoryboy==2.0.1 recommonmark==0.4.0 From 274cb793a9011a4ad179d23d55b70181433164eb Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 11 Nov 2018 08:54:20 -0800 Subject: [PATCH 056/488] Update sphinx from 1.8.1 to 1.8.2 (#511) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 053a7e21..67613c74 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -12,6 +12,6 @@ pytest==3.9.3 pytest-django==3.4.3 pytest-factoryboy==2.0.1 recommonmark==0.4.0 -Sphinx==1.8.1 +Sphinx==1.8.2 sphinx_rtd_theme==0.4.2 twine==1.12.1 From 0f09a6e4962874b11a5ecdc0428b51ab5c75cc02 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 11 Nov 2018 09:52:07 -0800 Subject: [PATCH 057/488] Update pytest-factoryboy from 2.0.1 to 2.0.2 (#510) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 67613c74..23a73db9 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -10,7 +10,7 @@ isort==4.3.4 mock==2.0.0 pytest==3.9.3 pytest-django==3.4.3 -pytest-factoryboy==2.0.1 +pytest-factoryboy==2.0.2 recommonmark==0.4.0 Sphinx==1.8.2 sphinx_rtd_theme==0.4.2 From 7cdcff764ed259825e6f68e575809eba7f00f0d9 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 12 Nov 2018 01:50:03 -0800 Subject: [PATCH 058/488] Update pytest from 3.9.3 to 3.10.1 (#512) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 23a73db9..9a96f4f1 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.6.0 flake8-isort==2.5 isort==4.3.4 mock==2.0.0 -pytest==3.9.3 +pytest==3.10.1 pytest-django==3.4.3 pytest-factoryboy==2.0.2 recommonmark==0.4.0 From 7ec8e422a146a5242999114d6ff624c9ba44c47d Mon Sep 17 00:00:00 2001 From: Mohammed Ali Zubair Date: Mon, 12 Nov 2018 20:09:19 +0600 Subject: [PATCH 059/488] Test support of DRF HyperlinkedIdentityField (#497) --- example/serializers.py | 17 +++++++- .../unit/test_default_drf_serializers.py | 42 +++++++++++++++++++ example/urls_test.py | 7 +++- example/views.py | 15 +++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/example/serializers.py b/example/serializers.py index 1fee79c4..c52f575f 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -63,7 +63,7 @@ class BlogDRFSerializer(drf_serilazers.ModelSerializer): DRF default serializer to test default DRF functionalities """ copyright = serializers.SerializerMethodField() - tags = TaggedItemSerializer(many=True, read_only=True) + tags = TaggedItemDRFSerializer(many=True, read_only=True) def get_copyright(self, resource): return datetime.now().year @@ -173,6 +173,21 @@ class JSONAPIMeta: included_resources = ['comments'] +class EntryDRFSerializers(drf_serilazers.ModelSerializer): + + tags = TaggedItemDRFSerializer(many=True, read_only=True) + url = drf_serilazers.HyperlinkedIdentityField( + view_name='drf-entry-blog-detail', + lookup_url_kwarg='entry_pk', + read_only=True, + ) + + class Meta: + model = Entry + fields = ('tags', 'url',) + read_only_fields = ('tags',) + + class AuthorTypeSerializer(serializers.ModelSerializer): class Meta: model = AuthorType diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index d09d293f..e17b9a52 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -184,3 +184,45 @@ def test_get_object_deletes_correct_blog(client, entry): resp = client.delete(url) assert resp.status_code == 204 + + +@pytest.mark.django_db +def test_get_entry_list_with_blogs(client, entry): + url = reverse('drf-entry-suggested', kwargs={'entry_pk': entry.id}) + resp = client.get(url) + + got = resp.json() + + expected = { + 'links': { + 'first': 'http://testserver/drf-entries/1/suggested/?page=1', + 'last': 'http://testserver/drf-entries/1/suggested/?page=1', + 'next': None, + 'prev': None + }, + 'data': [ + { + 'type': 'entries', + 'id': '1', + 'attributes': {}, + 'relationships': { + 'tags': { + 'data': [] + } + }, + 'links': { + 'self': 'http://testserver/drf-blogs/1' + } + } + ], + 'meta': { + 'pagination': { + 'page': 1, + 'pages': 1, + 'count': 1 + } + } + } + + assert resp.status_code == 200 + assert got == expected diff --git a/example/urls_test.py b/example/urls_test.py index 2e7d2d64..e51121ac 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -11,6 +11,7 @@ CommentViewSet, CompanyViewset, DRFBlogViewSet, + DRFEntryViewSet, EntryRelationshipView, EntryViewSet, FiltersetEntryViewSet, @@ -23,7 +24,7 @@ router = routers.DefaultRouter(trailing_slash=False) router.register(r'blogs', BlogViewSet) -# router to test default DRF functionalities +# router to test default DRF blog functionalities router.register(r'drf-blogs', DRFBlogViewSet, 'drf-entry-blog') router.register(r'entries', EntryViewSet) # these "flavors" of entries are used for various tests: @@ -59,6 +60,10 @@ EntryViewSet.as_view({'get': 'list'}), name='entry-suggested' ), + url(r'^drf-entries/(?P[^/.]+)/suggested/', + DRFEntryViewSet.as_view({'get': 'list'}), + name='drf-entry-suggested' + ), url(r'entries/(?P[^/.]+)/authors', AuthorViewSet.as_view({'get': 'list'}), name='entry-authors'), diff --git a/example/views.py b/example/views.py index 78cdc6ad..41036fc6 100644 --- a/example/views.py +++ b/example/views.py @@ -21,6 +21,7 @@ BlogSerializer, CommentSerializer, CompanySerializer, + EntryDRFSerializers, EntrySerializer, ProjectSerializer, ProjectTypeSerializer @@ -104,6 +105,20 @@ def get_object(self): return super(EntryViewSet, self).get_object() +class DRFEntryViewSet(viewsets.ModelViewSet): + queryset = Entry.objects.all() + serializer_class = EntryDRFSerializers + lookup_url_kwarg = 'entry_pk' + + def get_object(self): + # Handle featured + entry_pk = self.kwargs.get(self.lookup_url_kwarg, None) + if entry_pk is not None: + return Entry.objects.exclude(pk=entry_pk).first() + + return super(DRFEntryViewSet, self).get_object() + + class NoPagination(PageNumberPagination): page_size = None From b3cd2c66496ff69e5a47e45eeadabbdb466562e3 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 13 Nov 2018 03:42:08 -0800 Subject: [PATCH 060/488] Update pytest-django from 3.4.3 to 3.4.4 (#514) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 9a96f4f1..97005292 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -9,7 +9,7 @@ flake8-isort==2.5 isort==4.3.4 mock==2.0.0 pytest==3.10.1 -pytest-django==3.4.3 +pytest-django==3.4.4 pytest-factoryboy==2.0.2 recommonmark==0.4.0 Sphinx==1.8.2 From 3447229659d3ebb5246a13987b98c62f57b25b00 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 13 Nov 2018 23:39:35 -0800 Subject: [PATCH 061/488] Update faker from 0.9.2 to 1.0.0 (#515) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 97005292..3898f1f7 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -3,7 +3,7 @@ django-debug-toolbar==1.10.1 django-filter==2.0.0 django-polymorphic==2.0.3 factory-boy==2.11.1 -Faker==0.9.2 +Faker==1.0.0 flake8==3.6.0 flake8-isort==2.5 isort==4.3.4 From 27cfac5b584839b2a60c13c5d08358fde90e4cdc Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 14 Nov 2018 23:42:30 -0800 Subject: [PATCH 062/488] Update pytest from 3.10.1 to 4.0.0 (#516) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 3898f1f7..7f7a5d5e 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.6.0 flake8-isort==2.5 isort==4.3.4 mock==2.0.0 -pytest==3.10.1 +pytest==4.0.0 pytest-django==3.4.4 pytest-factoryboy==2.0.2 recommonmark==0.4.0 From 8c075d02744540121f5af50d46126652d9697da8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 16 Nov 2018 19:59:19 +0100 Subject: [PATCH 063/488] Avoid error with related urls when retrieving relationship (#517) Fixes raising of error when retrieving relationship is referenced as `ForeignKey`on parent --- CHANGELOG.md | 1 + example/serializers.py | 1 + example/tests/test_views.py | 18 ++++++++++++++++-- rest_framework_json_api/views.py | 8 +++++++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c60329ef..a9d4e794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ any parts of the framework not mentioned in the documentation should generally b * Pass context from `PolymorphicModelSerializer` to child serializers to support fields which require a `request` context such as `url`. * Avoid patch on `RelationshipView` deleting relationship instance when constraint would allow null ([#242](https://github.com/django-json-api/django-rest-framework-json-api/issues/242)) +* Avoid error with related urls when retrieving relationship which is referenced as `ForeignKey` on parent ## [2.6.0] - 2018-09-20 diff --git a/example/serializers.py b/example/serializers.py index c52f575f..97dde4bb 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -225,6 +225,7 @@ class AuthorSerializer(serializers.ModelSerializer): } related_serializers = { 'bio': 'example.serializers.AuthorBioSerializer', + 'type': 'example.serializers.AuthorTypeSerializer', 'entries': 'example.serializers.EntrySerializer', 'first_entry': 'example.serializers.EntrySerializer' } diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 5b778b90..198b8b00 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -325,11 +325,11 @@ def test_get_serializer_comes_from_included_serializers(self): view.serializer_class.related_serializers = related_serializers def test_get_serializer_class_raises_error(self): - kwargs = {'pk': self.author.id, 'related_field': 'type'} + kwargs = {'pk': self.author.id, 'related_field': 'unknown'} view = self._get_view(kwargs) self.assertRaises(NotFound, view.get_serializer_class) - def test_retrieve_related_single(self): + def test_retrieve_related_single_reverse_lookup(self): url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'bio'}) resp = self.client.get(url) expected = { @@ -345,6 +345,20 @@ def test_retrieve_related_single(self): self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json(), expected) + def test_retrieve_related_single(self): + url = reverse('author-related', kwargs={'pk': self.author.type.pk, 'related_field': 'type'}) + resp = self.client.get(url) + expected = { + 'data': { + 'type': 'authorTypes', 'id': str(self.author.type.id), + 'attributes': { + 'name': str(self.author.type.name) + }, + } + } + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), expected) + def test_retrieve_related_many(self): entry = EntryFactory(authors=self.author) url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'entries'}) diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 9f3178a1..35a213fc 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -14,6 +14,8 @@ from django.utils.module_loading import import_string as import_class_from_dotted_path from rest_framework import generics, viewsets from rest_framework.exceptions import MethodNotAllowed, NotFound +from rest_framework.fields import get_attribute +from rest_framework.relations import PKOnlyObject from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.serializers import Serializer @@ -164,7 +166,11 @@ def get_related_instance(self): field = parent_serializer.fields.get(field_name, None) if field is not None: - return field.get_attribute(parent_obj) + instance = field.get_attribute(parent_obj) + if isinstance(instance, PKOnlyObject): + # need whole object + instance = get_attribute(parent_obj, field.source_attrs) + return instance else: try: return getattr(parent_obj, field_name) From 1675eabb9dce4a25b994b3b09a1ce37d8b923aa6 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 25 Nov 2018 11:22:40 -0800 Subject: [PATCH 064/488] Update pytest from 4.0.0 to 4.0.1 (#520) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 7f7a5d5e..1e36cb8a 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.6.0 flake8-isort==2.5 isort==4.3.4 mock==2.0.0 -pytest==4.0.0 +pytest==4.0.1 pytest-django==3.4.4 pytest-factoryboy==2.0.2 recommonmark==0.4.0 From 9208b4dd10cde4ec8792b132f5e298503a27ed30 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Thu, 29 Nov 2018 10:02:20 +0100 Subject: [PATCH 065/488] Don't render write only relations (#522) --- AUTHORS | 1 + CHANGELOG.md | 1 + example/tests/unit/test_renderers.py | 32 +++++++++++++++++++++++++++- rest_framework_json_api/renderers.py | 4 ++++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index f24fc6a6..25129f59 100644 --- a/AUTHORS +++ b/AUTHORS @@ -3,6 +3,7 @@ Adam Ziolkowski Alan Crosswell Anton Shutik Christian Zosel +David Vogt Greg Aker Jamie Bliss Jerel Unruh diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d4e794..73a17c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ any parts of the framework not mentioned in the documentation should generally b * Pass context from `PolymorphicModelSerializer` to child serializers to support fields which require a `request` context such as `url`. * Avoid patch on `RelationshipView` deleting relationship instance when constraint would allow null ([#242](https://github.com/django-json-api/django-rest-framework-json-api/issues/242)) * Avoid error with related urls when retrieving relationship which is referenced as `ForeignKey` on parent +* Do not render `write_only` relations ## [2.6.0] - 2018-09-20 diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index 42a45a94..8065e470 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -46,7 +46,7 @@ class ReadOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): def render_dummy_test_serialized_view(view_class): - serializer = DummyTestSerializer(instance=Entry()) + serializer = view_class.serializer_class(instance=Entry()) renderer = JSONRenderer() return renderer.render( serializer.data, @@ -87,3 +87,33 @@ def test_render_format_keys(settings): result = json.loads(rendered.decode()) assert result['data']['attributes']['json-field'] == {'json-key': 'JsonValue'} + + +def test_writeonly_not_in_response(settings): + """Test that writeonly fields are not shown in list response""" + + settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' + + class WriteonlyTestSerializer(serializers.ModelSerializer): + '''Serializer for testing the absence of write_only fields''' + comments = serializers.ResourceRelatedField( + many=True, + write_only=True, + queryset=Comment.objects.all() + ) + + rating = serializers.IntegerField(write_only=True) + + class Meta: + model = Entry + fields = ('comments', 'rating') + + class WriteOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): + queryset = Entry.objects.all() + serializer_class = WriteonlyTestSerializer + + rendered = render_dummy_test_serialized_view(WriteOnlyDummyTestViewSet) + result = json.loads(rendered.decode()) + + assert 'rating' not in result['data']['attributes'] + assert 'relationships' not in result['data'] diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 8fae9483..de727f38 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -99,6 +99,10 @@ def extract_relationships(cls, fields, resource, resource_instance): if field_name == api_settings.URL_FIELD_NAME: continue + # don't output a key for write only fields + if fields[field_name].write_only: + continue + # Skip fields without relations if not isinstance( field, (relations.RelatedField, relations.ManyRelatedField, BaseSerializer) From ca9ab6e75db9fa1500b5f1e4fdcbb7833e3a2ddd Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 2 Dec 2018 23:46:52 -0800 Subject: [PATCH 066/488] Update flake8-isort from 2.5 to 2.6.0 (#523) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 1e36cb8a..623a3d07 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -5,7 +5,7 @@ django-polymorphic==2.0.3 factory-boy==2.11.1 Faker==1.0.0 flake8==3.6.0 -flake8-isort==2.5 +flake8-isort==2.6.0 isort==4.3.4 mock==2.0.0 pytest==4.0.1 From 7c60e76c52a46deb5f3b130b30521a8e4cea822b Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 3 Dec 2018 10:57:53 -0800 Subject: [PATCH 067/488] Update django-debug-toolbar from 1.10.1 to 1.11 (#525) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 623a3d07..ca154365 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -1,5 +1,5 @@ -e . -django-debug-toolbar==1.10.1 +django-debug-toolbar==1.11 django-filter==2.0.0 django-polymorphic==2.0.3 factory-boy==2.11.1 From e5bea804ddee4987fc425c3228df363216c32577 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 6 Dec 2018 19:14:16 +0100 Subject: [PATCH 068/488] Do not skip empty one-to-one relationships (#526) --- CHANGELOG.md | 1 + example/tests/unit/test_renderers.py | 39 ++++++++++++++++++++-------- rest_framework_json_api/renderers.py | 7 ----- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a17c5f..f3aafdd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ any parts of the framework not mentioned in the documentation should generally b * Avoid patch on `RelationshipView` deleting relationship instance when constraint would allow null ([#242](https://github.com/django-json-api/django-rest-framework-json-api/issues/242)) * Avoid error with related urls when retrieving relationship which is referenced as `ForeignKey` on parent * Do not render `write_only` relations +* Do not skip empty one-to-one relationships ## [2.6.0] - 2018-09-20 diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index 8065e470..8234392d 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -3,7 +3,7 @@ from rest_framework_json_api import serializers, views from rest_framework_json_api.renderers import JSONRenderer -from example.models import Comment, Entry +from example.models import Author, Comment, Entry # serializers @@ -45,8 +45,8 @@ class ReadOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): serializer_class = DummyTestSerializer -def render_dummy_test_serialized_view(view_class): - serializer = view_class.serializer_class(instance=Entry()) +def render_dummy_test_serialized_view(view_class, instance): + serializer = view_class.serializer_class(instance=instance) renderer = JSONRenderer() return renderer.render( serializer.data, @@ -58,14 +58,14 @@ def test_simple_reverse_relation_included_renderer(): Test renderer when a single reverse fk relation is passed. ''' rendered = render_dummy_test_serialized_view( - DummyTestViewSet) + DummyTestViewSet, Entry()) assert rendered def test_simple_reverse_relation_included_read_only_viewset(): rendered = render_dummy_test_serialized_view( - ReadOnlyDummyTestViewSet) + ReadOnlyDummyTestViewSet, Entry()) assert rendered @@ -73,7 +73,7 @@ def test_simple_reverse_relation_included_read_only_viewset(): def test_render_format_field_names(settings): """Test that json field is kept untouched.""" settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' - rendered = render_dummy_test_serialized_view(DummyTestViewSet) + rendered = render_dummy_test_serialized_view(DummyTestViewSet, Entry()) result = json.loads(rendered.decode()) assert result['data']['attributes']['json-field'] == {'JsonKey': 'JsonValue'} @@ -83,17 +83,15 @@ def test_render_format_keys(settings): """Test that json field value keys are formated.""" delattr(settings, 'JSON_API_FORMAT_FILED_NAMES') settings.JSON_API_FORMAT_KEYS = 'dasherize' - rendered = render_dummy_test_serialized_view(DummyTestViewSet) + rendered = render_dummy_test_serialized_view(DummyTestViewSet, Entry()) result = json.loads(rendered.decode()) assert result['data']['attributes']['json-field'] == {'json-key': 'JsonValue'} -def test_writeonly_not_in_response(settings): +def test_writeonly_not_in_response(): """Test that writeonly fields are not shown in list response""" - settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' - class WriteonlyTestSerializer(serializers.ModelSerializer): '''Serializer for testing the absence of write_only fields''' comments = serializers.ResourceRelatedField( @@ -112,8 +110,27 @@ class WriteOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): queryset = Entry.objects.all() serializer_class = WriteonlyTestSerializer - rendered = render_dummy_test_serialized_view(WriteOnlyDummyTestViewSet) + rendered = render_dummy_test_serialized_view(WriteOnlyDummyTestViewSet, Entry()) result = json.loads(rendered.decode()) assert 'rating' not in result['data']['attributes'] assert 'relationships' not in result['data'] + + +def test_render_empty_relationship_reverse_lookup(): + """Test that empty relationships are rendered as None.""" + + class EmptyRelationshipSerializer(serializers.ModelSerializer): + class Meta: + model = Author + fields = ('bio', ) + + class EmptyRelationshipViewSet(views.ReadOnlyModelViewSet): + queryset = Author.objects.all() + serializer_class = EmptyRelationshipSerializer + + rendered = render_dummy_test_serialized_view(EmptyRelationshipViewSet, Author()) + result = json.loads(rendered.decode()) + assert 'relationships' in result['data'] + assert 'bio' in result['data']['relationships'] + assert result['data']['relationships']['bio'] == {'data': None} diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index de727f38..d50685da 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -150,13 +150,6 @@ def extract_relationships(cls, fields, resource, resource_instance): data.update({field_name: relation_data}) if isinstance(field, (ResourceRelatedField, )): - relation_instance_id = getattr(resource_instance, source + "_id", None) - if not relation_instance_id: - resolved, relation_instance = utils.get_relation_instance(resource_instance, - source, field.parent) - if not resolved: - continue - if not isinstance(field, SkipDataMixin): relation_data.update({'data': resource.get(field_name)}) From fd77738b2f930fcd3da2b54345632b653e4e65b7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 6 Dec 2018 19:32:19 +0100 Subject: [PATCH 069/488] Avoid deprecation warnings during tests (#527) Steps: * Change to non deprecation pagination class per default * filterwarnings per test level where a warning is actually tested * Add some global filterwarnings for warnings which happen during import time. (those can be removed when tests are being restructured) All in all when running tests it should bloat the output with unrelated warning messages anymore. --- example/api/resources/identity.py | 13 +----------- example/settings/dev.py | 2 +- example/tests/integration/test_includes.py | 14 ++++--------- example/tests/integration/test_meta.py | 4 ++-- example/tests/integration/test_pagination.py | 4 ++-- .../tests/integration/test_polymorphism.py | 4 ++-- example/tests/test_format_keys.py | 6 +++--- example/tests/test_model_viewsets.py | 20 +++++++++---------- example/tests/test_multiple_id_mixin.py | 12 +++++------ example/tests/test_parsers.py | 2 ++ example/tests/test_performance.py | 4 ++-- .../unit/test_default_drf_serializers.py | 5 +++-- example/tests/unit/test_renderers.py | 3 +++ example/tests/unit/test_utils.py | 1 + example/views.py | 6 +++--- pytest.ini | 15 ++++++++++++++ 16 files changed, 60 insertions(+), 55 deletions(-) diff --git a/example/api/resources/identity.py b/example/api/resources/identity.py index a00def74..3da13a3a 100644 --- a/example/api/resources/identity.py +++ b/example/api/resources/identity.py @@ -11,20 +11,9 @@ class Identity(mixins.MultipleIDMixin, viewsets.ModelViewSet): - queryset = auth_models.User.objects.all() + queryset = auth_models.User.objects.all().order_by('pk') serializer_class = IdentitySerializer - @list_route() - def empty_list(self, request): - """ - This is a hack/workaround to return an empty result on a list - endpoint because the delete operation in the test_empty_pluralization - test doesn't prevent the /identities endpoint from still returning - records when called in the same test. Suggestions welcome. - """ - self.queryset = self.queryset.filter(pk=None) - return super(Identity, self).list(request) - # demonstrate sideloading data for use at app boot time @list_route() def posts(self, request): diff --git a/example/settings/dev.py b/example/settings/dev.py index 9e70aeb5..4b989e89 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -72,7 +72,7 @@ 'PAGE_SIZE': 5, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', 'DEFAULT_PAGINATION_CLASS': - 'rest_framework_json_api.pagination.PageNumberPagination', + 'rest_framework_json_api.pagination.JsonApiPageNumberPagination', 'DEFAULT_PARSER_CLASSES': ( 'rest_framework_json_api.parsers.JSONParser', 'rest_framework.parsers.FormParser', diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index 6c0d6958..694e20d1 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -4,14 +4,8 @@ pytestmark = pytest.mark.django_db -def test_default_included_data_on_list(multiple_entries, client): - return test_included_data_on_list( - multiple_entries=multiple_entries, client=client, query='?page_size=5' - ) - - -def test_included_data_on_list(multiple_entries, client, query='?include=comments&page_size=5'): - response = client.get(reverse("entry-list") + query) +def test_included_data_on_list(multiple_entries, client): + response = client.get(reverse("entry-list"), data={'include': 'comments', 'page[size]': 5}) included = response.json().get('included') assert len(response.json()['data']) == len(multiple_entries), ( @@ -79,7 +73,7 @@ def test_missing_field_not_included(author_bio_factory, author_factory, client): def test_deep_included_data_on_list(multiple_entries, client): response = client.get(reverse("entry-list") + '?include=comments,comments.author,' - 'comments.author.bio,comments.writer&page_size=5') + 'comments.author.bio,comments.writer&page[size]=5') included = response.json().get('included') assert len(response.json()['data']) == len(multiple_entries), ( @@ -113,7 +107,7 @@ def test_deep_included_data_on_list(multiple_entries, client): # Also include entry authors response = client.get(reverse("entry-list") + '?include=authors,comments,comments.author,' - 'comments.author.bio&page_size=5') + 'comments.author.bio&page[size]=5') included = response.json().get('included') assert len(response.json()['data']) == len(multiple_entries), ( diff --git a/example/tests/integration/test_meta.py b/example/tests/integration/test_meta.py index 20fb0778..05856865 100644 --- a/example/tests/integration/test_meta.py +++ b/example/tests/integration/test_meta.py @@ -28,8 +28,8 @@ def test_top_level_meta_for_list_view(blog, client): }, }], 'links': { - 'first': 'http://testserver/blogs?page=1', - 'last': 'http://testserver/blogs?page=1', + 'first': 'http://testserver/blogs?page%5Bnumber%5D=1', + 'last': 'http://testserver/blogs?page%5Bnumber%5D=1', 'next': None, 'prev': None }, diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 18306e3e..ceb57b0f 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -87,8 +87,8 @@ def test_pagination_with_single_entry(single_entry, client): } }], "links": { - "first": "http://testserver/entries?page=1", - "last": "http://testserver/entries?page=1", + 'first': 'http://testserver/entries?page%5Bnumber%5D=1', + 'last': 'http://testserver/entries?page%5Bnumber%5D=1', "next": None, "prev": None, }, diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 9bfac112..bc80599a 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -105,8 +105,8 @@ def test_polymorphism_on_polymorphic_model_w_included_serializers(client): def test_polymorphic_model_without_any_instance(client): expected = { "links": { - "first": "http://testserver/projects?page=1", - "last": "http://testserver/projects?page=1", + 'first': 'http://testserver/projects?page%5Bnumber%5D=1', + 'last': 'http://testserver/projects?page%5Bnumber%5D=1', "next": None, "prev": None }, diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index 1481103a..22de023d 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -36,9 +36,9 @@ def test_camelization(self): } ], 'links': { - 'first': 'http://testserver/identities?page=1', - 'last': 'http://testserver/identities?page=2', - 'next': 'http://testserver/identities?page=2', + 'first': 'http://testserver/identities?page%5Bnumber%5D=1', + 'last': 'http://testserver/identities?page%5Bnumber%5D=2', + 'next': 'http://testserver/identities?page%5Bnumber%5D=2', 'prev': None }, 'meta': { diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index ee3e4ba0..df95bf42 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -43,9 +43,9 @@ def test_key_in_list_result(self): } ], 'links': { - 'first': 'http://testserver/identities?page=1', - 'last': 'http://testserver/identities?page=2', - 'next': 'http://testserver/identities?page=2', + 'first': 'http://testserver/identities?page%5Bnumber%5D=1', + 'last': 'http://testserver/identities?page%5Bnumber%5D=2', + 'next': 'http://testserver/identities?page%5Bnumber%5D=2', 'prev': None }, 'meta': { @@ -63,7 +63,7 @@ def test_page_two_in_list_result(self): """ Ensure that the second page is reachable and is the correct data. """ - response = self.client.get(self.list_url, {'page': 2}) + response = self.client.get(self.list_url, {'page[number]': 2}) self.assertEqual(response.status_code, 200) user = get_user_model().objects.all()[1] @@ -80,10 +80,10 @@ def test_page_two_in_list_result(self): } ], 'links': { - 'first': 'http://testserver/identities?page=1', - 'last': 'http://testserver/identities?page=2', + 'first': 'http://testserver/identities?page%5Bnumber%5D=1', + 'last': 'http://testserver/identities?page%5Bnumber%5D=2', 'next': None, - 'prev': 'http://testserver/identities?page=1', + 'prev': 'http://testserver/identities?page%5Bnumber%5D=1' }, 'meta': { 'pagination': { @@ -102,7 +102,7 @@ def test_page_range_in_list_result(self): tests pluralization as two objects means it converts ``user`` to ``users``. """ - response = self.client.get(self.list_url, {'page_size': 2}) + response = self.client.get(self.list_url, {'page[size]': 2}) self.assertEqual(response.status_code, 200) users = get_user_model().objects.all() @@ -128,8 +128,8 @@ def test_page_range_in_list_result(self): } ], 'links': { - 'first': 'http://testserver/identities?page=1&page_size=2', - 'last': 'http://testserver/identities?page=1&page_size=2', + 'first': 'http://testserver/identities?page%5Bnumber%5D=1&page%5Bsize%5D=2', + 'last': 'http://testserver/identities?page%5Bnumber%5D=1&page%5Bsize%5D=2', 'next': None, 'prev': None }, diff --git a/example/tests/test_multiple_id_mixin.py b/example/tests/test_multiple_id_mixin.py index 6ec73ad8..ac21b2e5 100644 --- a/example/tests/test_multiple_id_mixin.py +++ b/example/tests/test_multiple_id_mixin.py @@ -39,9 +39,9 @@ def test_single_id_in_query_params(self): links = json_content.get("links") meta = json_content.get("meta").get('pagination') - self.assertEquals(expected.get('user'), json_content.get('user')) - self.assertEquals(meta.get('count', 0), 1) - self.assertEquals(links.get("next"), None) + self.assertEqual(expected.get('user'), json_content.get('user')) + self.assertEqual(meta.get('count', 0), 1) + self.assertEqual(links.get("next"), None) self.assertEqual(meta.get("page"), 1) def test_multiple_ids_in_query_params(self): @@ -69,11 +69,11 @@ def test_multiple_ids_in_query_params(self): links = json_content.get("links") meta = json_content.get("meta").get('pagination') - self.assertEquals(expected.get('user'), json_content.get('user')) - self.assertEquals(meta.get('count', 0), 2) + self.assertEqual(expected.get('user'), json_content.get('user')) + self.assertEqual(meta.get('count', 0), 2) self.assertEqual( sorted( - 'http://testserver/identities?ids%5B%5D=2&ids%5B%5D=1&page=2' + 'http://testserver/identities?ids%5B%5D=2&ids%5B%5D=1&page%5Bnumber%5D=2' .split('?')[1].split('&') ), sorted( diff --git a/example/tests/test_parsers.py b/example/tests/test_parsers.py index aec80a12..6c4d7252 100644 --- a/example/tests/test_parsers.py +++ b/example/tests/test_parsers.py @@ -1,6 +1,7 @@ import json from io import BytesIO +import pytest from django.test import TestCase, override_settings from rest_framework.exceptions import ParseError @@ -34,6 +35,7 @@ def __init__(self): self.string = json.dumps(data) + @pytest.mark.filterwarnings("ignore:`format_keys` function and `JSON_API_FORMAT_KEYS`") @override_settings(JSON_API_FORMAT_KEYS='camelize') def test_parse_include_metadata_format_keys(self): parser = JSONParser() diff --git a/example/tests/test_performance.py b/example/tests/test_performance.py index d9ae1db9..7da69bc9 100644 --- a/example/tests/test_performance.py +++ b/example/tests/test_performance.py @@ -41,7 +41,7 @@ def test_query_count_no_includes(self): 2. The SELECT query for the set """ with self.assertNumQueries(2): - response = self.client.get('/comments?page_size=25') + response = self.client.get('/comments?page[size]=25') self.assertEqual(len(response.data['results']), 25) def test_query_count_include_author(self): @@ -54,5 +54,5 @@ def test_query_count_include_author(self): 5. Entries prefetched """ with self.assertNumQueries(5): - response = self.client.get('/comments?include=author&page_size=25') + response = self.client.get('/comments?include=author&page[size]=25') self.assertEqual(len(response.data['results']), 25) diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index e17b9a52..f43925a9 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -68,6 +68,7 @@ def test_render_format_field_names(settings): assert result['data']['attributes']['json-field'] == {'JsonKey': 'JsonValue'} +@pytest.mark.filterwarnings("ignore:`format_keys` function and `JSON_API_FORMAT_KEYS`") def test_render_format_keys(settings): """Test that json field value keys are formated.""" delattr(settings, 'JSON_API_FORMAT_FILED_NAMES') @@ -195,8 +196,8 @@ def test_get_entry_list_with_blogs(client, entry): expected = { 'links': { - 'first': 'http://testserver/drf-entries/1/suggested/?page=1', - 'last': 'http://testserver/drf-entries/1/suggested/?page=1', + 'first': 'http://testserver/drf-entries/1/suggested/?page%5Bnumber%5D=1', + 'last': 'http://testserver/drf-entries/1/suggested/?page%5Bnumber%5D=1', 'next': None, 'prev': None }, diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index 8234392d..6126cff0 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -1,5 +1,7 @@ import json +import pytest + from rest_framework_json_api import serializers, views from rest_framework_json_api.renderers import JSONRenderer @@ -79,6 +81,7 @@ def test_render_format_field_names(settings): assert result['data']['attributes']['json-field'] == {'JsonKey': 'JsonValue'} +@pytest.mark.filterwarnings("ignore:`format_keys` function and `JSON_API_FORMAT_KEYS`") def test_render_format_keys(settings): """Test that json field value keys are formated.""" delattr(settings, 'JSON_API_FORMAT_FILED_NAMES') diff --git a/example/tests/unit/test_utils.py b/example/tests/unit/test_utils.py index a1414050..1a230113 100644 --- a/example/tests/unit/test_utils.py +++ b/example/tests/unit/test_utils.py @@ -62,6 +62,7 @@ def test_get_resource_name(): assert 'users' == utils.get_resource_name(context), 'derived from non-model serializer' +@pytest.mark.filterwarnings("ignore:`format_keys` function and `JSON_API_FORMAT_KEYS`") def test_format_keys(): underscored = { 'first_name': 'a', diff --git a/example/views.py b/example/views.py index 41036fc6..de6579bd 100644 --- a/example/views.py +++ b/example/views.py @@ -10,7 +10,7 @@ import rest_framework_json_api.renderers from rest_framework_json_api.django_filters import DjangoFilterBackend from rest_framework_json_api.filters import OrderingFilter, QueryParameterValidationFilter -from rest_framework_json_api.pagination import PageNumberPagination +from rest_framework_json_api.pagination import JsonApiPageNumberPagination from rest_framework_json_api.utils import format_drf_errors from rest_framework_json_api.views import ModelViewSet, RelationshipView @@ -119,7 +119,7 @@ def get_object(self): return super(DRFEntryViewSet, self).get_object() -class NoPagination(PageNumberPagination): +class NoPagination(JsonApiPageNumberPagination): page_size = None @@ -203,7 +203,7 @@ class CompanyViewset(ModelViewSet): class ProjectViewset(ModelViewSet): - queryset = Project.objects.all() + queryset = Project.objects.all().order_by('pk') serializer_class = ProjectSerializer diff --git a/pytest.ini b/pytest.ini index 49c4dae9..dfbed830 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,17 @@ [pytest] DJANGO_SETTINGS_MODULE=example.settings.test +filterwarnings = + error::DeprecationWarning + error::PendingDeprecationWarning + # TODO: restructure tests so this can be ignored on a test level + ignore:MarkInfo objects are deprecated as they contain merged marks which are hard to deal with correctly. + ignore:use of getfuncargvalue is deprecated, use getfixturevalue + ignore:`list_route` + ignore:`detail_route` + ignore:`FiltersetEntryViewSet.filter_fields` attribute should be renamed + ignore:`NoFiltersetEntryViewSet.filter_fields` attribute should be renamed + ignore:`NoFiltersetEntryViewSet.filter_class` attribute should be renamed `filterset_class` + ignore:MultipleIDMixin is deprecated + # can be removed once following DRF PR is released + # https://github.com/encode/django-rest-framework/pull/6268 + ignore:Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated From f309c7c5e4c06bd2d22adbde57d7a728239f5238 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 7 Dec 2018 15:42:27 +0100 Subject: [PATCH 070/488] Enforce successful upload of coverage files codecov (#530) --- .gitignore | 1 + .travis.yml | 2 +- tox.ini | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 6b952e8f..a49ab451 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ pip-delete-this-directory.txt # Coverage .coverage +coverage.xml # Tox .tox/ diff --git a/.travis.yml b/.travis.yml index c76e079e..007bb852 100644 --- a/.travis.yml +++ b/.travis.yml @@ -100,4 +100,4 @@ script: - tox after_success: - pip install codecov - - codecov -e TOXENV + - codecov -e TOXENV --required diff --git a/tox.ini b/tox.ini index 30f6b86f..c8b47325 100644 --- a/tox.ini +++ b/tox.ini @@ -24,7 +24,7 @@ setenv = DJANGO_SETTINGS_MODULE=example.settings.test commands = - python setup.py test --addopts '--cov --no-cov-on-fail' {posargs} + python setup.py test --addopts '--cov --no-cov-on-fail --cov-report xml' {posargs} [testenv:flake8] deps = From 8c2b5af95ed8e630c61ef5b10c8e2807a24ff7e7 Mon Sep 17 00:00:00 2001 From: Jason Housley Date: Mon, 10 Dec 2018 02:03:53 -0700 Subject: [PATCH 071/488] Allow HyperlinkedRelatedField to be used with related urls (#529) Fixes #521 --- AUTHORS | 1 + CHANGELOG.md | 1 + example/models.py | 1 + example/serializers.py | 9 ++++++++- example/settings/test.py | 2 +- example/tests/test_views.py | 22 ++++++++++++++++------ rest_framework_json_api/views.py | 12 ++++++++---- 7 files changed, 36 insertions(+), 12 deletions(-) diff --git a/AUTHORS b/AUTHORS index 25129f59..d90d4be3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -20,3 +20,4 @@ santiavenda Tim Selman Yaniv Peer Mohammed Ali Zubair +Jason Housley diff --git a/CHANGELOG.md b/CHANGELOG.md index f3aafdd2..4072276c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ any parts of the framework not mentioned in the documentation should generally b * Avoid error with related urls when retrieving relationship which is referenced as `ForeignKey` on parent * Do not render `write_only` relations * Do not skip empty one-to-one relationships +* Allow `HyperlinkRelatedField` to be used with [related urls](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html?highlight=related%20links#related-urls) ## [2.6.0] - 2018-09-20 diff --git a/example/models.py b/example/models.py index f183391e..e280c827 100644 --- a/example/models.py +++ b/example/models.py @@ -110,6 +110,7 @@ class Comment(BaseModel): null=True, blank=True, on_delete=models.CASCADE, + related_name='comments', ) def __str__(self): diff --git a/example/serializers.py b/example/serializers.py index 97dde4bb..70699987 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -219,6 +219,12 @@ class AuthorSerializer(serializers.ModelSerializer): read_only=True, source='get_first_entry' ) + comments = relations.HyperlinkedRelatedField( + related_link_view_name='author-related', + self_link_view_name='author-relationships', + queryset=Comment.objects, + many=True + ) included_serializers = { 'bio': AuthorBioSerializer, 'type': AuthorTypeSerializer @@ -226,13 +232,14 @@ class AuthorSerializer(serializers.ModelSerializer): related_serializers = { 'bio': 'example.serializers.AuthorBioSerializer', 'type': 'example.serializers.AuthorTypeSerializer', + 'comments': 'example.serializers.CommentSerializer', 'entries': 'example.serializers.EntrySerializer', 'first_entry': 'example.serializers.EntrySerializer' } class Meta: model = Author - fields = ('name', 'email', 'bio', 'entries', 'first_entry', 'type') + fields = ('name', 'email', 'bio', 'entries', 'comments', 'first_entry', 'type') def get_first_entry(self, obj): return obj.entries.first() diff --git a/example/settings/test.py b/example/settings/test.py index c32aa95f..b47c3fe5 100644 --- a/example/settings/test.py +++ b/example/settings/test.py @@ -13,6 +13,6 @@ JSON_API_FORMAT_TYPES = 'camelize' JSON_API_PLURALIZE_TYPES = True -REST_FRAMEWORK.update({ +REST_FRAMEWORK.update({ # noqa 'PAGE_SIZE': 1, }) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 198b8b00..0f249139 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -102,11 +102,11 @@ def test_get_empty_to_one_relationship(self): assert response.data == expected_data def test_get_to_many_relationship_self_link(self): - url = '/authors/{}/relationships/comment_set'.format(self.author.id) + url = '/authors/{}/relationships/comments'.format(self.author.id) response = self.client.get(url) expected_data = { - 'links': {'self': 'http://testserver/authors/1/relationships/comment_set'}, + 'links': {'self': 'http://testserver/authors/1/relationships/comments'}, 'data': [{'id': str(self.second_comment.id), 'type': format_resource_type('Comment')}] } assert json.loads(response.content.decode('utf-8')) == expected_data @@ -222,7 +222,7 @@ def test_delete_one_to_many_relationship_with_not_null_constraint(self): assert response.status_code == 409, response.content.decode() def test_delete_to_many_relationship_with_change(self): - url = '/authors/{}/relationships/comment_set'.format(self.author.id) + url = '/authors/{}/relationships/comments'.format(self.author.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] } @@ -233,7 +233,7 @@ def test_new_comment_data_patch_to_many_relationship(self): entry = EntryFactory(blog=self.blog, authors=(self.author,)) comment = CommentFactory(entry=entry) - url = '/authors/{}/relationships/comment_set'.format(self.author.id) + url = '/authors/{}/relationships/comments'.format(self.author.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(comment.id)}, ] } @@ -244,7 +244,7 @@ def test_new_comment_data_patch_to_many_relationship(self): } ], 'links': { - 'self': 'http://testserver/authors/{}/relationships/comment_set'.format( + 'self': 'http://testserver/authors/{}/relationships/comments'.format( self.author.id ) } @@ -261,7 +261,7 @@ def test_new_comment_data_patch_to_many_relationship(self): } ], 'links': { - 'self': 'http://testserver/authors/{}/relationships/comment_set'.format( + 'self': 'http://testserver/authors/{}/relationships/comments'.format( self.author.id ) } @@ -369,6 +369,16 @@ def test_retrieve_related_many(self): self.assertEqual(len(resp.json()['data']), 1) self.assertEqual(resp.json()['data'][0]['id'], str(entry.id)) + def test_retrieve_related_many_hyperlinked(self): + comment = CommentFactory(author=self.author) + url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'comments'}) + resp = self.client.get(url) + + self.assertEqual(resp.status_code, 200) + self.assertTrue(isinstance(resp.json()['data'], list)) + self.assertEqual(len(resp.json()['data']), 1) + self.assertEqual(resp.json()['data'][0]['id'], str(comment.id)) + def test_retrieve_related_None(self): kwargs = {'pk': self.author.pk, 'related_field': 'first_entry'} url = reverse('author-related', kwargs=kwargs) diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 35a213fc..46f3a2bc 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -18,7 +18,7 @@ from rest_framework.relations import PKOnlyObject from rest_framework.response import Response from rest_framework.reverse import reverse -from rest_framework.serializers import Serializer +from rest_framework.serializers import Serializer, SkipField from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.serializers import ResourceIdentifierObjectSerializer @@ -166,10 +166,14 @@ def get_related_instance(self): field = parent_serializer.fields.get(field_name, None) if field is not None: - instance = field.get_attribute(parent_obj) - if isinstance(instance, PKOnlyObject): - # need whole object + try: + instance = field.get_attribute(parent_obj) + except SkipField: instance = get_attribute(parent_obj, field.source_attrs) + else: + if isinstance(instance, PKOnlyObject): + # need whole object + instance = get_attribute(parent_obj, field.source_attrs) return instance else: try: From 92a2ed60352df083a217d814eed91cb64f9c1528 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 12 Dec 2018 10:48:05 -0800 Subject: [PATCH 072/488] Update faker from 1.0.0 to 1.0.1 (#531) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index ca154365..19bce91b 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -3,7 +3,7 @@ django-debug-toolbar==1.11 django-filter==2.0.0 django-polymorphic==2.0.3 factory-boy==2.11.1 -Faker==1.0.0 +Faker==1.0.1 flake8==3.6.0 flake8-isort==2.6.0 isort==4.3.4 From 8fd82655da8cb34d8dc74bd3c8e0b724def10425 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 14 Dec 2018 08:06:08 -0800 Subject: [PATCH 073/488] Update pytest from 4.0.1 to 4.0.2 (#533) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 19bce91b..083bfa3a 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.6.0 flake8-isort==2.6.0 isort==4.3.4 mock==2.0.0 -pytest==4.0.1 +pytest==4.0.2 pytest-django==3.4.4 pytest-factoryboy==2.0.2 recommonmark==0.4.0 From cc64dad7f1fd7bbd3c5dc85742d73c0a1e4920fe Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 26 Dec 2018 09:49:59 -0800 Subject: [PATCH 074/488] Update sphinx from 1.8.2 to 1.8.3 (#534) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 083bfa3a..63ea11ff 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -12,6 +12,6 @@ pytest==4.0.2 pytest-django==3.4.4 pytest-factoryboy==2.0.2 recommonmark==0.4.0 -Sphinx==1.8.2 +Sphinx==1.8.3 sphinx_rtd_theme==0.4.2 twine==1.12.1 From 6518cdbf6741265157acd5869b73864fdb5825be Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 1 Jan 2019 19:46:03 +0100 Subject: [PATCH 075/488] Omit common debugging statement from coverage (#538) --- requirements-development.txt | 1 + setup.cfg | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 63ea11ff..f3c7c8ff 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -9,6 +9,7 @@ flake8-isort==2.6.0 isort==4.3.4 mock==2.0.0 pytest==4.0.2 +pytest-cov==2.6.0 pytest-django==3.4.4 pytest-factoryboy==2.0.2 recommonmark==0.4.0 diff --git a/setup.cfg b/setup.cfg index effb04ad..879ff157 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,8 +29,19 @@ skip= .eggs .tox, +[coverage:run] +source = + example + rest_framework_json_api + [coverage:report] -omit= +omit = .tox/* .eggs/* show_missing = True +exclude_lines = + pragma: no cover + pragma: todo cover + def __str__ + def __unicode__ + def __repr__ From 11e0eddc90a912e98c93e0a082bfceba55af926c Mon Sep 17 00:00:00 2001 From: Beni Keller Date: Thu, 3 Jan 2019 15:51:19 +0100 Subject: [PATCH 076/488] Removed hardcoded year 2018 from tests (#539) (#541) * Removed hardcoded year 2018 from tests (#539) --- AUTHORS | 1 + CHANGELOG.md | 2 +- example/tests/test_views.py | 3 ++- example/tests/unit/test_default_drf_serializers.py | 9 +++++---- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/AUTHORS b/AUTHORS index d90d4be3..f3e3d2b2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -21,3 +21,4 @@ Tim Selman Yaniv Peer Mohammed Ali Zubair Jason Housley +Beni Keller diff --git a/CHANGELOG.md b/CHANGELOG.md index 4072276c..bed4287d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ any parts of the framework not mentioned in the documentation should generally b * Do not render `write_only` relations * Do not skip empty one-to-one relationships * Allow `HyperlinkRelatedField` to be used with [related urls](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html?highlight=related%20links#related-urls) - +* Fixed hardcoded year 2018 in tests ([#539](https://github.com/django-json-api/django-rest-framework-json-api/issues/539)) ## [2.6.0] - 2018-09-20 diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 0f249139..d4a3d062 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -1,5 +1,6 @@ import json +from datetime import datetime from django.test import RequestFactory from django.utils import timezone from rest_framework.exceptions import NotFound @@ -466,7 +467,7 @@ def test_get_object_gives_correct_blog(self): 'attributes': {'name': self.blog.name}, 'id': '{}'.format(self.blog.id), 'links': {'self': 'http://testserver/blogs/{}'.format(self.blog.id)}, - 'meta': {'copyright': 2018}, + 'meta': {'copyright': datetime.now().year}, 'relationships': {'tags': {'data': []}}, 'type': 'blogs' }, diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index f43925a9..61e07cb5 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -1,6 +1,7 @@ import json import pytest +from datetime import datetime from django.urls import reverse from rest_framework import viewsets from rest_framework.serializers import ModelSerializer, SerializerMethodField @@ -108,7 +109,7 @@ def test_blog_create(client): 'attributes': {'name': blog.name}, 'id': '{}'.format(blog.id), 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, - 'meta': {'copyright': 2018}, + 'meta': {'copyright': datetime.now().year}, 'relationships': {'tags': {'data': []}}, 'type': 'blogs' }, @@ -129,7 +130,7 @@ def test_get_object_gives_correct_blog(client, blog, entry): 'attributes': {'name': blog.name}, 'id': '{}'.format(blog.id), 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, - 'meta': {'copyright': 2018}, + 'meta': {'copyright': datetime.now().year}, 'relationships': {'tags': {'data': []}}, 'type': 'blogs' }, @@ -151,7 +152,7 @@ def test_get_object_patches_correct_blog(client, blog, entry): 'attributes': {'name': new_name}, 'id': '{}'.format(blog.id), 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, - 'meta': {'copyright': 2018}, + 'meta': {'copyright': datetime.now().year}, 'relationships': {'tags': {'data': []}}, 'type': 'blogs' }, @@ -167,7 +168,7 @@ def test_get_object_patches_correct_blog(client, blog, entry): 'attributes': {'name': new_name}, 'id': '{}'.format(blog.id), 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, - 'meta': {'copyright': 2018}, + 'meta': {'copyright': datetime.now().year}, 'relationships': {'tags': {'data': []}}, 'type': 'blogs' }, From 55e8def79cdfe19c62425a8486ac69712f6b54fe Mon Sep 17 00:00:00 2001 From: santiavenda Date: Fri, 4 Jan 2019 05:27:14 -0300 Subject: [PATCH 077/488] Avoid exception in `AutoPrefetchMixin` when including a reverse one to one relation (#536) --- CHANGELOG.md | 1 + example/factories.py | 14 ++++++-- example/migrations/0006_auto_20181228_0752.py | 32 +++++++++++++++++++ example/models.py | 15 +++++++++ example/serializers.py | 16 ++++++++-- example/tests/conftest.py | 2 ++ example/tests/integration/test_includes.py | 19 +++++++++++ example/tests/test_views.py | 7 ++-- rest_framework_json_api/views.py | 6 +++- 9 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 example/migrations/0006_auto_20181228_0752.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bed4287d..4e4bc23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ any parts of the framework not mentioned in the documentation should generally b * Do not skip empty one-to-one relationships * Allow `HyperlinkRelatedField` to be used with [related urls](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html?highlight=related%20links#related-urls) * Fixed hardcoded year 2018 in tests ([#539](https://github.com/django-json-api/django-rest-framework-json-api/issues/539)) +* Avoid exception in `AutoPrefetchMixin` when including a reverse one to one relation ([#537](https://github.com/django-json-api/django-rest-framework-json-api/issues/537)) ## [2.6.0] - 2018-09-20 diff --git a/example/factories.py b/example/factories.py index dbdd9838..9a8f4966 100644 --- a/example/factories.py +++ b/example/factories.py @@ -14,8 +14,8 @@ Entry, ProjectType, ResearchProject, - TaggedItem -) + TaggedItem, + AuthorBioMetadata) faker = FakerFactory.create() faker.seed(983843) @@ -53,6 +53,16 @@ class Meta: author = factory.SubFactory(AuthorFactory) body = factory.LazyAttribute(lambda x: faker.text()) + metadata = factory.RelatedFactory('example.factories.AuthorBioMetadataFactory', 'bio') + + +class AuthorBioMetadataFactory(factory.django.DjangoModelFactory): + class Meta: + model = AuthorBioMetadata + + bio = factory.SubFactory(AuthorBioFactory) + body = factory.LazyAttribute(lambda x: faker.text()) + class EntryFactory(factory.django.DjangoModelFactory): class Meta: diff --git a/example/migrations/0006_auto_20181228_0752.py b/example/migrations/0006_auto_20181228_0752.py new file mode 100644 index 00000000..2cfb0c29 --- /dev/null +++ b/example/migrations/0006_auto_20181228_0752.py @@ -0,0 +1,32 @@ +# Generated by Django 2.1.4 on 2018-12-28 07:52 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('example', '0005_auto_20180922_1508'), + ] + + operations = [ + migrations.CreateModel( + name='AuthorBioMetadata', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('body', models.TextField()), + ('bio', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='metadata', to='example.AuthorBio')), + ], + options={ + 'ordering': ('id',), + }, + ), + migrations.AlterField( + model_name='comment', + name='author', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='example.Author'), + ), + ] diff --git a/example/models.py b/example/models.py index e280c827..179cbfe5 100644 --- a/example/models.py +++ b/example/models.py @@ -81,6 +81,21 @@ class Meta: ordering = ('id',) +@python_2_unicode_compatible +class AuthorBioMetadata(BaseModel): + """ + Just a class to have a relation with author bio + """ + bio = models.OneToOneField(AuthorBio, related_name='metadata', on_delete=models.CASCADE) + body = models.TextField() + + def __str__(self): + return self.bio.author.name + + class Meta: + ordering = ('id',) + + @python_2_unicode_compatible class Entry(BaseModel): blog = models.ForeignKey(Blog, on_delete=models.CASCADE) diff --git a/example/serializers.py b/example/serializers.py index 70699987..6661dcac 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -16,8 +16,8 @@ Project, ProjectType, ResearchProject, - TaggedItem -) + TaggedItem, + AuthorBioMetadata) class TaggedItemSerializer(serializers.ModelSerializer): @@ -197,7 +197,17 @@ class Meta: class AuthorBioSerializer(serializers.ModelSerializer): class Meta: model = AuthorBio - fields = ('author', 'body') + fields = ('author', 'body', 'metadata') + + included_serializers = { + 'metadata': 'example.serializers.AuthorBioMetadataSerializer', + } + + +class AuthorBioMetadataSerializer(serializers.ModelSerializer): + class Meta: + model = AuthorBioMetadata + fields = ('body',) class AuthorSerializer(serializers.ModelSerializer): diff --git a/example/tests/conftest.py b/example/tests/conftest.py index ceaa5dcf..f2362bfd 100644 --- a/example/tests/conftest.py +++ b/example/tests/conftest.py @@ -6,6 +6,7 @@ ArtProjectFactory, AuthorBioFactory, AuthorFactory, + AuthorBioMetadataFactory, AuthorTypeFactory, BlogFactory, CommentFactory, @@ -18,6 +19,7 @@ register(BlogFactory) register(AuthorFactory) register(AuthorBioFactory) +register(AuthorBioMetadataFactory) register(AuthorTypeFactory) register(EntryFactory) register(CommentFactory) diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index 694e20d1..78c12539 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -20,6 +20,25 @@ def test_included_data_on_list(multiple_entries, client): assert comment_count == expected_comment_count, 'List comment count is incorrect' +def test_included_data_on_list_with_one_to_one_relations(multiple_entries, client): + response = client.get(reverse("entry-list"), + data={'include': 'authors.bio.metadata', 'page[size]': 5}) + included = response.json().get('included') + + assert len(response.json()['data']) == len(multiple_entries), ( + 'Incorrect entry count' + ) + expected_include_types = [ + 'authorBioMetadata', 'authorBioMetadata', + 'authorBios', 'authorBios', + 'authors', 'authors' + ] + include_types = [x.get('type') for x in included] + assert include_types == expected_include_types, ( + 'List included types are incorrect' + ) + + def test_default_included_data_on_detail(single_entry, client): return test_included_data_on_detail(single_entry=single_entry, client=client, query='') diff --git a/example/tests/test_views.py b/example/tests/test_views.py index d4a3d062..95fc92a3 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -1,6 +1,6 @@ import json - from datetime import datetime + from django.test import RequestFactory from django.utils import timezone from rest_framework.exceptions import NotFound @@ -337,7 +337,10 @@ def test_retrieve_related_single_reverse_lookup(self): 'data': { 'type': 'authorBios', 'id': str(self.author.bio.id), 'relationships': { - 'author': {'data': {'type': 'authors', 'id': str(self.author.id)}}}, + 'author': {'data': {'type': 'authors', 'id': str(self.author.id)}}, + 'metadata': {'data': {'id': str(self.author.bio.metadata.id), + 'type': 'authorBioMetadata'}} + }, 'attributes': { 'body': str(self.author.bio.body) }, diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 46f3a2bc..b692844b 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -92,7 +92,11 @@ def get_queryset(self, *args, **kwargs): if level == levels[-1]: included_model = field else: - model_field = field.field + + if issubclass(field_class, ReverseOneToOneDescriptor): + model_field = field.related.field + else: + model_field = field.field if is_forward_relation: level_model = model_field.related_model From 517511f2d02cf36e17a92c6570d0133119cc6c7f Mon Sep 17 00:00:00 2001 From: Beni Keller Date: Fri, 4 Jan 2019 10:00:34 +0100 Subject: [PATCH 078/488] Remove requested resources from included field (#540) --- CHANGELOG.md | 1 + example/tests/conftest.py | 7 +++++++ example/tests/integration/test_includes.py | 19 +++++++++++++++++++ rest_framework_json_api/renderers.py | 15 +++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e4bc23c..409c5158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ any parts of the framework not mentioned in the documentation should generally b * Allow `HyperlinkRelatedField` to be used with [related urls](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html?highlight=related%20links#related-urls) * Fixed hardcoded year 2018 in tests ([#539](https://github.com/django-json-api/django-rest-framework-json-api/issues/539)) * Avoid exception in `AutoPrefetchMixin` when including a reverse one to one relation ([#537](https://github.com/django-json-api/django-rest-framework-json-api/issues/537)) +* Avoid requested resource(s) to be added to included as well ([#524](https://github.com/django-json-api/django-rest-framework-json-api/issues/524)) ## [2.6.0] - 2018-09-20 diff --git a/example/tests/conftest.py b/example/tests/conftest.py index f2362bfd..760e4672 100644 --- a/example/tests/conftest.py +++ b/example/tests/conftest.py @@ -50,6 +50,13 @@ def multiple_entries(blog_factory, author_factory, entry_factory, comment_factor return entries +@pytest.fixture +def single_comment(blog, author, entry_factory, comment_factory): + entry = entry_factory(blog=blog, authors=(author,)) + comment_factory(entry=entry) + return comment_factory(entry=entry) + + @pytest.fixture def single_company(art_project_factory, research_project_factory, company_factory): company = company_factory(future_projects=(research_project_factory(), art_project_factory())) diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index 78c12539..953052de 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -160,3 +160,22 @@ def test_deep_included_data_on_detail(single_entry, client): author_bio_count = len([resource for resource in included if resource["type"] == "authorBios"]) expected_author_bio_count = single_entry.comments.filter(author__bio__isnull=False).count() assert author_bio_count == expected_author_bio_count, 'Detail author bio count is incorrect' + + +def test_data_resource_not_included_again(single_comment, client): + # This test makes sure that the resource which is in the data field is excluded + # from the included field. + response = client.get(reverse("comment-detail", kwargs={'pk': single_comment.pk}) + + '?include=entry.comments') + + included = response.json().get('included') + + included_comments = [resource for resource in included if resource["type"] == "comments"] + assert single_comment.pk not in [int(x.get('id')) for x in included_comments], \ + "Resource of the data field duplicated in included" + + comment_count = len(included_comments) + expected_comment_count = single_comment.entry.comments.count() + # The comment in the data attribute must not be included again. + expected_comment_count -= 1 + assert comment_count == expected_comment_count, "Comment count incorrect" diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index d50685da..9aa16212 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -643,6 +643,21 @@ def render(self, data, accepted_media_type=None, renderer_context=None): else: render_data['data'] = json_api_data + if included_cache: + if isinstance(json_api_data, list): + objects = json_api_data + else: + objects = [json_api_data] + + for object in objects: + obj_type = object.get('type') + obj_id = object.get('id') + if obj_type in included_cache and \ + obj_id in included_cache[obj_type]: + del included_cache[obj_type][obj_id] + if not included_cache[obj_type]: + del included_cache[obj_type] + if included_cache: render_data['included'] = list() for included_type in sorted(included_cache.keys()): From 70be81b63704b8777736404c5afe5514de2feaaf Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 4 Jan 2019 15:34:54 +0100 Subject: [PATCH 079/488] Mark or remove uncovered example code (#542) --- example/factories.py | 7 ++++--- example/serializers.py | 5 +++-- example/tests/conftest.py | 2 +- example/tests/integration/test_non_paginated_responses.py | 2 +- example/tests/integration/test_pagination.py | 2 +- example/tests/test_serializers.py | 2 +- example/tests/unit/test_default_drf_serializers.py | 2 +- example/tests/unit/test_renderer_class_methods.py | 3 --- setup.cfg | 1 + 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/example/factories.py b/example/factories.py index 9a8f4966..0639a0dd 100644 --- a/example/factories.py +++ b/example/factories.py @@ -7,6 +7,7 @@ ArtProject, Author, AuthorBio, + AuthorBioMetadata, AuthorType, Blog, Comment, @@ -14,8 +15,8 @@ Entry, ProjectType, ResearchProject, - TaggedItem, - AuthorBioMetadata) + TaggedItem +) faker = FakerFactory.create() faker.seed(983843) @@ -134,7 +135,7 @@ class Meta: @factory.post_generation def future_projects(self, create, extracted, **kwargs): - if not create: + if not create: # pragma: no cover return if extracted: for project in extracted: diff --git a/example/serializers.py b/example/serializers.py index 6661dcac..7917062e 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -8,6 +8,7 @@ ArtProject, Author, AuthorBio, + AuthorBioMetadata, AuthorType, Blog, Comment, @@ -16,8 +17,8 @@ Project, ProjectType, ResearchProject, - TaggedItem, - AuthorBioMetadata) + TaggedItem +) class TaggedItemSerializer(serializers.ModelSerializer): diff --git a/example/tests/conftest.py b/example/tests/conftest.py index 760e4672..de2bea19 100644 --- a/example/tests/conftest.py +++ b/example/tests/conftest.py @@ -5,8 +5,8 @@ from example.factories import ( ArtProjectFactory, AuthorBioFactory, - AuthorFactory, AuthorBioMetadataFactory, + AuthorFactory, AuthorTypeFactory, BlogFactory, CommentFactory, diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index dab32d94..bfe8dfa5 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -3,7 +3,7 @@ try: from unittest import mock -except ImportError: +except ImportError: # pragma: no cover import mock pytestmark = pytest.mark.django_db diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index ceb57b0f..0ac5835a 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -3,7 +3,7 @@ try: from unittest import mock -except ImportError: +except ImportError: # pragma: no cover import mock diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index 5dc5ce81..cdc3bddd 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -17,7 +17,7 @@ try: from unittest import mock -except ImportError: +except ImportError: # pragma: no cover import mock request_factory = APIRequestFactory() diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index 61e07cb5..1fa74565 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -1,7 +1,7 @@ import json +from datetime import datetime import pytest -from datetime import datetime from django.urls import reverse from rest_framework import viewsets from rest_framework.serializers import ModelSerializer, SerializerMethodField diff --git a/example/tests/unit/test_renderer_class_methods.py b/example/tests/unit/test_renderer_class_methods.py index b6f9d69b..7a9230d3 100644 --- a/example/tests/unit/test_renderer_class_methods.py +++ b/example/tests/unit/test_renderer_class_methods.py @@ -145,9 +145,6 @@ def test_extract_root_meta_many(): def test_extract_root_meta_invalid_meta(): - def get_root_meta(resource, many): - return 'not a dict' - serializer = InvalidExtractRootMetaResourceSerializer() with pytest.raises(AssertionError): JSONRenderer.extract_root_meta(serializer, {}) diff --git a/setup.cfg b/setup.cfg index 879ff157..0b965b30 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,6 +38,7 @@ source = omit = .tox/* .eggs/* + example/urls.py show_missing = True exclude_lines = pragma: no cover From 6bf0f8923ad6774a01d379fb2c0a406edd1bc4a5 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 4 Jan 2019 16:12:45 +0100 Subject: [PATCH 080/488] Clarify only user specific change log entries be added (#543) --- CHANGELOG.md | 1 - docs/pull_request_template.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 409c5158..2ea07f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,6 @@ any parts of the framework not mentioned in the documentation should generally b * Do not render `write_only` relations * Do not skip empty one-to-one relationships * Allow `HyperlinkRelatedField` to be used with [related urls](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html?highlight=related%20links#related-urls) -* Fixed hardcoded year 2018 in tests ([#539](https://github.com/django-json-api/django-rest-framework-json-api/issues/539)) * Avoid exception in `AutoPrefetchMixin` when including a reverse one to one relation ([#537](https://github.com/django-json-api/django-rest-framework-json-api/issues/537)) * Avoid requested resource(s) to be added to included as well ([#524](https://github.com/django-json-api/django-rest-framework-json-api/issues/524)) diff --git a/docs/pull_request_template.md b/docs/pull_request_template.md index a862bcbb..f7edc0b8 100644 --- a/docs/pull_request_template.md +++ b/docs/pull_request_template.md @@ -7,5 +7,5 @@ Fixes # - [ ] PR only contains one change (considered splitting up PR) - [ ] unit-test added - [ ] documentation updated -- [ ] changelog entry added to `CHANGELOG.md` +- [ ] `CHANGELOG.md` updated (only for user relevant changes) - [ ] author name in `AUTHORS` From 575ba22145570ec97dc25e16142552c1bd99aeab Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 7 Jan 2019 23:52:29 -0800 Subject: [PATCH 081/488] Update pytest-django from 3.4.4 to 3.4.5 (#555) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index f3c7c8ff..a0eb35d0 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -10,7 +10,7 @@ isort==4.3.4 mock==2.0.0 pytest==4.0.2 pytest-cov==2.6.0 -pytest-django==3.4.4 +pytest-django==3.4.5 pytest-factoryboy==2.0.2 recommonmark==0.4.0 Sphinx==1.8.3 From d99b8ad5d258e1659276c549ae7701af2e8fa8e1 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 8 Jan 2019 00:35:49 -0800 Subject: [PATCH 082/488] Update pytest-cov from 2.6.0 to 2.6.1 (#546) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index a0eb35d0..b9a7b689 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -9,7 +9,7 @@ flake8-isort==2.6.0 isort==4.3.4 mock==2.0.0 pytest==4.0.2 -pytest-cov==2.6.0 +pytest-cov==2.6.1 pytest-django==3.4.5 pytest-factoryboy==2.0.2 recommonmark==0.4.0 From 0ec28f381e98b01b02af08a7f6a082582e048f2f Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 8 Jan 2019 01:17:51 -0800 Subject: [PATCH 083/488] Update pytest from 4.0.2 to 4.1.0 (#545) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index b9a7b689..e43e6ef8 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.6.0 flake8-isort==2.6.0 isort==4.3.4 mock==2.0.0 -pytest==4.0.2 +pytest==4.1.0 pytest-cov==2.6.1 pytest-django==3.4.5 pytest-factoryboy==2.0.2 From 5871b7cf8aa6d2fbc1c7c718f0e10415a3118b19 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 13 Jan 2019 23:56:40 -0800 Subject: [PATCH 084/488] Update pytest from 4.1.0 to 4.1.1 (#557) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index e43e6ef8..2283789b 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.6.0 flake8-isort==2.6.0 isort==4.3.4 mock==2.0.0 -pytest==4.1.0 +pytest==4.1.1 pytest-cov==2.6.1 pytest-django==3.4.5 pytest-factoryboy==2.0.2 From 3fb71cb59637d0c6828ff1f89cdea3e9c064568b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 14 Jan 2019 20:09:39 +0100 Subject: [PATCH 085/488] Release version 2.7.0 (#559) --- CHANGELOG.md | 6 +----- rest_framework_json_api/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea07f9a..d193884b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,13 @@ Note that in line with [Django REST Framework policy](http://www.django-rest-fra any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [2.7.0] - 2019-01-14 ### Added * Add support for Django 2.1, DRF 3.9 and Python 3.7. Please note: - Django >= 2.1 is not supported with Python < 3.5. -### Deprecated - -### Changed - ### Fixed * Pass context from `PolymorphicModelSerializer` to child serializers to support fields which require a `request` context such as `url`. diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 5a34935c..7a07f511 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- __title__ = 'djangorestframework-jsonapi' -__version__ = '2.6.0' +__version__ = '2.7.0' __author__ = '' -__license__ = 'MIT' +__license__ = 'BSD' __copyright__ = '' # Version synonym From b4bc65db075e1435cd8d1cf00f6e62810f9449f2 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 17 Jan 2019 06:40:43 -0800 Subject: [PATCH 086/488] Update recommonmark from 0.4.0 to 0.5.0 (#556) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 2283789b..800e262e 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -12,7 +12,7 @@ pytest==4.1.1 pytest-cov==2.6.1 pytest-django==3.4.5 pytest-factoryboy==2.0.2 -recommonmark==0.4.0 +recommonmark==0.5.0 Sphinx==1.8.3 sphinx_rtd_theme==0.4.2 twine==1.12.1 From 46bee735dceca23fc1124cd7e593a269a280c99d Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 20 Jan 2019 23:45:48 -0800 Subject: [PATCH 087/488] Update django-filter from 2.0.0 to 2.1.0 (#561) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 800e262e..500a3b45 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -1,6 +1,6 @@ -e . django-debug-toolbar==1.11 -django-filter==2.0.0 +django-filter==2.1.0 django-polymorphic==2.0.3 factory-boy==2.11.1 Faker==1.0.1 From 48653831b413f8e23ffb2c14836a94bb4eccc9bd Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 23 Jan 2019 00:06:43 -0800 Subject: [PATCH 088/488] Update faker from 1.0.1 to 1.0.2 (#562) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 500a3b45..7a5dd127 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -3,7 +3,7 @@ django-debug-toolbar==1.11 django-filter==2.1.0 django-polymorphic==2.0.3 factory-boy==2.11.1 -Faker==1.0.1 +Faker==1.0.2 flake8==3.6.0 flake8-isort==2.6.0 isort==4.3.4 From bc61e6648accc596d99c9f57b644509c4a2fca8d Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 4 Feb 2019 00:07:37 -0800 Subject: [PATCH 089/488] Update pytest-django from 3.4.5 to 3.4.7 (#569) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 7a5dd127..80f778a0 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -10,7 +10,7 @@ isort==4.3.4 mock==2.0.0 pytest==4.1.1 pytest-cov==2.6.1 -pytest-django==3.4.5 +pytest-django==3.4.7 pytest-factoryboy==2.0.2 recommonmark==0.5.0 Sphinx==1.8.3 From fe3ed18357bc6c9bad7b85d9861f99936111e388 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 4 Feb 2019 23:40:37 -0800 Subject: [PATCH 090/488] Update sphinx from 1.8.3 to 1.8.4 (#571) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 80f778a0..55340b41 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -13,6 +13,6 @@ pytest-cov==2.6.1 pytest-django==3.4.7 pytest-factoryboy==2.0.2 recommonmark==0.5.0 -Sphinx==1.8.3 +Sphinx==1.8.4 sphinx_rtd_theme==0.4.2 twine==1.12.1 From 03e3a409c6102c8645f2763a008db6f3c84ffce9 Mon Sep 17 00:00:00 2001 From: Stas Date: Mon, 11 Feb 2019 08:38:45 +0000 Subject: [PATCH 091/488] Avoid exception when trying to include skipped relationship (#453) --- AUTHORS | 1 + CHANGELOG.md | 5 ++++ example/serializers.py | 27 ++++++++++++++++++- .../tests/integration/test_polymorphism.py | 7 +++-- rest_framework_json_api/renderers.py | 2 +- 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/AUTHORS b/AUTHORS index f3e3d2b2..0f4c5167 100644 --- a/AUTHORS +++ b/AUTHORS @@ -22,3 +22,4 @@ Yaniv Peer Mohammed Ali Zubair Jason Housley Beni Keller +Stas S. diff --git a/CHANGELOG.md b/CHANGELOG.md index d193884b..19ce7f65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Fixed + +* Avoid exception when trying to include skipped relationship ## [2.7.0] - 2019-01-14 diff --git a/example/serializers.py b/example/serializers.py index 7917062e..9be6d4d7 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -1,6 +1,6 @@ from datetime import datetime -from rest_framework import serializers as drf_serilazers +from rest_framework import serializers as drf_serilazers, fields as drf_fields from rest_framework_json_api import relations, serializers @@ -318,15 +318,40 @@ class Meta: exclude = ('polymorphic_ctype',) +class CurrentProjectRelatedField(relations.PolymorphicResourceRelatedField): + def get_attribute(self, instance): + obj = super(CurrentProjectRelatedField, self).get_attribute(instance) + + is_art = ( + self.field_name == 'current_art_project' and + isinstance(obj, ArtProject) + ) + is_res = ( + self.field_name == 'current_research_project' and + isinstance(obj, ResearchProject) + ) + + if is_art or is_res: + return obj + + raise drf_fields.SkipField() + + class CompanySerializer(serializers.ModelSerializer): current_project = relations.PolymorphicResourceRelatedField( ProjectSerializer, queryset=Project.objects.all()) + current_art_project = CurrentProjectRelatedField( + ProjectSerializer, source='current_project', read_only=True) + current_research_project = CurrentProjectRelatedField( + ProjectSerializer, source='current_project', read_only=True) future_projects = relations.PolymorphicResourceRelatedField( ProjectSerializer, queryset=Project.objects.all(), many=True) included_serializers = { 'current_project': ProjectSerializer, 'future_projects': ProjectSerializer, + 'current_art_project': ProjectSerializer, + 'current_research_project': ProjectSerializer } class Meta: diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index bc80599a..48ee80f3 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -25,10 +25,13 @@ def test_polymorphism_on_detail_relations(single_company, client): def test_polymorphism_on_included_relations(single_company, client): - response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk}) + - '?include=current_project,future_projects') + response = client.get( + reverse("company-detail", kwargs={'pk': single_company.pk}) + + '?include=current_project,future_projects,current_art_project,current_research_project') content = response.json() assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" + assert content["data"]["relationships"]["currentArtProject"]["data"]["type"] == "artProjects" + assert content["data"]["relationships"]["currentResearchProject"]["data"] is None assert ( set([rel["type"] for rel in content["data"]["relationships"]["futureProjects"]["data"]]) == set(["researchProjects", "artProjects"]) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 9aa16212..d83fcdf0 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -375,7 +375,7 @@ def extract_included(cls, fields, resource, resource_instance, included_resource serializer_data = field.data if isinstance(field, relations.RelatedField): - if relation_instance is None: + if relation_instance is None or not serializer_data: continue many = field._kwargs.get('child_relation', None) is not None From 3c8d39bf48fa891a5b1853a9ed4d6cab4f12355a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Feb 2019 01:38:51 -0800 Subject: [PATCH 092/488] Update flake8 from 3.6.0 to 3.7.5 (#570) --- example/tests/integration/test_polymorphism.py | 4 ++-- requirements-development.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 48ee80f3..bd33c3b3 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -145,7 +145,7 @@ def test_invalid_type_on_polymorphic_model(client): response = client.post(url, data=data) assert response.status_code == 409 content = response.json() - assert len(content["errors"]) is 1 + assert len(content["errors"]) == 1 assert content["errors"][0]["status"] == "409" try: assert content["errors"][0]["detail"] == \ @@ -191,7 +191,7 @@ def test_invalid_type_on_polymorphic_relation(single_company, research_project_f data=content) assert response.status_code == 409 content = response.json() - assert len(content["errors"]) is 1 + assert len(content["errors"]) == 1 assert content["errors"][0]["status"] == "409" try: assert content["errors"][0]["detail"] == \ diff --git a/requirements-development.txt b/requirements-development.txt index 55340b41..cf8ecb3b 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -4,7 +4,7 @@ django-filter==2.1.0 django-polymorphic==2.0.3 factory-boy==2.11.1 Faker==1.0.2 -flake8==3.6.0 +flake8==3.7.5 flake8-isort==2.6.0 isort==4.3.4 mock==2.0.0 From e214dcf6c3fa18bc66ccbfc19fa4000681375b02 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Feb 2019 02:44:13 -0800 Subject: [PATCH 093/488] Update pytest from 4.1.1 to 4.2.0 (#565) --- example/tests/test_generic_viewset.py | 19 ++++++++++--------- example/tests/test_model_viewsets.py | 16 ++++++++++------ requirements-development.txt | 2 +- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index 15d9bd1e..01e78053 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -4,7 +4,6 @@ from example.tests import TestBase -@override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize') class GenericViewSet(TestBase): """ Test expected responses coming from a Generic ViewSet @@ -36,7 +35,8 @@ def test_ember_expected_renderer(self): """ url = reverse('user-manual-resource-name', kwargs={'pk': self.miles.pk}) - response = self.client.get(url) + with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + response = self.client.get(url) self.assertEqual(200, response.status_code) expected = { @@ -75,14 +75,15 @@ def test_default_validation_exceptions(self): } ] } - response = self.client.post('/identities', { - 'data': { - 'type': 'users', - 'attributes': { - 'email': 'bar', 'first_name': 'alajflajaljalajlfjafljalj' + with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + response = self.client.post('/identities', { + 'data': { + 'type': 'users', + 'attributes': { + 'email': 'bar', 'first_name': 'alajflajaljalajlfjafljalj' + } } - } - }) + }) assert expected == response.json() diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index df95bf42..185ebcdc 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -7,7 +7,6 @@ from example.tests import TestBase -@override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize') class ModelViewSetTests(TestBase): """ Test usage with ModelViewSets, also tests pluralization, camelization, @@ -26,7 +25,8 @@ def test_key_in_list_result(self): """ Ensure the result has a 'user' key since that is the name of the model """ - response = self.client.get(self.list_url) + with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + response = self.client.get(self.list_url) self.assertEqual(response.status_code, 200) user = get_user_model().objects.all()[0] @@ -63,7 +63,8 @@ def test_page_two_in_list_result(self): """ Ensure that the second page is reachable and is the correct data. """ - response = self.client.get(self.list_url, {'page[number]': 2}) + with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + response = self.client.get(self.list_url, {'page[number]': 2}) self.assertEqual(response.status_code, 200) user = get_user_model().objects.all()[1] @@ -102,7 +103,8 @@ def test_page_range_in_list_result(self): tests pluralization as two objects means it converts ``user`` to ``users``. """ - response = self.client.get(self.list_url, {'page[size]': 2}) + with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + response = self.client.get(self.list_url, {'page[size]': 2}) self.assertEqual(response.status_code, 200) users = get_user_model().objects.all() @@ -148,7 +150,8 @@ def test_key_in_detail_result(self): """ Ensure the result has a 'user' key. """ - response = self.client.get(self.detail_url) + with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + response = self.client.get(self.detail_url) self.assertEqual(response.status_code, 200) expected = { @@ -199,7 +202,8 @@ def test_key_in_post(self): } } - response = self.client.put(self.detail_url, data=data) + with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + response = self.client.put(self.detail_url, data=data) assert data == response.json() diff --git a/requirements-development.txt b/requirements-development.txt index cf8ecb3b..614b6503 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.7.5 flake8-isort==2.6.0 isort==4.3.4 mock==2.0.0 -pytest==4.1.1 +pytest==4.2.0 pytest-cov==2.6.1 pytest-django==3.4.7 pytest-factoryboy==2.0.2 From 13eb28e32c31a97aaa511abc53a4595ee1c6f8aa Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 14 Feb 2019 00:14:33 -0800 Subject: [PATCH 094/488] Update sphinx_rtd_theme from 0.4.2 to 0.4.3 (#574) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 614b6503..0f5e1966 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -14,5 +14,5 @@ pytest-django==3.4.7 pytest-factoryboy==2.0.2 recommonmark==0.5.0 Sphinx==1.8.4 -sphinx_rtd_theme==0.4.2 +sphinx_rtd_theme==0.4.3 twine==1.12.1 From 046e794cc8716c649fc3f6cf85f3663f34ca18ae Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 14 Feb 2019 23:14:55 -0800 Subject: [PATCH 095/488] Update pytest from 4.2.0 to 4.2.1 (#573) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 0f5e1966..2d445b97 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.7.5 flake8-isort==2.6.0 isort==4.3.4 mock==2.0.0 -pytest==4.2.0 +pytest==4.2.1 pytest-cov==2.6.1 pytest-django==3.4.7 pytest-factoryboy==2.0.2 From 435325e1170d6d5f34f0ed93a85585bb7f376a86 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 18 Feb 2019 16:12:38 +0200 Subject: [PATCH 096/488] Update twine from 1.12.1 to 1.13.0 (#575) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 2d445b97..fb4f7a1f 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -15,4 +15,4 @@ pytest-factoryboy==2.0.2 recommonmark==0.5.0 Sphinx==1.8.4 sphinx_rtd_theme==0.4.3 -twine==1.12.1 +twine==1.13.0 From b1eb60a03b84650b09d3dae26c92d523c9442070 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 19 Feb 2019 09:42:48 +0200 Subject: [PATCH 097/488] Update pytest from 4.2.1 to 4.3.0 (#578) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index fb4f7a1f..025ff6cb 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.7.5 flake8-isort==2.6.0 isort==4.3.4 mock==2.0.0 -pytest==4.2.1 +pytest==4.3.0 pytest-cov==2.6.1 pytest-django==3.4.7 pytest-factoryboy==2.0.2 From 75e2f828ecf052f26dbff317a90d6210cfa036d7 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 19 Feb 2019 11:48:34 +0200 Subject: [PATCH 098/488] Update flake8 from 3.7.5 to 3.7.6 (#577) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 025ff6cb..35164e4e 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -4,7 +4,7 @@ django-filter==2.1.0 django-polymorphic==2.0.3 factory-boy==2.11.1 Faker==1.0.2 -flake8==3.7.5 +flake8==3.7.6 flake8-isort==2.6.0 isort==4.3.4 mock==2.0.0 From 876892fb45676795326f9e445b5eac84550fad3d Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 25 Feb 2019 09:33:00 +0200 Subject: [PATCH 099/488] Update isort from 4.3.4 to 4.3.5 (#581) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 35164e4e..3b015ea3 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -6,7 +6,7 @@ factory-boy==2.11.1 Faker==1.0.2 flake8==3.7.6 flake8-isort==2.6.0 -isort==4.3.4 +isort==4.3.5 mock==2.0.0 pytest==4.3.0 pytest-cov==2.6.1 From 95e7ed44a993c8464707fa72041626d70a38290b Mon Sep 17 00:00:00 2001 From: Nathanael Gordon Date: Mon, 25 Feb 2019 19:11:14 +1100 Subject: [PATCH 100/488] Clarify docs on controlling compound documents (#579) --- AUTHORS | 1 + docs/usage.md | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index 0f4c5167..4f54c3c6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,3 +23,4 @@ Mohammed Ali Zubair Jason Housley Beni Keller Stas S. +Nathanael Gordon diff --git a/docs/usage.md b/docs/usage.md index 345356be..0531daa3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -789,8 +789,9 @@ that are related to the primary resource. To make a Compound Document, you need to modify your `ModelSerializer`. -The two required additions are `included_resources` -and `included_serializers`. +`included_serializers` is required to inform DJA of what and how you would like +to include. +`included_resources` tells DJA what you want to include by default. For example, suppose you are making an app to go on quests, @@ -818,9 +819,6 @@ class QuestSerializer(serializers.ModelSerializer): included_resources = ['knight'] ``` -`included_resources` informs DJA of **what** you would like to include. -`included_serializers` tells DJA **how** you want to include it. - #### Performance improvements Be aware that using included resources without any form of prefetching **WILL HURT PERFORMANCE** as it will introduce m\*(n+1) queries. From 30ca94b7dc306ebc3e797326d1421426a6aaabc2 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 1 Mar 2019 22:31:28 +0200 Subject: [PATCH 101/488] Update isort from 4.3.5 to 4.3.9 (#586) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 3b015ea3..90c520ee 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -6,7 +6,7 @@ factory-boy==2.11.1 Faker==1.0.2 flake8==3.7.6 flake8-isort==2.6.0 -isort==4.3.5 +isort==4.3.9 mock==2.0.0 pytest==4.3.0 pytest-cov==2.6.1 From 272a7526d7870c52a9a4635d8d9f913507be79bf Mon Sep 17 00:00:00 2001 From: Arion Sprague Date: Sat, 2 Mar 2019 11:23:12 -0800 Subject: [PATCH 102/488] DjangoFilterBackend.get_filterset_kwargs doesn't modify data dict while iterating (#580) --- CHANGELOG.md | 1 + example/tests/test_filters.py | 15 +++++++++++++++ .../django_filters/backends.py | 3 +-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19ce7f65..3f81d85e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Avoid exception when trying to include skipped relationship +* Don't swallow `filter[]` params when there are several ## [2.7.0] - 2019-01-14 diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index f0243457..586105f6 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -503,3 +503,18 @@ def test_param_duplicate(self): dja_response = response.json() self.assertEqual(dja_response['errors'][0]['detail'], "repeated query parameter not allowed: sort") + + def test_many_params(self): + """ + Test that filter params aren't ignored when many params are present + """ + response = self.client.get(self.url, + data={'filter[headline.regex]': '^A', + 'filter[body_text.regex]': '^IN', + 'filter[blog.name]': 'ANTB', + 'page[size]': 3}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(len(dja_response['data']), 1) + self.assertEqual(dja_response['data'][0]['id'], '1') diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 073315bc..42671ca8 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -4,7 +4,6 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings - from rest_framework_json_api.utils import format_value @@ -102,7 +101,7 @@ def get_filterset_kwargs(self, request, queryset, view): filter_keys = [] # rewrite filter[field] query params to make DjangoFilterBackend work. data = request.query_params.copy() - for qp, val in data.items(): + for qp, val in request.query_params.items(): m = self.filter_regex.match(qp) if m and (not m.groupdict()['assoc'] or m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'): From a3c4e5fb9d6f27f52fc0c7bd9f0b58927a620fd3 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sat, 2 Mar 2019 23:09:06 +0200 Subject: [PATCH 103/488] Update pytest-django from 3.4.7 to 3.4.8 (#587) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 90c520ee..82d03ce8 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -10,7 +10,7 @@ isort==4.3.9 mock==2.0.0 pytest==4.3.0 pytest-cov==2.6.1 -pytest-django==3.4.7 +pytest-django==3.4.8 pytest-factoryboy==2.0.2 recommonmark==0.5.0 Sphinx==1.8.4 From 74af88aa56b79b0e28217367056cea98b41a3e76 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 3 Mar 2019 21:31:37 +0200 Subject: [PATCH 104/488] Update flake8 from 3.7.6 to 3.7.7 (#585) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 82d03ce8..02c5bf30 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -4,7 +4,7 @@ django-filter==2.1.0 django-polymorphic==2.0.3 factory-boy==2.11.1 Faker==1.0.2 -flake8==3.7.6 +flake8==3.7.7 flake8-isort==2.6.0 isort==4.3.9 mock==2.0.0 From 0b0b1140333347032009d4fcf6f8459f10ca5bde Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 7 Mar 2019 10:39:57 +0200 Subject: [PATCH 105/488] Update isort from 4.3.9 to 4.3.12 (#590) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 02c5bf30..d141ccdf 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -6,7 +6,7 @@ factory-boy==2.11.1 Faker==1.0.2 flake8==3.7.7 flake8-isort==2.6.0 -isort==4.3.9 +isort==4.3.12 mock==2.0.0 pytest==4.3.0 pytest-cov==2.6.1 From bae4fe32fc99100c1b86d06d1a94a01512746170 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 8 Mar 2019 13:35:56 +0200 Subject: [PATCH 106/488] Update isort from 4.3.12 to 4.3.13 (#592) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index d141ccdf..f25396ea 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -6,7 +6,7 @@ factory-boy==2.11.1 Faker==1.0.2 flake8==3.7.7 flake8-isort==2.6.0 -isort==4.3.12 +isort==4.3.13 mock==2.0.0 pytest==4.3.0 pytest-cov==2.6.1 From 28c13d98db21f7b7002f8adf93698abd2329e1c0 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 12 Mar 2019 21:10:36 +0200 Subject: [PATCH 107/488] Update pytest from 4.3.0 to 4.3.1 (#598) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index f25396ea..4db135a5 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.7.7 flake8-isort==2.6.0 isort==4.3.13 mock==2.0.0 -pytest==4.3.0 +pytest==4.3.1 pytest-cov==2.6.1 pytest-django==3.4.8 pytest-factoryboy==2.0.2 From 75a0878a56993b4c9eb74b7b74c9c72be1b21c77 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 12 Mar 2019 21:17:55 +0200 Subject: [PATCH 108/488] Update isort from 4.3.13 to 4.3.15 (#595) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 4db135a5..c1f50d0f 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -6,7 +6,7 @@ factory-boy==2.11.1 Faker==1.0.2 flake8==3.7.7 flake8-isort==2.6.0 -isort==4.3.13 +isort==4.3.15 mock==2.0.0 pytest==4.3.1 pytest-cov==2.6.1 From 20ac9aab084d9b7f393de687d89bea98c974180c Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 12 Mar 2019 21:18:40 +0200 Subject: [PATCH 109/488] Update sphinx from 1.8.4 to 1.8.5 (#594) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index c1f50d0f..1fc56667 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -13,6 +13,6 @@ pytest-cov==2.6.1 pytest-django==3.4.8 pytest-factoryboy==2.0.2 recommonmark==0.5.0 -Sphinx==1.8.4 +Sphinx==1.8.5 sphinx_rtd_theme==0.4.3 twine==1.13.0 From 9f1d999cefdeaf39ad6a910abc015419b4bb2214 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 12 Mar 2019 21:55:46 +0200 Subject: [PATCH 110/488] Update faker from 1.0.2 to 1.0.4 (#597) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 1fc56667..e598117e 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -3,7 +3,7 @@ django-debug-toolbar==1.11 django-filter==2.1.0 django-polymorphic==2.0.3 factory-boy==2.11.1 -Faker==1.0.2 +Faker==1.0.4 flake8==3.7.7 flake8-isort==2.6.0 isort==4.3.15 From efb52d658b05200e70c0d2537271467c0aacbc93 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 20 Mar 2019 11:49:53 +0200 Subject: [PATCH 111/488] Update flake8-isort from 2.6.0 to 2.7.0 (#601) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index e598117e..ce24fc9b 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -5,7 +5,7 @@ django-polymorphic==2.0.3 factory-boy==2.11.1 Faker==1.0.4 flake8==3.7.7 -flake8-isort==2.6.0 +flake8-isort==2.7.0 isort==4.3.15 mock==2.0.0 pytest==4.3.1 From 850877bc2c0b041ab78bdbfafd9ba7d194a591d3 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 25 Mar 2019 10:57:12 +0200 Subject: [PATCH 112/488] Update isort from 4.3.15 to 4.3.16 (#602) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index ce24fc9b..80fd6d69 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -6,7 +6,7 @@ factory-boy==2.11.1 Faker==1.0.4 flake8==3.7.7 flake8-isort==2.7.0 -isort==4.3.15 +isort==4.3.16 mock==2.0.0 pytest==4.3.1 pytest-cov==2.6.1 From 27a368e47e2e7de652390fe8d956eb4f0cb3cf1f Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 29 Mar 2019 09:14:58 +0200 Subject: [PATCH 113/488] Update sphinx from 1.8.5 to 2.0.0 (#605) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 80fd6d69..31005a3d 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -13,6 +13,6 @@ pytest-cov==2.6.1 pytest-django==3.4.8 pytest-factoryboy==2.0.2 recommonmark==0.5.0 -Sphinx==1.8.5 +Sphinx==2.0.0 sphinx_rtd_theme==0.4.3 twine==1.13.0 From d3c9f74863ce3a2d202a7723a0250b50dec0b3c6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 29 Mar 2019 16:19:19 +0100 Subject: [PATCH 114/488] Adjust sphinx apidoc import as for version 2.0 requires (#606) Fix to make #605 work --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 5b2f42de..d0533d64 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,7 +27,7 @@ django.setup() # Auto-generate API documentation. -from sphinx.apidoc import main +from sphinx.ext.apidoc import main main(['sphinx-apidoc', '-e', '-T', '-M', '-f', '-o', 'apidoc', '../rest_framework_json_api']) # -- General configuration ------------------------------------------------ From 7c6a517989b834ad693d578e48b73dc2f4de46cc Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 1 Apr 2019 08:25:12 +0200 Subject: [PATCH 115/488] Update pytest from 4.3.1 to 4.4.0 (#607) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 31005a3d..6249a249 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.7.7 flake8-isort==2.7.0 isort==4.3.16 mock==2.0.0 -pytest==4.3.1 +pytest==4.4.0 pytest-cov==2.6.1 pytest-django==3.4.8 pytest-factoryboy==2.0.2 From 4b8d6156ca528999abd72db503bca7209387e4d9 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 10 Apr 2019 10:55:09 +0200 Subject: [PATCH 116/488] Update isort from 4.3.16 to 4.3.17 (#608) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 6249a249..3e352a2e 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -6,7 +6,7 @@ factory-boy==2.11.1 Faker==1.0.4 flake8==3.7.7 flake8-isort==2.7.0 -isort==4.3.16 +isort==4.3.17 mock==2.0.0 pytest==4.4.0 pytest-cov==2.6.1 From 3994c0cd12097afb10235abb4cb517ef7e22c68e Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 10 Apr 2019 10:56:34 +0200 Subject: [PATCH 117/488] Update sphinx from 2.0.0 to 2.0.1 (#609) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 3e352a2e..d6089eca 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -13,6 +13,6 @@ pytest-cov==2.6.1 pytest-django==3.4.8 pytest-factoryboy==2.0.2 recommonmark==0.5.0 -Sphinx==2.0.0 +Sphinx==2.0.1 sphinx_rtd_theme==0.4.3 twine==1.13.0 From f4745007c3fc6cd0173a0dea44f168cee8a95427 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 14 Apr 2019 18:31:59 +0200 Subject: [PATCH 118/488] Update faker from 1.0.4 to 1.0.5 (#610) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index d6089eca..b3027062 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -3,7 +3,7 @@ django-debug-toolbar==1.11 django-filter==2.1.0 django-polymorphic==2.0.3 factory-boy==2.11.1 -Faker==1.0.4 +Faker==1.0.5 flake8==3.7.7 flake8-isort==2.7.0 isort==4.3.17 From de7021f9e011615ce8b65d0cb38227c6c12721b6 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 16 Apr 2019 07:56:51 +0200 Subject: [PATCH 119/488] Update pytest from 4.4.0 to 4.4.1 (#612) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index b3027062..add42b4c 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.7.7 flake8-isort==2.7.0 isort==4.3.17 mock==2.0.0 -pytest==4.4.0 +pytest==4.4.1 pytest-cov==2.6.1 pytest-django==3.4.8 pytest-factoryboy==2.0.2 From cf102613fcd242da958c1c880e682a4f637848de Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 2 May 2019 09:11:53 +0200 Subject: [PATCH 120/488] Update isort from 4.3.17 to 4.3.18 (#615) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index add42b4c..58748689 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -6,7 +6,7 @@ factory-boy==2.11.1 Faker==1.0.5 flake8==3.7.7 flake8-isort==2.7.0 -isort==4.3.17 +isort==4.3.18 mock==2.0.0 pytest==4.4.1 pytest-cov==2.6.1 From 82c27091831209f27842efa91752de3d0e2d4918 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 2 May 2019 13:18:34 +0200 Subject: [PATCH 121/488] Update mock from 2.0.0 to 3.0.2 (#616) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 58748689..64b41a91 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -7,7 +7,7 @@ Faker==1.0.5 flake8==3.7.7 flake8-isort==2.7.0 isort==4.3.18 -mock==2.0.0 +mock==3.0.2 pytest==4.4.1 pytest-cov==2.6.1 pytest-django==3.4.8 From e80d03132dba16776a0dcee62e7ae2951a142cad Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 6 May 2019 16:56:25 +0200 Subject: [PATCH 122/488] Update mock from 3.0.2 to 3.0.4 (#619) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 64b41a91..405acc79 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -7,7 +7,7 @@ Faker==1.0.5 flake8==3.7.7 flake8-isort==2.7.0 isort==4.3.18 -mock==3.0.2 +mock==3.0.4 pytest==4.4.1 pytest-cov==2.6.1 pytest-django==3.4.8 From eb445137ba3731b091c99e269d82adcea36b60e0 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 6 May 2019 17:01:11 +0200 Subject: [PATCH 123/488] Update pytest-cov from 2.6.1 to 2.7.1 (#618) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 405acc79..ab76df61 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -9,7 +9,7 @@ flake8-isort==2.7.0 isort==4.3.18 mock==3.0.4 pytest==4.4.1 -pytest-cov==2.6.1 +pytest-cov==2.7.1 pytest-django==3.4.8 pytest-factoryboy==2.0.2 recommonmark==0.5.0 From 8a31eef147e7702fa74a09a9dd2f67b4b9545514 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 9 May 2019 11:49:01 +0200 Subject: [PATCH 124/488] Update pytest from 4.4.1 to 4.4.2 (#623) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index ab76df61..f588a69d 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.7.7 flake8-isort==2.7.0 isort==4.3.18 mock==3.0.4 -pytest==4.4.1 +pytest==4.4.2 pytest-cov==2.7.1 pytest-django==3.4.8 pytest-factoryboy==2.0.2 From e11ae7550a6ffd9529f20d974bf6be69bb771265 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 9 May 2019 11:49:55 +0200 Subject: [PATCH 125/488] Update faker from 1.0.5 to 1.0.6 (#622) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index f588a69d..0d27b3c7 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -3,7 +3,7 @@ django-debug-toolbar==1.11 django-filter==2.1.0 django-polymorphic==2.0.3 factory-boy==2.11.1 -Faker==1.0.5 +Faker==1.0.6 flake8==3.7.7 flake8-isort==2.7.0 isort==4.3.18 From 4e4d55fa677523fee35ba2834a2157b201150ce8 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 9 May 2019 13:01:04 +0200 Subject: [PATCH 126/488] Update mock from 3.0.4 to 3.0.5 (#620) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 0d27b3c7..4ade7544 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -7,7 +7,7 @@ Faker==1.0.6 flake8==3.7.7 flake8-isort==2.7.0 isort==4.3.18 -mock==3.0.4 +mock==3.0.5 pytest==4.4.2 pytest-cov==2.7.1 pytest-django==3.4.8 From b1ca90aef61d90b5915abcd71c1e47e319313d92 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 14 May 2019 21:43:33 +0200 Subject: [PATCH 127/488] Update faker from 1.0.6 to 1.0.7 (#628) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 4ade7544..0f6b49a8 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -3,7 +3,7 @@ django-debug-toolbar==1.11 django-filter==2.1.0 django-polymorphic==2.0.3 factory-boy==2.11.1 -Faker==1.0.6 +Faker==1.0.7 flake8==3.7.7 flake8-isort==2.7.0 isort==4.3.18 From 727c71ece1127a877576c701e975925c5a52dbc3 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 14 May 2019 21:44:01 +0200 Subject: [PATCH 128/488] Update isort from 4.3.18 to 4.3.19 (#627) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 0f6b49a8..857cf38d 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -6,7 +6,7 @@ factory-boy==2.11.1 Faker==1.0.7 flake8==3.7.7 flake8-isort==2.7.0 -isort==4.3.18 +isort==4.3.19 mock==3.0.5 pytest==4.4.2 pytest-cov==2.7.1 From 26e65a14b27c82b16cd393a3d5821a966464de51 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 14 May 2019 22:37:21 +0200 Subject: [PATCH 129/488] Update pytest from 4.4.2 to 4.5.0 (#626) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index 857cf38d..c63ececb 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -8,7 +8,7 @@ flake8==3.7.7 flake8-isort==2.7.0 isort==4.3.19 mock==3.0.5 -pytest==4.4.2 +pytest==4.5.0 pytest-cov==2.7.1 pytest-django==3.4.8 pytest-factoryboy==2.0.2 From bd51818f0419aa36fd7193724dd08f9c4f51faaf Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 15 May 2019 12:28:51 +0200 Subject: [PATCH 130/488] Update factory-boy to 2.12.0 (#625) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index c63ececb..dd927438 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -2,8 +2,8 @@ django-debug-toolbar==1.11 django-filter==2.1.0 django-polymorphic==2.0.3 -factory-boy==2.11.1 Faker==1.0.7 +factory-boy==2.12.0 flake8==3.7.7 flake8-isort==2.7.0 isort==4.3.19 From f048aa2aa9dda7727092cf9826edf8041bf3616e Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 15 May 2019 12:29:18 +0200 Subject: [PATCH 131/488] Update isort from 4.3.19 to 4.3.20 (#631) --- requirements-development.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-development.txt b/requirements-development.txt index dd927438..61d666a4 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -6,7 +6,7 @@ Faker==1.0.7 factory-boy==2.12.0 flake8==3.7.7 flake8-isort==2.7.0 -isort==4.3.19 +isort==4.3.20 mock==3.0.5 pytest==4.5.0 pytest-cov==2.7.1 From 4c220878ee2148dc5838d10c2a0a50ec056e510f Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 15 May 2019 09:39:36 -0400 Subject: [PATCH 132/488] import missing six (#630) --- rest_framework_json_api/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 5085eeec..e1fe3d9f 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,4 +1,5 @@ import inflection +import six from django.db.models.query import QuerySet from django.utils.translation import ugettext_lazy as _ from rest_framework.exceptions import ParseError From db5cd728f472c1f75570f118842469b3f9eec242 Mon Sep 17 00:00:00 2001 From: Xtreak Date: Wed, 15 May 2019 23:01:43 +0530 Subject: [PATCH 133/488] Fix deprecation warnings regarding collections.abc (#624) --- CHANGELOG.md | 1 + rest_framework_json_api/compat.py | 4 ++++ rest_framework_json_api/relations.py | 4 ++-- rest_framework_json_api/renderers.py | 5 +++-- rest_framework_json_api/views.py | 4 ++-- 5 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 rest_framework_json_api/compat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f81d85e..f68ebeba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ any parts of the framework not mentioned in the documentation should generally b * Avoid exception when trying to include skipped relationship * Don't swallow `filter[]` params when there are several +* Fix DeprecationWarning regarding collections.abc import in Python 3.7 ## [2.7.0] - 2019-01-14 diff --git a/rest_framework_json_api/compat.py b/rest_framework_json_api/compat.py new file mode 100644 index 00000000..577a91e0 --- /dev/null +++ b/rest_framework_json_api/compat.py @@ -0,0 +1,4 @@ +try: + import collections.abc as collections_abc # noqa: F401 +except ImportError: + import collections as collections_abc # noqa: F401 diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 82b94cd5..942c61d9 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -1,4 +1,3 @@ -import collections import json from collections import OrderedDict @@ -14,6 +13,7 @@ from rest_framework.reverse import reverse from rest_framework.serializers import Serializer +from rest_framework_json_api.compat import collections_abc from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.utils import ( Hyperlink, @@ -388,7 +388,7 @@ def get_attribute(self, instance): return super(SerializerMethodResourceRelatedField, self).get_attribute(instance) def to_representation(self, value): - if isinstance(value, collections.Iterable): + if isinstance(value, collections_abc.Iterable): base = super(SerializerMethodResourceRelatedField, self) return [base.to_representation(x) for x in value] return super(SerializerMethodResourceRelatedField, self).to_representation(value) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index d83fcdf0..0023399a 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -2,7 +2,7 @@ Renderers """ import copy -from collections import Iterable, OrderedDict, defaultdict +from collections import OrderedDict, defaultdict import inflection from django.db.models import Manager @@ -13,6 +13,7 @@ import rest_framework_json_api from rest_framework_json_api import utils +from rest_framework_json_api.compat import collections_abc from rest_framework_json_api.relations import HyperlinkedMixin, ResourceRelatedField, SkipDataMixin @@ -196,7 +197,7 @@ def extract_relationships(cls, fields, resource, resource_instance): relation_data = {} - if isinstance(resource.get(field_name), Iterable): + if isinstance(resource.get(field_name), collections_abc.Iterable): relation_data.update( { 'meta': {'count': len(resource.get(field_name))} diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index b692844b..4b4c6ce3 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -1,4 +1,3 @@ -from collections import Iterable from django.core.exceptions import ImproperlyConfigured from django.db.models import Model @@ -20,6 +19,7 @@ from rest_framework.reverse import reverse from rest_framework.serializers import Serializer, SkipField +from rest_framework_json_api.compat import collections_abc from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.serializers import ResourceIdentifierObjectSerializer from rest_framework_json_api.utils import ( @@ -127,7 +127,7 @@ def retrieve_related(self, request, *args, **kwargs): if instance is None: return Response(data=None) - if isinstance(instance, Iterable): + if isinstance(instance, collections_abc.Iterable): serializer_kwargs['many'] = True serializer = self.get_serializer(instance, **serializer_kwargs) From ba16324a91a6ee5cd6c4573ebdcc8e1dc9d8200a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 24 May 2019 15:31:49 +0200 Subject: [PATCH 134/488] Pin django-debug-toolbar version to fix Python 2.7 tests (#634) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 18dd868b..2c8513f6 100755 --- a/setup.py +++ b/setup.py @@ -110,7 +110,7 @@ def get_package_data(package): 'pytest', 'pytest-cov', 'django-polymorphic>=2.0', - 'django-debug-toolbar' + 'django-debug-toolbar==1.11' ] + mock, zip_safe=False, ) From 22476fe8902efc27f512bc27a0c75b5484816445 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 24 May 2019 16:35:44 +0200 Subject: [PATCH 135/488] Adjust building of apidoc to new sphinx version (#637) This fixes failing readthedocs build --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index d0533d64..569d4269 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,7 +28,7 @@ # Auto-generate API documentation. from sphinx.ext.apidoc import main -main(['sphinx-apidoc', '-e', '-T', '-M', '-f', '-o', 'apidoc', '../rest_framework_json_api']) +main(['-o', 'apidoc', '-f', '-e', '-T', '-M', '../rest_framework_json_api']) # -- General configuration ------------------------------------------------ From c77fe0fc69735c4d18141a7a19a994ce12e14175 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 24 May 2019 11:48:23 -0400 Subject: [PATCH 136/488] Allow OPTIONS request to be used on RelationshipView (#633) --- CHANGELOG.md | 1 + example/tests/test_views.py | 33 ++++++++++++++++++++++++++ rest_framework_json_api/serializers.py | 6 ++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f68ebeba..6e2f731a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ any parts of the framework not mentioned in the documentation should generally b * Avoid exception when trying to include skipped relationship * Don't swallow `filter[]` params when there are several * Fix DeprecationWarning regarding collections.abc import in Python 3.7 +* Allow OPTIONS request to be used on RelationshipView. ## [2.7.0] - 2019-01-14 diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 95fc92a3..16d0e4e1 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -274,6 +274,39 @@ def test_new_comment_data_patch_to_many_relationship(self): assert Comment.objects.filter(id=self.second_comment.id).exists() + def test_options_entry_relationship_blog(self): + url = reverse( + 'entry-relationships', kwargs={'pk': self.first_entry.id, 'related_field': 'blog'} + ) + response = self.client.options(url) + expected_data = { + "data": { + "name": "Entry Relationship", + "description": "", + "renders": [ + "application/vnd.api+json", + "text/html" + ], + "parses": [ + "application/vnd.api+json", + "application/x-www-form-urlencoded", + "multipart/form-data" + ], + "allowed_methods": [ + "GET", + "POST", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS" + ], + "actions": { + "POST": {} + } + } + } + assert response.json() == expected_data + class TestRelatedMixin(APITestCase): diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index e1fe3d9f..9bfa0f62 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -29,10 +29,8 @@ class ResourceIdentifierObjectSerializer(BaseSerializer): def __init__(self, *args, **kwargs): self.model_class = kwargs.pop('model_class', self.model_class) - if 'instance' not in kwargs and not self.model_class: - raise RuntimeError( - 'ResourceIdentifierObjectsSerializer must be initialized with a model class.' - ) + # this has no fields but assumptions are made elsewhere that self.fields exists. + self.fields = {} super(ResourceIdentifierObjectSerializer, self).__init__(*args, **kwargs) def to_representation(self, instance): From 2109c52821b53f9e75314cf9820b5c358700294e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 24 May 2019 19:00:51 +0200 Subject: [PATCH 137/488] Add Django 2.2 support (#640) --- .travis.yml | 23 +++++++++++++++++++++++ CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 1 + tox.ini | 1 + 6 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 007bb852..c2cb1952 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,9 @@ matrix: - env: TOXENV=py35-df20-django21-drfmaster - env: TOXENV=py36-df20-django21-drfmaster - env: TOXENV=py37-df20-django21-drfmaster + - env: TOXENV=py35-df20-django22-drfmaster + - env: TOXENV=py36-df20-django22-drfmaster + - env: TOXENV=py37-df20-django22-drfmaster include: - python: 3.6 @@ -58,6 +61,12 @@ matrix: env: TOXENV=py35-df20-django21-drf39 - python: 3.5 env: TOXENV=py35-df20-django21-drfmaster + - python: 3.5 + dist: xenial + env: TOXENV=py35-df20-django22-drf39 + - python: 3.5 + dist: xenial + env: TOXENV=py35-df20-django22-drfmaster - python: 3.6 env: TOXENV=py36-df20-django111-drf36 @@ -77,6 +86,12 @@ matrix: env: TOXENV=py36-df20-django21-drf39 - python: 3.6 env: TOXENV=py36-df20-django21-drfmaster + - python: 3.6 + dist: xenial + env: TOXENV=py36-df20-django22-drf39 + - python: 3.6 + dist: xenial + env: TOXENV=py36-df20-django22-drfmaster - python: 3.7 dist: xenial @@ -94,6 +109,14 @@ matrix: dist: xenial sudo: required env: TOXENV=py37-df20-django21-drfmaster + - python: 3.7 + dist: xenial + sudo: required + env: TOXENV=py37-df20-django22-drf39 + - python: 3.7 + dist: xenial + sudo: required + env: TOXENV=py37-df20-django22-drfmaster install: - pip install tox script: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2f731a..cf1b1723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Added + +* Add support for Django 2.2 + ### Fixed * Avoid exception when trying to include skipped relationship diff --git a/README.rst b/README.rst index 61ea29c2..06986433 100644 --- a/README.rst +++ b/README.rst @@ -88,7 +88,7 @@ Requirements ------------ 1. Python (2.7, 3.4, 3.5, 3.6, 3.7) -2. Django (1.11, 2.0, 2.1) +2. Django (1.11, 2.0, 2.1, 2.2) 3. Django REST Framework (3.6, 3.7, 3.8, 3.9) ------------ diff --git a/docs/getting-started.md b/docs/getting-started.md index baa53189..2f37ba04 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (2.7, 3.4, 3.5, 3.6, 3.7) -2. Django (1.11, 2.0, 2.1) +2. Django (1.11, 2.0, 2.1, 2.2) 3. Django REST Framework (3.6, 3.7, 3.8, 3.9) ## Installation diff --git a/setup.py b/setup.py index 2c8513f6..7d75c14b 100755 --- a/setup.py +++ b/setup.py @@ -92,6 +92,7 @@ def get_package_data(package): 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Software Development :: Libraries :: Application Frameworks', 'Topic :: Software Development :: Libraries :: Python Modules', diff --git a/tox.ini b/tox.ini index c8b47325..5cbce547 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ deps = django111: Django>=1.11,<1.12 django20: Django>=2.0,<2.1 django21: Django>=2.1,<2.2 + django22: Django>=2.2,<2.3 drf36: djangorestframework>=3.6.3,<3.7 drf37: djangorestframework>=3.7.0,<3.8 drf38: djangorestframework>=3.8.0,<3.9 From b207f998ac0984eedfb8d9336e610cf08766036a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 24 May 2019 20:42:01 +0200 Subject: [PATCH 138/488] Add missing envlist for Django 2.2 support (#641) --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 5cbce547..2ea9bad2 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist = py{34,35,36}-df20-django20-drf{37,38,39,master}, py37-df20-django20-drf{39,master}, py{35,36,37}-df20-django21-drf{39,master}, + py{35,36,37}-df20-django22-drf{39,master}, [testenv] deps = From f3e67a7bf5b8fbeed66f0e9f76c8f7265b00428a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 27 May 2019 20:24:31 +0200 Subject: [PATCH 139/488] Allow example app and tests to be run with Django 2.2 (#642) --- example/fixtures/blogentry.json | 8 ++------ example/settings/dev.py | 1 - 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/example/fixtures/blogentry.json b/example/fixtures/blogentry.json index 15ceded9..44b70d97 100644 --- a/example/fixtures/blogentry.json +++ b/example/fixtures/blogentry.json @@ -83,9 +83,7 @@ "n_comments": 0, "n_pingbacks": 0, "rating": 0, - "authors": [ - 1 - ] + "authors": [] } }, { @@ -102,9 +100,7 @@ "n_comments": 0, "n_pingbacks": 0, "rating": 0, - "authors": [ - 2 - ] + "authors": [] } }, { diff --git a/example/settings/dev.py b/example/settings/dev.py index 4b989e89..ade24139 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -21,7 +21,6 @@ 'django.contrib.sites', 'django.contrib.sessions', 'django.contrib.auth', - 'django.contrib.admin', 'rest_framework', 'polymorphic', 'example', From 8bb123cf7afb7d68f9d5382e9badbf71f3aeabdb Mon Sep 17 00:00:00 2001 From: Anton Shutik Date: Wed, 29 May 2019 17:57:20 +0300 Subject: [PATCH 140/488] Allow defining of `select_related` per include (#600) --- CHANGELOG.md | 12 +++- docs/usage.md | 16 ++++-- example/tests/test_performance.py | 12 +++- example/views.py | 10 +++- rest_framework_json_api/views.py | 96 +++++++++++++++++++++++++++---- 5 files changed, 125 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf1b1723..c5d5e074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,22 @@ any parts of the framework not mentioned in the documentation should generally b * Add support for Django 2.2 +### Changed + +* Allow to define `select_related` per include using [select_for_includes](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#performance-improvements) +* Reduce number of queries to calculate includes by using `select_related` when possible + ### Fixed * Avoid exception when trying to include skipped relationship * Don't swallow `filter[]` params when there are several * Fix DeprecationWarning regarding collections.abc import in Python 3.7 -* Allow OPTIONS request to be used on RelationshipView. +* Allow OPTIONS request to be used on RelationshipView + +### Deprecated + +* Deprecate `PrefetchForIncludesHelperMixin` use `PreloadIncludesMixin` instead +* Deprecate `AutoPrefetchMixin` use `AutoPreloadMixin` instead ## [2.7.0] - 2019-01-14 diff --git a/docs/usage.md b/docs/usage.md index 0531daa3..c06d276c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -823,7 +823,10 @@ class QuestSerializer(serializers.ModelSerializer): Be aware that using included resources without any form of prefetching **WILL HURT PERFORMANCE** as it will introduce m\*(n+1) queries. -A viewset helper was designed to allow for greater flexibility and it is automatically available when subclassing +A viewset helper was therefore designed to automatically preload data when possible. Such is automatically available when subclassing `ModelViewSet`. + +It also allows to define custom `select_related` and `prefetch_related` for each requested `include` when needed in special cases: + `rest_framework_json_api.views.ModelViewSet`: ```python from rest_framework_json_api import views @@ -831,9 +834,12 @@ from rest_framework_json_api import views # When MyViewSet is called with ?include=author it will dynamically prefetch author and author.bio class MyViewSet(views.ModelViewSet): queryset = Book.objects.all() + select_for_includes = { + 'author': ['author__bio'], + } prefetch_for_includes = { '__all__': [], - 'author': ['author', 'author__bio'], + 'all_authors': [Prefetch('all_authors', queryset=Author.objects.select_related('bio'))], 'category.section': ['category'] } ``` @@ -848,7 +854,7 @@ class MyReadOnlyViewSet(views.ReadOnlyModelViewSet): The special keyword `__all__` can be used to specify a prefetch which should be done regardless of the include, similar to making the prefetch yourself on the QuerySet. -Using the helper to prefetch, rather than attempting to minimise queries via select_related might give you better performance depending on the characteristics of your data and database. +Using the helper to prefetch, rather than attempting to minimise queries via `select_related` might give you better performance depending on the characteristics of your data and database. For example: @@ -861,11 +867,11 @@ a) 1 query via selected_related, e.g. SELECT * FROM books LEFT JOIN author LEFT b) 4 small queries via prefetch_related. If you have 1M books, 50k authors, 10k categories, 10k copyrightholders -in the select_related scenario, you've just created a in-memory table +in the `select_related` scenario, you've just created a in-memory table with 1e18 rows which will likely exhaust any available memory and slow your database to crawl. -The prefetch_related case will issue 4 queries, but they will be small and fast queries. +The `prefetch_related` case will issue 4 queries, but they will be small and fast queries. + +## Generating an OpenAPI Specification (OAS) 3.0 schema document + +DRF >= 3.12 has a [new OAS schema functionality](https://www.django-rest-framework.org/api-guide/schemas/) to generate an +[OAS 3.0 schema](https://www.openapis.org/) as a YAML or JSON file. + +DJA extends DRF's schema support to generate an OAS schema in the JSON:API format. + +### AutoSchema Settings + +In order to produce an OAS schema that properly represents the JSON:API structure +you have to either add a `schema` attribute to each view class or set the `REST_FRAMEWORK['DEFAULT_SCHEMA_CLASS']` +to DJA's version of AutoSchema. + +#### View-based + +```python +from rest_framework_json_api.schemas.openapi import AutoSchema + +class MyViewset(ModelViewSet): + schema = AutoSchema + ... +``` + +#### Default schema class + +```python +REST_FRAMEWORK = { + # ... + 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', +} +``` + +### Adding additional OAS schema content + +You can extend the OAS schema document by subclassing +[`SchemaGenerator`](https://www.django-rest-framework.org/api-guide/schemas/#schemagenerator) +and extending `get_schema`. + + +Here's an example that adds OAS `info` and `servers` objects. + +```python +from rest_framework_json_api.schemas.openapi import SchemaGenerator as JSONAPISchemaGenerator + + +class MySchemaGenerator(JSONAPISchemaGenerator): + """ + Describe my OAS schema info in detail (overriding what DRF put in) and list the servers where it can be found. + """ + def get_schema(self, request, public): + schema = super().get_schema(request, public) + schema['info'] = { + 'version': '1.0', + 'title': 'my demo API', + 'description': 'A demonstration of [OAS 3.0](https://www.openapis.org)', + 'contact': { + 'name': 'my name' + }, + 'license': { + 'name': 'BSD 2 clause', + 'url': 'https://github.com/django-json-api/django-rest-framework-json-api/blob/master/LICENSE', + } + } + schema['servers'] = [ + {'url': 'https://localhost/v1', 'description': 'local docker'}, + {'url': 'http://localhost:8000/v1', 'description': 'local dev'}, + {'url': 'https://api.example.com/v1', 'description': 'demo server'}, + {'url': '{serverURL}', 'description': 'provide your server URL', + 'variables': {'serverURL': {'default': 'http://localhost:8000/v1'}}} + ] + return schema +``` + +### Generate a Static Schema on Command Line + +See [DRF documentation for generateschema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-static-schema-with-the-generateschema-management-command) +To generate an OAS schema document, use something like: + +```text +$ django-admin generateschema --settings=example.settings \ + --generator_class myapp.views.MySchemaGenerator >myschema.yaml +``` + +You can then use any number of OAS tools such as +[swagger-ui-watcher](https://www.npmjs.com/package/swagger-ui-watcher) +to render the schema: +```text +$ swagger-ui-watcher myschema.yaml +``` + +Note: Swagger-ui-watcher will complain that "DELETE operations cannot have a requestBody" +but it will still work. This [error](https://github.com/OAI/OpenAPI-Specification/pull/2117) +in the OAS specification will be fixed when [OAS 3.1.0](https://www.openapis.org/blog/2020/06/18/openapi-3-1-0-rc0-its-here) +is published. + +([swagger-ui](https://www.npmjs.com/package/swagger-ui) will work silently.) + +### Generate a Dynamic Schema in a View + +See [DRF documentation for a Dynamic Schema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-dynamic-schema-with-schemaview). + +```python +from rest_framework.schemas import get_schema_view + +urlpatterns = [ + ... + path('openapi', get_schema_view( + title="Example API", + description="API for all things …", + version="1.0.0", + generator_class=MySchemaGenerator, + ), name='openapi-schema'), + path('swagger-ui/', TemplateView.as_view( + template_name='swagger-ui.html', + extra_context={'schema_url': 'openapi-schema'} + ), name='swagger-ui'), + ... +] +``` + diff --git a/example/serializers.py b/example/serializers.py index 1728b742..9dc84a4a 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -230,6 +230,16 @@ class AuthorSerializer(serializers.ModelSerializer): queryset=Comment.objects, many=True ) + secrets = serializers.HiddenField( + default='Shhhh!' + ) + defaults = serializers.CharField( + default='default', + max_length=20, + min_length=3, + write_only=True, + help_text='help for defaults', + ) included_serializers = { 'bio': AuthorBioSerializer, 'type': AuthorTypeSerializer @@ -244,7 +254,8 @@ class AuthorSerializer(serializers.ModelSerializer): class Meta: model = Author - fields = ('name', 'email', 'bio', 'entries', 'comments', 'first_entry', 'type') + fields = ('name', 'email', 'bio', 'entries', 'comments', 'first_entry', 'type', + 'secrets', 'defaults') def get_first_entry(self, obj): return obj.entries.first() diff --git a/example/settings/dev.py b/example/settings/dev.py index ade24139..961807e3 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -21,6 +21,7 @@ 'django.contrib.sites', 'django.contrib.sessions', 'django.contrib.auth', + 'rest_framework_json_api', 'rest_framework', 'polymorphic', 'example', @@ -88,6 +89,7 @@ 'rest_framework.renderers.BrowsableAPIRenderer', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', + 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', diff --git a/example/templates/swagger-ui.html b/example/templates/swagger-ui.html new file mode 100644 index 00000000..29776491 --- /dev/null +++ b/example/templates/swagger-ui.html @@ -0,0 +1,28 @@ + + + + Swagger + + + + + +
+ + + + \ No newline at end of file diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py new file mode 100644 index 00000000..ec8da388 --- /dev/null +++ b/example/tests/snapshots/snap_test_openapi.py @@ -0,0 +1,647 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_path_without_parameters 1'] = '''{ + "description": "", + "operationId": "List/authors/", + "parameters": [ + { + "$ref": "#/components/parameters/include" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "$ref": "#/components/parameters/sort" + }, + { + "description": "A page number within the paginated result set.", + "in": "query", + "name": "page[number]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Number of results to return per page.", + "in": "query", + "name": "page[size]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Which field to use when ordering the results.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search term.", + "in": "query", + "name": "filter[search]", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Author" + }, + "type": "array" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "List/authors/" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not found" + } + } +}''' + +snapshots['test_path_with_id_parameter 1'] = '''{ + "description": "", + "operationId": "retrieve/authors/{id}/", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/include" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "$ref": "#/components/parameters/sort" + }, + { + "description": "Which field to use when ordering the results.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search term.", + "in": "query", + "name": "filter[search]", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "retrieve/authors/{id}/" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not found" + } + } +}''' + +snapshots['test_post_request 1'] = '''{ + "description": "", + "operationId": "create/authors/", + "parameters": [], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "defaults": { + "default": "default", + "description": "help for defaults", + "maxLength": 20, + "minLength": 3, + "type": "string", + "writeOnly": true + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "name": { + "maxLength": 50, + "type": "string" + } + }, + "required": [ + "name", + "email" + ], + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "properties": { + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationships": { + "properties": { + "bio": { + "$ref": "#/components/schemas/reltoone" + }, + "comments": { + "$ref": "#/components/schemas/reltomany" + }, + "entries": { + "$ref": "#/components/schemas/reltomany" + }, + "first_entry": { + "$ref": "#/components/schemas/reltoone" + }, + "type": { + "$ref": "#/components/schemas/reltoone" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "201": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-201). Assigned `id` and/or any other changes are in this response." + }, + "202": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/datum" + } + } + }, + "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" + }, + "204": { + "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) with the supplied `id`. No other changes from what was POSTed." + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "403": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-creating-responses-404)" + }, + "409": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" + } + } +}''' + +snapshots['test_patch_request 1'] = '''{ + "description": "", + "operationId": "update/authors/{id}", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "defaults": { + "default": "default", + "description": "help for defaults", + "maxLength": 20, + "minLength": 3, + "type": "string", + "writeOnly": true + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "name": { + "maxLength": 50, + "type": "string" + } + }, + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "properties": { + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationships": { + "properties": { + "bio": { + "$ref": "#/components/schemas/reltoone" + }, + "comments": { + "$ref": "#/components/schemas/reltomany" + }, + "entries": { + "$ref": "#/components/schemas/reltomany" + }, + "first_entry": { + "$ref": "#/components/schemas/reltoone" + }, + "type": { + "$ref": "#/components/schemas/reltoone" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "update/authors/{id}" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "403": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-updating-responses-404)" + }, + "409": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" + } + } +}''' + +snapshots['test_delete_request 1'] = '''{ + "description": "", + "operationId": "destroy/authors/{id}", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/onlymeta" + } + } + }, + "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)" + }, + "202": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/datum" + } + } + }, + "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" + }, + "204": { + "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" + } + } +}''' diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index ba3f4920..0fd76c67 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -58,5 +58,6 @@ def test_options_format_field_names(db, client): response = client.options(reverse('author-list')) assert response.status_code == status.HTTP_200_OK data = response.json()['data'] - expected_keys = {'name', 'email', 'bio', 'entries', 'firstEntry', 'type', 'comments'} + expected_keys = {'name', 'email', 'bio', 'entries', 'firstEntry', 'type', + 'comments', 'secrets', 'defaults'} assert expected_keys == data['actions']['POST'].keys() diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py new file mode 100644 index 00000000..e7a2b6ca --- /dev/null +++ b/example/tests/test_openapi.py @@ -0,0 +1,149 @@ +# largely based on DRF's test_openapi +import json + +from django.test import RequestFactory, override_settings +from django.urls import re_path +from rest_framework.request import Request + +from rest_framework_json_api.schemas.openapi import AutoSchema, SchemaGenerator + +from example import views + + +def create_request(path): + factory = RequestFactory() + request = Request(factory.get(path)) + return request + + +def create_view_with_kw(view_cls, method, request, initkwargs): + generator = SchemaGenerator() + view = generator.create_view(view_cls.as_view(initkwargs), method, request) + return view + + +def test_path_without_parameters(snapshot): + path = '/authors/' + method = 'GET' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'get': 'list'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +def test_path_with_id_parameter(snapshot): + path = '/authors/{id}/' + method = 'GET' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'get': 'retrieve'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +def test_post_request(snapshot): + method = 'POST' + path = '/authors/' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'post': 'create'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +def test_patch_request(snapshot): + method = 'PATCH' + path = '/authors/{id}' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'patch': 'update'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +def test_delete_request(snapshot): + method = 'DELETE' + path = '/authors/{id}' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'delete': 'delete'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +@override_settings(REST_FRAMEWORK={ + 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema'}) +def test_schema_construction(): + """Construction of the top level dictionary.""" + patterns = [ + re_path('^authors/?$', views.AuthorViewSet.as_view({'get': 'list'})), + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + + assert 'openapi' in schema + assert 'info' in schema + assert 'paths' in schema + assert 'components' in schema + + +def test_schema_related_serializers(): + """ + Confirm that paths are generated for related fields. For example: + /authors/{pk}/{related_field>} + /authors/{id}/comments/ + /authors/{id}/entries/ + /authors/{id}/first_entry/ + and confirm that the schema for the related field is properly rendered + """ + generator = SchemaGenerator() + request = create_request('/') + schema = generator.get_schema(request=request) + # make sure the path's relationship and related {related_field}'s got expanded + assert '/authors/{id}/relationships/{related_field}' in schema['paths'] + assert '/authors/{id}/comments/' in schema['paths'] + assert '/authors/{id}/entries/' in schema['paths'] + assert '/authors/{id}/first_entry/' in schema['paths'] + first_get = schema['paths']['/authors/{id}/first_entry/']['get']['responses']['200'] + first_schema = first_get['content']['application/vnd.api+json']['schema'] + first_props = first_schema['properties']['data'] + assert '$ref' in first_props + assert first_props['$ref'] == '#/components/schemas/Entry' diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py new file mode 100644 index 00000000..2044c467 --- /dev/null +++ b/example/tests/unit/test_filter_schema_params.py @@ -0,0 +1,77 @@ +from rest_framework import filters as drf_filters + +from rest_framework_json_api import filters as dja_filters +from rest_framework_json_api.django_filters import backends + +from example.views import EntryViewSet + + +class DummyEntryViewSet(EntryViewSet): + filter_backends = (dja_filters.QueryParameterValidationFilter, dja_filters.OrderingFilter, + backends.DjangoFilterBackend, drf_filters.SearchFilter) + filterset_fields = { + 'id': ('exact',), + 'headline': ('exact', 'contains'), + 'blog__name': ('contains', ), + } + + def __init__(self, **kwargs): + # dummy up self.request since PreloadIncludesMixin expects it to be defined + self.request = None + super(DummyEntryViewSet, self).__init__(**kwargs) + + +def test_filters_get_schema_params(): + """ + test all my filters for `get_schema_operation_parameters()` + """ + # list of tuples: (filter, expected result) + filters = [ + (dja_filters.QueryParameterValidationFilter, []), + (backends.DjangoFilterBackend, [ + { + 'name': 'filter[id]', 'required': False, 'in': 'query', + 'description': 'id', 'schema': {'type': 'string'} + }, + { + 'name': 'filter[headline]', 'required': False, 'in': 'query', + 'description': 'headline', 'schema': {'type': 'string'} + }, + { + 'name': 'filter[headline.contains]', 'required': False, 'in': 'query', + 'description': 'headline__contains', 'schema': {'type': 'string'} + }, + { + 'name': 'filter[blog.name.contains]', 'required': False, 'in': 'query', + 'description': 'blog__name__contains', 'schema': {'type': 'string'} + }, + ]), + (dja_filters.OrderingFilter, [ + { + 'name': 'sort', 'required': False, 'in': 'query', + 'description': 'Which field to use when ordering the results.', + 'schema': {'type': 'string'} + } + ]), + (drf_filters.SearchFilter, [ + { + 'name': 'filter[search]', 'required': False, 'in': 'query', + 'description': 'A search term.', + 'schema': {'type': 'string'} + } + ]), + ] + view = DummyEntryViewSet() + + for c, expected in filters: + f = c() + result = f.get_schema_operation_parameters(view) + assert len(result) == len(expected) + if len(result) == 0: + continue + # py35: the result list/dict ordering isn't guaranteed + for res_item in result: + assert 'name' in res_item + for exp_item in expected: + if res_item['name'] == exp_item['name']: + assert res_item == exp_item diff --git a/example/urls.py b/example/urls.py index 72788060..9b882ce5 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,6 +1,11 @@ from django.conf import settings from django.conf.urls import include, url +from django.urls import path +from django.views.generic import TemplateView from rest_framework import routers +from rest_framework.schemas import get_schema_view + +from rest_framework_json_api.schemas.openapi import SchemaGenerator from example.views import ( AuthorRelationshipView, @@ -63,11 +68,21 @@ url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', AuthorRelationshipView.as_view(), name='author-relationships'), + path('openapi', get_schema_view( + title="Example API", + description="API for all things …", + version="1.0.0", + generator_class=SchemaGenerator + ), name='openapi-schema'), + path('swagger-ui/', TemplateView.as_view( + template_name='swagger-ui.html', + extra_context={'schema_url': 'openapi-schema'} + ), name='swagger-ui'), ] - if settings.DEBUG: import debug_toolbar + urlpatterns = [ url(r'^__debug__/', include(debug_toolbar.urls)), ] + urlpatterns diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index ac092e33..95f36814 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,2 +1,4 @@ django-filter==2.4.0 django-polymorphic==3.0.0 +pyyaml==5.3 +uritemplate==3.0.1 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 29acfa5c..814a79f3 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -122,3 +122,17 @@ def get_filterset_kwargs(self, request, queryset, view): 'request': request, 'filter_keys': filter_keys, } + + def get_schema_operation_parameters(self, view): + """ + Convert backend filter `name` to JSON:API-style `filter[name]`. + For filters that are relationship paths, rewrite ORM-style `__` to our preferred `.`. + For example: `blog__name__contains` becomes `filter[blog.name.contains]`. + + This is basically the reverse of `get_filterset_kwargs` above. + """ + result = super(DjangoFilterBackend, self).get_schema_operation_parameters(view) + for res in result: + if 'name' in res: + res['name'] = 'filter[{}]'.format(res['name']).replace('__', '.') + return result diff --git a/rest_framework_json_api/schemas/__init__.py b/rest_framework_json_api/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py new file mode 100644 index 00000000..fe6b095e --- /dev/null +++ b/rest_framework_json_api/schemas/openapi.py @@ -0,0 +1,818 @@ +import warnings +from urllib.parse import urljoin + +from django.utils.module_loading import import_string as import_class_from_dotted_path +from rest_framework.fields import empty +from rest_framework.relations import ManyRelatedField +from rest_framework.schemas import openapi as drf_openapi +from rest_framework.schemas.utils import is_list_view + +from rest_framework_json_api import serializers, views + + +class SchemaGenerator(drf_openapi.SchemaGenerator): + """ + Extend DRF's SchemaGenerator to implement jsonapi-flavored generateschema command. + """ + #: These JSONAPI component definitions are referenced by the generated OAS schema. + #: If you need to add more or change these static component definitions, extend this dict. + jsonapi_components = { + 'schemas': { + 'jsonapi': { + 'type': 'object', + 'description': "The server's implementation", + 'properties': { + 'version': {'type': 'string'}, + 'meta': {'$ref': '#/components/schemas/meta'} + }, + 'additionalProperties': False + }, + 'resource': { + 'type': 'object', + 'required': ['type', 'id'], + 'additionalProperties': False, + 'properties': { + 'type': { + '$ref': '#/components/schemas/type' + }, + 'id': { + '$ref': '#/components/schemas/id' + }, + 'attributes': { + 'type': 'object', + # ... + }, + 'relationships': { + 'type': 'object', + # ... + }, + 'links': { + '$ref': '#/components/schemas/links' + }, + 'meta': {'$ref': '#/components/schemas/meta'}, + } + }, + 'link': { + 'oneOf': [ + { + 'description': "a string containing the link's URL", + 'type': 'string', + 'format': 'uri-reference' + }, + { + 'type': 'object', + 'required': ['href'], + 'properties': { + 'href': { + 'description': "a string containing the link's URL", + 'type': 'string', + 'format': 'uri-reference' + }, + 'meta': {'$ref': '#/components/schemas/meta'} + } + } + ] + }, + 'links': { + 'type': 'object', + 'additionalProperties': {'$ref': '#/components/schemas/link'} + }, + 'reltoone': { + 'description': "a singular 'to-one' relationship", + 'type': 'object', + 'properties': { + 'links': {'$ref': '#/components/schemas/relationshipLinks'}, + 'data': {'$ref': '#/components/schemas/relationshipToOne'}, + 'meta': {'$ref': '#/components/schemas/meta'} + } + }, + 'relationshipToOne': { + 'description': "reference to other resource in a to-one relationship", + 'anyOf': [ + {'$ref': '#/components/schemas/nulltype'}, + {'$ref': '#/components/schemas/linkage'} + ], + }, + 'reltomany': { + 'description': "a multiple 'to-many' relationship", + 'type': 'object', + 'properties': { + 'links': {'$ref': '#/components/schemas/relationshipLinks'}, + 'data': {'$ref': '#/components/schemas/relationshipToMany'}, + 'meta': {'$ref': '#/components/schemas/meta'} + } + }, + 'relationshipLinks': { + 'description': 'optional references to other resource objects', + 'type': 'object', + 'additionalProperties': True, + 'properties': { + 'self': {'$ref': '#/components/schemas/link'}, + 'related': {'$ref': '#/components/schemas/link'} + } + }, + 'relationshipToMany': { + 'description': "An array of objects each containing the " + "'type' and 'id' for to-many relationships", + 'type': 'array', + 'items': {'$ref': '#/components/schemas/linkage'}, + 'uniqueItems': True + }, + # A RelationshipView uses a ResourceIdentifierObjectSerializer (hence the name + # ResourceIdentifierObject returned by get_component_name()) which serializes type and + # id. These can be lists or individual items depending on whether the relationship is + # toMany or toOne so offer both options since we are not iterating over all the + # possible {related_field}'s but rather rendering one path schema which may represent + # toMany and toOne relationships. + 'ResourceIdentifierObject': { + 'oneOf': [ + {'$ref': '#/components/schemas/relationshipToOne'}, + {'$ref': '#/components/schemas/relationshipToMany'} + ] + }, + 'linkage': { + 'type': 'object', + 'description': "the 'type' and 'id'", + 'required': ['type', 'id'], + 'properties': { + 'type': {'$ref': '#/components/schemas/type'}, + 'id': {'$ref': '#/components/schemas/id'}, + 'meta': {'$ref': '#/components/schemas/meta'} + } + }, + 'pagination': { + 'type': 'object', + 'properties': { + 'first': {'$ref': '#/components/schemas/pageref'}, + 'last': {'$ref': '#/components/schemas/pageref'}, + 'prev': {'$ref': '#/components/schemas/pageref'}, + 'next': {'$ref': '#/components/schemas/pageref'}, + } + }, + 'pageref': { + 'oneOf': [ + {'type': 'string', 'format': 'uri-reference'}, + {'$ref': '#/components/schemas/nulltype'} + ] + }, + 'failure': { + 'type': 'object', + 'required': ['errors'], + 'properties': { + 'errors': {'$ref': '#/components/schemas/errors'}, + 'meta': {'$ref': '#/components/schemas/meta'}, + 'jsonapi': {'$ref': '#/components/schemas/jsonapi'}, + 'links': {'$ref': '#/components/schemas/links'} + } + }, + 'errors': { + 'type': 'array', + 'items': {'$ref': '#/components/schemas/error'}, + 'uniqueItems': True + }, + 'error': { + 'type': 'object', + 'additionalProperties': False, + 'properties': { + 'id': {'type': 'string'}, + 'status': {'type': 'string'}, + 'links': {'$ref': '#/components/schemas/links'}, + 'code': {'type': 'string'}, + 'title': {'type': 'string'}, + 'detail': {'type': 'string'}, + 'source': { + 'type': 'object', + 'properties': { + 'pointer': { + 'type': 'string', + 'description': + "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " + "to the associated entity in the request document " + "[e.g. `/data` for a primary data object, or " + "`/data/attributes/title` for a specific attribute." + }, + 'parameter': { + 'type': 'string', + 'description': + "A string indicating which query parameter " + "caused the error." + }, + 'meta': {'$ref': '#/components/schemas/meta'} + } + } + } + }, + 'onlymeta': { + 'additionalProperties': False, + 'properties': { + 'meta': {'$ref': '#/components/schemas/meta'} + } + }, + 'meta': { + 'type': 'object', + 'additionalProperties': True + }, + 'datum': { + 'description': 'singular item', + 'properties': { + 'data': {'$ref': '#/components/schemas/resource'} + } + }, + 'nulltype': { + 'type': 'object', + 'nullable': True, + 'default': None + }, + 'type': { + 'type': 'string', + 'description': + 'The [type]' + '(https://jsonapi.org/format/#document-resource-object-identification) ' + 'member is used to describe resource objects that share common attributes ' + 'and relationships.' + }, + 'id': { + 'type': 'string', + 'description': + "Each resource object’s type and id pair MUST " + "[identify]" + "(https://jsonapi.org/format/#document-resource-object-identification) " + "a single, unique resource." + }, + }, + 'parameters': { + 'include': { + 'name': 'include', + 'in': 'query', + 'description': '[list of included related resources]' + '(https://jsonapi.org/format/#fetching-includes)', + 'required': False, + 'style': 'form', + 'schema': { + 'type': 'string' + } + }, + # TODO: deepObject not well defined/supported: + # https://github.com/OAI/OpenAPI-Specification/issues/1706 + 'fields': { + 'name': 'fields', + 'in': 'query', + 'description': '[sparse fieldsets]' + '(https://jsonapi.org/format/#fetching-sparse-fieldsets).\n' + 'Use fields[\\]=field1,field2,...,fieldN', + 'required': False, + 'style': 'deepObject', + 'schema': { + 'type': 'object', + }, + 'explode': True + }, + 'sort': { + 'name': 'sort', + 'in': 'query', + 'description': '[list of fields to sort by]' + '(https://jsonapi.org/format/#fetching-sorting)', + 'required': False, + 'style': 'form', + 'schema': { + 'type': 'string' + } + }, + }, + } + + def get_schema(self, request=None, public=False): + """ + Generate a JSONAPI OpenAPI schema. + Overrides upstream DRF's get_schema. + """ + # TODO: avoid copying so much of upstream get_schema() + schema = super().get_schema(request, public) + + components_schemas = {} + + # Iterate endpoints generating per method path operations. + paths = {} + _, view_endpoints = self._get_paths_and_endpoints(None if public else request) + + #: `expanded_endpoints` is like view_endpoints with one extra field tacked on: + #: - 'action' copy of current view.action (list/fetch) as this gets reset for each request. + expanded_endpoints = [] + for path, method, view in view_endpoints: + if hasattr(view, 'action') and view.action == 'retrieve_related': + expanded_endpoints += self._expand_related(path, method, view, view_endpoints) + else: + expanded_endpoints.append((path, method, view, getattr(view, 'action', None))) + + for path, method, view, action in expanded_endpoints: + if not self.has_view_permissions(path, method, view): + continue + # kludge to preserve view.action as it is 'list' for the parent ViewSet + # but the related viewset that was expanded may be either 'fetch' (to_one) or 'list' + # (to_many). This patches the view.action appropriately so that + # view.schema.get_operation() "does the right thing" for fetch vs. list. + current_action = None + if hasattr(view, 'action'): + current_action = view.action + view.action = action + operation = view.schema.get_operation(path, method) + components = view.schema.get_components(path, method) + for k in components.keys(): + if k not in components_schemas: + continue + if components_schemas[k] == components[k]: + continue + warnings.warn( + 'Schema component "{}" has been overriden with a different value.'.format(k)) + + components_schemas.update(components) + + if hasattr(view, 'action'): + view.action = current_action + # Normalise path for any provided mount url. + if path.startswith('/'): + path = path[1:] + path = urljoin(self.url or '/', path) + + paths.setdefault(path, {}) + paths[path][method.lower()] = operation + + self.check_duplicate_operation_id(paths) + + # Compile final schema, overriding stuff from super class. + schema['paths'] = paths + schema['components'] = self.jsonapi_components + schema['components']['schemas'].update(components_schemas) + + return schema + + def _expand_related(self, path, method, view, view_endpoints): + """ + Expand path containing .../{id}/{related_field} into list of related fields + and **their** views, making sure toOne relationship's views are a 'fetch' and toMany + relationship's are a 'list'. + :param path + :param method + :param view + :param view_endpoints + :return:list[tuple(path, method, view, action)] + """ + result = [] + serializer = view.get_serializer() + # It's not obvious if it's allowed to have both included_ and related_ serializers, + # so just merge both dicts. + serializers = {} + if hasattr(serializer, 'included_serializers'): + serializers = {**serializers, **serializer.included_serializers} + if hasattr(serializer, 'related_serializers'): + serializers = {**serializers, **serializer.related_serializers} + related_fields = [fs for fs in serializers.items()] + + for field, related_serializer in related_fields: + related_view = self._find_related_view(view_endpoints, related_serializer, view) + if related_view: + action = self._field_is_one_or_many(field, view) + result.append( + (path.replace('{related_field}', field), method, related_view, action) + ) + + return result + + def _find_related_view(self, view_endpoints, related_serializer, parent_view): + """ + For a given related_serializer, try to find it's "parent" view instance in view_endpoints. + :param view_endpoints: list of all view endpoints + :param related_serializer: the related serializer for a given related field + :param parent_view: the parent view (used to find toMany vs. toOne). + TODO: not actually used. + :return:view + """ + for path, method, view in view_endpoints: + view_serializer = view.get_serializer() + if not isinstance(related_serializer, type): + related_serializer_class = import_class_from_dotted_path(related_serializer) + else: + related_serializer_class = related_serializer + if isinstance(view_serializer, related_serializer_class): + return view + + return None + + def _field_is_one_or_many(self, field, view): + serializer = view.get_serializer() + if isinstance(serializer.fields[field], ManyRelatedField): + return 'list' + else: + return 'fetch' + + +class AutoSchema(drf_openapi.AutoSchema): + """ + Extend DRF's openapi.AutoSchema for JSONAPI serialization. + """ + #: ignore all the media types and only generate a JSONAPI schema. + content_types = ['application/vnd.api+json'] + + def get_operation(self, path, method): + """ + JSONAPI adds some standard fields to the API response that are not in upstream DRF: + - some that only apply to GET/HEAD methods. + - collections + - special handling for POST, PATCH, DELETE + """ + operation = {} + operation['operationId'] = self.get_operation_id(path, method) + operation['description'] = self.get_description(path, method) + + parameters = [] + parameters += self.get_path_parameters(path, method) + # pagination, filters only apply to GET/HEAD of collections and items + if method in ['GET', 'HEAD']: + parameters += self._get_include_parameters(path, method) + parameters += self._get_fields_parameters(path, method) + parameters += self._get_sort_parameters(path, method) + parameters += self.get_pagination_parameters(path, method) + parameters += self.get_filter_parameters(path, method) + operation['parameters'] = parameters + + # get request and response code schemas + if method == 'GET': + if is_list_view(path, method, self.view): + self._add_get_collection_response(operation) + else: + self._add_get_item_response(operation) + elif method == 'POST': + self._add_post_item_response(operation, path) + elif method == 'PATCH': + self._add_patch_item_response(operation, path) + elif method == 'DELETE': + # should only allow deleting a resource, not a collection + # TODO: implement delete of a relationship in future release. + self._add_delete_item_response(operation, path) + return operation + + def get_operation_id(self, path, method): + """ + The upstream DRF version creates non-unique operationIDs, because the same view is + used for the main path as well as such as related and relationships. + This concatenates the (mapped) method name and path as the spec allows most any + """ + method_name = getattr(self.view, 'action', method.lower()) + if is_list_view(path, method, self.view): + action = 'List' + elif method_name not in self.method_mapping: + action = method_name + else: + action = self.method_mapping[method.lower()] + return action + path + + def _get_include_parameters(self, path, method): + """ + includes parameter: https://jsonapi.org/format/#fetching-includes + """ + return [{'$ref': '#/components/parameters/include'}] + + def _get_fields_parameters(self, path, method): + """ + sparse fieldsets https://jsonapi.org/format/#fetching-sparse-fieldsets + """ + # TODO: See if able to identify the specific types for fields[type]=... and return this: + # name: fields + # in: query + # description: '[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets)' + # required: true + # style: deepObject + # schema: + # type: object + # properties: + # hello: + # type: string # noqa F821 + # world: + # type: string # noqa F821 + # explode: true + return [{'$ref': '#/components/parameters/fields'}] + + def _get_sort_parameters(self, path, method): + """ + sort parameter: https://jsonapi.org/format/#fetching-sorting + """ + return [{'$ref': '#/components/parameters/sort'}] + + def _add_get_collection_response(self, operation): + """ + Add GET 200 response for a collection to operation + """ + operation['responses'] = { + '200': self._get_toplevel_200_response(operation, collection=True) + } + self._add_get_4xx_responses(operation) + + def _add_get_item_response(self, operation): + """ + add GET 200 response for an item to operation + """ + operation['responses'] = { + '200': self._get_toplevel_200_response(operation, collection=False) + } + self._add_get_4xx_responses(operation) + + def _get_toplevel_200_response(self, operation, collection=True): + """ + return top-level JSONAPI GET 200 response + + :param collection: True for collections; False for individual items. + + Uses a $ref to the components.schemas. component definition. + """ + if collection: + data = {'type': 'array', 'items': self._get_reference(self.view.get_serializer())} + else: + data = self._get_reference(self.view.get_serializer()) + + return { + 'description': operation['operationId'], + 'content': { + 'application/vnd.api+json': { + 'schema': { + 'type': 'object', + 'required': ['data'], + 'properties': { + 'data': data, + 'included': { + 'type': 'array', + 'uniqueItems': True, + 'items': { + '$ref': '#/components/schemas/resource' + } + }, + 'links': { + 'description': 'Link members related to primary data', + 'allOf': [ + {'$ref': '#/components/schemas/links'}, + {'$ref': '#/components/schemas/pagination'} + ] + }, + 'jsonapi': { + '$ref': '#/components/schemas/jsonapi' + } + } + } + } + } + } + + def _add_post_item_response(self, operation, path): + """ + add response for POST of an item to operation + """ + operation['requestBody'] = self.get_request_body(path, 'POST') + operation['responses'] = { + '201': self._get_toplevel_200_response(operation, collection=False) + } + operation['responses']['201']['description'] = ( + '[Created](https://jsonapi.org/format/#crud-creating-responses-201). ' + 'Assigned `id` and/or any other changes are in this response.' + ) + self._add_async_response(operation) + operation['responses']['204'] = { + 'description': '[Created](https://jsonapi.org/format/#crud-creating-responses-204) ' + 'with the supplied `id`. No other changes from what was POSTed.' + } + self._add_post_4xx_responses(operation) + + def _add_patch_item_response(self, operation, path): + """ + Add PATCH response for an item to operation + """ + operation['requestBody'] = self.get_request_body(path, 'PATCH') + operation['responses'] = { + '200': self._get_toplevel_200_response(operation, collection=False) + } + self._add_patch_4xx_responses(operation) + + def _add_delete_item_response(self, operation, path): + """ + add DELETE response for item or relationship(s) to operation + """ + # Only DELETE of relationships has a requestBody + if isinstance(self.view, views.RelationshipView): + operation['requestBody'] = self.get_request_body(path, 'DELETE') + self._add_delete_responses(operation) + + def get_request_body(self, path, method): + """ + A request body is required by jsonapi for POST, PATCH, and DELETE methods. + """ + serializer = self.get_serializer(path, method) + if not isinstance(serializer, (serializers.BaseSerializer, )): + return {} + is_relationship = isinstance(self.view, views.RelationshipView) + + # DRF uses a $ref to the component schema definition, but this + # doesn't work for jsonapi due to the different required fields based on + # the method, so make those changes and inline another copy of the schema. + # TODO: A future improvement could make this DRYer with multiple component schemas: + # A base schema for each viewset that has no required fields + # One subclassed from the base that requires some fields (`type` but not `id` for POST) + # Another subclassed from base with required type/id but no required attributes (PATCH) + + if is_relationship: + item_schema = {'$ref': '#/components/schemas/ResourceIdentifierObject'} + else: + item_schema = self.map_serializer(serializer) + if method == 'POST': + # 'type' and 'id' are both required for: + # - all relationship operations + # - regular PATCH or DELETE + # Only 'type' is required for POST: system may assign the 'id'. + item_schema['required'] = ['type'] + + if 'properties' in item_schema and 'attributes' in item_schema['properties']: + # No required attributes for PATCH + if method in ['PATCH', 'PUT'] and 'required' in item_schema['properties']['attributes']: + del item_schema['properties']['attributes']['required'] + # No read_only fields for request. + for name, schema in item_schema['properties']['attributes']['properties'].copy().items(): # noqa E501 + if 'readOnly' in schema: + del item_schema['properties']['attributes']['properties'][name] + return { + 'content': { + ct: { + 'schema': { + 'required': ['data'], + 'properties': { + 'data': item_schema + } + } + } + for ct in self.content_types + } + } + + def map_serializer(self, serializer): + """ + Custom map_serializer that serializes the schema using the jsonapi spec. + Non-attributes like related and identity fields, are move to 'relationships' and 'links'. + """ + # TODO: remove attributes, etc. for relationshipView?? + required = [] + attributes = {} + relationships = {} + + for field in serializer.fields.values(): + if isinstance(field, serializers.HyperlinkedIdentityField): + # the 'url' is not an attribute but rather a self.link, so don't map it here. + continue + if isinstance(field, serializers.HiddenField): + continue + if isinstance(field, serializers.RelatedField): + relationships[field.field_name] = {'$ref': '#/components/schemas/reltoone'} + continue + if isinstance(field, serializers.ManyRelatedField): + relationships[field.field_name] = {'$ref': '#/components/schemas/reltomany'} + continue + + if field.required: + required.append(field.field_name) + + schema = self.map_field(field) + if field.read_only: + schema['readOnly'] = True + if field.write_only: + schema['writeOnly'] = True + if field.allow_null: + schema['nullable'] = True + if field.default and field.default != empty: + schema['default'] = field.default + if field.help_text: + # Ensure django gettext_lazy is rendered correctly + schema['description'] = str(field.help_text) + self.map_field_validators(field, schema) + + attributes[field.field_name] = schema + + result = { + 'type': 'object', + 'required': ['type', 'id'], + 'additionalProperties': False, + 'properties': { + 'type': {'$ref': '#/components/schemas/type'}, + 'id': {'$ref': '#/components/schemas/id'}, + 'links': { + 'type': 'object', + 'properties': { + 'self': {'$ref': '#/components/schemas/link'} + } + } + } + } + if attributes: + result['properties']['attributes'] = { + 'type': 'object', + 'properties': attributes + } + if required: + result['properties']['attributes']['required'] = required + + if relationships: + result['properties']['relationships'] = { + 'type': 'object', + 'properties': relationships + } + return result + + def _add_async_response(self, operation): + """ + Add async response to operation + """ + operation['responses']['202'] = { + 'description': 'Accepted for [asynchronous processing]' + '(https://jsonapi.org/recommendations/#asynchronous-processing)', + 'content': { + 'application/vnd.api+json': { + 'schema': {'$ref': '#/components/schemas/datum'} + } + } + } + + def _failure_response(self, reason): + """ + Return failure response reason as the description + """ + return { + 'description': reason, + 'content': { + 'application/vnd.api+json': { + 'schema': {'$ref': '#/components/schemas/failure'} + } + } + } + + def _add_generic_failure_responses(self, operation): + """ + Add generic failure response(s) to operation + """ + for code, reason in [('401', 'not authorized'), ]: + operation['responses'][code] = self._failure_response(reason) + + def _add_get_4xx_responses(self, operation): + """ + Add generic 4xx GET responses to operation + """ + self._add_generic_failure_responses(operation) + for code, reason in [('404', 'not found')]: + operation['responses'][code] = self._failure_response(reason) + + def _add_post_4xx_responses(self, operation): + """ + Add POST 4xx error responses to operation + """ + self._add_generic_failure_responses(operation) + for code, reason in [ + ('403', '[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)'), + ('404', '[Related resource does not exist]' + '(https://jsonapi.org/format/#crud-creating-responses-404)'), + ('409', '[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)'), + ]: + operation['responses'][code] = self._failure_response(reason) + + def _add_patch_4xx_responses(self, operation): + """ + Add PATCH 4xx error responses to operation + """ + self._add_generic_failure_responses(operation) + for code, reason in [ + ('403', '[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)'), + ('404', '[Related resource does not exist]' + '(https://jsonapi.org/format/#crud-updating-responses-404)'), + ('409', '[Conflict]([Conflict]' + '(https://jsonapi.org/format/#crud-updating-responses-409)'), + ]: + operation['responses'][code] = self._failure_response(reason) + + def _add_delete_responses(self, operation): + """ + Add generic DELETE responses to operation + """ + # the 2xx statuses: + operation['responses'] = { + '200': { + 'description': '[OK](https://jsonapi.org/format/#crud-deleting-responses-200)', + 'content': { + 'application/vnd.api+json': { + 'schema': {'$ref': '#/components/schemas/onlymeta'} + } + } + } + } + self._add_async_response(operation) + operation['responses']['204'] = { + 'description': '[no content](https://jsonapi.org/format/#crud-deleting-responses-204)', + } + # the 4xx errors: + self._add_generic_failure_responses(operation) + for code, reason in [ + ('404', '[Resource does not exist]' + '(https://jsonapi.org/format/#crud-deleting-responses-404)'), + ]: + operation['responses'][code] = self._failure_response(reason) diff --git a/setup.py b/setup.py index f6d7c292..3bf1c728 100755 --- a/setup.py +++ b/setup.py @@ -96,7 +96,8 @@ def get_package_data(package): ], extras_require={ 'django-polymorphic': ['django-polymorphic>=2.0'], - 'django-filter': ['django-filter>=2.0'] + 'django-filter': ['django-filter>=2.0'], + 'openapi': ['pyyaml>=5.3', 'uritemplate>=3.0.1'] }, setup_requires=wheel, python_requires=">=3.6", From 1ee45f6cd968950da27ec3c2f6d2d5d21fb871e5 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 30 Oct 2020 18:29:01 +0100 Subject: [PATCH 260/488] Move to official major Python 3.9 in Travis (#847) --- .travis.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index f5ad4aa7..05feb314 100644 --- a/.travis.yml +++ b/.travis.yml @@ -63,19 +63,20 @@ matrix: - python: 3.8 env: TOXENV=py38-django31-drfmaster - - python: 3.9-dev + - python: 3.9 env: TOXENV=py39-django22-drf312 - - python: 3.9-dev + - python: 3.9 env: TOXENV=py39-django22-drfmaster - - python: 3.9-dev + - python: 3.9 env: TOXENV=py39-django30-drf312 - - python: 3.9-dev + - python: 3.9 env: TOXENV=py39-django30-drfmaster - - python: 3.9-dev + - python: 3.9 env: TOXENV=py39-django31-drf312 - - python: 3.9-dev + - python: 3.9 env: TOXENV=py39-django31-drfmaster + install: - pip install tox script: From 200bf249769d26463937004fa1522ce40c574396 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 30 Oct 2020 20:18:31 +0100 Subject: [PATCH 261/488] Release version 4.0.0 (#849) --- CHANGELOG.md | 19 ++++++++++--------- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 957d5334..99629181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,16 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [4.0.0] - 2020-10-31 This release is not backwards compatible. For easy migration best upgrade first to version 3.2.0 and resolve all deprecation warnings before updating to 4.0.0 ### Added -* Added support for Django REST framework 3.12 -* Added support for Django 3.1 -* Added support for Python 3.9 +* Added support for Django REST framework 3.12. +* Added support for Django 3.1. +* Added support for Python 3.9. * Added initial optional support for [openapi](https://www.openapis.org/) schema generation. Enable with: ``` pip install djangorestframework-jsonapi['openapi'] @@ -32,17 +32,18 @@ This release is not backwards compatible. For easy migration best upgrade first * Removed support for Python 3.5. * Removed support for Django 1.11. * Removed support for Django 2.1. -* Removed support for Django REST framework 3.10, 3.11 -* Removed obsolete `source` argument of `SerializerMethodResourceRelatedField` -* Removed obsolete setting `JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE` to render nested serializers as relationships. Default is as attribute now. +* Removed support for Django REST framework 3.10 and 3.11. +* Removed obsolete `source` argument of `SerializerMethodResourceRelatedField`. +* Removed obsolete setting `JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE` to render nested serializers as relationships. + Default is render as attribute now. ### Fixed -* Stopped `SparseFieldsetsMixin` interpretting invalid fields query parameter (e.g. invalidfields[entries]=blog,headline) +* Stopped `SparseFieldsetsMixin` interpretting invalid fields query parameter (e.g. invalidfields[entries]=blog,headline). ## [3.2.0] - 2020-08-26 -This is the last release supporting Django 1.11, Django 2.1, DRF 3.10, DRF 3.11 and Python 3.5. +This is the last release supporting Django 1.11, Django 2.1, Django REST Framework 3.10, Django REST Framework 3.11 and Python 3.5. ### Added diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 28a440ce..f059f020 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = 'djangorestframework-jsonapi' -__version__ = '3.2.0' +__version__ = '4.0.0' __author__ = '' __license__ = 'BSD' __copyright__ = '' From 3eaa033c773ebb648b6ffe5fff195daf5a771335 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 30 Oct 2020 21:09:44 +0100 Subject: [PATCH 262/488] Move to Travis CI com (#850) Co-authored-by: Alan Crosswell --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 31e4ba2a..d5d5959f 100644 --- a/README.rst +++ b/README.rst @@ -2,8 +2,8 @@ JSON API and Django Rest Framework ================================== -.. image:: https://travis-ci.org/django-json-api/django-rest-framework-json-api.svg?branch=develop - :target: https://travis-ci.org/django-json-api/django-rest-framework-json-api +.. image:: https://travis-ci.com/django-json-api/django-rest-framework-json-api.svg?branch=master + :target: https://travis-ci.com/django-json-api/django-rest-framework-json-api .. image:: https://readthedocs.org/projects/django-rest-framework-json-api/badge/?version=latest :alt: Read the docs From a67a521327c9ec62a18a875abf13504a72d8eaf0 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 3 Nov 2020 17:44:11 +0200 Subject: [PATCH 263/488] Scheduled biweekly dependency update for week 44 (#852) * Update sphinx from 3.2.1 to 3.3.0 * Update pyyaml from 5.3 to 5.3.1 * Update pytest from 6.1.1 to 6.1.2 * Update pytest-django from 4.0.0 to 4.1.0 --- requirements/requirements-documentation.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index f3cb3cac..e8378b09 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.6.0 -Sphinx==3.2.1 +Sphinx==3.3.0 sphinx_rtd_theme==0.5.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 95f36814..e5ceb15f 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ django-filter==2.4.0 django-polymorphic==3.0.0 -pyyaml==5.3 +pyyaml==5.3.1 uritemplate==3.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index e854996e..9508b0a3 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.1.1 factory-boy==3.1.0 Faker==4.14.0 -pytest==6.1.1 +pytest==6.1.2 pytest-cov==2.10.1 -pytest-django==4.0.0 +pytest-django==4.1.0 pytest-factoryboy==2.0.3 snapshottest==0.6.0 From 56ef6f3f6837d2517e6fdad25a9eb142a8d59586 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Nov 2020 23:21:51 +0100 Subject: [PATCH 264/488] Create tests module (#855) Module used to migrate test from example --- pytest.ini | 5 ----- setup.cfg | 9 +++++++++ tests/__init__.py | 0 tests/conftest.py | 21 +++++++++++++++++++++ 4 files changed, 30 insertions(+), 5 deletions(-) delete mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 2c69372d..00000000 --- a/pytest.ini +++ /dev/null @@ -1,5 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE=example.settings.test -filterwarnings = - error::DeprecationWarning - error::PendingDeprecationWarning diff --git a/setup.cfg b/setup.cfg index d8247c1d..4f483e02 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,3 +50,12 @@ exclude_lines = def __str__ def __unicode__ def __repr__ + +[tool:pytest] +DJANGO_SETTINGS_MODULE=example.settings.test +filterwarnings = + error::DeprecationWarning + error::PendingDeprecationWarning +testpaths = + example + tests diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..4942bf63 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +import pytest + + +@pytest.fixture(autouse=True) +def use_rest_framework_json_api_defaults(settings): + """ + Enfroce default settings for tests modules. + + + As for now example and tests modules share the same settings file + some defaults which have been overwritten in the example app need + to be overwritten. This way testing actually happens on default resp. + each test defines what non default setting it wants to test. + + Once migration to tests module is finished and tests can have + its own settings file, this fixture can be removed. + """ + + settings.JSON_API_FORMAT_FIELD_NAMES = False + settings.JSON_API_FORMAT_TYPES = False + settings.JSON_API_PLURALIZE_TYPES = False From 69a720a87f3bdbabb3ce0591a45d0c9b44ab9125 Mon Sep 17 00:00:00 2001 From: David Guillot Date: Mon, 9 Nov 2020 14:48:19 +0100 Subject: [PATCH 265/488] Add apply includes option in BrowsableAPI custom BrowsableAPI to add choice of JSON:API includes --- AUTHORS | 1 + CHANGELOG.md | 7 +++ README.rst | 2 +- docs/usage.md | 2 +- example/settings/dev.py | 2 +- .../tests/integration/test_browsable_api.py | 29 +++++++++++ rest_framework_json_api/renderers.py | 51 +++++++++++++++++++ .../rest_framework_json_api/api.html | 19 +++++++ .../rest_framework_json_api/includes.html | 40 +++++++++++++++ 9 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 example/tests/integration/test_browsable_api.py create mode 100644 rest_framework_json_api/templates/rest_framework_json_api/api.html create mode 100644 rest_framework_json_api/templates/rest_framework_json_api/includes.html diff --git a/AUTHORS b/AUTHORS index b876d4ae..b440df81 100644 --- a/AUTHORS +++ b/AUTHORS @@ -6,6 +6,7 @@ Beni Keller Boris Pleshakov Charlie Allatson Christian Zosel +David Guillot, for Contexte David Vogt Felix Viernickel Greg Aker diff --git a/CHANGELOG.md b/CHANGELOG.md index 99629181..12b3e805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] - TBD + +### Added + +* Ability for the user to select `included_serializers` to apply when using `BrowsableAPI`, based on available `included_serializers` defined for the current endpoint. + + ## [4.0.0] - 2020-10-31 This release is not backwards compatible. For easy migration best upgrade first to version diff --git a/README.rst b/README.rst index d5d5959f..7ffbcc0e 100644 --- a/README.rst +++ b/README.rst @@ -184,7 +184,7 @@ override ``settings.REST_FRAMEWORK`` ), 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', - 'rest_framework.renderers.BrowsableAPIRenderer', + 'rest_framework_json_api.renderers.BrowsableAPIRenderer', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( diff --git a/docs/usage.md b/docs/usage.md index c091cd4d..afc8a7ac 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -28,7 +28,7 @@ REST_FRAMEWORK = { # If performance testing, enable: # 'example.utils.BrowsableAPIRendererWithoutForms', # Otherwise, to play around with the browseable API, enable: - 'rest_framework.renderers.BrowsableAPIRenderer' + 'rest_framework_json_api.renderers.BrowsableAPIRenderer' ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', diff --git a/example/settings/dev.py b/example/settings/dev.py index 961807e3..d0e19fd2 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -86,7 +86,7 @@ # If performance testing, enable: # 'example.utils.BrowsableAPIRendererWithoutForms', # Otherwise, to play around with the browseable API, enable: - 'rest_framework.renderers.BrowsableAPIRenderer', + 'rest_framework_json_api.renderers.BrowsableAPIRenderer', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', diff --git a/example/tests/integration/test_browsable_api.py b/example/tests/integration/test_browsable_api.py new file mode 100644 index 00000000..d4bc3fbb --- /dev/null +++ b/example/tests/integration/test_browsable_api.py @@ -0,0 +1,29 @@ +import re + +import pytest +from django.urls import reverse + +pytestmark = pytest.mark.django_db + + +def test_browsable_api_with_included_serializers(single_entry, client): + response = client.get( + reverse( + "entry-detail", + kwargs={'pk': single_entry.pk, 'format': 'api'} + ) + ) + content = str(response.content) + assert response.status_code == 200 + assert re.search(r'JSON:API includes', content) + assert re.search( + r']* value="authors.bio"', + content + ) + + +def test_browsable_api_with_no_included_serializers(client): + response = client.get(reverse("projecttype-list", kwargs={'format': 'api'})) + content = str(response.content) + assert response.status_code == 200 + assert not re.search(r'JSON:API includes', content) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index b6f216be..a9b8dd82 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -7,6 +7,7 @@ import inflection from django.db.models import Manager +from django.template import loader from django.utils import encoding from rest_framework import relations, renderers from rest_framework.fields import SkipField, get_attribute @@ -606,3 +607,53 @@ def render(self, data, accepted_media_type=None, renderer_context=None): return super(JSONRenderer, self).render( render_data, accepted_media_type, renderer_context ) + + +class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer): + template = 'rest_framework_json_api/api.html' + includes_template = 'rest_framework_json_api/includes.html' + + def get_context(self, data, accepted_media_type, renderer_context): + context = super(BrowsableAPIRenderer, self).get_context( + data, accepted_media_type, renderer_context + ) + view = renderer_context['view'] + + context['includes_form'] = self.get_includes_form(view) + + return context + + @classmethod + def _get_included_serializers(cls, serializer, prefix='', already_seen=None): + if not already_seen: + already_seen = set() + + if serializer in already_seen: + return [] + + included_serializers = [] + already_seen.add(serializer) + + for include, included_serializer in utils.get_included_serializers(serializer).items(): + included_serializers.append(f'{prefix}{include}') + included_serializers.extend( + cls._get_included_serializers( + included_serializer, f'{prefix}{include}.', + already_seen=already_seen + ) + ) + + return included_serializers + + def get_includes_form(self, view): + try: + serializer_class = view.get_serializer_class() + except AttributeError: + return + + if not hasattr(serializer_class, 'included_serializers'): + return + + template = loader.get_template(self.includes_template) + context = {'elements': self._get_included_serializers(serializer_class)} + return template.render(context) diff --git a/rest_framework_json_api/templates/rest_framework_json_api/api.html b/rest_framework_json_api/templates/rest_framework_json_api/api.html new file mode 100644 index 00000000..a9f7fae1 --- /dev/null +++ b/rest_framework_json_api/templates/rest_framework_json_api/api.html @@ -0,0 +1,19 @@ +{% extends "rest_framework/base.html" %} +{% load i18n %} + +{% block request_forms %} + {{ block.super }} + {% if includes_form %} + + {% endif %} +{% endblock request_forms %} + +{% block script %} + {{ block.super }} + {% if includes_form %} + {{ includes_form }} + {% endif %} +{% endblock script %} diff --git a/rest_framework_json_api/templates/rest_framework_json_api/includes.html b/rest_framework_json_api/templates/rest_framework_json_api/includes.html new file mode 100644 index 00000000..a14a9285 --- /dev/null +++ b/rest_framework_json_api/templates/rest_framework_json_api/includes.html @@ -0,0 +1,40 @@ +{% load i18n %} + + + From c392a439510ef05e6b9f9554b59cb3577fd86610 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 10 Nov 2020 02:10:34 +0100 Subject: [PATCH 266/488] Convert test_utils to pytest styled tests (#856) Added generic models as by example of DRF. Those are not example models as before but generic in representing different constellations possible with Django models. --- example/tests/test_utils.py | 40 ------ example/tests/unit/test_utils.py | 129 ----------------- tests/models.py | 37 +++++ tests/test_utils.py | 240 +++++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 169 deletions(-) delete mode 100644 example/tests/test_utils.py delete mode 100644 example/tests/unit/test_utils.py create mode 100644 tests/models.py create mode 100644 tests/test_utils.py diff --git a/example/tests/test_utils.py b/example/tests/test_utils.py deleted file mode 100644 index 4fff3225..00000000 --- a/example/tests/test_utils.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Test rest_framework_json_api's utils functions. -""" -from rest_framework_json_api import utils - -from example.serializers import AuthorSerializer, EntrySerializer -from example.tests import TestBase - - -class GetRelatedResourceTests(TestBase): - """ - Ensure the `get_related_resource_type` function returns correct types. - """ - - def test_reverse_relation(self): - """ - Ensure reverse foreign keys have their types identified correctly. - """ - serializer = EntrySerializer() - field = serializer.fields['comments'] - - self.assertEqual(utils.get_related_resource_type(field), 'comments') - - def test_m2m_relation(self): - """ - Ensure m2ms have their types identified correctly. - """ - serializer = EntrySerializer() - field = serializer.fields['authors'] - - self.assertEqual(utils.get_related_resource_type(field), 'authors') - - def test_m2m_reverse_relation(self): - """ - Ensure reverse m2ms have their types identified correctly. - """ - serializer = AuthorSerializer() - field = serializer.fields['entries'] - - self.assertEqual(utils.get_related_resource_type(field), 'entries') diff --git a/example/tests/unit/test_utils.py b/example/tests/unit/test_utils.py deleted file mode 100644 index f2822f2b..00000000 --- a/example/tests/unit/test_utils.py +++ /dev/null @@ -1,129 +0,0 @@ -import pytest -from django.contrib.auth import get_user_model -from django.test import override_settings -from rest_framework import serializers -from rest_framework.generics import GenericAPIView -from rest_framework.response import Response -from rest_framework.views import APIView - -from rest_framework_json_api import utils -from rest_framework_json_api.utils import get_included_serializers - -from example.serializers import AuthorSerializer, BlogSerializer, CommentSerializer, EntrySerializer - -pytestmark = pytest.mark.django_db - - -class NonModelResourceSerializer(serializers.Serializer): - class Meta: - resource_name = 'users' - - -class ResourceSerializer(serializers.ModelSerializer): - class Meta: - fields = ('username',) - model = get_user_model() - - -def test_get_resource_name(): - view = APIView() - context = {'view': view} - with override_settings(JSON_API_FORMAT_TYPES=None): - assert 'APIViews' == utils.get_resource_name(context), 'not formatted' - - context = {'view': view} - with override_settings(JSON_API_FORMAT_TYPES='dasherize'): - assert 'api-views' == utils.get_resource_name(context), 'derived from view' - - view.model = get_user_model() - assert 'users' == utils.get_resource_name(context), 'derived from view model' - - view.resource_name = 'custom' - assert 'custom' == utils.get_resource_name(context), 'manually set on view' - - view.response = Response(status=403) - assert 'errors' == utils.get_resource_name(context), 'handles 4xx error' - - view.response = Response(status=500) - assert 'errors' == utils.get_resource_name(context), 'handles 500 error' - - view = GenericAPIView() - view.serializer_class = ResourceSerializer - context = {'view': view} - assert 'users' == utils.get_resource_name(context), 'derived from serializer' - - view.serializer_class.Meta.resource_name = 'rcustom' - assert 'rcustom' == utils.get_resource_name(context), 'set on serializer' - - view = GenericAPIView() - view.serializer_class = NonModelResourceSerializer - context = {'view': view} - assert 'users' == utils.get_resource_name(context), 'derived from non-model serializer' - - -@pytest.mark.parametrize("format_type,output", [ - ('camelize', {'fullName': {'last-name': 'a', 'first-name': 'b'}}), - ('capitalize', {'FullName': {'last-name': 'a', 'first-name': 'b'}}), - ('dasherize', {'full-name': {'last-name': 'a', 'first-name': 'b'}}), - ('underscore', {'full_name': {'last-name': 'a', 'first-name': 'b'}}), -]) -def test_format_field_names(settings, format_type, output): - settings.JSON_API_FORMAT_FIELD_NAMES = format_type - - value = {'full_name': {'last-name': 'a', 'first-name': 'b'}} - assert utils.format_field_names(value, format_type) == output - - -def test_format_value(): - assert utils.format_value('first_name', 'camelize') == 'firstName' - assert utils.format_value('first_name', 'capitalize') == 'FirstName' - assert utils.format_value('first_name', 'dasherize') == 'first-name' - assert utils.format_value('first-name', 'underscore') == 'first_name' - - -def test_format_resource_type(): - assert utils.format_resource_type('first_name', 'capitalize') == 'FirstNames' - assert utils.format_resource_type('first_name', 'camelize') == 'firstNames' - - -class SerializerWithIncludedSerializers(EntrySerializer): - included_serializers = { - 'blog': BlogSerializer, - 'authors': 'example.serializers.AuthorSerializer', - 'comments': 'example.serializers.CommentSerializer', - 'self': 'self' # this wouldn't make sense in practice (and would be prohibited by - # IncludedResourcesValidationMixin) but it's useful for the test - } - - -def test_get_included_serializers_against_class(): - klass = SerializerWithIncludedSerializers - included_serializers = get_included_serializers(klass) - expected_included_serializers = { - 'blog': BlogSerializer, - 'authors': AuthorSerializer, - 'comments': CommentSerializer, - 'self': klass - } - assert included_serializers.keys() == klass.included_serializers.keys(), ( - 'the keys must be preserved' - ) - - assert included_serializers == expected_included_serializers - - -def test_get_included_serializers_against_instance(): - klass = SerializerWithIncludedSerializers - instance = klass() - included_serializers = get_included_serializers(instance) - expected_included_serializers = { - 'blog': BlogSerializer, - 'authors': AuthorSerializer, - 'comments': CommentSerializer, - 'self': klass - } - assert included_serializers.keys() == klass.included_serializers.keys(), ( - 'the keys must be preserved' - ) - - assert included_serializers == expected_included_serializers diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 00000000..e91911ce --- /dev/null +++ b/tests/models.py @@ -0,0 +1,37 @@ +from django.db import models + + +class DJAModel(models.Model): + """ + Base for test models that sets app_label, so they play nicely. + """ + + class Meta: + app_label = 'tests' + abstract = True + + +class BasicModel(DJAModel): + text = models.CharField(max_length=100) + + +# Models for relations tests +# ManyToMany +class ManyToManyTarget(DJAModel): + name = models.CharField(max_length=100) + + +class ManyToManySource(DJAModel): + name = models.CharField(max_length=100) + targets = models.ManyToManyField(ManyToManyTarget, related_name='sources') + + +# ForeignKey +class ForeignKeyTarget(DJAModel): + name = models.CharField(max_length=100) + + +class ForeignKeySource(DJAModel): + name = models.CharField(max_length=100) + target = models.ForeignKey(ForeignKeyTarget, related_name='sources', + on_delete=models.CASCADE) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..8f272e1a --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,240 @@ +import pytest +from django.db import models +from rest_framework import status +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response +from rest_framework.views import APIView + +from rest_framework_json_api import serializers +from rest_framework_json_api.utils import ( + format_field_names, + format_resource_type, + format_value, + get_included_serializers, + get_related_resource_type, + get_resource_name +) +from tests.models import ( + BasicModel, + DJAModel, + ForeignKeySource, + ForeignKeyTarget, + ManyToManySource, + ManyToManyTarget +) + + +def test_get_resource_name_no_view(): + assert get_resource_name({}) is None + + +@pytest.mark.parametrize("format_type,pluralize_type,output", [ + (False, False, 'APIView'), + (False, True, 'APIViews'), + ('dasherize', False, 'api-view'), + ('dasherize', True, 'api-views'), +]) +def test_get_resource_name_from_view(settings, format_type, pluralize_type, output): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + view = APIView() + context = {'view': view} + assert output == get_resource_name(context) + + +@pytest.mark.parametrize("format_type,pluralize_type", [ + (False, False), + (False, True), + ('dasherize', False), + ('dasherize', True), +]) +def test_get_resource_name_from_view_custom_resource_name(settings, format_type, pluralize_type): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + view = APIView() + view.resource_name = 'custom' + context = {'view': view} + assert 'custom' == get_resource_name(context) + + +@pytest.mark.parametrize("format_type,pluralize_type,output", [ + (False, False, 'BasicModel'), + (False, True, 'BasicModels'), + ('dasherize', False, 'basic-model'), + ('dasherize', True, 'basic-models'), +]) +def test_get_resource_name_from_model(settings, format_type, pluralize_type, output): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + view = APIView() + view.model = BasicModel + context = {'view': view} + assert output == get_resource_name(context) + + +@pytest.mark.parametrize("format_type,pluralize_type,output", [ + (False, False, 'BasicModel'), + (False, True, 'BasicModels'), + ('dasherize', False, 'basic-model'), + ('dasherize', True, 'basic-models'), +]) +def test_get_resource_name_from_model_serializer_class(settings, format_type, + pluralize_type, output): + class BasicModelSerializer(serializers.ModelSerializer): + class Meta: + fields = ('text',) + model = BasicModel + + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + view = GenericAPIView() + view.serializer_class = BasicModelSerializer + context = {'view': view} + assert output == get_resource_name(context) + + +@pytest.mark.parametrize("format_type,pluralize_type", [ + (False, False), + (False, True), + ('dasherize', False), + ('dasherize', True), +]) +def test_get_resource_name_from_model_serializer_class_custom_resource_name(settings, + format_type, + pluralize_type): + class BasicModelSerializer(serializers.ModelSerializer): + class Meta: + fields = ('text',) + model = BasicModel + + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + view = GenericAPIView() + view.serializer_class = BasicModelSerializer + view.serializer_class.Meta.resource_name = 'custom' + + context = {'view': view} + assert 'custom' == get_resource_name(context) + + +@pytest.mark.parametrize("format_type,pluralize_type", [ + (False, False), + (False, True), + ('dasherize', False), + ('dasherize', True), +]) +def test_get_resource_name_from_plain_serializer_class(settings, format_type, pluralize_type): + class PlainSerializer(serializers.Serializer): + class Meta: + resource_name = 'custom' + + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + view = GenericAPIView() + view.serializer_class = PlainSerializer + + context = {'view': view} + assert 'custom' == get_resource_name(context) + + +@pytest.mark.parametrize("status_code", [ + status.HTTP_400_BAD_REQUEST, + status.HTTP_403_FORBIDDEN, + status.HTTP_500_INTERNAL_SERVER_ERROR +]) +def test_get_resource_name_with_errors(status_code): + view = APIView() + context = {'view': view} + view.response = Response(status=status_code) + assert 'errors' == get_resource_name(context) + + +@pytest.mark.parametrize("format_type,output", [ + ('camelize', {'fullName': {'last-name': 'a', 'first-name': 'b'}}), + ('capitalize', {'FullName': {'last-name': 'a', 'first-name': 'b'}}), + ('dasherize', {'full-name': {'last-name': 'a', 'first-name': 'b'}}), + ('underscore', {'full_name': {'last-name': 'a', 'first-name': 'b'}}), +]) +def test_format_field_names(settings, format_type, output): + settings.JSON_API_FORMAT_FIELD_NAMES = format_type + + value = {'full_name': {'last-name': 'a', 'first-name': 'b'}} + assert format_field_names(value, format_type) == output + + +@pytest.mark.parametrize("format_type,output", [ + (None, 'first_name'), + ('camelize', 'firstName'), + ('capitalize', 'FirstName'), + ('dasherize', 'first-name'), + ('underscore', 'first_name') +]) +def test_format_value(settings, format_type, output): + assert format_value('first_name', format_type) == output + + +@pytest.mark.parametrize("resource_type,pluralize,output", [ + (None, None, 'ResourceType'), + ('camelize', False, 'resourceType'), + ('camelize', True, 'resourceTypes'), +]) +def test_format_resource_type(settings, resource_type, pluralize, output): + assert format_resource_type('ResourceType', resource_type, pluralize) == output + + +@pytest.mark.parametrize('model_class,field,output', [ + (ManyToManySource, 'targets', 'ManyToManyTarget'), + (ManyToManyTarget, 'sources', 'ManyToManySource'), + (ForeignKeySource, 'target', 'ForeignKeyTarget'), + (ForeignKeyTarget, 'sources', 'ForeignKeySource'), +]) +def test_get_related_resource_type(model_class, field, output): + class RelatedResourceTypeSerializer(serializers.ModelSerializer): + class Meta: + model = model_class + fields = (field,) + + serializer = RelatedResourceTypeSerializer() + field = serializer.fields[field] + assert get_related_resource_type(field) == output + + +class ManyToManyTargetSerializer(serializers.ModelSerializer): + class Meta: + model = ManyToManyTarget + + +def test_get_included_serializers(): + class IncludedSerializersModel(DJAModel): + self = models.ForeignKey('self', on_delete=models.CASCADE) + target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) + other_target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) + + class Meta: + app_label = 'tests' + + class IncludedSerializersSerializer(serializers.ModelSerializer): + included_serializers = { + 'self': 'self', + 'target': ManyToManyTargetSerializer, + 'other_target': 'tests.test_utils.ManyToManyTargetSerializer' + } + + class Meta: + model = IncludedSerializersModel + fields = ('self', 'other_target', 'target') + + included_serializers = get_included_serializers(IncludedSerializersSerializer) + expected_included_serializers = { + 'self': IncludedSerializersSerializer, + 'target': ManyToManyTargetSerializer, + 'other_target': ManyToManyTargetSerializer + } + + assert included_serializers == expected_included_serializers From 1e2594f1272aa07775ea67667a8186b5bfa48187 Mon Sep 17 00:00:00 2001 From: Ulrich Schuster <62716187+uliSchuster@users.noreply.github.com> Date: Thu, 12 Nov 2020 00:54:02 +0100 Subject: [PATCH 267/488] Allow users to overwrite `get_serializer_class` while using related urls (#860) * This attempt to fix the issues breaks tests An attempt to fix #859, which unfortunately breaks two tests in test_views. * Removed misleading comment Tha basic method was copied over from the GenericAPIView. The comment here does not fit, though. * bein more explicit about the changes * Use correct serializer for rendering Ensure that the correct resource is returned during rendering, which in turn requires to use the correct serializer, depending on if it is a related resource or the parent resource. sliverc pointed out the issue here. * Add provision for tests * Describe the fix. * Fixed failing tests As pointed out by n2ygk, the schema names here must reflect the Serializer class names, which I changed in the two affected test cases. * Update CHANGELOG.md Clarified changelog entry * Fixed linting issues Co-authored-by: Ulrich Schuster Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 4 ++++ example/serializers.py | 8 ++++++++ example/tests/snapshots/snap_test_openapi.py | 4 ++-- example/tests/test_views.py | 14 +++++++------- example/views.py | 13 ++++++++++++- rest_framework_json_api/utils.py | 5 ++++- rest_framework_json_api/views.py | 12 +++++++++--- 8 files changed, 47 insertions(+), 14 deletions(-) diff --git a/AUTHORS b/AUTHORS index b440df81..d4c03b2c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -33,4 +33,5 @@ Sergey Kolomenkin Stas S. Tim Selman Tom Glowka +Ulrich Schuster Yaniv Peer diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b3e805..4613aa18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ any parts of the framework not mentioned in the documentation should generally b * Ability for the user to select `included_serializers` to apply when using `BrowsableAPI`, based on available `included_serializers` defined for the current endpoint. +### Fixed + +* Allow users to overwrite a view's `get_serializer_class()` method when using [related urls](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#related-urls) + ## [4.0.0] - 2020-10-31 diff --git a/example/serializers.py b/example/serializers.py index 9dc84a4a..566f39d5 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -261,6 +261,14 @@ def get_first_entry(self, obj): return obj.entries.first() +class AuthorListSerializer(AuthorSerializer): + pass + + +class AuthorDetailSerializer(AuthorSerializer): + pass + + class WriterSerializer(serializers.ModelSerializer): included_serializers = { 'bio': AuthorBioSerializer diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py index ec8da388..aca41d70 100644 --- a/example/tests/snapshots/snap_test_openapi.py +++ b/example/tests/snapshots/snap_test_openapi.py @@ -65,7 +65,7 @@ "properties": { "data": { "items": { - "$ref": "#/components/schemas/Author" + "$ref": "#/components/schemas/AuthorList" }, "type": "array" }, @@ -171,7 +171,7 @@ "schema": { "properties": { "data": { - "$ref": "#/components/schemas/Author" + "$ref": "#/components/schemas/AuthorDetail" }, "included": { "items": { diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 9cd493f7..25eeca89 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -367,16 +367,16 @@ def test_get_related_instance_model_field(self): got = view.get_related_instance() self.assertEqual(got, self.author.id) - def test_get_serializer_class(self): + def test_get_related_serializer_class(self): kwargs = {'pk': self.author.id, 'related_field': 'bio'} view = self._get_view(kwargs) - got = view.get_serializer_class() + got = view.get_related_serializer_class() self.assertEqual(got, AuthorBioSerializer) - def test_get_serializer_class_many(self): + def test_get_related_serializer_class_many(self): kwargs = {'pk': self.author.id, 'related_field': 'entries'} view = self._get_view(kwargs) - got = view.get_serializer_class() + got = view.get_related_serializer_class() self.assertEqual(got, EntrySerializer) def test_get_serializer_comes_from_included_serializers(self): @@ -384,15 +384,15 @@ def test_get_serializer_comes_from_included_serializers(self): view = self._get_view(kwargs) related_serializers = view.serializer_class.related_serializers delattr(view.serializer_class, 'related_serializers') - got = view.get_serializer_class() + got = view.get_related_serializer_class() self.assertEqual(got, AuthorTypeSerializer) view.serializer_class.related_serializers = related_serializers - def test_get_serializer_class_raises_error(self): + def test_get_related_serializer_class_raises_error(self): kwargs = {'pk': self.author.id, 'related_field': 'unknown'} view = self._get_view(kwargs) - self.assertRaises(NotFound, view.get_serializer_class) + self.assertRaises(NotFound, view.get_related_serializer_class) def test_retrieve_related_single_reverse_lookup(self): url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'bio'}) diff --git a/example/views.py b/example/views.py index 8c80d145..99a54193 100644 --- a/example/views.py +++ b/example/views.py @@ -15,6 +15,8 @@ from example.models import Author, Blog, Comment, Company, Entry, Project, ProjectType from example.serializers import ( + AuthorDetailSerializer, + AuthorListSerializer, AuthorSerializer, BlogDRFSerializer, BlogSerializer, @@ -185,7 +187,16 @@ class NoFiltersetEntryViewSet(EntryViewSet): class AuthorViewSet(ModelViewSet): queryset = Author.objects.all() - serializer_class = AuthorSerializer + serializer_classes = { + "list": AuthorListSerializer, + "retrieve": AuthorDetailSerializer} + serializer_class = AuthorSerializer # fallback + + def get_serializer_class(self): + try: + return self.serializer_classes.get(self.action, self.serializer_class) + except AttributeError: + return self.serializer_class class CommentViewSet(ModelViewSet): diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 2443575f..b99de91a 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -52,7 +52,10 @@ def get_resource_name(context, expand_polymorphic_types=False): resource_name = getattr(view, 'resource_name') except AttributeError: try: - serializer = view.get_serializer_class() + if 'kwargs' in context and 'related_field' in context['kwargs']: + serializer = view.get_related_serializer_class() + else: + serializer = view.get_serializer_class() if expand_polymorphic_types and issubclass(serializer, PolymorphicModelSerializer): return serializer.get_polymorphic_types() else: diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 2e0b22ef..7c874e7a 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -144,10 +144,15 @@ def retrieve_related(self, request, *args, **kwargs): if isinstance(instance, Iterable): serializer_kwargs['many'] = True - serializer = self.get_serializer(instance, **serializer_kwargs) + serializer = self.get_related_serializer(instance, **serializer_kwargs) return Response(serializer.data) - def get_serializer_class(self): + def get_related_serializer(self, instance, **kwargs): + serializer_class = self.get_related_serializer_class() + kwargs.setdefault('context', self.get_serializer_context()) + return serializer_class(instance, **kwargs) + + def get_related_serializer_class(self): parent_serializer_class = super(RelatedMixin, self).get_serializer_class() if 'related_field' in self.kwargs: @@ -179,7 +184,8 @@ def get_related_field_name(self): def get_related_instance(self): parent_obj = self.get_object() - parent_serializer = self.serializer_class(parent_obj) + parent_serializer_class = self.get_serializer_class() + parent_serializer = parent_serializer_class(parent_obj) field_name = self.get_related_field_name() field = parent_serializer.fields.get(field_name, None) From 5c40b980e201fe3b7ee899c6d271574009504a25 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 13 Nov 2020 16:56:41 +0100 Subject: [PATCH 268/488] Add configuration to format with black --- .travis.yml | 2 ++ requirements/requirements-codestyle.txt | 1 + setup.cfg | 25 +++++++++++++++---------- tox.ini | 6 ++++++ 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.travis.yml b/.travis.yml index 05feb314..65ea1cc8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,8 @@ matrix: - env: TOXENV=py39-django31-drfmaster include: + - python: 3.6 + env: TOXENV=black - python: 3.6 env: TOXENV=lint - python: 3.6 diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index c144f975..cdb8b514 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,3 +1,4 @@ +black==20.8b1 flake8==3.8.4 flake8-isort==4.0.0 isort==5.6.4 diff --git a/setup.cfg b/setup.cfg index 4f483e02..07b279c8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,29 +5,34 @@ test = pytest universal = 1 [flake8] -ignore = F405,W504 -max-line-length = 100 +max-line-length = 88 +extend-ignore = + # whitespace before ':' - disabled as not PEP8 compliant + E203, + # line too long (managed by black) + E501, + # usage of star imports + # TODO mark star imports directly in code to ignore this error + F405 exclude = - snapshots build/lib, - docs/conf.py, - migrations, .eggs .tox, env .venv [isort] -indent = 4 +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +line_length = 88 known_first_party = rest_framework_json_api # This is to "trick" isort into putting example below DJA imports. known_localfolder = example -line_length = 100 -multi_line_output = 3 skip= build/lib, - docs/conf.py, - migrations, .eggs .tox, env diff --git a/tox.ini b/tox.ini index c0ebf760..dc808c01 100644 --- a/tox.ini +++ b/tox.ini @@ -20,6 +20,12 @@ setenv = commands = pytest --cov --no-cov-on-fail --cov-report xml {posargs} +[testenv:black] +basepython = python3.6 +deps = + -rrequirements/requirements-codestyle.txt +commands = black --check . + [testenv:lint] basepython = python3.6 deps = From 0146890ecf4ba62d242c7981fdf932c01a7c9f8f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 13 Nov 2020 17:00:05 +0100 Subject: [PATCH 269/488] Format code with black --- docs/conf.py | 173 ++-- example/api/resources/identity.py | 23 +- example/api/serializers/identity.py | 15 +- example/api/serializers/post.py | 1 + example/factories.py | 8 +- example/migrations/0001_initial.py | 157 ++-- example/migrations/0002_taggeditem.py | 36 +- example/migrations/0003_polymorphics.py | 114 ++- example/migrations/0004_auto_20171011_0631.py | 64 +- example/migrations/0005_auto_20180922_1508.py | 46 +- example/migrations/0006_auto_20181228_0752.py | 45 +- .../migrations/0007_artproject_description.py | 6 +- example/migrations/0008_labresults.py | 29 +- example/models.py | 40 +- example/serializers.py | 257 +++--- example/settings/dev.py | 116 ++- example/settings/test.py | 20 +- example/tests/__init__.py | 16 +- example/tests/conftest.py | 6 +- .../tests/integration/test_browsable_api.py | 14 +- example/tests/integration/test_includes.py | 272 ++++--- example/tests/integration/test_meta.py | 62 +- .../integration/test_model_resource_name.py | 169 ++-- .../test_non_paginated_responses.py | 76 +- example/tests/integration/test_pagination.py | 66 +- .../tests/integration/test_polymorphism.py | 261 +++--- .../integration/test_sparse_fieldsets.py | 24 +- example/tests/snapshots/snap_test_errors.py | 123 ++- example/tests/snapshots/snap_test_openapi.py | 31 +- example/tests/test_errors.py | 180 ++--- example/tests/test_filters.py | 610 ++++++++------ example/tests/test_format_keys.py | 56 +- example/tests/test_generic_validation.py | 18 +- example/tests/test_generic_viewset.py | 112 +-- example/tests/test_model_viewsets.py | 203 ++--- example/tests/test_openapi.py | 84 +- example/tests/test_parsers.py | 87 +- example/tests/test_performance.py | 40 +- example/tests/test_relations.py | 199 +++-- example/tests/test_serializers.py | 149 ++-- example/tests/test_sideload_resources.py | 9 +- example/tests/test_views.py | 613 +++++++------- .../unit/test_default_drf_serializers.py | 127 ++- example/tests/unit/test_factories.py | 34 +- .../tests/unit/test_filter_schema_params.py | 107 ++- example/tests/unit/test_pagination.py | 62 +- .../tests/unit/test_renderer_class_methods.py | 92 +-- example/tests/unit/test_renderers.py | 94 +-- .../unit/test_serializer_method_field.py | 11 +- example/tests/unit/test_settings.py | 4 +- example/urls.py | 125 +-- example/urls_test.py | 129 +-- example/utils.py | 2 +- example/views.py | 96 ++- rest_framework_json_api/__init__.py | 10 +- .../django_filters/backends.py | 36 +- rest_framework_json_api/exceptions.py | 7 +- rest_framework_json_api/filters.py | 43 +- rest_framework_json_api/metadata.py | 160 ++-- rest_framework_json_api/pagination.py | 88 +- rest_framework_json_api/parsers.py | 98 ++- rest_framework_json_api/relations.py | 192 +++-- rest_framework_json_api/renderers.py | 376 +++++---- rest_framework_json_api/schemas/openapi.py | 760 +++++++++--------- rest_framework_json_api/serializers.py | 157 ++-- rest_framework_json_api/settings.py | 20 +- rest_framework_json_api/utils.py | 166 ++-- rest_framework_json_api/views.py | 128 +-- setup.py | 98 +-- tests/models.py | 9 +- tests/test_utils.py | 246 +++--- 71 files changed, 4453 insertions(+), 3654 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index ee435d36..0b8caa69 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,7 +15,6 @@ import datetime import os -import shlex import sys import django @@ -26,44 +25,44 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('..')) -os.environ['DJANGO_SETTINGS_MODULE'] = 'example.settings' +sys.path.insert(0, os.path.abspath("..")) +os.environ["DJANGO_SETTINGS_MODULE"] = "example.settings" django.setup() # Auto-generate API documentation. -main(['-o', 'apidoc', '-f', '-e', '-T', '-M', '../rest_framework_json_api']) +main(["-o", "apidoc", "-f", "-e", "-T", "-M", "../rest_framework_json_api"]) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.autodoc', 'recommonmark'] -autodoc_member_order = 'bysource' +extensions = ["sphinx.ext.autodoc", "recommonmark"] +autodoc_member_order = "bysource" autodoc_inherit_docstrings = False # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] -source_suffix = ['.rst', '.md'] +source_suffix = [".rst", ".md"] # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'Django REST Framework JSON API' +project = "Django REST Framework JSON API" year = datetime.date.today().year -copyright = '{}, Django REST Framework JSON API contributors'.format(year) -author = 'Django REST Framework JSON API contributors' +copyright = "{}, Django REST Framework JSON API contributors".format(year) +author = "Django REST Framework JSON API contributors" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -83,37 +82,37 @@ # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build', 'pull_request_template.md'] +exclude_patterns = ["_build", "pull_request_template.md"] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'default' +pygments_style = "default" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -123,150 +122,153 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = "default" -on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +on_rtd = os.environ.get("READTHEDOCS", None) == "True" if not on_rtd: # only import and set the theme if we're building docs locally import sphinx_rtd_theme - html_theme = 'sphinx_rtd_theme' + + html_theme = "sphinx_rtd_theme" html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' -#html_search_language = 'en' +# html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # Now only 'ja' uses this config value -#html_search_options = {'type': 'default'} +# html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' +# html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. -htmlhelp_basename = 'DjangoRESTFrameworkJSONAPIdoc' +htmlhelp_basename = "DjangoRESTFrameworkJSONAPIdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', + # Latex figure (float) alignment + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'DjangoRESTFrameworkJSONAPI.tex', 'Django REST Framework JSON API Documentation', - 'Django REST Framework JSON API contributors', 'manual'), + ( + master_doc, + "DjangoRESTFrameworkJSONAPI.tex", + "Django REST Framework JSON API Documentation", + "Django REST Framework JSON API contributors", + "manual", + ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- @@ -274,12 +276,17 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'djangorestframeworkjsonapi', 'Django REST Framework JSON API Documentation', - [author], 1) + ( + master_doc, + "djangorestframeworkjsonapi", + "Django REST Framework JSON API Documentation", + [author], + 1, + ) ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -288,19 +295,25 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'DjangoRESTFrameworkJSONAPI', 'Django REST Framework JSON API Documentation', - author, 'DjangoRESTFrameworkJSONAPI', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "DjangoRESTFrameworkJSONAPI", + "Django REST Framework JSON API Documentation", + author, + "DjangoRESTFrameworkJSONAPI", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/example/api/resources/identity.py b/example/api/resources/identity.py index 6785e5d9..a291ba4f 100644 --- a/example/api/resources/identity.py +++ b/example/api/resources/identity.py @@ -11,7 +11,7 @@ class Identity(viewsets.ModelViewSet): - queryset = auth_models.User.objects.all().order_by('pk') + queryset = auth_models.User.objects.all().order_by("pk") serializer_class = IdentitySerializer # demonstrate sideloading data for use at app boot time @@ -20,22 +20,24 @@ def posts(self, request): self.resource_name = False identities = self.queryset - posts = [{'id': 1, 'title': 'Test Blog Post'}] + posts = [{"id": 1, "title": "Test Blog Post"}] data = { - encoding.force_str('identities'): IdentitySerializer(identities, many=True).data, - encoding.force_str('posts'): PostSerializer(posts, many=True).data, + encoding.force_str("identities"): IdentitySerializer( + identities, many=True + ).data, + encoding.force_str("posts"): PostSerializer(posts, many=True).data, } - return Response(utils.format_field_names(data, format_type='camelize')) + return Response(utils.format_field_names(data, format_type="camelize")) @action(detail=True) def manual_resource_name(self, request, *args, **kwargs): - self.resource_name = 'data' + self.resource_name = "data" return super(Identity, self).retrieve(request, args, kwargs) @action(detail=True) def validation(self, request, *args, **kwargs): - raise serializers.ValidationError('Oh nohs!') + raise serializers.ValidationError("Oh nohs!") class GenericIdentity(generics.GenericAPIView): @@ -44,10 +46,11 @@ class GenericIdentity(generics.GenericAPIView): GET /identities/generic """ + serializer_class = IdentitySerializer - allowed_methods = ['GET'] - renderer_classes = (renderers.JSONRenderer, ) - parser_classes = (parsers.JSONParser, ) + allowed_methods = ["GET"] + renderer_classes = (renderers.JSONRenderer,) + parser_classes = (parsers.JSONParser,) def get_queryset(self): return auth_models.User.objects.all() diff --git a/example/api/serializers/identity.py b/example/api/serializers/identity.py index 2538c6df..069259d2 100644 --- a/example/api/serializers/identity.py +++ b/example/api/serializers/identity.py @@ -9,17 +9,16 @@ class IdentitySerializer(serializers.ModelSerializer): def validate_first_name(self, data): if len(data) > 10: - raise serializers.ValidationError( - 'There\'s a problem with first name') + raise serializers.ValidationError("There's a problem with first name") return data def validate_last_name(self, data): if len(data) > 10: raise serializers.ValidationError( { - 'id': 'armageddon101', - 'detail': 'Hey! You need a last name!', - 'meta': 'something', + "id": "armageddon101", + "detail": "Hey! You need a last name!", + "meta": "something", } ) return data @@ -27,4 +26,8 @@ def validate_last_name(self, data): class Meta: model = auth_models.User fields = ( - 'id', 'first_name', 'last_name', 'email', ) + "id", + "first_name", + "last_name", + "email", + ) diff --git a/example/api/serializers/post.py b/example/api/serializers/post.py index bf0cf463..dbd78cfc 100644 --- a/example/api/serializers/post.py +++ b/example/api/serializers/post.py @@ -5,4 +5,5 @@ class PostSerializer(serializers.Serializer): """ Blog post serializer """ + title = serializers.CharField(max_length=50) diff --git a/example/factories.py b/example/factories.py index 0639a0dd..0de561f6 100644 --- a/example/factories.py +++ b/example/factories.py @@ -15,7 +15,7 @@ Entry, ProjectType, ResearchProject, - TaggedItem + TaggedItem, ) faker = FakerFactory.create() @@ -43,7 +43,7 @@ class Meta: name = factory.LazyAttribute(lambda x: faker.name()) email = factory.LazyAttribute(lambda x: faker.email()) - bio = factory.RelatedFactory('example.factories.AuthorBioFactory', 'author') + bio = factory.RelatedFactory("example.factories.AuthorBioFactory", "author") type = factory.SubFactory(AuthorTypeFactory) @@ -54,7 +54,9 @@ class Meta: author = factory.SubFactory(AuthorFactory) body = factory.LazyAttribute(lambda x: faker.text()) - metadata = factory.RelatedFactory('example.factories.AuthorBioMetadataFactory', 'bio') + metadata = factory.RelatedFactory( + "example.factories.AuthorBioMetadataFactory", "bio" + ) class AuthorBioMetadataFactory(factory.django.DjangoModelFactory): diff --git a/example/migrations/0001_initial.py b/example/migrations/0001_initial.py index 38cc0be9..35e01afe 100644 --- a/example/migrations/0001_initial.py +++ b/example/migrations/0001_initial.py @@ -2,93 +2,154 @@ # Generated by Django 1.9.5 on 2016-05-02 08:26 from __future__ import unicode_literals -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Author', + name="Author", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=50)), - ('email', models.EmailField(max_length=254)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=50)), + ("email", models.EmailField(max_length=254)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='AuthorBio', + name="AuthorBio", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('body', models.TextField()), - ('author', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='bio', to='example.Author')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("body", models.TextField()), + ( + "author", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="bio", + to="example.Author", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='Blog', + name="Blog", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=100)), - ('tagline', models.TextField()), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=100)), + ("tagline", models.TextField()), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='Comment', + name="Comment", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('body', models.TextField()), - ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='example.Author')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("body", models.TextField()), + ( + "author", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="example.Author", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='Entry', + name="Entry", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('headline', models.CharField(max_length=255)), - ('body_text', models.TextField(null=True)), - ('pub_date', models.DateField(null=True)), - ('mod_date', models.DateField(null=True)), - ('n_comments', models.IntegerField(default=0)), - ('n_pingbacks', models.IntegerField(default=0)), - ('rating', models.IntegerField(default=0)), - ('authors', models.ManyToManyField(to='example.Author')), - ('blog', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='example.Blog')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("headline", models.CharField(max_length=255)), + ("body_text", models.TextField(null=True)), + ("pub_date", models.DateField(null=True)), + ("mod_date", models.DateField(null=True)), + ("n_comments", models.IntegerField(default=0)), + ("n_pingbacks", models.IntegerField(default=0)), + ("rating", models.IntegerField(default=0)), + ("authors", models.ManyToManyField(to="example.Author")), + ( + "blog", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="example.Blog" + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.AddField( - model_name='comment', - name='entry', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='example.Entry'), + model_name="comment", + name="entry", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="example.Entry" + ), ), ] diff --git a/example/migrations/0002_taggeditem.py b/example/migrations/0002_taggeditem.py index 46a79de9..3a57d22f 100644 --- a/example/migrations/0002_taggeditem.py +++ b/example/migrations/0002_taggeditem.py @@ -2,30 +2,44 @@ # Generated by Django 1.10.5 on 2017-02-01 08:34 from __future__ import unicode_literals -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('example', '0001_initial'), + ("contenttypes", "0002_remove_content_type_name"), + ("example", "0001_initial"), ] operations = [ migrations.CreateModel( - name='TaggedItem', + name="TaggedItem", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('tag', models.SlugField()), - ('object_id', models.PositiveIntegerField()), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("tag", models.SlugField()), + ("object_id", models.PositiveIntegerField()), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.ContentType", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/example/migrations/0003_polymorphics.py b/example/migrations/0003_polymorphics.py index 9020176b..46919dbb 100644 --- a/example/migrations/0003_polymorphics.py +++ b/example/migrations/0003_polymorphics.py @@ -2,75 +2,125 @@ # Generated by Django 1.11.1 on 2017-05-17 14:49 from __future__ import unicode_literals -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('example', '0002_taggeditem'), + ("contenttypes", "0002_remove_content_type_name"), + ("example", "0002_taggeditem"), ] operations = [ migrations.CreateModel( - name='Company', + name="Company", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), ], ), migrations.CreateModel( - name='Project', + name="Project", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('topic', models.CharField(max_length=30)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("topic", models.CharField(max_length=30)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.AlterField( - model_name='comment', - name='entry', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='example.Entry'), + model_name="comment", + name="entry", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="example.Entry", + ), ), migrations.CreateModel( - name='ArtProject', + name="ArtProject", fields=[ - ('project_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='example.Project')), - ('artist', models.CharField(max_length=30)), + ( + "project_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="example.Project", + ), + ), + ("artist", models.CharField(max_length=30)), ], options={ - 'abstract': False, + "abstract": False, }, - bases=('example.project',), + bases=("example.project",), ), migrations.CreateModel( - name='ResearchProject', + name="ResearchProject", fields=[ - ('project_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='example.Project')), - ('supervisor', models.CharField(max_length=30)), + ( + "project_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="example.Project", + ), + ), + ("supervisor", models.CharField(max_length=30)), ], options={ - 'abstract': False, + "abstract": False, }, - bases=('example.project',), + bases=("example.project",), ), migrations.AddField( - model_name='project', - name='polymorphic_ctype', - field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_example.project_set+', to='contenttypes.ContentType'), + model_name="project", + name="polymorphic_ctype", + field=models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="polymorphic_example.project_set+", + to="contenttypes.ContentType", + ), ), migrations.AddField( - model_name='company', - name='current_project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='companies', to='example.Project'), + model_name="company", + name="current_project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="companies", + to="example.Project", + ), ), migrations.AddField( - model_name='company', - name='future_projects', - field=models.ManyToManyField(to='example.Project'), + model_name="company", + name="future_projects", + field=models.ManyToManyField(to="example.Project"), ), ] diff --git a/example/migrations/0004_auto_20171011_0631.py b/example/migrations/0004_auto_20171011_0631.py index 96df2aa7..b1035dfe 100644 --- a/example/migrations/0004_auto_20171011_0631.py +++ b/example/migrations/0004_auto_20171011_0631.py @@ -2,61 +2,73 @@ # Generated by Django 1.11.6 on 2017-10-11 06:31 from __future__ import unicode_literals -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('example', '0003_polymorphics'), + ("example", "0003_polymorphics"), ] operations = [ migrations.CreateModel( - name='AuthorType', + name="AuthorType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=50)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=50)), ], options={ - 'ordering': ('id',), + "ordering": ("id",), }, ), migrations.AlterModelOptions( - name='author', - options={'ordering': ('id',)}, + name="author", + options={"ordering": ("id",)}, ), migrations.AlterModelOptions( - name='authorbio', - options={'ordering': ('id',)}, + name="authorbio", + options={"ordering": ("id",)}, ), migrations.AlterModelOptions( - name='blog', - options={'ordering': ('id',)}, + name="blog", + options={"ordering": ("id",)}, ), migrations.AlterModelOptions( - name='comment', - options={'ordering': ('id',)}, + name="comment", + options={"ordering": ("id",)}, ), migrations.AlterModelOptions( - name='entry', - options={'ordering': ('id',)}, + name="entry", + options={"ordering": ("id",)}, ), migrations.AlterModelOptions( - name='taggeditem', - options={'ordering': ('id',)}, + name="taggeditem", + options={"ordering": ("id",)}, ), migrations.AlterField( - model_name='entry', - name='authors', - field=models.ManyToManyField(related_name='entries', to='example.Author'), + model_name="entry", + name="authors", + field=models.ManyToManyField(related_name="entries", to="example.Author"), ), migrations.AddField( - model_name='author', - name='type', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='example.AuthorType'), + model_name="author", + name="type", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="example.AuthorType", + ), ), ] diff --git a/example/migrations/0005_auto_20180922_1508.py b/example/migrations/0005_auto_20180922_1508.py index 99d397f6..58b2808d 100644 --- a/example/migrations/0005_auto_20180922_1508.py +++ b/example/migrations/0005_auto_20180922_1508.py @@ -1,43 +1,55 @@ # Generated by Django 2.1.1 on 2018-09-22 15:08 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('example', '0004_auto_20171011_0631'), + ("example", "0004_auto_20171011_0631"), ] operations = [ migrations.CreateModel( - name='ProjectType', + name="ProjectType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=50)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=50)), ], options={ - 'ordering': ('id',), + "ordering": ("id",), }, ), migrations.AlterModelOptions( - name='artproject', - options={'base_manager_name': 'objects'}, + name="artproject", + options={"base_manager_name": "objects"}, ), migrations.AlterModelOptions( - name='project', - options={'base_manager_name': 'objects'}, + name="project", + options={"base_manager_name": "objects"}, ), migrations.AlterModelOptions( - name='researchproject', - options={'base_manager_name': 'objects'}, + name="researchproject", + options={"base_manager_name": "objects"}, ), migrations.AddField( - model_name='project', - name='project_type', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='example.ProjectType'), + model_name="project", + name="project_type", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="example.ProjectType", + ), ), ] diff --git a/example/migrations/0006_auto_20181228_0752.py b/example/migrations/0006_auto_20181228_0752.py index 2cfb0c29..4126c797 100644 --- a/example/migrations/0006_auto_20181228_0752.py +++ b/example/migrations/0006_auto_20181228_0752.py @@ -1,32 +1,53 @@ # Generated by Django 2.1.4 on 2018-12-28 07:52 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('example', '0005_auto_20180922_1508'), + ("example", "0005_auto_20180922_1508"), ] operations = [ migrations.CreateModel( - name='AuthorBioMetadata', + name="AuthorBioMetadata", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('body', models.TextField()), - ('bio', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='metadata', to='example.AuthorBio')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("body", models.TextField()), + ( + "bio", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="metadata", + to="example.AuthorBio", + ), + ), ], options={ - 'ordering': ('id',), + "ordering": ("id",), }, ), migrations.AlterField( - model_name='comment', - name='author', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='example.Author'), + model_name="comment", + name="author", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="example.Author", + ), ), ] diff --git a/example/migrations/0007_artproject_description.py b/example/migrations/0007_artproject_description.py index 8dec0124..20f9d42e 100644 --- a/example/migrations/0007_artproject_description.py +++ b/example/migrations/0007_artproject_description.py @@ -6,13 +6,13 @@ class Migration(migrations.Migration): dependencies = [ - ('example', '0006_auto_20181228_0752'), + ("example", "0006_auto_20181228_0752"), ] operations = [ migrations.AddField( - model_name='artproject', - name='description', + model_name="artproject", + name="description", field=models.CharField(max_length=100, null=True), ), ] diff --git a/example/migrations/0008_labresults.py b/example/migrations/0008_labresults.py index 89323d77..e0a1d6ba 100644 --- a/example/migrations/0008_labresults.py +++ b/example/migrations/0008_labresults.py @@ -1,23 +1,38 @@ # Generated by Django 3.0.3 on 2020-02-06 10:24 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('example', '0007_artproject_description'), + ("example", "0007_artproject_description"), ] operations = [ migrations.CreateModel( - name='LabResults', + name="LabResults", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date', models.DateField()), - ('measurements', models.TextField()), - ('research_project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lab_results', to='example.ResearchProject')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date", models.DateField()), + ("measurements", models.TextField()), + ( + "research_project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lab_results", + to="example.ResearchProject", + ), + ), ], ), ] diff --git a/example/models.py b/example/models.py index 4df4dc27..47537b57 100644 --- a/example/models.py +++ b/example/models.py @@ -11,6 +11,7 @@ class BaseModel(models.Model): """ I hear RoR has this by default, who doesn't need these two fields! """ + created_at = models.DateTimeField(auto_now_add=True) modified_at = models.DateTimeField(auto_now=True) @@ -22,13 +23,13 @@ class TaggedItem(BaseModel): tag = models.SlugField() content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() - content_object = GenericForeignKey('content_type', 'object_id') + content_object = GenericForeignKey("content_type", "object_id") def __str__(self): return self.tag class Meta: - ordering = ('id',) + ordering = ("id",) class Blog(BaseModel): @@ -40,7 +41,7 @@ def __str__(self): return self.name class Meta: - ordering = ('id',) + ordering = ("id",) class AuthorType(BaseModel): @@ -50,7 +51,7 @@ def __str__(self): return self.name class Meta: - ordering = ('id',) + ordering = ("id",) class Author(BaseModel): @@ -62,32 +63,35 @@ def __str__(self): return self.name class Meta: - ordering = ('id',) + ordering = ("id",) class AuthorBio(BaseModel): - author = models.OneToOneField(Author, related_name='bio', on_delete=models.CASCADE) + author = models.OneToOneField(Author, related_name="bio", on_delete=models.CASCADE) body = models.TextField() def __str__(self): return self.author.name class Meta: - ordering = ('id',) + ordering = ("id",) class AuthorBioMetadata(BaseModel): """ Just a class to have a relation with author bio """ - bio = models.OneToOneField(AuthorBio, related_name='metadata', on_delete=models.CASCADE) + + bio = models.OneToOneField( + AuthorBio, related_name="metadata", on_delete=models.CASCADE + ) body = models.TextField() def __str__(self): return self.bio.author.name class Meta: - ordering = ('id',) + ordering = ("id",) class Entry(BaseModel): @@ -96,7 +100,7 @@ class Entry(BaseModel): body_text = models.TextField(null=True) pub_date = models.DateField(null=True) mod_date = models.DateField(null=True) - authors = models.ManyToManyField(Author, related_name='entries') + authors = models.ManyToManyField(Author, related_name="entries") n_comments = models.IntegerField(default=0) n_pingbacks = models.IntegerField(default=0) rating = models.IntegerField(default=0) @@ -106,25 +110,25 @@ def __str__(self): return self.headline class Meta: - ordering = ('id',) + ordering = ("id",) class Comment(BaseModel): - entry = models.ForeignKey(Entry, related_name='comments', on_delete=models.CASCADE) + entry = models.ForeignKey(Entry, related_name="comments", on_delete=models.CASCADE) body = models.TextField() author = models.ForeignKey( Author, null=True, blank=True, on_delete=models.CASCADE, - related_name='comments', + related_name="comments", ) def __str__(self): return self.body class Meta: - ordering = ('id',) + ordering = ("id",) class ProjectType(BaseModel): @@ -134,7 +138,7 @@ def __str__(self): return self.name class Meta: - ordering = ('id',) + ordering = ("id",) class Project(PolymorphicModel): @@ -153,7 +157,8 @@ class ResearchProject(Project): class LabResults(models.Model): research_project = models.ForeignKey( - ResearchProject, related_name='lab_results', on_delete=models.CASCADE) + ResearchProject, related_name="lab_results", on_delete=models.CASCADE + ) date = models.DateField() measurements = models.TextField() @@ -161,7 +166,8 @@ class LabResults(models.Model): class Company(models.Model): name = models.CharField(max_length=100) current_project = models.ForeignKey( - Project, related_name='companies', on_delete=models.CASCADE) + Project, related_name="companies", on_delete=models.CASCADE + ) future_projects = models.ManyToManyField(Project) def __str__(self): diff --git a/example/serializers.py b/example/serializers.py index 566f39d5..7a923d4e 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -19,23 +19,24 @@ Project, ProjectType, ResearchProject, - TaggedItem + TaggedItem, ) class TaggedItemSerializer(serializers.ModelSerializer): class Meta: model = TaggedItem - fields = ('tag',) + fields = ("tag",) class TaggedItemDRFSerializer(drf_serilazers.ModelSerializer): """ DRF default serializer to test default DRF functionalities """ + class Meta: model = TaggedItem - fields = ('tag',) + fields = ("tag",) class BlogSerializer(serializers.ModelSerializer): @@ -43,28 +44,27 @@ class BlogSerializer(serializers.ModelSerializer): tags = relations.ResourceRelatedField(many=True, read_only=True) included_serializers = { - 'tags': 'example.serializers.TaggedItemSerializer', + "tags": "example.serializers.TaggedItemSerializer", } def get_copyright(self, resource): return datetime.now().year def get_root_meta(self, resource, many): - return { - 'api_docs': '/docs/api/blogs' - } + return {"api_docs": "/docs/api/blogs"} class Meta: model = Blog - fields = ('name', 'url', 'tags') - read_only_fields = ('tags',) - meta_fields = ('copyright',) + fields = ("name", "url", "tags") + read_only_fields = ("tags",) + meta_fields = ("copyright",) class BlogDRFSerializer(drf_serilazers.ModelSerializer): """ DRF default serializer to test default DRF functionalities """ + copyright = serializers.SerializerMethodField() tags = TaggedItemDRFSerializer(many=True, read_only=True) @@ -72,15 +72,13 @@ def get_copyright(self, resource): return datetime.now().year def get_root_meta(self, resource, many): - return { - 'api_docs': '/docs/api/blogs' - } + return {"api_docs": "/docs/api/blogs"} class Meta: model = Blog - fields = ('name', 'url', 'tags', 'copyright') - read_only_fields = ('tags',) - meta_fields = ('copyright',) + fields = ("name", "url", "tags", "copyright") + read_only_fields = ("tags",) + meta_fields = ("copyright",) class EntrySerializer(serializers.ModelSerializer): @@ -88,52 +86,51 @@ def __init__(self, *args, **kwargs): super(EntrySerializer, self).__init__(*args, **kwargs) # to make testing more concise we'll only output the # `featured` field when it's requested via `include` - request = kwargs.get('context', {}).get('request') - if request and 'featured' not in request.query_params.get('include', []): - self.fields.pop('featured', None) + request = kwargs.get("context", {}).get("request") + if request and "featured" not in request.query_params.get("include", []): + self.fields.pop("featured", None) included_serializers = { - 'authors': 'example.serializers.AuthorSerializer', - 'comments': 'example.serializers.CommentSerializer', - 'featured': 'example.serializers.EntrySerializer', - 'suggested': 'example.serializers.EntrySerializer', - 'tags': 'example.serializers.TaggedItemSerializer', + "authors": "example.serializers.AuthorSerializer", + "comments": "example.serializers.CommentSerializer", + "featured": "example.serializers.EntrySerializer", + "suggested": "example.serializers.EntrySerializer", + "tags": "example.serializers.TaggedItemSerializer", } body_format = serializers.SerializerMethodField() # single related from model blog_hyperlinked = relations.HyperlinkedRelatedField( - related_link_view_name='entry-blog', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-blog", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", read_only=True, - source='blog' + source="blog", ) # many related from model - comments = relations.ResourceRelatedField( - many=True, read_only=True) + comments = relations.ResourceRelatedField(many=True, read_only=True) # many related hyperlinked from model comments_hyperlinked = relations.HyperlinkedRelatedField( - related_link_view_name='entry-comments', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-comments", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", many=True, read_only=True, - source='comments' + source="comments", ) # many related from serializer suggested = relations.SerializerMethodResourceRelatedField( - related_link_view_name='entry-suggested', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-suggested", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", model=Entry, many=True, ) # many related hyperlinked from serializer suggested_hyperlinked = relations.SerializerMethodHyperlinkedRelatedField( - related_link_view_name='entry-suggested', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-suggested", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", model=Entry, many=True, ) @@ -141,11 +138,11 @@ def __init__(self, *args, **kwargs): featured = relations.SerializerMethodResourceRelatedField(model=Entry) # single related hyperlinked from serializer featured_hyperlinked = relations.SerializerMethodHyperlinkedRelatedField( - related_link_view_name='entry-featured', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-featured", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", model=Entry, - read_only=True + read_only=True, ) tags = relations.ResourceRelatedField(many=True, read_only=True) @@ -156,106 +153,126 @@ def get_featured(self, obj): return Entry.objects.exclude(pk=obj.pk).first() def get_body_format(self, obj): - return 'text' + return "text" class Meta: model = Entry - fields = ('blog', 'blog_hyperlinked', 'headline', 'body_text', 'pub_date', 'mod_date', - 'authors', 'comments', 'comments_hyperlinked', 'featured', 'suggested', - 'suggested_hyperlinked', 'tags', 'featured_hyperlinked') - read_only_fields = ('tags',) - meta_fields = ('body_format',) + fields = ( + "blog", + "blog_hyperlinked", + "headline", + "body_text", + "pub_date", + "mod_date", + "authors", + "comments", + "comments_hyperlinked", + "featured", + "suggested", + "suggested_hyperlinked", + "tags", + "featured_hyperlinked", + ) + read_only_fields = ("tags",) + meta_fields = ("body_format",) class JSONAPIMeta: - included_resources = ['comments'] + included_resources = ["comments"] class EntryDRFSerializers(drf_serilazers.ModelSerializer): tags = TaggedItemDRFSerializer(many=True, read_only=True) url = drf_serilazers.HyperlinkedIdentityField( - view_name='drf-entry-blog-detail', - lookup_url_kwarg='entry_pk', + view_name="drf-entry-blog-detail", + lookup_url_kwarg="entry_pk", read_only=True, ) class Meta: model = Entry - fields = ('tags', 'url',) - read_only_fields = ('tags',) + fields = ( + "tags", + "url", + ) + read_only_fields = ("tags",) class AuthorTypeSerializer(serializers.ModelSerializer): class Meta: model = AuthorType - fields = ('name', ) + fields = ("name",) class AuthorBioSerializer(serializers.ModelSerializer): class Meta: model = AuthorBio - fields = ('author', 'body', 'metadata') + fields = ("author", "body", "metadata") included_serializers = { - 'metadata': 'example.serializers.AuthorBioMetadataSerializer', + "metadata": "example.serializers.AuthorBioMetadataSerializer", } class AuthorBioMetadataSerializer(serializers.ModelSerializer): class Meta: model = AuthorBioMetadata - fields = ('body',) + fields = ("body",) class AuthorSerializer(serializers.ModelSerializer): bio = relations.ResourceRelatedField( - related_link_view_name='author-related', - self_link_view_name='author-relationships', + related_link_view_name="author-related", + self_link_view_name="author-relationships", queryset=AuthorBio.objects, ) entries = relations.ResourceRelatedField( - related_link_view_name='author-related', - self_link_view_name='author-relationships', + related_link_view_name="author-related", + self_link_view_name="author-relationships", queryset=Entry.objects, - many=True + many=True, ) first_entry = relations.SerializerMethodResourceRelatedField( - related_link_view_name='author-related', - self_link_view_name='author-relationships', + related_link_view_name="author-related", + self_link_view_name="author-relationships", model=Entry, ) comments = relations.HyperlinkedRelatedField( - related_link_view_name='author-related', - self_link_view_name='author-relationships', + related_link_view_name="author-related", + self_link_view_name="author-relationships", queryset=Comment.objects, - many=True - ) - secrets = serializers.HiddenField( - default='Shhhh!' + many=True, ) + secrets = serializers.HiddenField(default="Shhhh!") defaults = serializers.CharField( - default='default', + default="default", max_length=20, min_length=3, write_only=True, - help_text='help for defaults', + help_text="help for defaults", ) - included_serializers = { - 'bio': AuthorBioSerializer, - 'type': AuthorTypeSerializer - } + included_serializers = {"bio": AuthorBioSerializer, "type": AuthorTypeSerializer} related_serializers = { - 'bio': 'example.serializers.AuthorBioSerializer', - 'type': 'example.serializers.AuthorTypeSerializer', - 'comments': 'example.serializers.CommentSerializer', - 'entries': 'example.serializers.EntrySerializer', - 'first_entry': 'example.serializers.EntrySerializer' + "bio": "example.serializers.AuthorBioSerializer", + "type": "example.serializers.AuthorTypeSerializer", + "comments": "example.serializers.CommentSerializer", + "entries": "example.serializers.EntrySerializer", + "first_entry": "example.serializers.EntrySerializer", } class Meta: model = Author - fields = ('name', 'email', 'bio', 'entries', 'comments', 'first_entry', 'type', - 'secrets', 'defaults') + fields = ( + "name", + "email", + "bio", + "entries", + "comments", + "first_entry", + "type", + "secrets", + "defaults", + ) def get_first_entry(self, obj): return obj.entries.first() @@ -270,48 +287,52 @@ class AuthorDetailSerializer(AuthorSerializer): class WriterSerializer(serializers.ModelSerializer): - included_serializers = { - 'bio': AuthorBioSerializer - } + included_serializers = {"bio": AuthorBioSerializer} class Meta: model = Author - fields = ('name', 'email', 'bio') - resource_name = 'writers' + fields = ("name", "email", "bio") + resource_name = "writers" class CommentSerializer(serializers.ModelSerializer): # testing remapping of related name - writer = relations.ResourceRelatedField(source='author', read_only=True) + writer = relations.ResourceRelatedField(source="author", read_only=True) included_serializers = { - 'entry': EntrySerializer, - 'author': AuthorSerializer, - 'writer': WriterSerializer + "entry": EntrySerializer, + "author": AuthorSerializer, + "writer": WriterSerializer, } class Meta: model = Comment - exclude = ('created_at', 'modified_at',) + exclude = ( + "created_at", + "modified_at", + ) # fields = ('entry', 'body', 'author',) class ProjectTypeSerializer(serializers.ModelSerializer): class Meta: model = ProjectType - fields = ('name', 'url',) + fields = ( + "name", + "url", + ) class BaseProjectSerializer(serializers.ModelSerializer): included_serializers = { - 'project_type': ProjectTypeSerializer, + "project_type": ProjectTypeSerializer, } class ArtProjectSerializer(BaseProjectSerializer): class Meta: model = ArtProject - exclude = ('polymorphic_ctype',) + exclude = ("polymorphic_ctype",) class ResearchProjectSerializer(BaseProjectSerializer): @@ -320,37 +341,35 @@ class ResearchProjectSerializer(BaseProjectSerializer): class Meta: model = ResearchProject - exclude = ('polymorphic_ctype',) + exclude = ("polymorphic_ctype",) class LabResultsSerializer(serializers.ModelSerializer): class Meta: model = LabResults - fields = ('date', 'measurements') + fields = ("date", "measurements") class ProjectSerializer(serializers.PolymorphicModelSerializer): included_serializers = { - 'project_type': ProjectTypeSerializer, + "project_type": ProjectTypeSerializer, } polymorphic_serializers = [ArtProjectSerializer, ResearchProjectSerializer] class Meta: model = Project - exclude = ('polymorphic_ctype',) + exclude = ("polymorphic_ctype",) class CurrentProjectRelatedField(relations.PolymorphicResourceRelatedField): def get_attribute(self, instance): obj = super(CurrentProjectRelatedField, self).get_attribute(instance) - is_art = ( - self.field_name == 'current_art_project' and - isinstance(obj, ArtProject) + is_art = self.field_name == "current_art_project" and isinstance( + obj, ArtProject ) - is_res = ( - self.field_name == 'current_research_project' and - isinstance(obj, ResearchProject) + is_res = self.field_name == "current_research_project" and isinstance( + obj, ResearchProject ) if is_art or is_res: @@ -361,21 +380,25 @@ def get_attribute(self, instance): class CompanySerializer(serializers.ModelSerializer): current_project = relations.PolymorphicResourceRelatedField( - ProjectSerializer, queryset=Project.objects.all()) + ProjectSerializer, queryset=Project.objects.all() + ) current_art_project = CurrentProjectRelatedField( - ProjectSerializer, source='current_project', read_only=True) + ProjectSerializer, source="current_project", read_only=True + ) current_research_project = CurrentProjectRelatedField( - ProjectSerializer, source='current_project', read_only=True) + ProjectSerializer, source="current_project", read_only=True + ) future_projects = relations.PolymorphicResourceRelatedField( - ProjectSerializer, queryset=Project.objects.all(), many=True) + ProjectSerializer, queryset=Project.objects.all(), many=True + ) included_serializers = { - 'current_project': ProjectSerializer, - 'future_projects': ProjectSerializer, - 'current_art_project': ProjectSerializer, - 'current_research_project': ProjectSerializer + "current_project": ProjectSerializer, + "future_projects": ProjectSerializer, + "current_art_project": ProjectSerializer, + "current_research_project": ProjectSerializer, } class Meta: model = Company - fields = '__all__' + fields = "__all__" diff --git a/example/settings/dev.py b/example/settings/dev.py index d0e19fd2..2db2c259 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -4,100 +4,96 @@ DEBUG = True MEDIA_ROOT = os.path.normcase(os.path.dirname(os.path.abspath(__file__))) -MEDIA_URL = '/media/' +MEDIA_URL = "/media/" -DATABASE_ENGINE = 'sqlite3' +DATABASE_ENGINE = "sqlite3" DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'drf_example', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "drf_example", } } INSTALLED_APPS = [ - 'django.contrib.contenttypes', - 'django.contrib.staticfiles', - 'django.contrib.sites', - 'django.contrib.sessions', - 'django.contrib.auth', - 'rest_framework_json_api', - 'rest_framework', - 'polymorphic', - 'example', - 'debug_toolbar', - 'django_filters', + "django.contrib.contenttypes", + "django.contrib.staticfiles", + "django.contrib.sites", + "django.contrib.sessions", + "django.contrib.auth", + "rest_framework_json_api", + "rest_framework", + "polymorphic", + "example", + "debug_toolbar", + "django_filters", ] TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ # insert your TEMPLATE_DIRS here ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this # list if you haven't customized them: - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.debug', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.contrib.messages.context_processors.messages', + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", ], }, }, ] -STATIC_URL = '/static/' +STATIC_URL = "/static/" -ROOT_URLCONF = 'example.urls' +ROOT_URLCONF = "example.urls" -SECRET_KEY = 'abc123' +SECRET_KEY = "abc123" -PASSWORD_HASHERS = ('django.contrib.auth.hashers.UnsaltedMD5PasswordHasher', ) +PASSWORD_HASHERS = ("django.contrib.auth.hashers.UnsaltedMD5PasswordHasher",) -MIDDLEWARE = ( - 'debug_toolbar.middleware.DebugToolbarMiddleware', -) +MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware",) -INTERNAL_IPS = ('127.0.0.1', ) +INTERNAL_IPS = ("127.0.0.1",) -JSON_API_FORMAT_FIELD_NAMES = 'camelize' -JSON_API_FORMAT_TYPES = 'camelize' +JSON_API_FORMAT_FIELD_NAMES = "camelize" +JSON_API_FORMAT_TYPES = "camelize" REST_FRAMEWORK = { - 'PAGE_SIZE': 5, - 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', - 'DEFAULT_PAGINATION_CLASS': - 'rest_framework_json_api.pagination.JsonApiPageNumberPagination', - 'DEFAULT_PARSER_CLASSES': ( - 'rest_framework_json_api.parsers.JSONParser', - 'rest_framework.parsers.FormParser', - 'rest_framework.parsers.MultiPartParser' + "PAGE_SIZE": 5, + "EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler", + "DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.JsonApiPageNumberPagination", + "DEFAULT_PARSER_CLASSES": ( + "rest_framework_json_api.parsers.JSONParser", + "rest_framework.parsers.FormParser", + "rest_framework.parsers.MultiPartParser", ), - 'DEFAULT_RENDERER_CLASSES': ( - 'rest_framework_json_api.renderers.JSONRenderer', - + "DEFAULT_RENDERER_CLASSES": ( + "rest_framework_json_api.renderers.JSONRenderer", # If you're performance testing, you will want to use the browseable API # without forms, as the forms can generate their own queries. # If performance testing, enable: # 'example.utils.BrowsableAPIRendererWithoutForms', # Otherwise, to play around with the browseable API, enable: - 'rest_framework_json_api.renderers.BrowsableAPIRenderer', + "rest_framework_json_api.renderers.BrowsableAPIRenderer", ), - 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', - 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', - 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.filters.OrderingFilter', - 'rest_framework_json_api.django_filters.DjangoFilterBackend', - 'rest_framework.filters.SearchFilter', + "DEFAULT_METADATA_CLASS": "rest_framework_json_api.metadata.JSONAPIMetadata", + "DEFAULT_SCHEMA_CLASS": "rest_framework_json_api.schemas.openapi.AutoSchema", + "DEFAULT_FILTER_BACKENDS": ( + "rest_framework_json_api.filters.OrderingFilter", + "rest_framework_json_api.django_filters.DjangoFilterBackend", + "rest_framework.filters.SearchFilter", ), - 'SEARCH_PARAM': 'filter[search]', - 'TEST_REQUEST_RENDERER_CLASSES': ( - 'rest_framework_json_api.renderers.JSONRenderer', + "SEARCH_PARAM": "filter[search]", + "TEST_REQUEST_RENDERER_CLASSES": ( + "rest_framework_json_api.renderers.JSONRenderer", ), - 'TEST_REQUEST_DEFAULT_FORMAT': 'vnd.api+json' + "TEST_REQUEST_DEFAULT_FORMAT": "vnd.api+json", } diff --git a/example/settings/test.py b/example/settings/test.py index b47c3fe5..5fa040d6 100644 --- a/example/settings/test.py +++ b/example/settings/test.py @@ -1,18 +1,20 @@ from .dev import * # noqa DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", } } -ROOT_URLCONF = 'example.urls_test' +ROOT_URLCONF = "example.urls_test" -JSON_API_FORMAT_FIELD_NAMES = 'camelize' -JSON_API_FORMAT_TYPES = 'camelize' +JSON_API_FORMAT_FIELD_NAMES = "camelize" +JSON_API_FORMAT_TYPES = "camelize" JSON_API_PLURALIZE_TYPES = True -REST_FRAMEWORK.update({ # noqa - 'PAGE_SIZE': 1, -}) +REST_FRAMEWORK.update( + { # noqa + "PAGE_SIZE": 1, + } +) diff --git a/example/tests/__init__.py b/example/tests/__init__.py index 30a04e22..daca2310 100644 --- a/example/tests/__init__.py +++ b/example/tests/__init__.py @@ -1,4 +1,3 @@ - from django.contrib.auth import get_user_model from rest_framework.test import APITestCase @@ -15,15 +14,12 @@ def setUp(self): super(TestBase, self).setUp() self.create_users() - def create_user(self, username, email, password="pw", - first_name='', last_name=''): + def create_user(self, username, email, password="pw", first_name="", last_name=""): """ Helper method to create a user """ User = get_user_model() - user = User.objects.create_user( - username, email, password=password - ) + user = User.objects.create_user(username, email, password=password) if first_name or last_name: user.first_name = first_name user.last_name = last_name @@ -35,8 +31,8 @@ def create_users(self): Create a couple users """ self.john = self.create_user( - 'trane', 'john@example.com', - first_name='John', last_name="Coltrane") + "trane", "john@example.com", first_name="John", last_name="Coltrane" + ) self.miles = self.create_user( - 'miles', 'miles@example.com', - first_name="Miles", last_name="Davis") + "miles", "miles@example.com", first_name="Miles", last_name="Davis" + ) diff --git a/example/tests/conftest.py b/example/tests/conftest.py index de2bea19..df5bbdfc 100644 --- a/example/tests/conftest.py +++ b/example/tests/conftest.py @@ -13,7 +13,7 @@ CompanyFactory, EntryFactory, ResearchProjectFactory, - TaggedItemFactory + TaggedItemFactory, ) register(BlogFactory) @@ -59,7 +59,9 @@ def single_comment(blog, author, entry_factory, comment_factory): @pytest.fixture def single_company(art_project_factory, research_project_factory, company_factory): - company = company_factory(future_projects=(research_project_factory(), art_project_factory())) + company = company_factory( + future_projects=(research_project_factory(), art_project_factory()) + ) return company diff --git a/example/tests/integration/test_browsable_api.py b/example/tests/integration/test_browsable_api.py index d4bc3fbb..9156eb92 100644 --- a/example/tests/integration/test_browsable_api.py +++ b/example/tests/integration/test_browsable_api.py @@ -8,22 +8,18 @@ def test_browsable_api_with_included_serializers(single_entry, client): response = client.get( - reverse( - "entry-detail", - kwargs={'pk': single_entry.pk, 'format': 'api'} - ) + reverse("entry-detail", kwargs={"pk": single_entry.pk, "format": "api"}) ) content = str(response.content) assert response.status_code == 200 - assert re.search(r'JSON:API includes', content) + assert re.search(r"JSON:API includes", content) assert re.search( - r']* value="authors.bio"', - content + r']* value="authors.bio"', content ) def test_browsable_api_with_no_included_serializers(client): - response = client.get(reverse("projecttype-list", kwargs={'format': 'api'})) + response = client.get(reverse("projecttype-list", kwargs={"format": "api"})) content = str(response.content) assert response.status_code == 200 - assert not re.search(r'JSON:API includes', content) + assert not re.search(r"JSON:API includes", content) diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index 953052de..0ee0d4fd 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -5,174 +5,256 @@ def test_included_data_on_list(multiple_entries, client): - response = client.get(reverse("entry-list"), data={'include': 'comments', 'page[size]': 5}) - included = response.json().get('included') - - assert len(response.json()['data']) == len(multiple_entries), ( - 'Incorrect entry count' + response = client.get( + reverse("entry-list"), data={"include": "comments", "page[size]": 5} ) - assert [x.get('type') for x in included] == ['comments', 'comments'], ( - 'List included types are incorrect' + included = response.json().get("included") + + assert len(response.json()["data"]) == len( + multiple_entries + ), "Incorrect entry count" + assert [x.get("type") for x in included] == [ + "comments", + "comments", + ], "List included types are incorrect" + + comment_count = len( + [resource for resource in included if resource["type"] == "comments"] ) - - comment_count = len([resource for resource in included if resource["type"] == "comments"]) expected_comment_count = sum([entry.comments.count() for entry in multiple_entries]) - assert comment_count == expected_comment_count, 'List comment count is incorrect' + assert comment_count == expected_comment_count, "List comment count is incorrect" def test_included_data_on_list_with_one_to_one_relations(multiple_entries, client): - response = client.get(reverse("entry-list"), - data={'include': 'authors.bio.metadata', 'page[size]': 5}) - included = response.json().get('included') - - assert len(response.json()['data']) == len(multiple_entries), ( - 'Incorrect entry count' + response = client.get( + reverse("entry-list"), data={"include": "authors.bio.metadata", "page[size]": 5} ) + included = response.json().get("included") + + assert len(response.json()["data"]) == len( + multiple_entries + ), "Incorrect entry count" expected_include_types = [ - 'authorBioMetadata', 'authorBioMetadata', - 'authorBios', 'authorBios', - 'authors', 'authors' + "authorBioMetadata", + "authorBioMetadata", + "authorBios", + "authorBios", + "authors", + "authors", ] - include_types = [x.get('type') for x in included] - assert include_types == expected_include_types, ( - 'List included types are incorrect' - ) + include_types = [x.get("type") for x in included] + assert include_types == expected_include_types, "List included types are incorrect" def test_default_included_data_on_detail(single_entry, client): - return test_included_data_on_detail(single_entry=single_entry, client=client, query='') + return test_included_data_on_detail( + single_entry=single_entry, client=client, query="" + ) -def test_included_data_on_detail(single_entry, client, query='?include=comments'): - response = client.get(reverse("entry-detail", kwargs={'pk': single_entry.pk}) + query) - included = response.json().get('included') +def test_included_data_on_detail(single_entry, client, query="?include=comments"): + response = client.get( + reverse("entry-detail", kwargs={"pk": single_entry.pk}) + query + ) + included = response.json().get("included") - assert [x.get('type') for x in included] == ['comments'], 'Detail included types are incorrect' + assert [x.get("type") for x in included] == [ + "comments" + ], "Detail included types are incorrect" - comment_count = len([resource for resource in included if resource["type"] == "comments"]) + comment_count = len( + [resource for resource in included if resource["type"] == "comments"] + ) expected_comment_count = single_entry.comments.count() - assert comment_count == expected_comment_count, 'Detail comment count is incorrect' + assert comment_count == expected_comment_count, "Detail comment count is incorrect" def test_dynamic_related_data_is_included(single_entry, entry_factory, client): entry_factory() response = client.get( - reverse("entry-detail", kwargs={'pk': single_entry.pk}) + '?include=featured' + reverse("entry-detail", kwargs={"pk": single_entry.pk}) + "?include=featured" ) - included = response.json().get('included') + included = response.json().get("included") - assert [x.get('type') for x in included] == ['entries'], 'Dynamic included types are incorrect' - assert len(included) == 1, 'The dynamically included blog entries are of an incorrect count' + assert [x.get("type") for x in included] == [ + "entries" + ], "Dynamic included types are incorrect" + assert ( + len(included) == 1 + ), "The dynamically included blog entries are of an incorrect count" def test_dynamic_many_related_data_is_included(single_entry, entry_factory, client): entry_factory() response = client.get( - reverse("entry-detail", kwargs={'pk': single_entry.pk}) + '?include=suggested' + reverse("entry-detail", kwargs={"pk": single_entry.pk}) + "?include=suggested" ) - included = response.json().get('included') + included = response.json().get("included") assert included - assert [x.get('type') for x in included] == ['entries'], 'Dynamic included types are incorrect' + assert [x.get("type") for x in included] == [ + "entries" + ], "Dynamic included types are incorrect" def test_missing_field_not_included(author_bio_factory, author_factory, client): # First author does not have a bio author = author_factory(bio=None) - response = client.get(reverse('author-detail', args=[author.pk]) + '?include=bio') - assert 'included' not in response.json() + response = client.get(reverse("author-detail", args=[author.pk]) + "?include=bio") + assert "included" not in response.json() # Second author does author = author_factory() - response = client.get(reverse('author-detail', args=[author.pk]) + '?include=bio') + response = client.get(reverse("author-detail", args=[author.pk]) + "?include=bio") data = response.json() - assert 'included' in data - assert len(data['included']) == 1 - assert data['included'][0]['attributes']['body'] == author.bio.body + assert "included" in data + assert len(data["included"]) == 1 + assert data["included"][0]["attributes"]["body"] == author.bio.body def test_deep_included_data_on_list(multiple_entries, client): - response = client.get(reverse("entry-list") + '?include=comments,comments.author,' - 'comments.author.bio,comments.writer&page[size]=5') - included = response.json().get('included') - - assert len(response.json()['data']) == len(multiple_entries), ( - 'Incorrect entry count' + response = client.get( + reverse("entry-list") + "?include=comments,comments.author," + "comments.author.bio,comments.writer&page[size]=5" + ) + included = response.json().get("included") + + assert len(response.json()["data"]) == len( + multiple_entries + ), "Incorrect entry count" + assert [x.get("type") for x in included] == [ + "authorBios", + "authorBios", + "authors", + "authors", + "comments", + "comments", + "writers", + "writers", + ], "List included types are incorrect" + + comment_count = len( + [resource for resource in included if resource["type"] == "comments"] ) - assert [x.get('type') for x in included] == [ - 'authorBios', 'authorBios', 'authors', 'authors', - 'comments', 'comments', 'writers', 'writers' - ], 'List included types are incorrect' - - comment_count = len([resource for resource in included if resource["type"] == "comments"]) expected_comment_count = sum([entry.comments.count() for entry in multiple_entries]) - assert comment_count == expected_comment_count, 'List comment count is incorrect' + assert comment_count == expected_comment_count, "List comment count is incorrect" - author_count = len([resource for resource in included if resource["type"] == "authors"]) + author_count = len( + [resource for resource in included if resource["type"] == "authors"] + ) expected_author_count = sum( - [entry.comments.filter(author__isnull=False).count() for entry in multiple_entries]) - assert author_count == expected_author_count, 'List author count is incorrect' + [ + entry.comments.filter(author__isnull=False).count() + for entry in multiple_entries + ] + ) + assert author_count == expected_author_count, "List author count is incorrect" - author_bio_count = len([resource for resource in included if resource["type"] == "authorBios"]) - expected_author_bio_count = sum([entry.comments.filter( - author__bio__isnull=False).count() for entry in multiple_entries]) - assert author_bio_count == expected_author_bio_count, 'List author bio count is incorrect' + author_bio_count = len( + [resource for resource in included if resource["type"] == "authorBios"] + ) + expected_author_bio_count = sum( + [ + entry.comments.filter(author__bio__isnull=False).count() + for entry in multiple_entries + ] + ) + assert ( + author_bio_count == expected_author_bio_count + ), "List author bio count is incorrect" writer_count = len( [resource for resource in included if resource["type"] == "writers"] ) expected_writer_count = sum( - [entry.comments.filter(author__isnull=False).count() for entry in multiple_entries]) - assert writer_count == expected_writer_count, 'List writer count is incorrect' + [ + entry.comments.filter(author__isnull=False).count() + for entry in multiple_entries + ] + ) + assert writer_count == expected_writer_count, "List writer count is incorrect" # Also include entry authors - response = client.get(reverse("entry-list") + '?include=authors,comments,comments.author,' - 'comments.author.bio&page[size]=5') - included = response.json().get('included') - - assert len(response.json()['data']) == len(multiple_entries), ( - 'Incorrect entry count' + response = client.get( + reverse("entry-list") + "?include=authors,comments,comments.author," + "comments.author.bio&page[size]=5" + ) + included = response.json().get("included") + + assert len(response.json()["data"]) == len( + multiple_entries + ), "Incorrect entry count" + assert [x.get("type") for x in included] == [ + "authorBios", + "authorBios", + "authors", + "authors", + "authors", + "authors", + "comments", + "comments", + ], "List included types are incorrect" + + author_count = len( + [resource for resource in included if resource["type"] == "authors"] ) - assert [x.get('type') for x in included] == [ - 'authorBios', 'authorBios', 'authors', 'authors', 'authors', 'authors', - 'comments', 'comments'], 'List included types are incorrect' - - author_count = len([resource for resource in included if resource["type"] == "authors"]) expected_author_count = sum( - [entry.authors.count() for entry in multiple_entries] + - [entry.comments.filter(author__isnull=False).count() for entry in multiple_entries]) - assert author_count == expected_author_count, 'List author count is incorrect' + [entry.authors.count() for entry in multiple_entries] + + [ + entry.comments.filter(author__isnull=False).count() + for entry in multiple_entries + ] + ) + assert author_count == expected_author_count, "List author count is incorrect" def test_deep_included_data_on_detail(single_entry, client): # Same test as in list but also ensures that intermediate resources (here comments' authors) # are returned along with the leaf nodes - response = client.get(reverse("entry-detail", kwargs={'pk': single_entry.pk}) + - '?include=comments,comments.author.bio') - included = response.json().get('included') + response = client.get( + reverse("entry-detail", kwargs={"pk": single_entry.pk}) + + "?include=comments,comments.author.bio" + ) + included = response.json().get("included") - assert [x.get('type') for x in included] == ['authorBios', 'authors', 'comments'], \ - 'Detail included types are incorrect' + assert [x.get("type") for x in included] == [ + "authorBios", + "authors", + "comments", + ], "Detail included types are incorrect" - comment_count = len([resource for resource in included if resource["type"] == "comments"]) + comment_count = len( + [resource for resource in included if resource["type"] == "comments"] + ) expected_comment_count = single_entry.comments.count() - assert comment_count == expected_comment_count, 'Detail comment count is incorrect' + assert comment_count == expected_comment_count, "Detail comment count is incorrect" - author_bio_count = len([resource for resource in included if resource["type"] == "authorBios"]) - expected_author_bio_count = single_entry.comments.filter(author__bio__isnull=False).count() - assert author_bio_count == expected_author_bio_count, 'Detail author bio count is incorrect' + author_bio_count = len( + [resource for resource in included if resource["type"] == "authorBios"] + ) + expected_author_bio_count = single_entry.comments.filter( + author__bio__isnull=False + ).count() + assert ( + author_bio_count == expected_author_bio_count + ), "Detail author bio count is incorrect" def test_data_resource_not_included_again(single_comment, client): # This test makes sure that the resource which is in the data field is excluded # from the included field. - response = client.get(reverse("comment-detail", kwargs={'pk': single_comment.pk}) + - '?include=entry.comments') + response = client.get( + reverse("comment-detail", kwargs={"pk": single_comment.pk}) + + "?include=entry.comments" + ) - included = response.json().get('included') + included = response.json().get("included") - included_comments = [resource for resource in included if resource["type"] == "comments"] - assert single_comment.pk not in [int(x.get('id')) for x in included_comments], \ - "Resource of the data field duplicated in included" + included_comments = [ + resource for resource in included if resource["type"] == "comments" + ] + assert single_comment.pk not in [ + int(x.get("id")) for x in included_comments + ], "Resource of the data field duplicated in included" comment_count = len(included_comments) expected_comment_count = single_comment.entry.comments.count() diff --git a/example/tests/integration/test_meta.py b/example/tests/integration/test_meta.py index 25457b1c..f54cda8e 100644 --- a/example/tests/integration/test_meta.py +++ b/example/tests/integration/test_meta.py @@ -9,30 +9,26 @@ def test_top_level_meta_for_list_view(blog, client): expected = { - "data": [{ - "type": "blogs", - "id": "1", - "attributes": { - "name": blog.name - }, - "links": { - "self": 'http://testserver/blogs/1' - }, - 'relationships': {'tags': {'data': [], 'meta': {'count': 0}}}, - "meta": { - "copyright": datetime.now().year - }, - }], - 'links': { - 'first': 'http://testserver/blogs?page%5Bnumber%5D=1', - 'last': 'http://testserver/blogs?page%5Bnumber%5D=1', - 'next': None, - 'prev': None + "data": [ + { + "type": "blogs", + "id": "1", + "attributes": {"name": blog.name}, + "links": {"self": "http://testserver/blogs/1"}, + "relationships": {"tags": {"data": [], "meta": {"count": 0}}}, + "meta": {"copyright": datetime.now().year}, + } + ], + "links": { + "first": "http://testserver/blogs?page%5Bnumber%5D=1", + "last": "http://testserver/blogs?page%5Bnumber%5D=1", + "next": None, + "prev": None, + }, + "meta": { + "pagination": {"count": 1, "page": 1, "pages": 1}, + "apiDocs": "/docs/api/blogs", }, - 'meta': { - 'pagination': {'count': 1, 'page': 1, 'pages': 1}, - 'apiDocs': '/docs/api/blogs' - } } response = client.get(reverse("blog-list")) @@ -46,22 +42,14 @@ def test_top_level_meta_for_detail_view(blog, client): "data": { "type": "blogs", "id": "1", - "attributes": { - "name": blog.name - }, - 'relationships': {'tags': {'data': [], 'meta': {'count': 0}}}, - "links": { - "self": "http://testserver/blogs/1" - }, - "meta": { - "copyright": datetime.now().year - }, - }, - "meta": { - "apiDocs": "/docs/api/blogs" + "attributes": {"name": blog.name}, + "relationships": {"tags": {"data": [], "meta": {"count": 0}}}, + "links": {"self": "http://testserver/blogs/1"}, + "meta": {"copyright": datetime.now().year}, }, + "meta": {"apiDocs": "/docs/api/blogs"}, } - response = client.get(reverse("blog-detail", kwargs={'pk': blog.pk})) + response = client.get(reverse("blog-detail", kwargs={"pk": blog.pk})) assert expected == response.json() diff --git a/example/tests/integration/test_model_resource_name.py b/example/tests/integration/test_model_resource_name.py index a69503ae..2dacd2d5 100644 --- a/example/tests/integration/test_model_resource_name.py +++ b/example/tests/integration/test_model_resource_name.py @@ -18,90 +18,100 @@ def _check_resource_and_relationship_comment_type_match(django_client): entry_response = django_client.get(reverse("entry-list")) comment_response = django_client.get(reverse("comment-list")) - comment_resource_type = comment_response.json().get('data')[0].get('type') - comment_relationship_type = entry_response.json().get( - 'data')[0].get('relationships').get('comments').get('data')[0].get('type') - - assert comment_resource_type == comment_relationship_type, ( - "The resource type seen in the relationships and head resource do not match" + comment_resource_type = comment_response.json().get("data")[0].get("type") + comment_relationship_type = ( + entry_response.json() + .get("data")[0] + .get("relationships") + .get("comments") + .get("data")[0] + .get("type") ) + assert ( + comment_resource_type == comment_relationship_type + ), "The resource type seen in the relationships and head resource do not match" + def _check_relationship_and_included_comment_type_are_the_same(django_client, url): response = django_client.get(url + "?include=comments") - data = response.json().get('data')[0] - comment = response.json().get('included')[0] - - comment_relationship_type = data.get('relationships').get('comments').get('data')[0].get('type') - comment_included_type = comment.get('type') + data = response.json().get("data")[0] + comment = response.json().get("included")[0] - assert comment_relationship_type == comment_included_type, ( - "The resource type seen in the relationships and included do not match" + comment_relationship_type = ( + data.get("relationships").get("comments").get("data")[0].get("type") ) + comment_included_type = comment.get("type") + + assert ( + comment_relationship_type == comment_included_type + ), "The resource type seen in the relationships and included do not match" @pytest.mark.usefixtures("single_entry") class TestModelResourceName: create_data = { - 'data': { - 'type': 'resource_name_from_JSONAPIMeta', - 'id': None, - 'attributes': { - 'body': 'example', + "data": { + "type": "resource_name_from_JSONAPIMeta", + "id": None, + "attributes": { + "body": "example", + }, + "relationships": { + "entry": {"data": {"type": "resource_name_from_JSONAPIMeta", "id": 1}} }, - 'relationships': { - 'entry': { - 'data': { - 'type': 'resource_name_from_JSONAPIMeta', - 'id': 1 - } - } - } } } def test_model_resource_name_on_list(self, client): models.Comment.__bases__ += (_PatchedModel,) response = client.get(reverse("comment-list")) - data = response.json()['data'][0] + data = response.json()["data"][0] # name should be super-author instead of model name RenamedAuthor - assert (data.get('type') == 'resource_name_from_JSONAPIMeta'), ( - 'resource_name from model incorrect on list') + assert ( + data.get("type") == "resource_name_from_JSONAPIMeta" + ), "resource_name from model incorrect on list" # Precedence tests def test_resource_name_precendence(self, client, monkeypatch): # default response = client.get(reverse("comment-list")) - data = response.json()['data'][0] - assert (data.get('type') == 'comments'), ( - 'resource_name from model incorrect on list') + data = response.json()["data"][0] + assert ( + data.get("type") == "comments" + ), "resource_name from model incorrect on list" # model > default models.Comment.__bases__ += (_PatchedModel,) response = client.get(reverse("comment-list")) - data = response.json()['data'][0] - assert (data.get('type') == 'resource_name_from_JSONAPIMeta'), ( - 'resource_name from model incorrect on list') + data = response.json()["data"][0] + assert ( + data.get("type") == "resource_name_from_JSONAPIMeta" + ), "resource_name from model incorrect on list" # serializer > model monkeypatch.setattr( serializers.CommentSerializer.Meta, - 'resource_name', - 'resource_name_from_serializer', - False + "resource_name", + "resource_name_from_serializer", + False, ) response = client.get(reverse("comment-list")) - data = response.json()['data'][0] - assert (data.get('type') == 'resource_name_from_serializer'), ( - 'resource_name from serializer incorrect on list') + data = response.json()["data"][0] + assert ( + data.get("type") == "resource_name_from_serializer" + ), "resource_name from serializer incorrect on list" # view > serializer > model - monkeypatch.setattr(views.CommentViewSet, 'resource_name', 'resource_name_from_view', False) + monkeypatch.setattr( + views.CommentViewSet, "resource_name", "resource_name_from_view", False + ) response = client.get(reverse("comment-list")) - data = response.json()['data'][0] - assert (data.get('type') == 'resource_name_from_view'), ( - 'resource_name from view incorrect on list') + data = response.json()["data"][0] + assert ( + data.get("type") == "resource_name_from_view" + ), "resource_name from view incorrect on list" def test_model_resource_name_create(self, client): models.Comment.__bases__ += (_PatchedModel,) @@ -113,19 +123,18 @@ def test_model_resource_name_create(self, client): def test_serializer_resource_name_create(self, client, monkeypatch): monkeypatch.setattr( serializers.CommentSerializer.Meta, - 'resource_name', - 'renamed_comments', - False + "resource_name", + "renamed_comments", + False, ) monkeypatch.setattr( - serializers.EntrySerializer.Meta, - 'resource_name', - 'renamed_entries', - False + serializers.EntrySerializer.Meta, "resource_name", "renamed_entries", False ) create_data = deepcopy(self.create_data) - create_data['data']['type'] = 'renamed_comments' - create_data['data']['relationships']['entry']['data']['type'] = 'renamed_entries' + create_data["data"]["type"] = "renamed_comments" + create_data["data"]["relationships"]["entry"]["data"][ + "type" + ] = "renamed_entries" response = client.post(reverse("comment-list"), create_data) @@ -141,37 +150,59 @@ class TestResourceNameConsistency: # Included rename tests def test_type_match_on_included_and_inline_base(self, client): - _check_relationship_and_included_comment_type_are_the_same(client, reverse("entry-list")) + _check_relationship_and_included_comment_type_are_the_same( + client, reverse("entry-list") + ) def test_type_match_on_included_and_inline_with_JSONAPIMeta(self, client): models.Comment.__bases__ += (_PatchedModel,) - _check_relationship_and_included_comment_type_are_the_same(client, reverse("entry-list")) + _check_relationship_and_included_comment_type_are_the_same( + client, reverse("entry-list") + ) - def test_type_match_on_included_and_inline_with_serializer_resource_name(self, client): - serializers.CommentSerializer.Meta.resource_name = "resource_name_from_serializer" + def test_type_match_on_included_and_inline_with_serializer_resource_name( + self, client + ): + serializers.CommentSerializer.Meta.resource_name = ( + "resource_name_from_serializer" + ) - _check_relationship_and_included_comment_type_are_the_same(client, reverse("entry-list")) + _check_relationship_and_included_comment_type_are_the_same( + client, reverse("entry-list") + ) - def test_type_match_on_included_and_inline_without_serializer_resource_name(self, client): + def test_type_match_on_included_and_inline_without_serializer_resource_name( + self, client + ): serializers.CommentSerializer.Meta.resource_name = None - _check_relationship_and_included_comment_type_are_the_same(client, reverse("entry-list")) + _check_relationship_and_included_comment_type_are_the_same( + client, reverse("entry-list") + ) def test_type_match_on_included_and_inline_with_serializer_resource_name_and_JSONAPIMeta( - self, client + self, client ): models.Comment.__bases__ += (_PatchedModel,) - serializers.CommentSerializer.Meta.resource_name = "resource_name_from_serializer" + serializers.CommentSerializer.Meta.resource_name = ( + "resource_name_from_serializer" + ) - _check_relationship_and_included_comment_type_are_the_same(client, reverse("entry-list")) + _check_relationship_and_included_comment_type_are_the_same( + client, reverse("entry-list") + ) # Relation rename tests def test_resource_and_relationship_type_match(self, client): _check_resource_and_relationship_comment_type_match(client) - def test_resource_and_relationship_type_match_with_serializer_resource_name(self, client): - serializers.CommentSerializer.Meta.resource_name = "resource_name_from_serializer" + def test_resource_and_relationship_type_match_with_serializer_resource_name( + self, client + ): + serializers.CommentSerializer.Meta.resource_name = ( + "resource_name_from_serializer" + ) _check_resource_and_relationship_comment_type_match(client) @@ -181,10 +212,12 @@ def test_resource_and_relationship_type_match_with_JSONAPIMeta(self, client): _check_resource_and_relationship_comment_type_match(client) def test_resource_and_relationship_type_match_with_serializer_resource_name_and_JSONAPIMeta( - self, client + self, client ): models.Comment.__bases__ += (_PatchedModel,) - serializers.CommentSerializer.Meta.resource_name = "resource_name_from_serializer" + serializers.CommentSerializer.Meta.resource_name = ( + "resource_name_from_serializer" + ) _check_resource_and_relationship_comment_type_match(client) diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index b73eae5e..5a2e59c8 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -7,9 +7,9 @@ @mock.patch( - 'rest_framework_json_api.utils' - '.get_default_included_resources_from_serializer', - new=lambda s: []) + "rest_framework_json_api.utils" ".get_default_included_resources_from_serializer", + new=lambda s: [], +) def test_multiple_entries_no_pagination(multiple_entries, client): expected = { @@ -17,82 +17,70 @@ def test_multiple_entries_no_pagination(multiple_entries, client): { "type": "posts", "id": "1", - "attributes": - { + "attributes": { "headline": multiple_entries[0].headline, "bodyText": multiple_entries[0].body_text, "pubDate": None, - "modDate": None + "modDate": None, }, - "meta": { - "bodyFormat": "text" - }, - "relationships": - { - "blog": { - "data": {"type": "blogs", "id": "1"} - }, + "meta": {"bodyFormat": "text"}, + "relationships": { + "blog": {"data": {"type": "blogs", "id": "1"}}, "blogHyperlinked": { "links": { "related": "http://testserver/entries/1/blog", - "self": "http://testserver/entries/1/relationships/blog_hyperlinked" + "self": "http://testserver/entries/1/relationships/blog_hyperlinked", } }, "authors": { "meta": {"count": 1}, - "data": [{"type": "authors", "id": "1"}] + "data": [{"type": "authors", "id": "1"}], }, "comments": { "meta": {"count": 1}, - "data": [{"type": "comments", "id": "1"}] + "data": [{"type": "comments", "id": "1"}], }, "commentsHyperlinked": { "links": { "related": "http://testserver/entries/1/comments", - "self": "http://testserver/entries/1/relationships/comments_hyperlinked" + "self": "http://testserver/entries/1/relationships/comments_hyperlinked", } }, "suggested": { "data": [{"type": "entries", "id": "2"}], "links": { "related": "http://testserver/entries/1/suggested/", - "self": "http://testserver/entries/1/relationships/suggested" - } + "self": "http://testserver/entries/1/relationships/suggested", + }, }, "suggestedHyperlinked": { "links": { "related": "http://testserver/entries/1/suggested/", "self": "http://testserver/entries/1" - "/relationships/suggested_hyperlinked" + "/relationships/suggested_hyperlinked", } }, "featuredHyperlinked": { "links": { "related": "http://testserver/entries/1/featured", - "self": "http://testserver/entries/1/relationships/featured_hyperlinked" + "self": "http://testserver/entries/1/relationships/featured_hyperlinked", } }, - 'tags': {'data': [], 'meta': {'count': 0}}, - } + "tags": {"data": [], "meta": {"count": 0}}, + }, }, { "type": "posts", "id": "2", - "attributes": - { + "attributes": { "headline": multiple_entries[1].headline, "bodyText": multiple_entries[1].body_text, "pubDate": None, - "modDate": None + "modDate": None, }, - "meta": { - "bodyFormat": "text" - }, - "relationships": - { - "blog": { - "data": {"type": "blogs", "id": "2"} - }, + "meta": {"bodyFormat": "text"}, + "relationships": { + "blog": {"data": {"type": "blogs", "id": "2"}}, "blogHyperlinked": { "links": { "related": "http://testserver/entries/2/blog", @@ -101,40 +89,40 @@ def test_multiple_entries_no_pagination(multiple_entries, client): }, "authors": { "meta": {"count": 1}, - "data": [{"type": "authors", "id": "2"}] + "data": [{"type": "authors", "id": "2"}], }, "comments": { "meta": {"count": 1}, - "data": [{"type": "comments", "id": "2"}] + "data": [{"type": "comments", "id": "2"}], }, "commentsHyperlinked": { "links": { "related": "http://testserver/entries/2/comments", - "self": "http://testserver/entries/2/relationships/comments_hyperlinked" + "self": "http://testserver/entries/2/relationships/comments_hyperlinked", } }, "suggested": { "data": [{"type": "entries", "id": "1"}], "links": { "related": "http://testserver/entries/2/suggested/", - "self": "http://testserver/entries/2/relationships/suggested" - } + "self": "http://testserver/entries/2/relationships/suggested", + }, }, "suggestedHyperlinked": { "links": { "related": "http://testserver/entries/2/suggested/", "self": "http://testserver/entries/2" - "/relationships/suggested_hyperlinked" + "/relationships/suggested_hyperlinked", } }, "featuredHyperlinked": { "links": { "related": "http://testserver/entries/2/featured", - "self": "http://testserver/entries/2/relationships/featured_hyperlinked" + "self": "http://testserver/entries/2/relationships/featured_hyperlinked", } }, - 'tags': {'data': [], 'meta': {'count': 0}}, - } + "tags": {"data": [], "meta": {"count": 0}}, + }, }, ] } diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 1b12a0d2..aedf8c6c 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -7,9 +7,9 @@ @mock.patch( - 'rest_framework_json_api.utils' - '.get_default_included_resources_from_serializer', - new=lambda s: []) + "rest_framework_json_api.utils" ".get_default_included_resources_from_serializer", + new=lambda s: [], +) def test_pagination_with_single_entry(single_entry, client): expected = { @@ -17,21 +17,15 @@ def test_pagination_with_single_entry(single_entry, client): { "type": "posts", "id": "1", - "attributes": - { + "attributes": { "headline": single_entry.headline, "bodyText": single_entry.body_text, "pubDate": None, - "modDate": None + "modDate": None, }, - "meta": { - "bodyFormat": "text" - }, - "relationships": - { - "blog": { - "data": {"type": "blogs", "id": "1"} - }, + "meta": {"bodyFormat": "text"}, + "relationships": { + "blog": {"data": {"type": "blogs", "id": "1"}}, "blogHyperlinked": { "links": { "related": "http://testserver/entries/1/blog", @@ -40,64 +34,52 @@ def test_pagination_with_single_entry(single_entry, client): }, "authors": { "meta": {"count": 1}, - "data": [{"type": "authors", "id": "1"}] + "data": [{"type": "authors", "id": "1"}], }, "comments": { "meta": {"count": 1}, - "data": [{"type": "comments", "id": "1"}] + "data": [{"type": "comments", "id": "1"}], }, "commentsHyperlinked": { "links": { "related": "http://testserver/entries/1/comments", - "self": "http://testserver/entries/1/relationships/comments_hyperlinked" + "self": "http://testserver/entries/1/relationships/comments_hyperlinked", } }, "suggested": { "data": [], "links": { "related": "http://testserver/entries/1/suggested/", - "self": "http://testserver/entries/1/relationships/suggested" - } + "self": "http://testserver/entries/1/relationships/suggested", + }, }, "suggestedHyperlinked": { "links": { "related": "http://testserver/entries/1/suggested/", "self": "http://testserver/entries/1" - "/relationships/suggested_hyperlinked" + "/relationships/suggested_hyperlinked", } }, "featuredHyperlinked": { "links": { "related": "http://testserver/entries/1/featured", - "self": "http://testserver/entries/1/relationships/featured_hyperlinked" + "self": "http://testserver/entries/1/relationships/featured_hyperlinked", } }, "tags": { - 'meta': {'count': 1}, - "data": [ - { - "id": "1", - "type": "taggedItems" - } - ] - } - } - }], + "meta": {"count": 1}, + "data": [{"id": "1", "type": "taggedItems"}], + }, + }, + } + ], "links": { - 'first': 'http://testserver/entries?page%5Bnumber%5D=1', - 'last': 'http://testserver/entries?page%5Bnumber%5D=1', + "first": "http://testserver/entries?page%5Bnumber%5D=1", + "last": "http://testserver/entries?page%5Bnumber%5D=1", "next": None, "prev": None, }, - "meta": - { - "pagination": - { - "page": 1, - "pages": 1, - "count": 1 - } - } + "meta": {"pagination": {"page": 1, "pages": 1, "count": 1}}, } response = client.get(reverse("entry-list")) diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 5c64776a..bd41f203 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -10,153 +10,157 @@ def test_polymorphism_on_detail(single_art_project, client): - response = client.get(reverse("project-detail", kwargs={'pk': single_art_project.pk})) + response = client.get( + reverse("project-detail", kwargs={"pk": single_art_project.pk}) + ) content = response.json() assert content["data"]["type"] == "artProjects" def test_polymorphism_on_detail_relations(single_company, client): - response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk})) + response = client.get(reverse("company-detail", kwargs={"pk": single_company.pk})) content = response.json() - assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" assert ( - set([rel["type"] for rel in content["data"]["relationships"]["futureProjects"]["data"]]) == - set(["researchProjects", "artProjects"]) + content["data"]["relationships"]["currentProject"]["data"]["type"] + == "artProjects" ) + assert set( + [ + rel["type"] + for rel in content["data"]["relationships"]["futureProjects"]["data"] + ] + ) == set(["researchProjects", "artProjects"]) def test_polymorphism_on_included_relations(single_company, client): response = client.get( - reverse("company-detail", kwargs={'pk': single_company.pk}) + - '?include=current_project,future_projects,current_art_project,current_research_project') + reverse("company-detail", kwargs={"pk": single_company.pk}) + + "?include=current_project,future_projects,current_art_project,current_research_project" + ) content = response.json() - assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" - assert content["data"]["relationships"]["currentArtProject"]["data"]["type"] == "artProjects" - assert content["data"]["relationships"]["currentResearchProject"]["data"] is None assert ( - set([rel["type"] for rel in content["data"]["relationships"]["futureProjects"]["data"]]) == - set(["researchProjects", "artProjects"]) + content["data"]["relationships"]["currentProject"]["data"]["type"] + == "artProjects" ) - assert set([x.get('type') for x in content.get('included')]) == set([ - 'artProjects', 'artProjects', 'researchProjects']), 'Detail included types are incorrect' + assert ( + content["data"]["relationships"]["currentArtProject"]["data"]["type"] + == "artProjects" + ) + assert content["data"]["relationships"]["currentResearchProject"]["data"] is None + assert set( + [ + rel["type"] + for rel in content["data"]["relationships"]["futureProjects"]["data"] + ] + ) == set(["researchProjects", "artProjects"]) + assert set([x.get("type") for x in content.get("included")]) == set( + ["artProjects", "artProjects", "researchProjects"] + ), "Detail included types are incorrect" # Ensure that the child fields are present. - assert content.get('included')[0].get('attributes').get('artist') is not None - assert content.get('included')[1].get('attributes').get('artist') is not None - assert content.get('included')[2].get('attributes').get('supervisor') is not None + assert content.get("included")[0].get("attributes").get("artist") is not None + assert content.get("included")[1].get("attributes").get("artist") is not None + assert content.get("included")[2].get("attributes").get("supervisor") is not None def test_polymorphism_on_polymorphic_model_detail_patch(single_art_project, client): - url = reverse("project-detail", kwargs={'pk': single_art_project.pk}) + url = reverse("project-detail", kwargs={"pk": single_art_project.pk}) response = client.get(url) content = response.json() - test_topic = 'test-{}'.format(random.randint(0, 999999)) - test_artist = 'test-{}'.format(random.randint(0, 999999)) - content['data']['attributes']['topic'] = test_topic - content['data']['attributes']['artist'] = test_artist + test_topic = "test-{}".format(random.randint(0, 999999)) + test_artist = "test-{}".format(random.randint(0, 999999)) + content["data"]["attributes"]["topic"] = test_topic + content["data"]["attributes"]["artist"] = test_artist response = client.patch(url, data=content) new_content = response.json() - assert new_content['data']['type'] == "artProjects" - assert new_content['data']['attributes']['topic'] == test_topic - assert new_content['data']['attributes']['artist'] == test_artist + assert new_content["data"]["type"] == "artProjects" + assert new_content["data"]["attributes"]["topic"] == test_topic + assert new_content["data"]["attributes"]["artist"] == test_artist -def test_patch_on_polymorphic_model_without_including_required_field(single_art_project, client): - url = reverse("project-detail", kwargs={'pk': single_art_project.pk}) +def test_patch_on_polymorphic_model_without_including_required_field( + single_art_project, client +): + url = reverse("project-detail", kwargs={"pk": single_art_project.pk}) data = { - 'data': { - 'id': single_art_project.pk, - 'type': 'artProjects', - 'attributes': { - 'description': 'New description' - } + "data": { + "id": single_art_project.pk, + "type": "artProjects", + "attributes": {"description": "New description"}, } } response = client.patch(url, data) assert response.status_code == status.HTTP_200_OK - assert response.json()['data']['attributes']['description'] == 'New description' + assert response.json()["data"]["attributes"]["description"] == "New description" def test_polymorphism_on_polymorphic_model_list_post(client): - test_topic = 'New test topic {}'.format(random.randint(0, 999999)) - test_artist = 'test-{}'.format(random.randint(0, 999999)) + test_topic = "New test topic {}".format(random.randint(0, 999999)) + test_artist = "test-{}".format(random.randint(0, 999999)) test_project_type = ProjectTypeFactory() - url = reverse('project-list') + url = reverse("project-list") data = { - 'data': { - 'type': 'artProjects', - 'attributes': { - 'topic': test_topic, - 'artist': test_artist - }, - 'relationships': { - 'projectType': { - 'data': { - 'type': 'projectTypes', - 'id': test_project_type.pk - } + "data": { + "type": "artProjects", + "attributes": {"topic": test_topic, "artist": test_artist}, + "relationships": { + "projectType": { + "data": {"type": "projectTypes", "id": test_project_type.pk} } - } + }, } } response = client.post(url, data=data) content = response.json() - assert content['data']['id'] is not None - assert content['data']['type'] == "artProjects" - assert content['data']['attributes']['topic'] == test_topic - assert content['data']['attributes']['artist'] == test_artist - assert content['data']['relationships']['projectType']['data']['id'] == \ - str(test_project_type.pk) + assert content["data"]["id"] is not None + assert content["data"]["type"] == "artProjects" + assert content["data"]["attributes"]["topic"] == test_topic + assert content["data"]["attributes"]["artist"] == test_artist + assert content["data"]["relationships"]["projectType"]["data"]["id"] == str( + test_project_type.pk + ) def test_polymorphism_on_polymorphic_model_w_included_serializers(client): test_project = ArtProjectFactory() - query = '?include=projectType' - url = reverse('project-list') + query = "?include=projectType" + url = reverse("project-list") response = client.get(url + query) content = response.json() - assert content['data'][0]['id'] == str(test_project.pk) - assert content['data'][0]['type'] == 'artProjects' - assert content['data'][0]['relationships']['projectType']['data']['id'] == \ - str(test_project.project_type.pk) - assert content['included'][0]['type'] == 'projectTypes' - assert content['included'][0]['id'] == str(test_project.project_type.pk) + assert content["data"][0]["id"] == str(test_project.pk) + assert content["data"][0]["type"] == "artProjects" + assert content["data"][0]["relationships"]["projectType"]["data"]["id"] == str( + test_project.project_type.pk + ) + assert content["included"][0]["type"] == "projectTypes" + assert content["included"][0]["id"] == str(test_project.project_type.pk) def test_polymorphic_model_without_any_instance(client): expected = { "links": { - 'first': 'http://testserver/projects?page%5Bnumber%5D=1', - 'last': 'http://testserver/projects?page%5Bnumber%5D=1', + "first": "http://testserver/projects?page%5Bnumber%5D=1", + "last": "http://testserver/projects?page%5Bnumber%5D=1", "next": None, - "prev": None + "prev": None, }, "data": [], - "meta": { - "pagination": { - "page": 1, - "pages": 1, - "count": 0 - } - } + "meta": {"pagination": {"page": 1, "pages": 1, "count": 0}}, } - response = client.get(reverse('project-list')) + response = client.get(reverse("project-list")) assert response.status_code == 200 content = response.json() assert expected == content def test_invalid_type_on_polymorphic_model(client): - test_topic = 'New test topic {}'.format(random.randint(0, 999999)) - test_artist = 'test-{}'.format(random.randint(0, 999999)) - url = reverse('project-list') + test_topic = "New test topic {}".format(random.randint(0, 999999)) + test_artist = "test-{}".format(random.randint(0, 999999)) + url = reverse("project-list") data = { - 'data': { - 'type': 'invalidProjects', - 'attributes': { - 'topic': test_topic, - 'artist': test_artist - } + "data": { + "type": "invalidProjects", + "attributes": {"topic": test_topic, "artist": test_artist}, } } response = client.post(url, data=data) @@ -165,72 +169,103 @@ def test_invalid_type_on_polymorphic_model(client): assert len(content["errors"]) == 1 assert content["errors"][0]["status"] == "409" try: - assert content["errors"][0]["detail"] == \ - "The resource object's type (invalidProjects) is not the type that constitute the " \ + assert ( + content["errors"][0]["detail"] + == "The resource object's type (invalidProjects) is not the type that constitute the " "collection represented by the endpoint (one of [researchProjects, artProjects])." + ) except AssertionError: # Available type list order isn't enforced - assert content["errors"][0]["detail"] == \ - "The resource object's type (invalidProjects) is not the type that constitute the " \ + assert ( + content["errors"][0]["detail"] + == "The resource object's type (invalidProjects) is not the type that constitute the " "collection represented by the endpoint (one of [artProjects, researchProjects])." + ) -def test_polymorphism_relations_update(single_company, research_project_factory, client): - response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk})) +def test_polymorphism_relations_update( + single_company, research_project_factory, client +): + response = client.get(reverse("company-detail", kwargs={"pk": single_company.pk})) content = response.json() - assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" + assert ( + content["data"]["relationships"]["currentProject"]["data"]["type"] + == "artProjects" + ) research_project = research_project_factory() content["data"]["relationships"]["currentProject"]["data"] = { "type": "researchProjects", - "id": research_project.pk + "id": research_project.pk, } - response = client.patch(reverse("company-detail", kwargs={'pk': single_company.pk}), - data=content) + response = client.patch( + reverse("company-detail", kwargs={"pk": single_company.pk}), data=content + ) assert response.status_code == 200 content = response.json() - assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "researchProjects" - assert int(content["data"]["relationships"]["currentProject"]["data"]["id"]) == \ - research_project.pk + assert ( + content["data"]["relationships"]["currentProject"]["data"]["type"] + == "researchProjects" + ) + assert ( + int(content["data"]["relationships"]["currentProject"]["data"]["id"]) + == research_project.pk + ) -def test_polymorphism_relations_put_405(single_company, research_project_factory, client): - response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk})) +def test_polymorphism_relations_put_405( + single_company, research_project_factory, client +): + response = client.get(reverse("company-detail", kwargs={"pk": single_company.pk})) content = response.json() - assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" + assert ( + content["data"]["relationships"]["currentProject"]["data"]["type"] + == "artProjects" + ) research_project = research_project_factory() content["data"]["relationships"]["currentProject"]["data"] = { "type": "researchProjects", - "id": research_project.pk + "id": research_project.pk, } - response = client.put(reverse("company-detail", kwargs={'pk': single_company.pk}), - data=content) + response = client.put( + reverse("company-detail", kwargs={"pk": single_company.pk}), data=content + ) assert response.status_code == 405 -def test_invalid_type_on_polymorphic_relation(single_company, research_project_factory, client): - response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk})) +def test_invalid_type_on_polymorphic_relation( + single_company, research_project_factory, client +): + response = client.get(reverse("company-detail", kwargs={"pk": single_company.pk})) content = response.json() - assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" + assert ( + content["data"]["relationships"]["currentProject"]["data"]["type"] + == "artProjects" + ) research_project = research_project_factory() content["data"]["relationships"]["currentProject"]["data"] = { "type": "invalidProjects", - "id": research_project.pk + "id": research_project.pk, } - response = client.patch(reverse("company-detail", kwargs={'pk': single_company.pk}), - data=content) + response = client.patch( + reverse("company-detail", kwargs={"pk": single_company.pk}), data=content + ) assert response.status_code == 409 content = response.json() assert len(content["errors"]) == 1 assert content["errors"][0]["status"] == "409" try: - assert content["errors"][0]["detail"] == \ - "Incorrect relation type. Expected one of [researchProjects, artProjects], " \ + assert ( + content["errors"][0]["detail"] + == "Incorrect relation type. Expected one of [researchProjects, artProjects], " "received invalidProjects." + ) except AssertionError: # Available type list order isn't enforced - assert content["errors"][0]["detail"] == \ - "Incorrect relation type. Expected one of [artProjects, researchProjects], " \ + assert ( + content["errors"][0]["detail"] + == "Incorrect relation type. Expected one of [artProjects, researchProjects], " "received invalidProjects." + ) diff --git a/example/tests/integration/test_sparse_fieldsets.py b/example/tests/integration/test_sparse_fieldsets.py index 83b8560a..605d218d 100644 --- a/example/tests/integration/test_sparse_fieldsets.py +++ b/example/tests/integration/test_sparse_fieldsets.py @@ -6,18 +6,20 @@ def test_sparse_fieldset_valid_fields(client, entry): - base_url = reverse('entry-list') - response = client.get(base_url, data={'fields[entries]': 'blog,headline'}) + base_url = reverse("entry-list") + response = client.get(base_url, data={"fields[entries]": "blog,headline"}) assert response.status_code == status.HTTP_200_OK - data = response.json()['data'] + data = response.json()["data"] assert len(data) == 1 entry = data[0] - assert entry['attributes'].keys() == {'headline'} - assert entry['relationships'].keys() == {'blog'} + assert entry["attributes"].keys() == {"headline"} + assert entry["relationships"].keys() == {"blog"} -@pytest.mark.parametrize("fields_param", ['invalidfields[entries]', 'fieldsinvalid[entries']) +@pytest.mark.parametrize( + "fields_param", ["invalidfields[entries]", "fieldsinvalid[entries"] +) def test_sparse_fieldset_invalid_fields_parameter(client, entry, fields_param): """ Test that invalid fields query parameter is not processed by sparse fieldset. @@ -25,12 +27,12 @@ def test_sparse_fieldset_invalid_fields_parameter(client, entry, fields_param): rest_framework_json_api.filters.QueryParameterValidationFilter takes care of error handling in such a case. """ - base_url = reverse('entry-list') - response = client.get(base_url, data={'invalidfields[entries]': 'blog,headline'}) + base_url = reverse("entry-list") + response = client.get(base_url, data={"invalidfields[entries]": "blog,headline"}) assert response.status_code == status.HTTP_200_OK - data = response.json()['data'] + data = response.json()["data"] assert len(data) == 1 entry = data[0] - assert entry['attributes'].keys() != {'headline'} - assert entry['relationships'].keys() != {'blog'} + assert entry["attributes"].keys() != {"headline"} + assert entry["relationships"].keys() != {"blog"} diff --git a/example/tests/snapshots/snap_test_errors.py b/example/tests/snapshots/snap_test_errors.py index 48b8e474..528b831b 100644 --- a/example/tests/snapshots/snap_test_errors.py +++ b/example/tests/snapshots/snap_test_errors.py @@ -4,116 +4,97 @@ from snapshottest import Snapshot - snapshots = Snapshot() -snapshots['test_first_level_attribute_error 1'] = { - 'errors': [ +snapshots["test_first_level_attribute_error 1"] = { + "errors": [ { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/headline' - }, - 'status': '400' + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/headline"}, + "status": "400", } ] } -snapshots['test_first_level_custom_attribute_error 1'] = { - 'errors': [ +snapshots["test_first_level_custom_attribute_error 1"] = { + "errors": [ { - 'detail': 'Too short', - 'source': { - 'pointer': '/data/attributes/body-text' - }, - 'title': 'Too Short title' + "detail": "Too short", + "source": {"pointer": "/data/attributes/body-text"}, + "title": "Too Short title", } ] } -snapshots['test_many_third_level_dict_errors 1'] = { - 'errors': [ +snapshots["test_many_third_level_dict_errors 1"] = { + "errors": [ { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comments/0/attachment/data' - }, - 'status': '400' + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/comments/0/attachment/data"}, + "status": "400", }, { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comments/0/body' - }, - 'status': '400' - } + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/comments/0/body"}, + "status": "400", + }, ] } -snapshots['test_second_level_array_error 1'] = { - 'errors': [ +snapshots["test_second_level_array_error 1"] = { + "errors": [ { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comments/0/body' - }, - 'status': '400' + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/comments/0/body"}, + "status": "400", } ] } -snapshots['test_second_level_dict_error 1'] = { - 'errors': [ +snapshots["test_second_level_dict_error 1"] = { + "errors": [ { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comment/body' - }, - 'status': '400' + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/comment/body"}, + "status": "400", } ] } -snapshots['test_third_level_array_error 1'] = { - 'errors': [ +snapshots["test_third_level_array_error 1"] = { + "errors": [ { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comments/0/attachments/0/data' - }, - 'status': '400' + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/comments/0/attachments/0/data"}, + "status": "400", } ] } -snapshots['test_third_level_custom_array_error 1'] = { - 'errors': [ +snapshots["test_third_level_custom_array_error 1"] = { + "errors": [ { - 'code': 'invalid', - 'detail': 'Too short data', - 'source': { - 'pointer': '/data/attributes/comments/0/attachments/0/data' - }, - 'status': '400' + "code": "invalid", + "detail": "Too short data", + "source": {"pointer": "/data/attributes/comments/0/attachments/0/data"}, + "status": "400", } ] } -snapshots['test_third_level_dict_error 1'] = { - 'errors': [ +snapshots["test_third_level_dict_error 1"] = { + "errors": [ { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comments/0/attachment/data' - }, - 'status': '400' + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/comments/0/attachment/data"}, + "status": "400", } ] } diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py index aca41d70..7c0b3b98 100644 --- a/example/tests/snapshots/snap_test_openapi.py +++ b/example/tests/snapshots/snap_test_openapi.py @@ -4,10 +4,11 @@ from snapshottest import Snapshot - snapshots = Snapshot() -snapshots['test_path_without_parameters 1'] = '''{ +snapshots[ + "test_path_without_parameters 1" +] = """{ "description": "", "operationId": "List/authors/", "parameters": [ @@ -121,9 +122,11 @@ "description": "not found" } } -}''' +}""" -snapshots['test_path_with_id_parameter 1'] = '''{ +snapshots[ + "test_path_with_id_parameter 1" +] = """{ "description": "", "operationId": "retrieve/authors/{id}/", "parameters": [ @@ -225,9 +228,11 @@ "description": "not found" } } -}''' +}""" -snapshots['test_post_request 1'] = '''{ +snapshots[ + "test_post_request 1" +] = """{ "description": "", "operationId": "create/authors/", "parameters": [], @@ -407,9 +412,11 @@ "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" } } -}''' +}""" -snapshots['test_patch_request 1'] = '''{ +snapshots[ + "test_patch_request 1" +] = """{ "description": "", "operationId": "update/authors/{id}", "parameters": [ @@ -583,9 +590,11 @@ "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" } } -}''' +}""" -snapshots['test_delete_request 1'] = '''{ +snapshots[ + "test_delete_request 1" +] = """{ "description": "", "operationId": "destroy/authors/{id}", "parameters": [ @@ -644,4 +653,4 @@ "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" } } -}''' +}""" diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py index ff2e8f95..93cdb235 100644 --- a/example/tests/test_errors.py +++ b/example/tests/test_errors.py @@ -14,7 +14,7 @@ class CommentAttachmentSerializer(serializers.Serializer): def validate_data(self, value): if value and len(value) < 10: - raise serializers.ValidationError('Too short data') + raise serializers.ValidationError("Too short data") class CommentSerializer(serializers.Serializer): @@ -32,17 +32,17 @@ class EntrySerializer(serializers.Serializer): body_text = serializers.CharField() def validate(self, attrs): - body_text = attrs['body_text'] + body_text = attrs["body_text"] if len(body_text) < 5: - raise serializers.ValidationError({'body_text': { - 'title': 'Too Short title', 'detail': 'Too short'} - }) + raise serializers.ValidationError( + {"body_text": {"title": "Too Short title", "detail": "Too short"}} + ) # view class DummyTestView(views.APIView): serializer_class = EntrySerializer - resource_name = 'entries' + resource_name = "entries" def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) @@ -50,21 +50,18 @@ def post(self, request, *args, **kwargs): urlpatterns = [ - path('entries-nested', DummyTestView.as_view(), - name='entries-nested-list') + path("entries-nested", DummyTestView.as_view(), name="entries-nested-list") ] -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def some_blog(db): - return Blog.objects.create(name='Some Blog', tagline="It's a blog") + return Blog.objects.create(name="Some Blog", tagline="It's a blog") def perform_error_test(client, data): - with override_settings( - ROOT_URLCONF=__name__ - ): - url = reverse('entries-nested-list') + with override_settings(ROOT_URLCONF=__name__): + url = reverse("entries-nested-list") response = client.post(url, data=data) return response.json() @@ -72,12 +69,12 @@ def perform_error_test(client, data): def test_first_level_attribute_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + }, } } snapshot.assert_match(perform_error_test(client, data)) @@ -85,32 +82,29 @@ def test_first_level_attribute_error(client, some_blog, snapshot): def test_first_level_custom_attribute_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'body-text': 'body', - 'headline': 'headline' - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "body-text": "body", + "headline": "headline", + }, } } - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): snapshot.assert_match(perform_error_test(client, data)) def test_second_level_array_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - 'headline': 'headline', - 'comments': [ - { - } - ] - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + "comments": [{}], + }, } } @@ -119,14 +113,14 @@ def test_second_level_array_error(client, some_blog, snapshot): def test_second_level_dict_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - 'headline': 'headline', - 'comment': {} - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + "comment": {}, + }, } } @@ -135,22 +129,14 @@ def test_second_level_dict_error(client, some_blog, snapshot): def test_third_level_array_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'body': 'test comment', - 'attachments': [ - { - } - ] - } - ] - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + "comments": [{"body": "test comment", "attachments": [{}]}], + }, } } @@ -159,23 +145,16 @@ def test_third_level_array_error(client, some_blog, snapshot): def test_third_level_custom_array_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'body': 'test comment', - 'attachments': [ - { - 'data': 'text' - } - ] - } - ] - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + "comments": [ + {"body": "test comment", "attachments": [{"data": "text"}]} + ], + }, } } @@ -184,19 +163,14 @@ def test_third_level_custom_array_error(client, some_blog, snapshot): def test_third_level_dict_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'body': 'test comment', - 'attachment': {} - } - ] - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + "comments": [{"body": "test comment", "attachment": {}}], + }, } } @@ -205,18 +179,14 @@ def test_third_level_dict_error(client, some_blog, snapshot): def test_many_third_level_dict_errors(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'attachment': {} - } - ] - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + "comments": [{"attachment": {}}], + }, } } diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index c3b1c42d..68ad1452 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -8,24 +8,26 @@ class DJATestFilters(APITestCase): """ tests of JSON:API filter backends """ - fixtures = ('blogentry',) + + fixtures = ("blogentry",) def setUp(self): self.entries = Entry.objects.all() self.blogs = Blog.objects.all() - self.url = reverse('nopage-entry-list') - self.fs_url = reverse('filterset-entry-list') - self.no_fs_url = reverse('nofilterset-entry-list') + self.url = reverse("nopage-entry-list") + self.fs_url = reverse("filterset-entry-list") + self.no_fs_url = reverse("nofilterset-entry-list") def test_sort(self): """ test sort """ - response = self.client.get(self.url, data={'sort': 'headline'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"sort": "headline"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - headlines = [c['attributes']['headline'] for c in dja_response['data']] + headlines = [c["attributes"]["headline"] for c in dja_response["data"]] sorted_headlines = sorted(headlines) self.assertEqual(headlines, sorted_headlines) @@ -33,11 +35,12 @@ def test_sort_reverse(self): """ confirm switching the sort order actually works """ - response = self.client.get(self.url, data={'sort': '-headline'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"sort": "-headline"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - headlines = [c['attributes']['headline'] for c in dja_response['data']] + headlines = [c["attributes"]["headline"] for c in dja_response["data"]] sorted_headlines = sorted(headlines) self.assertNotEqual(headlines, sorted_headlines) @@ -45,11 +48,12 @@ def test_sort_double_negative(self): """ what if they provide multiple `-`'s? It's OK. """ - response = self.client.get(self.url, data={'sort': '--headline'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"sort": "--headline"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - headlines = [c['attributes']['headline'] for c in dja_response['data']] + headlines = [c["attributes"]["headline"] for c in dja_response["data"]] sorted_headlines = sorted(headlines) self.assertNotEqual(headlines, sorted_headlines) @@ -57,23 +61,28 @@ def test_sort_invalid(self): """ test sort of invalid field """ - response = self.client.get(self.url, - data={'sort': 'nonesuch,headline,-not_a_field'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get( + self.url, data={"sort": "nonesuch,headline,-not_a_field"} + ) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid sort parameters: nonesuch,-not_a_field") + self.assertEqual( + dja_response["errors"][0]["detail"], + "invalid sort parameters: nonesuch,-not_a_field", + ) def test_sort_camelcase(self): """ test sort of camelcase field name """ - response = self.client.get(self.url, data={'sort': 'bodyText'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"sort": "bodyText"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - blog_ids = [(c['attributes']['bodyText'] or '') for c in dja_response['data']] + blog_ids = [(c["attributes"]["bodyText"] or "") for c in dja_response["data"]] sorted_blog_ids = sorted(blog_ids) self.assertEqual(blog_ids, sorted_blog_ids) @@ -84,11 +93,12 @@ def test_sort_underscore(self): "Be conservative in what you send, be liberal in what you accept" -- https://en.wikipedia.org/wiki/Robustness_principle """ - response = self.client.get(self.url, data={'sort': 'body_text'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"sort": "body_text"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - blog_ids = [(c['attributes']['bodyText'] or '') for c in dja_response['data']] + blog_ids = [(c["attributes"]["bodyText"] or "") for c in dja_response["data"]] sorted_blog_ids = sorted(blog_ids) self.assertEqual(blog_ids, sorted_blog_ids) @@ -97,12 +107,15 @@ def test_sort_related(self): test sort via related field using jsonapi path `.` and django orm `__` notation. ORM relations must be predefined in the View's .ordering_fields attr """ - for datum in ('blog__id', 'blog.id'): - response = self.client.get(self.url, data={'sort': datum}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + for datum in ("blog__id", "blog.id"): + response = self.client.get(self.url, data={"sort": datum}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - blog_ids = [c['relationships']['blog']['data']['id'] for c in dja_response['data']] + blog_ids = [ + c["relationships"]["blog"]["data"]["id"] for c in dja_response["data"] + ] sorted_blog_ids = sorted(blog_ids) self.assertEqual(blog_ids, sorted_blog_ids) @@ -110,46 +123,50 @@ def test_filter_exact(self): """ filter for an exact match """ - response = self.client.get(self.url, data={'filter[headline]': 'CHEM3271X'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[headline]": "CHEM3271X"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(len(dja_response['data']), 1) + self.assertEqual(len(dja_response["data"]), 1) def test_filter_exact_fail(self): """ failed search for an exact match """ - response = self.client.get(self.url, data={'filter[headline]': 'XXXXX'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[headline]": "XXXXX"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(len(dja_response['data']), 0) + self.assertEqual(len(dja_response["data"]), 0) def test_filter_isnull(self): """ search for null value """ - response = self.client.get(self.url, data={'filter[bodyText.isnull]': 'true'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[bodyText.isnull]": "true"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() self.assertEqual( - len(dja_response['data']), - len([k for k in self.entries if k.body_text is None]) + len(dja_response["data"]), + len([k for k in self.entries if k.body_text is None]), ) def test_filter_not_null(self): """ search for not null """ - response = self.client.get(self.url, data={'filter[bodyText.isnull]': 'false'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[bodyText.isnull]": "false"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() self.assertEqual( - len(dja_response['data']), - len([k for k in self.entries if k.body_text is not None]) + len(dja_response["data"]), + len([k for k in self.entries if k.body_text is not None]), ) def test_filter_isempty(self): @@ -157,26 +174,35 @@ def test_filter_isempty(self): search for an empty value (different from null!) the easiest way to do this is search for r'^$' """ - response = self.client.get(self.url, data={'filter[bodyText.regex]': '^$'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[bodyText.regex]": "^$"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(len(dja_response['data']), - len([k for k in self.entries - if k.body_text is not None and - len(k.body_text) == 0])) + self.assertEqual( + len(dja_response["data"]), + len( + [ + k + for k in self.entries + if k.body_text is not None and len(k.body_text) == 0 + ] + ), + ) def test_filter_related(self): """ filter via a relationship chain """ - response = self.client.get(self.url, data={'filter[blog.name]': 'ANTB'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[blog.name]": "ANTB"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(len(dja_response['data']), - len([k for k in self.entries - if k.blog.name == 'ANTB'])) + self.assertEqual( + len(dja_response["data"]), + len([k for k in self.entries if k.blog.name == "ANTB"]), + ) def test_filter_related_fieldset_class(self): """ @@ -184,133 +210,164 @@ def test_filter_related_fieldset_class(self): This tests a shortcut for a longer ORM path: `bname` is a shortcut name for `blog.name`. """ - response = self.client.get(self.fs_url, data={'filter[bname]': 'ANTB'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.fs_url, data={"filter[bname]": "ANTB"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(len(dja_response['data']), - len([k for k in self.entries - if k.blog.name == 'ANTB'])) + self.assertEqual( + len(dja_response["data"]), + len([k for k in self.entries if k.blog.name == "ANTB"]), + ) def test_filter_related_missing_fieldset_class(self): """ filter via with neither filterset_fields nor filterset_class This should return an error for any filter[] """ - response = self.client.get(self.no_fs_url, data={'filter[bname]': 'ANTB'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.no_fs_url, data={"filter[bname]": "ANTB"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter[bname]") + self.assertEqual(dja_response["errors"][0]["detail"], "invalid filter[bname]") def test_filter_fields_union_list(self): """ test field for a list of values(ORed): ?filter[field.in]': 'val1,val2,val3 """ - response = self.client.get(self.url, - data={'filter[headline.in]': 'CLCV2442V,XXX,BIOL3594X'}) + response = self.client.get( + self.url, data={"filter[headline.in]": "CLCV2442V,XXX,BIOL3594X"} + ) dja_response = response.json() - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) self.assertEqual( - len(dja_response['data']), - len([k for k in self.entries if k.headline == 'CLCV2442V']) + - len([k for k in self.entries if k.headline == 'XXX']) + - len([k for k in self.entries if k.headline == 'BIOL3594X']), - msg="filter field list (union)") + response.status_code, 200, msg=response.content.decode("utf-8") + ) + self.assertEqual( + len(dja_response["data"]), + len([k for k in self.entries if k.headline == "CLCV2442V"]) + + len([k for k in self.entries if k.headline == "XXX"]) + + len([k for k in self.entries if k.headline == "BIOL3594X"]), + msg="filter field list (union)", + ) def test_filter_fields_intersection(self): """ test fields (ANDed): ?filter[field1]': 'val1&filter[field2]'='val2 """ # - response = self.client.get(self.url, - data={'filter[headline.regex]': '^A', - 'filter[body_text.icontains]': 'in'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get( + self.url, + data={"filter[headline.regex]": "^A", "filter[body_text.icontains]": "in"}, + ) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertGreater(len(dja_response['data']), 1) + self.assertGreater(len(dja_response["data"]), 1) self.assertEqual( - len(dja_response['data']), - len([k for k in self.entries if k.headline.startswith('A') and - 'in' in k.body_text.lower()])) + len(dja_response["data"]), + len( + [ + k + for k in self.entries + if k.headline.startswith("A") and "in" in k.body_text.lower() + ] + ), + ) def test_filter_invalid_association_name(self): """ test for filter with invalid filter association name """ - response = self.client.get(self.url, data={'filter[nonesuch]': 'CHEM3271X'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[nonesuch]": "CHEM3271X"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter[nonesuch]") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid filter[nonesuch]" + ) def test_filter_empty_association_name(self): """ test for filter with missing association name error texts are different depending on whether QueryParameterValidationFilter is in use. """ - response = self.client.get(self.url, data={'filter[]': 'foobar'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[]": "foobar"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], "invalid query parameter: filter[]") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid query parameter: filter[]" + ) def test_filter_no_brackets(self): """ test for `filter=foobar` with missing filter[association] name """ - response = self.client.get(self.url, data={'filter': 'foobar'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter": "foobar"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid query parameter: filter") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid query parameter: filter" + ) def test_filter_missing_right_bracket(self): """ test for filter missing right bracket """ - response = self.client.get(self.url, data={'filter[headline': 'foobar'}) - self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[headline": "foobar"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid query parameter: filter[headline") + self.assertEqual( + dja_response["errors"][0]["detail"], + "invalid query parameter: filter[headline", + ) def test_filter_no_brackets_rvalue(self): """ test for `filter=` with missing filter[association] and value """ - response = self.client.get(self.url + '?filter=') - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url + "?filter=") + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid query parameter: filter") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid query parameter: filter" + ) def test_filter_no_brackets_equal(self): """ test for `filter` with missing filter[association] name and =value """ - response = self.client.get(self.url + '?filter') - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url + "?filter") + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid query parameter: filter") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid query parameter: filter" + ) def test_filter_malformed_left_bracket(self): """ test for filter with invalid filter syntax """ - response = self.client.get(self.url, data={'filter[': 'foobar'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[": "foobar"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], "invalid query parameter: filter[") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid query parameter: filter[" + ) def test_filter_missing_rvalue(self): """ @@ -318,24 +375,30 @@ def test_filter_missing_rvalue(self): this should probably be an error rather than ignoring the filter: https://django-filter.readthedocs.io/en/latest/guide/tips.html#filtering-by-an-empty-string """ - response = self.client.get(self.url, data={'filter[headline]': ''}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[headline]": ""}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "missing value for query parameter filter[headline]") + self.assertEqual( + dja_response["errors"][0]["detail"], + "missing value for query parameter filter[headline]", + ) def test_filter_missing_rvalue_equal(self): """ test for filter with missing value to test against this should probably be an error rather than ignoring the filter: """ - response = self.client.get(self.url + '?filter[headline]') - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url + "?filter[headline]") + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "missing value for query parameter filter[headline]") + self.assertEqual( + dja_response["errors"][0]["detail"], + "missing value for query parameter filter[headline]", + ) def test_filter_single_relation(self): """ @@ -343,13 +406,14 @@ def test_filter_single_relation(self): e.g. filterset-entries?filter[authors.id]=1 looks for entries written by (at least) author.id=1 """ - response = self.client.get(self.fs_url, data={'filter[authors.id]': 1}) + response = self.client.get(self.fs_url, data={"filter[authors.id]": 1}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - ids = [k['id'] for k in dja_response['data']] + ids = [k["id"] for k in dja_response["data"]] expected_ids = [str(k.id) for k in self.entries.filter(authors__id=1)] @@ -361,15 +425,18 @@ def test_filter_repeated_relations(self): e.g. filterset-entries?filter[authors.id]=1&filter[authors.id]=2 looks for entries written by (at least) author.id=1 AND author.id=2 """ - response = self.client.get(self.fs_url, data={'filter[authors.id]': [1, 2]}) + response = self.client.get(self.fs_url, data={"filter[authors.id]": [1, 2]}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - ids = [k['id'] for k in dja_response['data']] + ids = [k["id"] for k in dja_response["data"]] - expected_ids = [str(k.id) for k in self.entries.filter(authors__id=1).filter(authors__id=2)] + expected_ids = [ + str(k.id) for k in self.entries.filter(authors__id=1).filter(authors__id=2) + ] self.assertEqual(set(ids), set(expected_ids)) @@ -379,13 +446,14 @@ def test_filter_in(self): e.g. filterset-entries?filter[authors.id.in]=1,2 looks for entries written by (at least) author.id=1 OR author.id=2 """ - response = self.client.get(self.fs_url, data={'filter[authors.id.in]': '1,2'}) + response = self.client.get(self.fs_url, data={"filter[authors.id.in]": "1,2"}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - ids = [k['id'] for k in dja_response['data']] + ids = [k["id"] for k in dja_response["data"]] expected_ids = [str(k.id) for k in self.entries.filter(authors__id__in=[1, 2])] @@ -396,82 +464,70 @@ def test_search_keywords(self): test for `filter[search]="keywords"` where some of the keywords are in the entry and others are in the related blog. """ - response = self.client.get(self.url, data={'filter[search]': 'barnard field research'}) + response = self.client.get( + self.url, data={"filter[search]": "barnard field research"} + ) expected_result = { - 'data': [ + "data": [ { - 'type': 'posts', - 'id': '7', - 'attributes': { - 'headline': 'ANTH3868X', - 'bodyText': 'ETHNOGRAPHIC FIELD RESEARCH IN NYC', - 'pubDate': None, - 'modDate': None}, - 'relationships': { - 'blog': { - 'data': { - 'type': 'blogs', - 'id': '1' + "type": "posts", + "id": "7", + "attributes": { + "headline": "ANTH3868X", + "bodyText": "ETHNOGRAPHIC FIELD RESEARCH IN NYC", + "pubDate": None, + "modDate": None, + }, + "relationships": { + "blog": {"data": {"type": "blogs", "id": "1"}}, + "blogHyperlinked": { + "links": { + "self": "http://testserver/entries/7/relationships/blog_hyperlinked", # noqa: E501 + "related": "http://testserver/entries/7/blog", } }, - 'blogHyperlinked': { - 'links': { - 'self': 'http://testserver/entries/7/relationships/blog_hyperlinked', # noqa: E501 - 'related': 'http://testserver/entries/7/blog'} - }, - 'authors': { - 'meta': { - 'count': 0 - }, - 'data': [] - }, - 'comments': { - 'meta': { - 'count': 0 - }, - 'data': [] - }, - 'commentsHyperlinked': { - 'links': { - 'self': 'http://testserver/entries/7/relationships/comments_hyperlinked', # noqa: E501 - 'related': 'http://testserver/entries/7/comments' + "authors": {"meta": {"count": 0}, "data": []}, + "comments": {"meta": {"count": 0}, "data": []}, + "commentsHyperlinked": { + "links": { + "self": "http://testserver/entries/7/relationships/comments_hyperlinked", # noqa: E501 + "related": "http://testserver/entries/7/comments", } }, - 'suggested': { - 'links': { - 'self': 'http://testserver/entries/7/relationships/suggested', - 'related': 'http://testserver/entries/7/suggested/' + "suggested": { + "links": { + "self": "http://testserver/entries/7/relationships/suggested", + "related": "http://testserver/entries/7/suggested/", }, - 'data': [ - {'type': 'entries', 'id': '1'}, - {'type': 'entries', 'id': '2'}, - {'type': 'entries', 'id': '3'}, - {'type': 'entries', 'id': '4'}, - {'type': 'entries', 'id': '5'}, - {'type': 'entries', 'id': '6'}, - {'type': 'entries', 'id': '8'}, - {'type': 'entries', 'id': '9'}, - {'type': 'entries', 'id': '10'}, - {'type': 'entries', 'id': '11'}, - {'type': 'entries', 'id': '12'} - ] + "data": [ + {"type": "entries", "id": "1"}, + {"type": "entries", "id": "2"}, + {"type": "entries", "id": "3"}, + {"type": "entries", "id": "4"}, + {"type": "entries", "id": "5"}, + {"type": "entries", "id": "6"}, + {"type": "entries", "id": "8"}, + {"type": "entries", "id": "9"}, + {"type": "entries", "id": "10"}, + {"type": "entries", "id": "11"}, + {"type": "entries", "id": "12"}, + ], }, - 'suggestedHyperlinked': { - 'links': { - 'self': 'http://testserver/entries/7/relationships/suggested_hyperlinked', # noqa: E501 - 'related': 'http://testserver/entries/7/suggested/'} + "suggestedHyperlinked": { + "links": { + "self": "http://testserver/entries/7/relationships/suggested_hyperlinked", # noqa: E501 + "related": "http://testserver/entries/7/suggested/", + } }, - 'tags': {'data': [], 'meta': {'count': 0}}, - 'featuredHyperlinked': { - 'links': { - 'self': 'http://testserver/entries/7/relationships/featured_hyperlinked', # noqa: E501 - 'related': 'http://testserver/entries/7/featured' + "tags": {"data": [], "meta": {"count": 0}}, + "featuredHyperlinked": { + "links": { + "self": "http://testserver/entries/7/relationships/featured_hyperlinked", # noqa: E501 + "related": "http://testserver/entries/7/featured", } - } + }, }, - 'meta': { - 'bodyFormat': 'text' - } + "meta": {"bodyFormat": "text"}, } ] } @@ -497,48 +553,68 @@ def test_search_multiple_keywords(self): See `example/fixtures/blogentry.json` for the test content that the searches are based on. The searches test for both direct entries and related blogs across multiple fields. """ - for searches in ("research", "chemistry", "nonesuch", - "research seminar", "research nonesuch", - "barnard classic", "barnard ethnographic field research"): - response = self.client.get(self.url, data={'filter[search]': searches}) - self.assertEqual(response.status_code, 200, msg=response.content.decode("utf-8")) + for searches in ( + "research", + "chemistry", + "nonesuch", + "research seminar", + "research nonesuch", + "barnard classic", + "barnard ethnographic field research", + ): + response = self.client.get(self.url, data={"filter[search]": searches}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() keys = searches.split() # dicts keyed by the search keys for the 4 search_fields: - headline = {} # list of entry ids where key is in entry__headline - body_text = {} # list of entry ids where key is in entry__body_text - blog_name = {} # list of entry ids where key is in entry__blog__name + headline = {} # list of entry ids where key is in entry__headline + body_text = {} # list of entry ids where key is in entry__body_text + blog_name = {} # list of entry ids where key is in entry__blog__name blog_tagline = {} # list of entry ids where key is in entry__blog__tagline for key in keys: - headline[key] = [str(k.id) for k in - self.entries.filter(headline__icontains=key)] - body_text[key] = [str(k.id) for k in - self.entries.filter(body_text__icontains=key)] - blog_name[key] = [str(k.id) for k in - self.entries.filter(blog__name__icontains=key)] - blog_tagline[key] = [str(k.id) for k in - self.entries.filter(blog__tagline__icontains=key)] + headline[key] = [ + str(k.id) for k in self.entries.filter(headline__icontains=key) + ] + body_text[key] = [ + str(k.id) for k in self.entries.filter(body_text__icontains=key) + ] + blog_name[key] = [ + str(k.id) for k in self.entries.filter(blog__name__icontains=key) + ] + blog_tagline[key] = [ + str(k.id) for k in self.entries.filter(blog__tagline__icontains=key) + ] union = [] # each list item is a set of entry ids matching the given key for key in keys: - union.append(set(headline[key] + body_text[key] + - blog_name[key] + blog_tagline[key])) + union.append( + set( + headline[key] + + body_text[key] + + blog_name[key] + + blog_tagline[key] + ) + ) # all keywords must be present: intersect the keyword sets expected_ids = set.intersection(*union) expected_len = len(expected_ids) - self.assertEqual(len(dja_response['data']), expected_len) - returned_ids = set([k['id'] for k in dja_response['data']]) + self.assertEqual(len(dja_response["data"]), expected_len) + returned_ids = set([k["id"] for k in dja_response["data"]]) self.assertEqual(returned_ids, expected_ids) def test_param_invalid(self): """ Test a "wrong" query parameter """ - response = self.client.get(self.url, data={'garbage': 'foo'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"garbage": "foo"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid query parameter: garbage") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid query parameter: garbage" + ) def test_param_duplicate_sort(self): """ @@ -546,38 +622,48 @@ def test_param_duplicate_sort(self): `?sort=headline&page[size]=3&sort=bodyText` is not allowed. This is not so obvious when using a data dict.... """ - response = self.client.get(self.url, - data={'sort': ['headline', 'bodyText'], - 'page[size]': 3} - ) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get( + self.url, data={"sort": ["headline", "bodyText"], "page[size]": 3} + ) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "repeated query parameter not allowed: sort") + self.assertEqual( + dja_response["errors"][0]["detail"], + "repeated query parameter not allowed: sort", + ) def test_param_duplicate_page(self): """ test a duplicated page[size] query parameter """ - response = self.client.get(self.fs_url, data={'page[size]': [1, 2]}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.fs_url, data={"page[size]": [1, 2]}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "repeated query parameter not allowed: page[size]") + self.assertEqual( + dja_response["errors"][0]["detail"], + "repeated query parameter not allowed: page[size]", + ) def test_many_params(self): """ Test that filter params aren't ignored when many params are present """ - response = self.client.get(self.url, - data={'filter[headline.regex]': '^A', - 'filter[body_text.regex]': '^IN', - 'filter[blog.name]': 'ANTB', - 'page[size]': 3}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get( + self.url, + data={ + "filter[headline.regex]": "^A", + "filter[body_text.regex]": "^IN", + "filter[blog.name]": "ANTB", + "page[size]": 3, + }, + ) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(len(dja_response['data']), 1) - self.assertEqual(dja_response['data'][0]['id'], '1') + self.assertEqual(len(dja_response["data"]), 1) + self.assertEqual(dja_response["data"][0]["id"], "1") diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index 0fd76c67..23a8a325 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -10,11 +10,12 @@ class FormatKeysSetTests(TestBase): """ Test that camelization and underscoring of key names works if they are activated. """ - list_url = reverse('user-list') + + list_url = reverse("user-list") def setUp(self): super(FormatKeysSetTests, self).setUp() - self.detail_url = reverse('user-detail', kwargs={'pk': self.miles.pk}) + self.detail_url = reverse("user-detail", kwargs={"pk": self.miles.pk}) def test_camelization(self): """ @@ -25,39 +26,42 @@ def test_camelization(self): user = get_user_model().objects.all()[0] expected = { - 'data': [ + "data": [ { - 'type': 'users', - 'id': encoding.force_str(user.pk), - 'attributes': { - 'firstName': user.first_name, - 'lastName': user.last_name, - 'email': user.email + "type": "users", + "id": encoding.force_str(user.pk), + "attributes": { + "firstName": user.first_name, + "lastName": user.last_name, + "email": user.email, }, } ], - 'links': { - 'first': 'http://testserver/identities?page%5Bnumber%5D=1', - 'last': 'http://testserver/identities?page%5Bnumber%5D=2', - 'next': 'http://testserver/identities?page%5Bnumber%5D=2', - 'prev': None + "links": { + "first": "http://testserver/identities?page%5Bnumber%5D=1", + "last": "http://testserver/identities?page%5Bnumber%5D=2", + "next": "http://testserver/identities?page%5Bnumber%5D=2", + "prev": None, }, - 'meta': { - 'pagination': { - 'page': 1, - 'pages': 2, - 'count': 2 - } - } + "meta": {"pagination": {"page": 1, "pages": 2, "count": 2}}, } assert expected == response.json() def test_options_format_field_names(db, client): - response = client.options(reverse('author-list')) + response = client.options(reverse("author-list")) assert response.status_code == status.HTTP_200_OK - data = response.json()['data'] - expected_keys = {'name', 'email', 'bio', 'entries', 'firstEntry', 'type', - 'comments', 'secrets', 'defaults'} - assert expected_keys == data['actions']['POST'].keys() + data = response.json()["data"] + expected_keys = { + "name", + "email", + "bio", + "entries", + "firstEntry", + "type", + "comments", + "secrets", + "defaults", + } + assert expected_keys == data["actions"]["POST"].keys() diff --git a/example/tests/test_generic_validation.py b/example/tests/test_generic_validation.py index ff2aff4b..99d2d09a 100644 --- a/example/tests/test_generic_validation.py +++ b/example/tests/test_generic_validation.py @@ -10,7 +10,7 @@ class GenericValidationTest(TestBase): def setUp(self): super(GenericValidationTest, self).setUp() - self.url = reverse('user-validation', kwargs={'pk': self.miles.pk}) + self.url = reverse("user-validation", kwargs={"pk": self.miles.pk}) def test_generic_validation_error(self): """ @@ -20,14 +20,14 @@ def test_generic_validation_error(self): self.assertEqual(response.status_code, 400) expected = { - 'errors': [{ - 'status': '400', - 'source': { - 'pointer': '/data' - }, - 'detail': 'Oh nohs!', - 'code': 'invalid', - }] + "errors": [ + { + "status": "400", + "source": {"pointer": "/data"}, + "detail": "Oh nohs!", + "code": "invalid", + } + ] } assert expected == response.json() diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index ba315740..a07eb610 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -13,17 +13,17 @@ def test_default_rest_framework_behavior(self): """ This is more of an example really, showing default behavior """ - url = reverse('user-default', kwargs={'pk': self.miles.pk}) + url = reverse("user-default", kwargs={"pk": self.miles.pk}) response = self.client.get(url) self.assertEqual(200, response.status_code) expected = { - 'id': 2, - 'first_name': 'Miles', - 'last_name': 'Davis', - 'email': 'miles@example.com' + "id": 2, + "first_name": "Miles", + "last_name": "Davis", + "email": "miles@example.com", } assert expected == response.json() @@ -33,21 +33,21 @@ def test_ember_expected_renderer(self): The :class:`UserEmber` ViewSet has the ``resource_name`` of 'data' so that should be the key in the JSON response. """ - url = reverse('user-manual-resource-name', kwargs={'pk': self.miles.pk}) + url = reverse("user-manual-resource-name", kwargs={"pk": self.miles.pk}) - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): response = self.client.get(url) self.assertEqual(200, response.status_code) expected = { - 'data': { - 'type': 'data', - 'id': '2', - 'attributes': { - 'first-name': 'Miles', - 'last-name': 'Davis', - 'email': 'miles@example.com' - } + "data": { + "type": "data", + "id": "2", + "attributes": { + "first-name": "Miles", + "last-name": "Davis", + "email": "miles@example.com", + }, } } @@ -58,34 +58,38 @@ def test_default_validation_exceptions(self): Default validation exceptions should conform to json api spec """ expected = { - 'errors': [ + "errors": [ { - 'status': '400', - 'source': { - 'pointer': '/data/attributes/email', + "status": "400", + "source": { + "pointer": "/data/attributes/email", }, - 'detail': 'Enter a valid email address.', - 'code': 'invalid', + "detail": "Enter a valid email address.", + "code": "invalid", }, { - 'status': '400', - 'source': { - 'pointer': '/data/attributes/first-name', + "status": "400", + "source": { + "pointer": "/data/attributes/first-name", }, - 'detail': 'There\'s a problem with first name', - 'code': 'invalid', - } + "detail": "There's a problem with first name", + "code": "invalid", + }, ] } - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): - response = self.client.post('/identities', { - 'data': { - 'type': 'users', - 'attributes': { - 'email': 'bar', 'first_name': 'alajflajaljalajlfjafljalj' + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): + response = self.client.post( + "/identities", + { + "data": { + "type": "users", + "attributes": { + "email": "bar", + "first_name": "alajflajaljalajlfjafljalj", + }, } - } - }) + }, + ) assert expected == response.json() @@ -94,30 +98,34 @@ def test_custom_validation_exceptions(self): Exceptions should be able to be formatted manually """ expected = { - 'errors': [ + "errors": [ { - 'status': '400', - 'source': { - 'pointer': '/data/attributes/email', + "status": "400", + "source": { + "pointer": "/data/attributes/email", }, - 'detail': 'Enter a valid email address.', - 'code': 'invalid', + "detail": "Enter a valid email address.", + "code": "invalid", }, { - 'id': 'armageddon101', - 'detail': 'Hey! You need a last name!', - 'meta': 'something', - 'source': {'pointer': '/data/attributes/lastName'} + "id": "armageddon101", + "detail": "Hey! You need a last name!", + "meta": "something", + "source": {"pointer": "/data/attributes/lastName"}, }, ] } - response = self.client.post('/identities', { - 'data': { - 'type': 'users', - 'attributes': { - 'email': 'bar', 'last_name': 'alajflajaljalajlfjafljalj' + response = self.client.post( + "/identities", + { + "data": { + "type": "users", + "attributes": { + "email": "bar", + "last_name": "alajflajaljalajlfjafljalj", + }, } - } - }) + }, + ) assert expected == response.json() diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index 1ce8336d..9d4a610f 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -15,46 +15,41 @@ class ModelViewSetTests(TestBase): [, [^/]+)/$>] """ - list_url = reverse('user-list') + + list_url = reverse("user-list") def setUp(self): super(ModelViewSetTests, self).setUp() - self.detail_url = reverse('user-detail', kwargs={'pk': self.miles.pk}) + self.detail_url = reverse("user-detail", kwargs={"pk": self.miles.pk}) def test_key_in_list_result(self): """ Ensure the result has a 'user' key since that is the name of the model """ - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): response = self.client.get(self.list_url) self.assertEqual(response.status_code, 200) user = get_user_model().objects.all()[0] expected = { - 'data': [ + "data": [ { - 'type': 'users', - 'id': encoding.force_str(user.pk), - 'attributes': { - 'first-name': user.first_name, - 'last-name': user.last_name, - 'email': user.email + "type": "users", + "id": encoding.force_str(user.pk), + "attributes": { + "first-name": user.first_name, + "last-name": user.last_name, + "email": user.email, }, } ], - 'links': { - 'first': 'http://testserver/identities?page%5Bnumber%5D=1', - 'last': 'http://testserver/identities?page%5Bnumber%5D=2', - 'next': 'http://testserver/identities?page%5Bnumber%5D=2', - 'prev': None + "links": { + "first": "http://testserver/identities?page%5Bnumber%5D=1", + "last": "http://testserver/identities?page%5Bnumber%5D=2", + "next": "http://testserver/identities?page%5Bnumber%5D=2", + "prev": None, }, - 'meta': { - 'pagination': { - 'page': 1, - 'pages': 2, - 'count': 2 - } - } + "meta": {"pagination": {"page": 1, "pages": 2, "count": 2}}, } assert expected == response.json() @@ -63,36 +58,30 @@ def test_page_two_in_list_result(self): """ Ensure that the second page is reachable and is the correct data. """ - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): - response = self.client.get(self.list_url, {'page[number]': 2}) + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): + response = self.client.get(self.list_url, {"page[number]": 2}) self.assertEqual(response.status_code, 200) user = get_user_model().objects.all()[1] expected = { - 'data': [ + "data": [ { - 'type': 'users', - 'id': encoding.force_str(user.pk), - 'attributes': { - 'first-name': user.first_name, - 'last-name': user.last_name, - 'email': user.email + "type": "users", + "id": encoding.force_str(user.pk), + "attributes": { + "first-name": user.first_name, + "last-name": user.last_name, + "email": user.email, }, } ], - 'links': { - 'first': 'http://testserver/identities?page%5Bnumber%5D=1', - 'last': 'http://testserver/identities?page%5Bnumber%5D=2', - 'next': None, - 'prev': 'http://testserver/identities?page%5Bnumber%5D=1' + "links": { + "first": "http://testserver/identities?page%5Bnumber%5D=1", + "last": "http://testserver/identities?page%5Bnumber%5D=2", + "next": None, + "prev": "http://testserver/identities?page%5Bnumber%5D=1", }, - 'meta': { - 'pagination': { - 'page': 2, - 'pages': 2, - 'count': 2 - } - } + "meta": {"pagination": {"page": 2, "pages": 2, "count": 2}}, } assert expected == response.json() @@ -103,45 +92,39 @@ def test_page_range_in_list_result(self): tests pluralization as two objects means it converts ``user`` to ``users``. """ - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): - response = self.client.get(self.list_url, {'page[size]': 2}) + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): + response = self.client.get(self.list_url, {"page[size]": 2}) self.assertEqual(response.status_code, 200) users = get_user_model().objects.all() expected = { - 'data': [ + "data": [ { - 'type': 'users', - 'id': encoding.force_str(users[0].pk), - 'attributes': { - 'first-name': users[0].first_name, - 'last-name': users[0].last_name, - 'email': users[0].email + "type": "users", + "id": encoding.force_str(users[0].pk), + "attributes": { + "first-name": users[0].first_name, + "last-name": users[0].last_name, + "email": users[0].email, }, }, { - 'type': 'users', - 'id': encoding.force_str(users[1].pk), - 'attributes': { - 'first-name': users[1].first_name, - 'last-name': users[1].last_name, - 'email': users[1].email + "type": "users", + "id": encoding.force_str(users[1].pk), + "attributes": { + "first-name": users[1].first_name, + "last-name": users[1].last_name, + "email": users[1].email, }, - } + }, ], - 'links': { - 'first': 'http://testserver/identities?page%5Bnumber%5D=1&page%5Bsize%5D=2', - 'last': 'http://testserver/identities?page%5Bnumber%5D=1&page%5Bsize%5D=2', - 'next': None, - 'prev': None + "links": { + "first": "http://testserver/identities?page%5Bnumber%5D=1&page%5Bsize%5D=2", + "last": "http://testserver/identities?page%5Bnumber%5D=1&page%5Bsize%5D=2", + "next": None, + "prev": None, }, - 'meta': { - 'pagination': { - 'page': 1, - 'pages': 1, - 'count': 2 - } - } + "meta": {"pagination": {"page": 1, "pages": 1, "count": 2}}, } assert expected == response.json() @@ -150,18 +133,18 @@ def test_key_in_detail_result(self): """ Ensure the result has a 'user' key. """ - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): response = self.client.get(self.detail_url) self.assertEqual(response.status_code, 200) expected = { - 'data': { - 'type': 'users', - 'id': encoding.force_str(self.miles.pk), - 'attributes': { - 'first-name': self.miles.first_name, - 'last-name': self.miles.last_name, - 'email': self.miles.email + "data": { + "type": "users", + "id": encoding.force_str(self.miles.pk), + "attributes": { + "first-name": self.miles.first_name, + "last-name": self.miles.last_name, + "email": self.miles.email, }, } } @@ -173,12 +156,7 @@ def test_patch_requires_id(self): Verify that 'id' is required to be passed in an update request. """ data = { - 'data': { - 'type': 'users', - 'attributes': { - 'first-name': 'DifferentName' - } - } + "data": {"type": "users", "attributes": {"first-name": "DifferentName"}} } response = self.client.patch(self.detail_url, data=data) @@ -190,12 +168,10 @@ def test_patch_requires_correct_id(self): Verify that 'id' is the same then in url """ data = { - 'data': { - 'type': 'users', - 'id': self.miles.pk + 1, - 'attributes': { - 'first-name': 'DifferentName' - } + "data": { + "type": "users", + "id": self.miles.pk + 1, + "attributes": {"first-name": "DifferentName"}, } } @@ -207,36 +183,34 @@ def test_key_in_post(self): """ Ensure a key is in the post. """ - self.client.login(username='miles', password='pw') + self.client.login(username="miles", password="pw") data = { - 'data': { - 'type': 'users', - 'id': encoding.force_str(self.miles.pk), - 'attributes': { - 'first-name': self.miles.first_name, - 'last-name': self.miles.last_name, - 'email': 'miles@trumpet.org' + "data": { + "type": "users", + "id": encoding.force_str(self.miles.pk), + "attributes": { + "first-name": self.miles.first_name, + "last-name": self.miles.last_name, + "email": "miles@trumpet.org", }, } } - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): response = self.client.put(self.detail_url, data=data) assert data == response.json() # is it updated? self.assertEqual( - get_user_model().objects.get(pk=self.miles.pk).email, - 'miles@trumpet.org') + get_user_model().objects.get(pk=self.miles.pk).email, "miles@trumpet.org" + ) def test_404_error_pointer(self): - self.client.login(username='miles', password='pw') - not_found_url = reverse('user-detail', kwargs={'pk': 12345}) + self.client.login(username="miles", password="pw") + not_found_url = reverse("user-detail", kwargs={"pk": 12345}) errors = { - 'errors': [ - {'detail': 'Not found.', 'status': '404', 'code': 'not_found'} - ] + "errors": [{"detail": "Not found.", "status": "404", "code": "not_found"}] } response = self.client.get(not_found_url) @@ -250,18 +224,13 @@ def test_patch_allow_field_type(author, author_type_factory, client): Verify that type field may be updated. """ author_type = author_type_factory() - url = reverse('author-detail', args=[author.id]) + url = reverse("author-detail", args=[author.id]) data = { - 'data': { - 'id': author.id, - 'type': 'authors', - 'relationships': { - 'data': { - 'id': author_type.id, - 'type': 'author-type' - } - } + "data": { + "id": author.id, + "type": "authors", + "relationships": {"data": {"id": author_type.id, "type": "author-type"}}, } } diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index e7a2b6ca..d32a9367 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -23,14 +23,11 @@ def create_view_with_kw(view_cls, method, request, initkwargs): def test_path_without_parameters(snapshot): - path = '/authors/' - method = 'GET' + path = "/authors/" + method = "GET" view = create_view_with_kw( - views.AuthorViewSet, - method, - create_request(path), - {'get': 'list'} + views.AuthorViewSet, method, create_request(path), {"get": "list"} ) inspector = AutoSchema() inspector.view = view @@ -40,14 +37,11 @@ def test_path_without_parameters(snapshot): def test_path_with_id_parameter(snapshot): - path = '/authors/{id}/' - method = 'GET' + path = "/authors/{id}/" + method = "GET" view = create_view_with_kw( - views.AuthorViewSet, - method, - create_request(path), - {'get': 'retrieve'} + views.AuthorViewSet, method, create_request(path), {"get": "retrieve"} ) inspector = AutoSchema() inspector.view = view @@ -57,14 +51,11 @@ def test_path_with_id_parameter(snapshot): def test_post_request(snapshot): - method = 'POST' - path = '/authors/' + method = "POST" + path = "/authors/" view = create_view_with_kw( - views.AuthorViewSet, - method, - create_request(path), - {'post': 'create'} + views.AuthorViewSet, method, create_request(path), {"post": "create"} ) inspector = AutoSchema() inspector.view = view @@ -74,14 +65,11 @@ def test_post_request(snapshot): def test_patch_request(snapshot): - method = 'PATCH' - path = '/authors/{id}' + method = "PATCH" + path = "/authors/{id}" view = create_view_with_kw( - views.AuthorViewSet, - method, - create_request(path), - {'patch': 'update'} + views.AuthorViewSet, method, create_request(path), {"patch": "update"} ) inspector = AutoSchema() inspector.view = view @@ -91,14 +79,11 @@ def test_patch_request(snapshot): def test_delete_request(snapshot): - method = 'DELETE' - path = '/authors/{id}' + method = "DELETE" + path = "/authors/{id}" view = create_view_with_kw( - views.AuthorViewSet, - method, - create_request(path), - {'delete': 'delete'} + views.AuthorViewSet, method, create_request(path), {"delete": "delete"} ) inspector = AutoSchema() inspector.view = view @@ -107,22 +92,25 @@ def test_delete_request(snapshot): snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) -@override_settings(REST_FRAMEWORK={ - 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema'}) +@override_settings( + REST_FRAMEWORK={ + "DEFAULT_SCHEMA_CLASS": "rest_framework_json_api.schemas.openapi.AutoSchema" + } +) def test_schema_construction(): """Construction of the top level dictionary.""" patterns = [ - re_path('^authors/?$', views.AuthorViewSet.as_view({'get': 'list'})), + re_path("^authors/?$", views.AuthorViewSet.as_view({"get": "list"})), ] generator = SchemaGenerator(patterns=patterns) - request = create_request('/') + request = create_request("/") schema = generator.get_schema(request=request) - assert 'openapi' in schema - assert 'info' in schema - assert 'paths' in schema - assert 'components' in schema + assert "openapi" in schema + assert "info" in schema + assert "paths" in schema + assert "components" in schema def test_schema_related_serializers(): @@ -135,15 +123,15 @@ def test_schema_related_serializers(): and confirm that the schema for the related field is properly rendered """ generator = SchemaGenerator() - request = create_request('/') + request = create_request("/") schema = generator.get_schema(request=request) # make sure the path's relationship and related {related_field}'s got expanded - assert '/authors/{id}/relationships/{related_field}' in schema['paths'] - assert '/authors/{id}/comments/' in schema['paths'] - assert '/authors/{id}/entries/' in schema['paths'] - assert '/authors/{id}/first_entry/' in schema['paths'] - first_get = schema['paths']['/authors/{id}/first_entry/']['get']['responses']['200'] - first_schema = first_get['content']['application/vnd.api+json']['schema'] - first_props = first_schema['properties']['data'] - assert '$ref' in first_props - assert first_props['$ref'] == '#/components/schemas/Entry' + assert "/authors/{id}/relationships/{related_field}" in schema["paths"] + assert "/authors/{id}/comments/" in schema["paths"] + assert "/authors/{id}/entries/" in schema["paths"] + assert "/authors/{id}/first_entry/" in schema["paths"] + first_get = schema["paths"]["/authors/{id}/first_entry/"]["get"]["responses"]["200"] + first_schema = first_get["content"]["application/vnd.api+json"]["schema"] + first_props = first_schema["properties"]["data"] + assert "$ref" in first_props + assert first_props["$ref"] == "#/components/schemas/Entry" diff --git a/example/tests/test_parsers.py b/example/tests/test_parsers.py index 38d7c6d7..b83f70a7 100644 --- a/example/tests/test_parsers.py +++ b/example/tests/test_parsers.py @@ -14,47 +14,41 @@ class TestJSONParser(TestCase): - def setUp(self): class MockRequest(object): - def __init__(self): - self.method = 'GET' + self.method = "GET" request = MockRequest() - self.parser_context = {'request': request, 'kwargs': {}, 'view': 'BlogViewSet'} + self.parser_context = {"request": request, "kwargs": {}, "view": "BlogViewSet"} data = { - 'data': { - 'id': 123, - 'type': 'Blog', - 'attributes': { - 'json-value': {'JsonKey': 'JsonValue'} - }, + "data": { + "id": 123, + "type": "Blog", + "attributes": {"json-value": {"JsonKey": "JsonValue"}}, }, - 'meta': { - 'random_key': 'random_value' - } + "meta": {"random_key": "random_value"}, } self.string = json.dumps(data) - @override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize') + @override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize") def test_parse_include_metadata_format_field_names(self): parser = JSONParser() - stream = BytesIO(self.string.encode('utf-8')) + stream = BytesIO(self.string.encode("utf-8")) data = parser.parse(stream, None, self.parser_context) - self.assertEqual(data['_meta'], {'random_key': 'random_value'}) - self.assertEqual(data['json_value'], {'JsonKey': 'JsonValue'}) + self.assertEqual(data["_meta"], {"random_key": "random_value"}) + self.assertEqual(data["json_value"], {"JsonKey": "JsonValue"}) def test_parse_invalid_data(self): parser = JSONParser() string = json.dumps([]) - stream = BytesIO(string.encode('utf-8')) + stream = BytesIO(string.encode("utf-8")) with self.assertRaises(ParseError): parser.parse(stream, None, self.parser_context) @@ -62,16 +56,18 @@ def test_parse_invalid_data(self): def test_parse_invalid_data_key(self): parser = JSONParser() - string = json.dumps({ - 'data': [{ - 'id': 123, - 'type': 'Blog', - 'attributes': { - 'json-value': {'JsonKey': 'JsonValue'} - }, - }] - }) - stream = BytesIO(string.encode('utf-8')) + string = json.dumps( + { + "data": [ + { + "id": 123, + "type": "Blog", + "attributes": {"json-value": {"JsonKey": "JsonValue"}}, + } + ] + } + ) + stream = BytesIO(string.encode("utf-8")) with self.assertRaises(ParseError): parser.parse(stream, None, self.parser_context) @@ -84,7 +80,7 @@ def __init__(self, response_dict): @property def pk(self): - return self.id if hasattr(self, 'id') else None + return self.id if hasattr(self, "id") else None class DummySerializer(serializers.Serializer): @@ -95,7 +91,7 @@ class DummySerializer(serializers.Serializer): class DummyAPIView(views.APIView): parser_classes = [JSONParser] renderer_classes = [JSONRenderer] - resource_name = 'dummy' + resource_name = "dummy" def patch(self, request, *args, **kwargs): serializer = DummySerializer(DummyDTO(request.data)) @@ -103,28 +99,25 @@ def patch(self, request, *args, **kwargs): urlpatterns = [ - path('repeater', DummyAPIView.as_view(), name='repeater'), + path("repeater", DummyAPIView.as_view(), name="repeater"), ] class TestParserOnAPIView(APITestCase): - def setUp(self): class MockRequest(object): def __init__(self): - self.method = 'PATCH' + self.method = "PATCH" request = MockRequest() # To be honest view string isn't resolved into actual view - self.parser_context = {'request': request, 'kwargs': {}, 'view': 'DummyAPIView'} + self.parser_context = {"request": request, "kwargs": {}, "view": "DummyAPIView"} self.data = { - 'data': { - 'id': 123, - 'type': 'strs', - 'attributes': { - 'body': 'hello' - }, + "data": { + "id": 123, + "type": "strs", + "attributes": {"body": "hello"}, } } @@ -133,20 +126,20 @@ def __init__(self): def test_patch_doesnt_raise_attribute_error(self): parser = JSONParser() - stream = BytesIO(self.string.encode('utf-8')) + stream = BytesIO(self.string.encode("utf-8")) data = parser.parse(stream, None, self.parser_context) - assert data['id'] == 123 - assert data['body'] == 'hello' + assert data["id"] == 123 + assert data["body"] == "hello" @override_settings(ROOT_URLCONF=__name__) def test_patch_request(self): - url = reverse('repeater') + url = reverse("repeater") data = self.data - data['data']['type'] = 'dummy' + data["data"]["type"] = "dummy" response = self.client.patch(url, data=data) data = response.json() - assert data['data']['id'] == str(123) - assert data['data']['attributes']['body'] == 'hello' + assert data["data"]["id"] == str(123) + assert data["data"]["attributes"]["body"] == "hello" diff --git a/example/tests/test_performance.py b/example/tests/test_performance.py index ac8b5956..e42afada 100644 --- a/example/tests/test_performance.py +++ b/example/tests/test_performance.py @@ -7,45 +7,49 @@ class PerformanceTestCase(APITestCase): def setUp(self): - self.author = Author.objects.create(name='Super powerful superhero', email='i.am@lost.com') - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") - self.other_blog = Blog.objects.create(name='Other blog', tagline="It's another blog") + self.author = Author.objects.create( + name="Super powerful superhero", email="i.am@lost.com" + ) + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") + self.other_blog = Blog.objects.create( + name="Other blog", tagline="It's another blog" + ) self.first_entry = Entry.objects.create( blog=self.blog, - headline='headline one', - body_text='body_text two', + headline="headline one", + body_text="body_text two", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=3 + rating=3, ) self.second_entry = Entry.objects.create( blog=self.blog, - headline='headline two', - body_text='body_text one', + headline="headline two", + body_text="body_text one", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=1 + rating=1, ) self.comment = Comment.objects.create(entry=self.first_entry) CommentFactory.create_batch(50) def test_query_count_no_includes(self): - """ We expect a simple list view to issue only two queries. + """We expect a simple list view to issue only two queries. 1. The number of results in the set (e.g. a COUNT query), only necessary because we're using PageNumberPagination 2. The SELECT query for the set """ with self.assertNumQueries(2): - response = self.client.get('/comments?page[size]=25') - self.assertEqual(len(response.data['results']), 25) + response = self.client.get("/comments?page[size]=25") + self.assertEqual(len(response.data["results"]), 25) def test_query_count_include_author(self): - """ We expect a list view with an include have three queries: + """We expect a list view with an include have three queries: 1. Primary resource COUNT query 2. Primary resource SELECT @@ -54,15 +58,15 @@ def test_query_count_include_author(self): 5. Entries prefetched """ with self.assertNumQueries(5): - response = self.client.get('/comments?include=author&page[size]=25') - self.assertEqual(len(response.data['results']), 25) + response = self.client.get("/comments?include=author&page[size]=25") + self.assertEqual(len(response.data["results"]), 25) def test_query_select_related_entry(self): - """ We expect a list view with an include have two queries: + """We expect a list view with an include have two queries: 1. Primary resource COUNT query 2. Primary resource SELECT + SELECT RELATED writer(author) and bio """ with self.assertNumQueries(2): - response = self.client.get('/comments?include=writer&page[size]=25') - self.assertEqual(len(response.data['results']), 25) + response = self.client.get("/comments?include=writer&page[size]=25") + self.assertEqual(len(response.data["results"]), 25) diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py index ef1dfb02..b83bbef4 100644 --- a/example/tests/test_relations.py +++ b/example/tests/test_relations.py @@ -10,7 +10,7 @@ from rest_framework_json_api.relations import ( HyperlinkedRelatedField, ResourceRelatedField, - SerializerMethodHyperlinkedRelatedField + SerializerMethodHyperlinkedRelatedField, ) from rest_framework_json_api.utils import format_resource_type @@ -21,76 +21,66 @@ class TestResourceRelatedField(TestBase): - def setUp(self): super(TestResourceRelatedField, self).setUp() - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") self.entry = Entry.objects.create( blog=self.blog, - headline='headline', - body_text='body_text', + headline="headline", + body_text="body_text", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=3 + rating=3, ) for i in range(1, 6): - name = 'some_author{}'.format(i) + name = "some_author{}".format(i) self.entry.authors.add( - Author.objects.create(name=name, email='{}@example.org'.format(name)) + Author.objects.create(name=name, email="{}@example.org".format(name)) ) self.comment = Comment.objects.create( entry=self.entry, - body='testing one two three', - author=Author.objects.first() + body="testing one two three", + author=Author.objects.first(), ) def test_data_in_correct_format_when_instantiated_with_blog_object(self): - serializer = BlogFKSerializer(instance={'blog': self.blog}) + serializer = BlogFKSerializer(instance={"blog": self.blog}) - expected_data = { - 'type': format_resource_type('Blog'), - 'id': str(self.blog.id) - } + expected_data = {"type": format_resource_type("Blog"), "id": str(self.blog.id)} - actual_data = serializer.data['blog'] + actual_data = serializer.data["blog"] self.assertEqual(actual_data, expected_data) def test_data_in_correct_format_when_instantiated_with_entry_object(self): - serializer = EntryFKSerializer(instance={'entry': self.entry}) + serializer = EntryFKSerializer(instance={"entry": self.entry}) expected_data = { - 'type': format_resource_type('Entry'), - 'id': str(self.entry.id) + "type": format_resource_type("Entry"), + "id": str(self.entry.id), } - actual_data = serializer.data['entry'] + actual_data = serializer.data["entry"] self.assertEqual(actual_data, expected_data) def test_deserialize_primitive_data_blog(self): - serializer = BlogFKSerializer(data={ - 'blog': { - 'type': format_resource_type('Blog'), - 'id': str(self.blog.id) + serializer = BlogFKSerializer( + data={ + "blog": {"type": format_resource_type("Blog"), "id": str(self.blog.id)} } - } ) self.assertTrue(serializer.is_valid()) - self.assertEqual(serializer.validated_data['blog'], self.blog) + self.assertEqual(serializer.validated_data["blog"], self.blog) def test_validation_fails_for_wrong_type(self): with self.assertRaises(Conflict) as cm: - serializer = BlogFKSerializer(data={ - 'blog': { - 'type': 'Entries', - 'id': str(self.blog.id) - } - } + serializer = BlogFKSerializer( + data={"blog": {"type": "Entries", "id": str(self.blog.id)}} ) serializer.is_valid() the_exception = cm.exception @@ -99,150 +89,156 @@ def test_validation_fails_for_wrong_type(self): def test_serialize_many_to_many_relation(self): serializer = EntryModelSerializer(instance=self.entry) - type_string = format_resource_type('Author') - author_pks = Author.objects.values_list('pk', flat=True) - expected_data = [{'type': type_string, 'id': str(pk)} for pk in author_pks] + type_string = format_resource_type("Author") + author_pks = Author.objects.values_list("pk", flat=True) + expected_data = [{"type": type_string, "id": str(pk)} for pk in author_pks] - self.assertEqual( - serializer.data['authors'], - expected_data - ) + self.assertEqual(serializer.data["authors"], expected_data) def test_deserialize_many_to_many_relation(self): - type_string = format_resource_type('Author') - author_pks = Author.objects.values_list('pk', flat=True) - authors = [{'type': type_string, 'id': pk} for pk in author_pks] + type_string = format_resource_type("Author") + author_pks = Author.objects.values_list("pk", flat=True) + authors = [{"type": type_string, "id": pk} for pk in author_pks] - serializer = EntryModelSerializer(data={'authors': authors, 'comments': []}) + serializer = EntryModelSerializer(data={"authors": authors, "comments": []}) self.assertTrue(serializer.is_valid()) - self.assertEqual(len(serializer.validated_data['authors']), Author.objects.count()) - for author in serializer.validated_data['authors']: + self.assertEqual( + len(serializer.validated_data["authors"]), Author.objects.count() + ) + for author in serializer.validated_data["authors"]: self.assertIsInstance(author, Author) def test_read_only(self): serializer = EntryModelSerializer( - data={'authors': [], 'comments': [{'type': 'Comments', 'id': 2}]} + data={"authors": [], "comments": [{"type": "Comments", "id": 2}]} ) serializer.is_valid(raise_exception=True) - self.assertNotIn('comments', serializer.validated_data) + self.assertNotIn("comments", serializer.validated_data) def test_invalid_resource_id_object(self): - comment = {'body': 'testing 123', 'entry': {'type': 'entry'}, 'author': {'id': '5'}} + comment = { + "body": "testing 123", + "entry": {"type": "entry"}, + "author": {"id": "5"}, + } serializer = CommentSerializer(data=comment) assert not serializer.is_valid() assert serializer.errors == { - 'author': ["Invalid resource identifier object: missing 'type' attribute"], - 'entry': ["Invalid resource identifier object: missing 'id' attribute"] + "author": ["Invalid resource identifier object: missing 'type' attribute"], + "entry": ["Invalid resource identifier object: missing 'id' attribute"], } class TestHyperlinkedFieldBase(TestBase): - def setUp(self): super(TestHyperlinkedFieldBase, self).setUp() - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") self.entry = Entry.objects.create( blog=self.blog, - headline='headline', - body_text='body_text', + headline="headline", + body_text="body_text", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=3 + rating=3, ) self.comment = Comment.objects.create( entry=self.entry, - body='testing one two three', + body="testing one two three", ) - self.request = RequestFactory().get(reverse('entry-detail', kwargs={'pk': self.entry.pk})) - self.view = EntryViewSet(request=self.request, kwargs={'entry_pk': self.entry.id}) + self.request = RequestFactory().get( + reverse("entry-detail", kwargs={"pk": self.entry.pk}) + ) + self.view = EntryViewSet( + request=self.request, kwargs={"entry_pk": self.entry.id} + ) class TestHyperlinkedRelatedField(TestHyperlinkedFieldBase): - def test_single_hyperlinked_related_field(self): field = HyperlinkedRelatedField( - related_link_view_name='entry-blog', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-blog", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", read_only=True, ) - field._context = {'request': self.request, 'view': self.view} - field.field_name = 'blog' + field._context = {"request": self.request, "view": self.view} + field.field_name = "blog" self.assertRaises(NotImplementedError, field.to_representation, self.entry) self.assertRaises(SkipField, field.get_attribute, self.entry) links_expected = { - 'self': 'http://testserver/entries/{}/relationships/blog'.format(self.entry.pk), - 'related': 'http://testserver/entries/{}/blog'.format(self.entry.pk) + "self": "http://testserver/entries/{}/relationships/blog".format( + self.entry.pk + ), + "related": "http://testserver/entries/{}/blog".format(self.entry.pk), } got = field.get_links(self.entry) self.assertEqual(got, links_expected) def test_many_hyperlinked_related_field(self): field = HyperlinkedRelatedField( - related_link_view_name='entry-comments', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-comments", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", read_only=True, - many=True + many=True, ) - field._context = {'request': self.request, 'view': self.view} - field.field_name = 'comments' + field._context = {"request": self.request, "view": self.view} + field.field_name = "comments" - self.assertRaises(NotImplementedError, field.to_representation, self.entry.comments.all()) + self.assertRaises( + NotImplementedError, field.to_representation, self.entry.comments.all() + ) self.assertRaises(SkipField, field.get_attribute, self.entry) links_expected = { - 'self': 'http://testserver/entries/{}/relationships/comments'.format(self.entry.pk), - 'related': 'http://testserver/entries/{}/comments'.format(self.entry.pk) + "self": "http://testserver/entries/{}/relationships/comments".format( + self.entry.pk + ), + "related": "http://testserver/entries/{}/comments".format(self.entry.pk), } got = field.child_relation.get_links(self.entry) self.assertEqual(got, links_expected) class TestSerializerMethodHyperlinkedRelatedField(TestHyperlinkedFieldBase): - def test_single_serializer_method_hyperlinked_related_field(self): serializer = EntryModelSerializerWithHyperLinks( - instance=self.entry, - context={ - 'request': self.request, - 'view': self.view - } + instance=self.entry, context={"request": self.request, "view": self.view} ) - field = serializer.fields['blog'] + field = serializer.fields["blog"] self.assertRaises(NotImplementedError, field.to_representation, self.entry) self.assertRaises(SkipField, field.get_attribute, self.entry) expected = { - 'self': 'http://testserver/entries/{}/relationships/blog'.format(self.entry.pk), - 'related': 'http://testserver/entries/{}/blog'.format(self.entry.pk) + "self": "http://testserver/entries/{}/relationships/blog".format( + self.entry.pk + ), + "related": "http://testserver/entries/{}/blog".format(self.entry.pk), } got = field.get_links(self.entry) self.assertEqual(got, expected) def test_many_serializer_method_hyperlinked_related_field(self): serializer = EntryModelSerializerWithHyperLinks( - instance=self.entry, - context={ - 'request': self.request, - 'view': self.view - } + instance=self.entry, context={"request": self.request, "view": self.view} ) - field = serializer.fields['comments'] + field = serializer.fields["comments"] self.assertRaises(NotImplementedError, field.to_representation, self.entry) self.assertRaises(SkipField, field.get_attribute, self.entry) expected = { - 'self': 'http://testserver/entries/{}/relationships/comments'.format(self.entry.pk), - 'related': 'http://testserver/entries/{}/comments'.format(self.entry.pk) + "self": "http://testserver/entries/{}/relationships/comments".format( + self.entry.pk + ), + "related": "http://testserver/entries/{}/comments".format(self.entry.pk), } got = field.get_links(self.entry) self.assertEqual(got, expected) @@ -281,26 +277,29 @@ class EntryModelSerializer(serializers.ModelSerializer): class Meta: model = Entry - fields = ('authors', 'comments') + fields = ("authors", "comments") class EntryModelSerializerWithHyperLinks(serializers.ModelSerializer): blog = SerializerMethodHyperlinkedRelatedField( - related_link_view_name='entry-blog', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-blog", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", many=True, ) comments = SerializerMethodHyperlinkedRelatedField( - related_link_view_name='entry-comments', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-comments", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", many=True, ) class Meta: model = Entry - fields = ('blog', 'comments',) + fields = ( + "blog", + "comments", + ) def get_blog(self, obj): return obj.blog diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index de97dcf8..b3b1dc2d 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -11,7 +11,7 @@ DateField, ModelSerializer, ResourceIdentifierObjectSerializer, - empty + empty, ) from rest_framework_json_api.utils import format_resource_type @@ -25,37 +25,40 @@ class TestResourceIdentifierObjectSerializer(TestCase): def setUp(self): - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") now = timezone.now() self.entry = Entry.objects.create( blog=self.blog, - headline='headline', - body_text='body_text', + headline="headline", + body_text="body_text", pub_date=now.date(), mod_date=now.date(), n_comments=0, n_pingbacks=0, - rating=3 + rating=3, ) for i in range(1, 6): - name = 'some_author{}'.format(i) + name = "some_author{}".format(i) self.entry.authors.add( - Author.objects.create(name=name, email='{}@example.org'.format(name)) + Author.objects.create(name=name, email="{}@example.org".format(name)) ) def test_forward_relationship_not_loaded_when_not_included(self): - to_representation_method = 'example.serializers.TaggedItemSerializer.to_representation' + to_representation_method = ( + "example.serializers.TaggedItemSerializer.to_representation" + ) with mock.patch(to_representation_method) as mocked_serializer: + class EntrySerializer(ModelSerializer): blog = BlogSerializer() class Meta: model = Entry - fields = '__all__' + fields = "__all__" - request_without_includes = Request(request_factory.get('/')) - serializer = EntrySerializer(context={'request': request_without_includes}) + request_without_includes = Request(request_factory.get("/")) + serializer = EntrySerializer(context={"request": request_without_includes}) serializer.to_representation(self.entry) mocked_serializer.assert_not_called() @@ -66,62 +69,74 @@ class EntrySerializer(ModelSerializer): class Meta: model = Entry - fields = '__all__' + fields = "__all__" - request_without_includes = Request(request_factory.get('/')) - serializer = EntrySerializer(context={'request': request_without_includes}) + request_without_includes = Request(request_factory.get("/")) + serializer = EntrySerializer(context={"request": request_without_includes}) result = serializer.to_representation(self.entry) # Remove non deterministic fields - result.pop('created_at') - result.pop('modified_at') + result.pop("created_at") + result.pop("modified_at") expected = dict( [ - ('id', 1), - ('blog', dict([ - ('name', 'Some Blog'), - ('tags', []), - ('copyright', 2020), - ('url', 'http://testserver/blogs/1') - ])), - ('headline', 'headline'), - ('body_text', 'body_text'), - ('pub_date', DateField().to_representation(self.entry.pub_date)), - ('mod_date', DateField().to_representation(self.entry.mod_date)), - ('n_comments', 0), - ('n_pingbacks', 0), - ('rating', 3), - ('authors', + ("id", 1), + ( + "blog", + dict( + [ + ("name", "Some Blog"), + ("tags", []), + ("copyright", 2020), + ("url", "http://testserver/blogs/1"), + ] + ), + ), + ("headline", "headline"), + ("body_text", "body_text"), + ("pub_date", DateField().to_representation(self.entry.pub_date)), + ("mod_date", DateField().to_representation(self.entry.mod_date)), + ("n_comments", 0), + ("n_pingbacks", 0), + ("rating", 3), + ( + "authors", [ - dict([('type', 'authors'), ('id', '1')]), - dict([('type', 'authors'), ('id', '2')]), - dict([('type', 'authors'), ('id', '3')]), - dict([('type', 'authors'), ('id', '4')]), - dict([('type', 'authors'), ('id', '5')])])]) + dict([("type", "authors"), ("id", "1")]), + dict([("type", "authors"), ("id", "2")]), + dict([("type", "authors"), ("id", "3")]), + dict([("type", "authors"), ("id", "4")]), + dict([("type", "authors"), ("id", "5")]), + ], + ), + ] + ) self.assertDictEqual(expected, result) def test_data_in_correct_format_when_instantiated_with_blog_object(self): serializer = ResourceIdentifierObjectSerializer(instance=self.blog) - expected_data = {'type': format_resource_type('Blog'), 'id': str(self.blog.id)} + expected_data = {"type": format_resource_type("Blog"), "id": str(self.blog.id)} assert serializer.data == expected_data def test_data_in_correct_format_when_instantiated_with_entry_object(self): serializer = ResourceIdentifierObjectSerializer(instance=self.entry) - expected_data = {'type': format_resource_type('Entry'), 'id': str(self.entry.id)} + expected_data = { + "type": format_resource_type("Entry"), + "id": str(self.entry.id), + } assert serializer.data == expected_data def test_deserialize_primitive_data_blog(self): - initial_data = { - 'type': format_resource_type('Blog'), - 'id': str(self.blog.id) - } - serializer = ResourceIdentifierObjectSerializer(data=initial_data, model_class=Blog) + initial_data = {"type": format_resource_type("Blog"), "id": str(self.blog.id)} + serializer = ResourceIdentifierObjectSerializer( + data=initial_data, model_class=Blog + ) self.assertTrue(serializer.is_valid(), msg=serializer.errors) assert serializer.validated_data == self.blog @@ -131,29 +146,28 @@ def test_deserialize_primitive_data_blog_with_unexisting_pk(self): self.blog.delete() assert not Blog.objects.filter(id=unexisting_pk).exists() - initial_data = { - 'type': format_resource_type('Blog'), - 'id': str(unexisting_pk) - } - serializer = ResourceIdentifierObjectSerializer(data=initial_data, model_class=Blog) + initial_data = {"type": format_resource_type("Blog"), "id": str(unexisting_pk)} + serializer = ResourceIdentifierObjectSerializer( + data=initial_data, model_class=Blog + ) self.assertFalse(serializer.is_valid()) - self.assertEqual(serializer.errors[0].code, 'does_not_exist') + self.assertEqual(serializer.errors[0].code, "does_not_exist") def test_data_in_correct_format_when_instantiated_with_queryset(self): qs = Author.objects.all() serializer = ResourceIdentifierObjectSerializer(instance=qs, many=True) - type_string = format_resource_type('Author') - author_pks = Author.objects.values_list('pk', flat=True) - expected_data = [{'type': type_string, 'id': str(pk)} for pk in author_pks] + type_string = format_resource_type("Author") + author_pks = Author.objects.values_list("pk", flat=True) + expected_data = [{"type": type_string, "id": str(pk)} for pk in author_pks] assert serializer.data == expected_data def test_deserialize_many(self): - type_string = format_resource_type('Author') - author_pks = Author.objects.values_list('pk', flat=True) - initial_data = [{'type': type_string, 'id': str(pk)} for pk in author_pks] + type_string = format_resource_type("Author") + author_pks = Author.objects.values_list("pk", flat=True) + initial_data = [{"type": type_string, "id": str(pk)} for pk in author_pks] serializer = ResourceIdentifierObjectSerializer( data=initial_data, model_class=Author, many=True @@ -170,33 +184,20 @@ def test_model_serializer_with_implicit_fields(self, comment, client): "data": { "type": "comments", "id": str(comment.pk), - "attributes": { - "body": comment.body - }, + "attributes": {"body": comment.body}, "relationships": { - "entry": { - "data": { - "type": "entries", - "id": str(comment.entry.pk) - } - }, + "entry": {"data": {"type": "entries", "id": str(comment.entry.pk)}}, "author": { - "data": { - "type": "authors", - "id": str(comment.author.pk) - } + "data": {"type": "authors", "id": str(comment.author.pk)} }, "writer": { - "data": { - "type": "writers", - "id": str(comment.author.pk) - } + "data": {"type": "writers", "id": str(comment.author.pk)} }, - } + }, } } - response = client.get(reverse("comment-detail", kwargs={'pk': comment.pk})) + response = client.get(reverse("comment-detail", kwargs={"pk": comment.pk})) assert response.status_code == 200 assert expected == response.json() diff --git a/example/tests/test_sideload_resources.py b/example/tests/test_sideload_resources.py index 69641af7..5ca96afe 100644 --- a/example/tests/test_sideload_resources.py +++ b/example/tests/test_sideload_resources.py @@ -13,7 +13,8 @@ class SideloadResourceTest(TestBase): """ Test that sideloading resources returns expected output. """ - url = reverse('user-posts') + + url = reverse("user-posts") def test_get_sideloaded_data(self): """ @@ -21,9 +22,9 @@ def test_get_sideloaded_data(self): do not return a single root key. """ response = self.client.get(self.url) - content = json.loads(response.content.decode('utf8')) + content = json.loads(response.content.decode("utf8")) self.assertEqual( sorted(content.keys()), - [encoding.force_str('identities'), - encoding.force_str('posts')]) + [encoding.force_str("identities"), encoding.force_str("posts")], + ) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 25eeca89..4b494b52 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -16,142 +16,164 @@ from example.factories import AuthorFactory, CommentFactory, EntryFactory from example.models import Author, Blog, Comment, Entry -from example.serializers import AuthorBioSerializer, AuthorTypeSerializer, EntrySerializer +from example.serializers import ( + AuthorBioSerializer, + AuthorTypeSerializer, + EntrySerializer, +) from example.tests import TestBase from example.views import AuthorViewSet, BlogViewSet class TestRelationshipView(APITestCase): def setUp(self): - self.author = Author.objects.create(name='Super powerful superhero', email='i.am@lost.com') - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") - self.other_blog = Blog.objects.create(name='Other blog', tagline="It's another blog") + self.author = Author.objects.create( + name="Super powerful superhero", email="i.am@lost.com" + ) + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") + self.other_blog = Blog.objects.create( + name="Other blog", tagline="It's another blog" + ) self.first_entry = Entry.objects.create( blog=self.blog, - headline='headline one', - body_text='body_text two', + headline="headline one", + body_text="body_text two", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=3 + rating=3, ) self.second_entry = Entry.objects.create( blog=self.blog, - headline='headline two', - body_text='body_text one', + headline="headline two", + body_text="body_text one", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=1 + rating=1, ) self.first_comment = Comment.objects.create( entry=self.first_entry, body="This entry is cool", author=None ) self.second_comment = Comment.objects.create( - entry=self.second_entry, - body="This entry is not cool", - author=self.author + entry=self.second_entry, body="This entry is not cool", author=self.author ) def test_get_entry_relationship_blog(self): url = reverse( - 'entry-relationships', kwargs={'pk': self.first_entry.id, 'related_field': 'blog'} + "entry-relationships", + kwargs={"pk": self.first_entry.id, "related_field": "blog"}, ) response = self.client.get(url) - expected_data = {'type': format_resource_type('Blog'), 'id': str(self.first_entry.blog.id)} + expected_data = { + "type": format_resource_type("Blog"), + "id": str(self.first_entry.blog.id), + } assert response.data == expected_data def test_get_entry_relationship_invalid_field(self): response = self.client.get( - '/entries/{}/relationships/invalid_field'.format(self.first_entry.id) + "/entries/{}/relationships/invalid_field".format(self.first_entry.id) ) assert response.status_code == 404 def test_get_blog_relationship_entry_set(self): - response = self.client.get('/blogs/{}/relationships/entry_set'.format(self.blog.id)) - expected_data = [{'type': format_resource_type('Entry'), 'id': str(self.first_entry.id)}, - {'type': format_resource_type('Entry'), 'id': str(self.second_entry.id)}] + response = self.client.get( + "/blogs/{}/relationships/entry_set".format(self.blog.id) + ) + expected_data = [ + {"type": format_resource_type("Entry"), "id": str(self.first_entry.id)}, + {"type": format_resource_type("Entry"), "id": str(self.second_entry.id)}, + ] assert response.data == expected_data def test_put_entry_relationship_blog_returns_405(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = "/entries/{}/relationships/blog".format(self.first_entry.id) response = self.client.put(url, data={}) assert response.status_code == 405 def test_patch_invalid_entry_relationship_blog_returns_400(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) - response = self.client.patch(url, data={'data': {'invalid': ''}}) + url = "/entries/{}/relationships/blog".format(self.first_entry.id) + response = self.client.patch(url, data={"data": {"invalid": ""}}) assert response.status_code == 400 def test_relationship_view_errors_format(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) - response = self.client.patch(url, data={'data': {'invalid': ''}}) + url = "/entries/{}/relationships/blog".format(self.first_entry.id) + response = self.client.patch(url, data={"data": {"invalid": ""}}) assert response.status_code == 400 - result = json.loads(response.content.decode('utf-8')) + result = json.loads(response.content.decode("utf-8")) - assert 'data' not in result - assert 'errors' in result + assert "data" not in result + assert "errors" in result def test_get_empty_to_one_relationship(self): - url = '/comments/{}/relationships/author'.format(self.first_entry.id) + url = "/comments/{}/relationships/author".format(self.first_entry.id) response = self.client.get(url) expected_data = None assert response.data == expected_data def test_get_to_many_relationship_self_link(self): - url = '/authors/{}/relationships/comments'.format(self.author.id) + url = "/authors/{}/relationships/comments".format(self.author.id) response = self.client.get(url) expected_data = { - 'links': {'self': 'http://testserver/authors/1/relationships/comments'}, - 'data': [{'id': str(self.second_comment.id), 'type': format_resource_type('Comment')}] + "links": {"self": "http://testserver/authors/1/relationships/comments"}, + "data": [ + { + "id": str(self.second_comment.id), + "type": format_resource_type("Comment"), + } + ], } - assert json.loads(response.content.decode('utf-8')) == expected_data + assert json.loads(response.content.decode("utf-8")) == expected_data def test_patch_to_one_relationship(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = "/entries/{}/relationships/blog".format(self.first_entry.id) request_data = { - 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} + "data": { + "type": format_resource_type("Blog"), + "id": str(self.other_blog.id), + } } response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert response.data == request_data['data'] + assert response.data == request_data["data"] response = self.client.get(url) - assert response.data == request_data['data'] + assert response.data == request_data["data"] def test_patch_one_to_many_relationship(self): - url = '/blogs/{}/relationships/entry_set'.format(self.first_entry.id) + url = "/blogs/{}/relationships/entry_set".format(self.first_entry.id) request_data = { - 'data': [{'type': format_resource_type('Entry'), 'id': str(self.first_entry.id)}, ] + "data": [ + {"type": format_resource_type("Entry"), "id": str(self.first_entry.id)}, + ] } response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert response.data == request_data['data'] + assert response.data == request_data["data"] response = self.client.get(url) - assert response.data == request_data['data'] + assert response.data == request_data["data"] # retry a second time should end up with same result response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert response.data == request_data['data'] + assert response.data == request_data["data"] response = self.client.get(url) - assert response.data == request_data['data'] + assert response.data == request_data["data"] def test_patch_one_to_many_relaitonship_with_none(self): - url = '/blogs/{}/relationships/entry_set'.format(self.first_entry.id) - request_data = { - 'data': None - } + url = "/blogs/{}/relationships/entry_set".format(self.first_entry.id) + request_data = {"data": None} response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() assert response.data == [] @@ -160,103 +182,127 @@ def test_patch_one_to_many_relaitonship_with_none(self): assert response.data == [] def test_patch_many_to_many_relationship(self): - url = '/entries/{}/relationships/authors'.format(self.first_entry.id) + url = "/entries/{}/relationships/authors".format(self.first_entry.id) request_data = { - 'data': [ - { - 'type': format_resource_type('Author'), - 'id': str(self.author.id) - }, + "data": [ + {"type": format_resource_type("Author"), "id": str(self.author.id)}, ] } response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert response.data == request_data['data'] + assert response.data == request_data["data"] response = self.client.get(url) - assert response.data == request_data['data'] + assert response.data == request_data["data"] # retry a second time should end up with same result response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert response.data == request_data['data'] + assert response.data == request_data["data"] response = self.client.get(url) - assert response.data == request_data['data'] + assert response.data == request_data["data"] def test_post_to_one_relationship_should_fail(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = "/entries/{}/relationships/blog".format(self.first_entry.id) request_data = { - 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} + "data": { + "type": format_resource_type("Blog"), + "id": str(self.other_blog.id), + } } response = self.client.post(url, data=request_data) assert response.status_code == 405, response.content.decode() def test_post_to_many_relationship_with_no_change(self): - url = '/entries/{}/relationships/comments'.format(self.first_entry.id) + url = "/entries/{}/relationships/comments".format(self.first_entry.id) request_data = { - 'data': [{'type': format_resource_type('Comment'), 'id': str(self.first_comment.id)}, ] + "data": [ + { + "type": format_resource_type("Comment"), + "id": str(self.first_comment.id), + }, + ] } response = self.client.post(url, data=request_data) assert response.status_code == 204, response.content.decode() assert len(response.rendered_content) == 0, response.rendered_content.decode() def test_post_to_many_relationship_with_change(self): - url = '/entries/{}/relationships/comments'.format(self.first_entry.id) + url = "/entries/{}/relationships/comments".format(self.first_entry.id) request_data = { - 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] + "data": [ + { + "type": format_resource_type("Comment"), + "id": str(self.second_comment.id), + }, + ] } response = self.client.post(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert request_data['data'][0] in response.data + assert request_data["data"][0] in response.data def test_delete_to_one_relationship_should_fail(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = "/entries/{}/relationships/blog".format(self.first_entry.id) request_data = { - 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} + "data": { + "type": format_resource_type("Blog"), + "id": str(self.other_blog.id), + } } response = self.client.delete(url, data=request_data) assert response.status_code == 405, response.content.decode() def test_delete_relationship_overriding_with_none(self): - url = '/comments/{}'.format(self.second_comment.id) + url = "/comments/{}".format(self.second_comment.id) request_data = { - 'data': { - 'type': 'comments', - 'id': self.second_comment.id, - 'relationships': { - 'author': { - 'data': None - } - } + "data": { + "type": "comments", + "id": self.second_comment.id, + "relationships": {"author": {"data": None}}, } } response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert response.data['author'] is None + assert response.data["author"] is None def test_delete_to_many_relationship_with_no_change(self): - url = '/entries/{}/relationships/comments'.format(self.first_entry.id) + url = "/entries/{}/relationships/comments".format(self.first_entry.id) request_data = { - 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] + "data": [ + { + "type": format_resource_type("Comment"), + "id": str(self.second_comment.id), + }, + ] } response = self.client.delete(url, data=request_data) assert response.status_code == 204, response.content.decode() assert len(response.rendered_content) == 0, response.rendered_content.decode() def test_delete_one_to_many_relationship_with_not_null_constraint(self): - url = '/entries/{}/relationships/comments'.format(self.first_entry.id) + url = "/entries/{}/relationships/comments".format(self.first_entry.id) request_data = { - 'data': [{'type': format_resource_type('Comment'), 'id': str(self.first_comment.id)}, ] + "data": [ + { + "type": format_resource_type("Comment"), + "id": str(self.first_comment.id), + }, + ] } response = self.client.delete(url, data=request_data) assert response.status_code == 409, response.content.decode() def test_delete_to_many_relationship_with_change(self): - url = '/authors/{}/relationships/comments'.format(self.author.id) + url = "/authors/{}/relationships/comments".format(self.author.id) request_data = { - 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] + "data": [ + { + "type": format_resource_type("Comment"), + "id": str(self.second_comment.id), + }, + ] } response = self.client.delete(url, data=request_data) assert response.status_code == 200, response.content.decode() @@ -265,21 +311,19 @@ def test_new_comment_data_patch_to_many_relationship(self): entry = EntryFactory(blog=self.blog, authors=(self.author,)) comment = CommentFactory(entry=entry) - url = '/authors/{}/relationships/comments'.format(self.author.id) + url = "/authors/{}/relationships/comments".format(self.author.id) request_data = { - 'data': [{'type': format_resource_type('Comment'), 'id': str(comment.id)}, ] + "data": [ + {"type": format_resource_type("Comment"), "id": str(comment.id)}, + ] } previous_response = { - 'data': [ - {'type': 'comments', - 'id': str(self.second_comment.id) - } - ], - 'links': { - 'self': 'http://testserver/authors/{}/relationships/comments'.format( + "data": [{"type": "comments", "id": str(self.second_comment.id)}], + "links": { + "self": "http://testserver/authors/{}/relationships/comments".format( self.author.id ) - } + }, } response = self.client.get(url) @@ -287,16 +331,12 @@ def test_new_comment_data_patch_to_many_relationship(self): assert response.json() == previous_response new_patched_response = { - 'data': [ - {'type': 'comments', - 'id': str(comment.id) - } - ], - 'links': { - 'self': 'http://testserver/authors/{}/relationships/comments'.format( + "data": [{"type": "comments", "id": str(comment.id)}], + "links": { + "self": "http://testserver/authors/{}/relationships/comments".format( self.author.id ) - } + }, } response = self.client.patch(url, data=request_data) @@ -307,21 +347,19 @@ def test_new_comment_data_patch_to_many_relationship(self): def test_options_entry_relationship_blog(self): url = reverse( - 'entry-relationships', kwargs={'pk': self.first_entry.id, 'related_field': 'blog'} + "entry-relationships", + kwargs={"pk": self.first_entry.id, "related_field": "blog"}, ) response = self.client.options(url) expected_data = { "data": { "name": "Entry Relationship", "description": "", - "renders": [ - "application/vnd.api+json", - "text/html" - ], + "renders": ["application/vnd.api+json", "text/html"], "parses": [ "application/vnd.api+json", "application/x-www-form-urlencoded", - "multipart/form-data" + "multipart/form-data", ], "allowed_methods": [ "GET", @@ -329,99 +367,103 @@ def test_options_entry_relationship_blog(self): "PATCH", "DELETE", "HEAD", - "OPTIONS" + "OPTIONS", ], - "actions": { - "POST": {} - } + "actions": {"POST": {}}, } } assert response.json() == expected_data class TestRelatedMixin(APITestCase): - def setUp(self): self.author = AuthorFactory() def _get_view(self, kwargs): factory = APIRequestFactory() - request = Request(factory.get('', content_type='application/vnd.api+json')) + request = Request(factory.get("", content_type="application/vnd.api+json")) return AuthorViewSet(request=request, kwargs=kwargs) def test_get_related_field_name(self): - kwargs = {'pk': self.author.id, 'related_field': 'bio'} + kwargs = {"pk": self.author.id, "related_field": "bio"} view = self._get_view(kwargs) got = view.get_related_field_name() - self.assertEqual(got, kwargs['related_field']) + self.assertEqual(got, kwargs["related_field"]) def test_get_related_instance_serializer_field(self): - kwargs = {'pk': self.author.id, 'related_field': 'bio'} + kwargs = {"pk": self.author.id, "related_field": "bio"} view = self._get_view(kwargs) got = view.get_related_instance() self.assertEqual(got, self.author.bio) def test_get_related_instance_model_field(self): - kwargs = {'pk': self.author.id, 'related_field': 'id'} + kwargs = {"pk": self.author.id, "related_field": "id"} view = self._get_view(kwargs) got = view.get_related_instance() self.assertEqual(got, self.author.id) def test_get_related_serializer_class(self): - kwargs = {'pk': self.author.id, 'related_field': 'bio'} + kwargs = {"pk": self.author.id, "related_field": "bio"} view = self._get_view(kwargs) got = view.get_related_serializer_class() self.assertEqual(got, AuthorBioSerializer) def test_get_related_serializer_class_many(self): - kwargs = {'pk': self.author.id, 'related_field': 'entries'} + kwargs = {"pk": self.author.id, "related_field": "entries"} view = self._get_view(kwargs) got = view.get_related_serializer_class() self.assertEqual(got, EntrySerializer) def test_get_serializer_comes_from_included_serializers(self): - kwargs = {'pk': self.author.id, 'related_field': 'type'} + kwargs = {"pk": self.author.id, "related_field": "type"} view = self._get_view(kwargs) related_serializers = view.serializer_class.related_serializers - delattr(view.serializer_class, 'related_serializers') + delattr(view.serializer_class, "related_serializers") got = view.get_related_serializer_class() self.assertEqual(got, AuthorTypeSerializer) view.serializer_class.related_serializers = related_serializers def test_get_related_serializer_class_raises_error(self): - kwargs = {'pk': self.author.id, 'related_field': 'unknown'} + kwargs = {"pk": self.author.id, "related_field": "unknown"} view = self._get_view(kwargs) self.assertRaises(NotFound, view.get_related_serializer_class) def test_retrieve_related_single_reverse_lookup(self): - url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'bio'}) + url = reverse( + "author-related", kwargs={"pk": self.author.pk, "related_field": "bio"} + ) resp = self.client.get(url) expected = { - 'data': { - 'type': 'authorBios', 'id': str(self.author.bio.id), - 'relationships': { - 'author': {'data': {'type': 'authors', 'id': str(self.author.id)}}, - 'metadata': {'data': {'id': str(self.author.bio.metadata.id), - 'type': 'authorBioMetadata'}} - }, - 'attributes': { - 'body': str(self.author.bio.body) + "data": { + "type": "authorBios", + "id": str(self.author.bio.id), + "relationships": { + "author": {"data": {"type": "authors", "id": str(self.author.id)}}, + "metadata": { + "data": { + "id": str(self.author.bio.metadata.id), + "type": "authorBioMetadata", + } + }, }, + "attributes": {"body": str(self.author.bio.body)}, } } self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json(), expected) def test_retrieve_related_single(self): - url = reverse('author-related', kwargs={'pk': self.author.type.pk, 'related_field': 'type'}) + url = reverse( + "author-related", + kwargs={"pk": self.author.type.pk, "related_field": "type"}, + ) resp = self.client.get(url) expected = { - 'data': { - 'type': 'authorTypes', 'id': str(self.author.type.id), - 'attributes': { - 'name': str(self.author.type.name) - }, + "data": { + "type": "authorTypes", + "id": str(self.author.type.id), + "attributes": {"name": str(self.author.type.name)}, } } self.assertEqual(resp.status_code, 200) @@ -429,211 +471,219 @@ def test_retrieve_related_single(self): def test_retrieve_related_many(self): entry = EntryFactory(authors=self.author) - url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'entries'}) + url = reverse( + "author-related", kwargs={"pk": self.author.pk, "related_field": "entries"} + ) resp = self.client.get(url) self.assertEqual(resp.status_code, 200) - self.assertTrue(isinstance(resp.json()['data'], list)) - self.assertEqual(len(resp.json()['data']), 1) - self.assertEqual(resp.json()['data'][0]['id'], str(entry.id)) + self.assertTrue(isinstance(resp.json()["data"], list)) + self.assertEqual(len(resp.json()["data"]), 1) + self.assertEqual(resp.json()["data"][0]["id"], str(entry.id)) def test_retrieve_related_many_hyperlinked(self): comment = CommentFactory(author=self.author) - url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'comments'}) + url = reverse( + "author-related", kwargs={"pk": self.author.pk, "related_field": "comments"} + ) resp = self.client.get(url) self.assertEqual(resp.status_code, 200) - self.assertTrue(isinstance(resp.json()['data'], list)) - self.assertEqual(len(resp.json()['data']), 1) - self.assertEqual(resp.json()['data'][0]['id'], str(comment.id)) + self.assertTrue(isinstance(resp.json()["data"], list)) + self.assertEqual(len(resp.json()["data"]), 1) + self.assertEqual(resp.json()["data"][0]["id"], str(comment.id)) def test_retrieve_related_None(self): - kwargs = {'pk': self.author.pk, 'related_field': 'first_entry'} - url = reverse('author-related', kwargs=kwargs) + kwargs = {"pk": self.author.pk, "related_field": "first_entry"} + url = reverse("author-related", kwargs=kwargs) resp = self.client.get(url) self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.json(), {'data': None}) + self.assertEqual(resp.json(), {"data": None}) class TestValidationErrorResponses(TestBase): def test_if_returns_error_on_empty_post(self): - view = BlogViewSet.as_view({'post': 'create'}) + view = BlogViewSet.as_view({"post": "create"}) response = self._get_create_response("{}", view) self.assertEqual(400, response.status_code) - expected = [{ - 'detail': 'Received document does not contain primary data', - 'status': '400', - 'source': {'pointer': '/data'}, - 'code': 'parse_error', - }] + expected = [ + { + "detail": "Received document does not contain primary data", + "status": "400", + "source": {"pointer": "/data"}, + "code": "parse_error", + } + ] self.assertEqual(expected, response.data) def test_if_returns_error_on_missing_form_data_post(self): - view = BlogViewSet.as_view({'post': 'create'}) - response = self._get_create_response('{"data":{"attributes":{},"type":"blogs"}}', view) + view = BlogViewSet.as_view({"post": "create"}) + response = self._get_create_response( + '{"data":{"attributes":{},"type":"blogs"}}', view + ) self.assertEqual(400, response.status_code) - expected = [{ - 'status': '400', - 'detail': 'This field is required.', - 'source': {'pointer': '/data/attributes/name'}, - 'code': 'required', - }] + expected = [ + { + "status": "400", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/name"}, + "code": "required", + } + ] self.assertEqual(expected, response.data) def test_if_returns_error_on_bad_endpoint_name(self): - view = BlogViewSet.as_view({'post': 'create'}) - response = self._get_create_response('{"data":{"attributes":{},"type":"bad"}}', view) + view = BlogViewSet.as_view({"post": "create"}) + response = self._get_create_response( + '{"data":{"attributes":{},"type":"bad"}}', view + ) self.assertEqual(409, response.status_code) - expected = [{ - 'detail': ( - "The resource object's type (bad) is not the type that constitute the collection " - "represented by the endpoint (blogs)." - ), - 'source': {'pointer': '/data'}, - 'status': '409', - 'code': 'error', - }] + expected = [ + { + "detail": ( + "The resource object's type (bad) is not the type that constitute the collection " + "represented by the endpoint (blogs)." + ), + "source": {"pointer": "/data"}, + "status": "409", + "code": "error", + } + ] self.assertEqual(expected, response.data) def _get_create_response(self, data, view): factory = RequestFactory() - request = factory.post('/', data, content_type='application/vnd.api+json') - user = self.create_user('user', 'pass') + request = factory.post("/", data, content_type="application/vnd.api+json") + user = self.create_user("user", "pass") force_authenticate(request, user) return view(request) class TestModelViewSet(TestBase): def setUp(self): - self.author = Author.objects.create(name='Super powerful superhero', email='i.am@lost.com') - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.author = Author.objects.create( + name="Super powerful superhero", email="i.am@lost.com" + ) + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") def test_no_content_response(self): - url = '/blogs/{}'.format(self.blog.pk) + url = "/blogs/{}".format(self.blog.pk) response = self.client.delete(url) assert response.status_code == 204, response.rendered_content.decode() assert len(response.rendered_content) == 0, response.rendered_content.decode() class TestBlogViewSet(APITestCase): - def setUp(self): - self.blog = Blog.objects.create( - name='Some Blog', - tagline="It's a blog" - ) + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") self.entry = Entry.objects.create( blog=self.blog, - headline='headline one', - body_text='body_text two', + headline="headline one", + body_text="body_text two", ) def test_get_object_gives_correct_blog(self): - url = reverse('entry-blog', kwargs={'entry_pk': self.entry.id}) + url = reverse("entry-blog", kwargs={"entry_pk": self.entry.id}) resp = self.client.get(url) expected = { - 'data': { - 'attributes': {'name': self.blog.name}, - 'id': '{}'.format(self.blog.id), - 'links': {'self': 'http://testserver/blogs/{}'.format(self.blog.id)}, - 'meta': {'copyright': datetime.now().year}, - 'relationships': {'tags': {'data': [], 'meta': {'count': 0}}}, - 'type': 'blogs' + "data": { + "attributes": {"name": self.blog.name}, + "id": "{}".format(self.blog.id), + "links": {"self": "http://testserver/blogs/{}".format(self.blog.id)}, + "meta": {"copyright": datetime.now().year}, + "relationships": {"tags": {"data": [], "meta": {"count": 0}}}, + "type": "blogs", }, - 'meta': {'apiDocs': '/docs/api/blogs'} + "meta": {"apiDocs": "/docs/api/blogs"}, } got = resp.json() self.assertEqual(got, expected) class TestEntryViewSet(APITestCase): - def setUp(self): - self.blog = Blog.objects.create( - name='Some Blog', - tagline="It's a blog" - ) + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") self.first_entry = Entry.objects.create( blog=self.blog, - headline='headline two', - body_text='body_text two', + headline="headline two", + body_text="body_text two", ) self.second_entry = Entry.objects.create( blog=self.blog, - headline='headline two', - body_text='body_text two', + headline="headline two", + body_text="body_text two", ) self.maxDiff = None def test_get_object_gives_correct_entry(self): - url = reverse('entry-featured', kwargs={'entry_pk': self.first_entry.id}) + url = reverse("entry-featured", kwargs={"entry_pk": self.first_entry.id}) resp = self.client.get(url) expected = { - 'data': { - 'attributes': { - 'bodyText': self.second_entry.body_text, - 'headline': self.second_entry.headline, - 'modDate': self.second_entry.mod_date, - 'pubDate': self.second_entry.pub_date + "data": { + "attributes": { + "bodyText": self.second_entry.body_text, + "headline": self.second_entry.headline, + "modDate": self.second_entry.mod_date, + "pubDate": self.second_entry.pub_date, }, - 'id': '{}'.format(self.second_entry.id), - 'meta': {'bodyFormat': 'text'}, - 'relationships': { - 'authors': {'data': [], 'meta': {'count': 0}}, - 'blog': { - 'data': { - 'id': '{}'.format(self.second_entry.blog_id), - 'type': 'blogs' + "id": "{}".format(self.second_entry.id), + "meta": {"bodyFormat": "text"}, + "relationships": { + "authors": {"data": [], "meta": {"count": 0}}, + "blog": { + "data": { + "id": "{}".format(self.second_entry.blog_id), + "type": "blogs", } }, - 'blogHyperlinked': { - 'links': { - 'related': 'http://testserver/entries/{}' - '/blog'.format(self.second_entry.id), - 'self': 'http://testserver/entries/{}' - '/relationships/blog_hyperlinked'.format(self.second_entry.id) + "blogHyperlinked": { + "links": { + "related": "http://testserver/entries/{}" + "/blog".format(self.second_entry.id), + "self": "http://testserver/entries/{}" + "/relationships/blog_hyperlinked".format( + self.second_entry.id + ), } }, - 'comments': { - 'data': [], - 'meta': {'count': 0} - }, - 'commentsHyperlinked': { - 'links': { - 'related': 'http://testserver/entries/{}' - '/comments'.format(self.second_entry.id), - 'self': 'http://testserver/entries/{}/relationships' - '/comments_hyperlinked'.format(self.second_entry.id) + "comments": {"data": [], "meta": {"count": 0}}, + "commentsHyperlinked": { + "links": { + "related": "http://testserver/entries/{}" + "/comments".format(self.second_entry.id), + "self": "http://testserver/entries/{}/relationships" + "/comments_hyperlinked".format(self.second_entry.id), } }, - 'featuredHyperlinked': { - 'links': { - 'related': 'http://testserver/entries/{}' - '/featured'.format(self.second_entry.id), - 'self': 'http://testserver/entries/{}/relationships' - '/featured_hyperlinked'.format(self.second_entry.id) + "featuredHyperlinked": { + "links": { + "related": "http://testserver/entries/{}" + "/featured".format(self.second_entry.id), + "self": "http://testserver/entries/{}/relationships" + "/featured_hyperlinked".format(self.second_entry.id), } }, - 'suggested': { - 'data': [{'id': '1', 'type': 'entries'}], - 'links': { - 'related': 'http://testserver/entries/{}' - '/suggested/'.format(self.second_entry.id), - 'self': 'http://testserver/entries/{}' - '/relationships/suggested'.format(self.second_entry.id) - } + "suggested": { + "data": [{"id": "1", "type": "entries"}], + "links": { + "related": "http://testserver/entries/{}" + "/suggested/".format(self.second_entry.id), + "self": "http://testserver/entries/{}" + "/relationships/suggested".format(self.second_entry.id), + }, }, - 'suggestedHyperlinked': { - 'links': { - 'related': 'http://testserver/entries/{}' - '/suggested/'.format(self.second_entry.id), - 'self': 'http://testserver/entries/{}/relationships' - '/suggested_hyperlinked'.format(self.second_entry.id) + "suggestedHyperlinked": { + "links": { + "related": "http://testserver/entries/{}" + "/suggested/".format(self.second_entry.id), + "self": "http://testserver/entries/{}/relationships" + "/suggested_hyperlinked".format(self.second_entry.id), } }, - 'tags': {'data': [], 'meta': {'count': 0}}}, - 'type': 'posts' + "tags": {"data": [], "meta": {"count": 0}}, + }, + "type": "posts", } } got = resp.json() @@ -643,74 +693,75 @@ def test_get_object_gives_correct_entry(self): class BasicAuthorSerializer(serializers.ModelSerializer): class Meta: model = Author - fields = ('name',) + fields = ("name",) class ReadOnlyViewSetWithCustomActions(views.ReadOnlyModelViewSet): queryset = Author.objects.all() serializer_class = BasicAuthorSerializer - @action(detail=False, methods=['get', 'post', 'patch', 'delete']) + @action(detail=False, methods=["get", "post", "patch", "delete"]) def group_action(self, request): return Response(status=status.HTTP_204_NO_CONTENT) - @action(detail=True, methods=['get', 'post', 'patch', 'delete']) + @action(detail=True, methods=["get", "post", "patch", "delete"]) def item_action(self, request, pk): return Response(status=status.HTTP_204_NO_CONTENT) class TestReadonlyModelViewSet(TestBase): """ - Test if ReadOnlyModelViewSet allows to have custom actions with POST, PATCH, DELETE methods + Test if ReadOnlyModelViewSet allows to have custom actions with POST, PATCH, DELETE methods """ + factory = RequestFactory() viewset_class = ReadOnlyViewSetWithCustomActions - media_type = 'application/vnd.api+json' + media_type = "application/vnd.api+json" def test_group_action_allows_get(self): - view = self.viewset_class.as_view({'get': 'group_action'}) - request = self.factory.get('/') + view = self.viewset_class.as_view({"get": "group_action"}) + request = self.factory.get("/") response = view(request) self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_group_action_allows_post(self): - view = self.viewset_class.as_view({'post': 'group_action'}) - request = self.factory.post('/', '{}', content_type=self.media_type) + view = self.viewset_class.as_view({"post": "group_action"}) + request = self.factory.post("/", "{}", content_type=self.media_type) response = view(request) self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_group_action_allows_patch(self): - view = self.viewset_class.as_view({'patch': 'group_action'}) - request = self.factory.patch('/', '{}', content_type=self.media_type) + view = self.viewset_class.as_view({"patch": "group_action"}) + request = self.factory.patch("/", "{}", content_type=self.media_type) response = view(request) self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_group_action_allows_delete(self): - view = self.viewset_class.as_view({'delete': 'group_action'}) - request = self.factory.delete('/', '{}', content_type=self.media_type) + view = self.viewset_class.as_view({"delete": "group_action"}) + request = self.factory.delete("/", "{}", content_type=self.media_type) response = view(request) self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_item_action_allows_get(self): - view = self.viewset_class.as_view({'get': 'item_action'}) - request = self.factory.get('/') - response = view(request, pk='1') + view = self.viewset_class.as_view({"get": "item_action"}) + request = self.factory.get("/") + response = view(request, pk="1") self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_item_action_allows_post(self): - view = self.viewset_class.as_view({'post': 'item_action'}) - request = self.factory.post('/', '{}', content_type=self.media_type) - response = view(request, pk='1') + view = self.viewset_class.as_view({"post": "item_action"}) + request = self.factory.post("/", "{}", content_type=self.media_type) + response = view(request, pk="1") self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_item_action_allows_patch(self): - view = self.viewset_class.as_view({'patch': 'item_action'}) - request = self.factory.patch('/', '{}', content_type=self.media_type) - response = view(request, pk='1') + view = self.viewset_class.as_view({"patch": "item_action"}) + request = self.factory.patch("/", "{}", content_type=self.media_type) + response = view(request, pk="1") self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_item_action_allows_delete(self): - view = self.viewset_class.as_view({'delete': 'item_action'}) - request = self.factory.delete('/', '{}', content_type=self.media_type) - response = view(request, pk='1') + view = self.viewset_class.as_view({"delete": "item_action"}) + request = self.factory.delete("/", "{}", content_type=self.media_type) + response = view(request, pk="1") self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index 3233ce84..e8c78df8 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -15,7 +15,7 @@ class RelatedModelSerializer(ModelSerializer): class Meta: model = Comment - fields = ('id',) + fields = ("id",) class DummyTestSerializer(ModelSerializer): @@ -23,16 +23,19 @@ class DummyTestSerializer(ModelSerializer): This serializer is a simple compound document serializer which includes only a single embedded relation """ - related_models = RelatedModelSerializer(source='comments', many=True, read_only=True) + + related_models = RelatedModelSerializer( + source="comments", many=True, read_only=True + ) json_field = SerializerMethodField() def get_json_field(self, entry): - return {'JsonKey': 'JsonValue'} + return {"JsonKey": "JsonValue"} class Meta: model = Entry - fields = ('related_models', 'json_field') + fields = ("related_models", "json_field") # views @@ -44,9 +47,7 @@ class DummyTestViewSet(viewsets.ModelViewSet): def render_dummy_test_serialized_view(view_class): serializer = DummyTestSerializer(instance=Entry()) renderer = JSONRenderer() - return renderer.render( - serializer.data, - renderer_context={'view': view_class()}) + return renderer.render(serializer.data, renderer_context={"view": view_class()}) # tests @@ -54,32 +55,28 @@ def test_simple_reverse_relation_included_renderer(): """ Test renderer when a single reverse fk relation is passed. """ - rendered = render_dummy_test_serialized_view( - DummyTestViewSet) + rendered = render_dummy_test_serialized_view(DummyTestViewSet) assert rendered def test_render_format_field_names(settings): """Test that json field is kept untouched.""" - settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' + settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" rendered = render_dummy_test_serialized_view(DummyTestViewSet) result = json.loads(rendered.decode()) - assert result['data']['attributes']['json-field'] == {'JsonKey': 'JsonValue'} + assert result["data"]["attributes"]["json-field"] == {"JsonKey": "JsonValue"} @pytest.mark.django_db def test_blog_create(client): - url = reverse('drf-entry-blog-list') + url = reverse("drf-entry-blog-list") name = "Dummy Name" request_data = { - 'data': { - 'attributes': {'name': name}, - 'type': 'blogs' - }, + "data": {"attributes": {"name": name}, "type": "blogs"}, } resp = client.post(url, request_data) @@ -94,14 +91,14 @@ def test_blog_create(client): blog = blog.first() expected = { - 'data': { - 'attributes': {'name': blog.name, 'tags': []}, - 'id': '{}'.format(blog.id), - 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, - 'meta': {'copyright': datetime.now().year}, - 'type': 'blogs' + "data": { + "attributes": {"name": blog.name, "tags": []}, + "id": "{}".format(blog.id), + "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "meta": {"copyright": datetime.now().year}, + "type": "blogs", }, - 'meta': {'apiDocs': '/docs/api/blogs'} + "meta": {"apiDocs": "/docs/api/blogs"}, } assert resp.status_code == 201 @@ -111,17 +108,17 @@ def test_blog_create(client): @pytest.mark.django_db def test_get_object_gives_correct_blog(client, blog, entry): - url = reverse('drf-entry-blog-detail', kwargs={'entry_pk': entry.id}) + url = reverse("drf-entry-blog-detail", kwargs={"entry_pk": entry.id}) resp = client.get(url) expected = { - 'data': { - 'attributes': {'name': blog.name, 'tags': []}, - 'id': '{}'.format(blog.id), - 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, - 'meta': {'copyright': datetime.now().year}, - 'type': 'blogs' + "data": { + "attributes": {"name": blog.name, "tags": []}, + "id": "{}".format(blog.id), + "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "meta": {"copyright": datetime.now().year}, + "type": "blogs", }, - 'meta': {'apiDocs': '/docs/api/blogs'} + "meta": {"apiDocs": "/docs/api/blogs"}, } got = resp.json() assert got == expected @@ -130,20 +127,20 @@ def test_get_object_gives_correct_blog(client, blog, entry): @pytest.mark.django_db def test_get_object_patches_correct_blog(client, blog, entry): - url = reverse('drf-entry-blog-detail', kwargs={'entry_pk': entry.id}) + url = reverse("drf-entry-blog-detail", kwargs={"entry_pk": entry.id}) new_name = blog.name + " update" assert not new_name == blog.name request_data = { - 'data': { - 'attributes': {'name': new_name}, - 'id': '{}'.format(blog.id), - 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, - 'meta': {'copyright': datetime.now().year}, - 'relationships': {'tags': {'data': []}}, - 'type': 'blogs' + "data": { + "attributes": {"name": new_name}, + "id": "{}".format(blog.id), + "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "meta": {"copyright": datetime.now().year}, + "relationships": {"tags": {"data": []}}, + "type": "blogs", }, - 'meta': {'apiDocs': '/docs/api/blogs'} + "meta": {"apiDocs": "/docs/api/blogs"}, } resp = client.patch(url, data=request_data) @@ -151,14 +148,14 @@ def test_get_object_patches_correct_blog(client, blog, entry): assert resp.status_code == 200 expected = { - 'data': { - 'attributes': {'name': new_name, 'tags': []}, - 'id': '{}'.format(blog.id), - 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, - 'meta': {'copyright': datetime.now().year}, - 'type': 'blogs' + "data": { + "attributes": {"name": new_name, "tags": []}, + "id": "{}".format(blog.id), + "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "meta": {"copyright": datetime.now().year}, + "type": "blogs", }, - 'meta': {'apiDocs': '/docs/api/blogs'} + "meta": {"apiDocs": "/docs/api/blogs"}, } got = resp.json() assert got == expected @@ -167,7 +164,7 @@ def test_get_object_patches_correct_blog(client, blog, entry): @pytest.mark.django_db def test_get_object_deletes_correct_blog(client, entry): - url = reverse('drf-entry-blog-detail', kwargs={'entry_pk': entry.id}) + url = reverse("drf-entry-blog-detail", kwargs={"entry_pk": entry.id}) resp = client.delete(url) @@ -176,37 +173,29 @@ def test_get_object_deletes_correct_blog(client, entry): @pytest.mark.django_db def test_get_entry_list_with_blogs(client, entry): - url = reverse('drf-entry-suggested', kwargs={'entry_pk': entry.id}) + url = reverse("drf-entry-suggested", kwargs={"entry_pk": entry.id}) resp = client.get(url) got = resp.json() expected = { - 'links': { - 'first': 'http://testserver/drf-entries/1/suggested/?page%5Bnumber%5D=1', - 'last': 'http://testserver/drf-entries/1/suggested/?page%5Bnumber%5D=1', - 'next': None, - 'prev': None, + "links": { + "first": "http://testserver/drf-entries/1/suggested/?page%5Bnumber%5D=1", + "last": "http://testserver/drf-entries/1/suggested/?page%5Bnumber%5D=1", + "next": None, + "prev": None, }, - 'data': [ + "data": [ { - 'type': 'entries', - 'id': '1', - 'attributes': { - 'tags': [], + "type": "entries", + "id": "1", + "attributes": { + "tags": [], }, - 'links': { - 'self': 'http://testserver/drf-blogs/1' - } + "links": {"self": "http://testserver/drf-blogs/1"}, } ], - 'meta': { - 'pagination': { - 'page': 1, - 'pages': 1, - 'count': 1 - } - } + "meta": {"pagination": {"page": 1, "pages": 1, "count": 1}}, } assert resp.status_code == 200 diff --git a/example/tests/unit/test_factories.py b/example/tests/unit/test_factories.py index 8acc9e74..ac9d7b2a 100644 --- a/example/tests/unit/test_factories.py +++ b/example/tests/unit/test_factories.py @@ -17,25 +17,31 @@ def test_model_instance(blog): def test_multiple_blog(blog_factory): - another_blog = blog_factory(name='Cool Blog') - new_blog = blog_factory(name='Awesome Blog') + another_blog = blog_factory(name="Cool Blog") + new_blog = blog_factory(name="Awesome Blog") - assert another_blog.name == 'Cool Blog' - assert new_blog.name == 'Awesome Blog' + assert another_blog.name == "Cool Blog" + assert new_blog.name == "Awesome Blog" def test_factories_with_relations(author_factory, entry_factory): author = author_factory(name="Joel Spolsky") entry = entry_factory( - headline=("The Absolute Minimum Every Software Developer" - "Absolutely, Positively Must Know About Unicode " - "and Character Sets (No Excuses!)"), - blog__name='Joel on Software', authors=(author, )) - - assert entry.blog.name == 'Joel on Software' - assert entry.headline == ("The Absolute Minimum Every Software Developer" - "Absolutely, Positively Must Know About Unicode " - "and Character Sets (No Excuses!)") + headline=( + "The Absolute Minimum Every Software Developer" + "Absolutely, Positively Must Know About Unicode " + "and Character Sets (No Excuses!)" + ), + blog__name="Joel on Software", + authors=(author,), + ) + + assert entry.blog.name == "Joel on Software" + assert entry.headline == ( + "The Absolute Minimum Every Software Developer" + "Absolutely, Positively Must Know About Unicode " + "and Character Sets (No Excuses!)" + ) assert entry.authors.all().count() == 1 - assert entry.authors.all()[0].name == 'Joel Spolsky' + assert entry.authors.all()[0].name == "Joel Spolsky" diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py index 2044c467..9c78dc61 100644 --- a/example/tests/unit/test_filter_schema_params.py +++ b/example/tests/unit/test_filter_schema_params.py @@ -7,12 +7,16 @@ class DummyEntryViewSet(EntryViewSet): - filter_backends = (dja_filters.QueryParameterValidationFilter, dja_filters.OrderingFilter, - backends.DjangoFilterBackend, drf_filters.SearchFilter) + filter_backends = ( + dja_filters.QueryParameterValidationFilter, + dja_filters.OrderingFilter, + backends.DjangoFilterBackend, + drf_filters.SearchFilter, + ) filterset_fields = { - 'id': ('exact',), - 'headline': ('exact', 'contains'), - 'blog__name': ('contains', ), + "id": ("exact",), + "headline": ("exact", "contains"), + "blog__name": ("contains",), } def __init__(self, **kwargs): @@ -28,38 +32,63 @@ def test_filters_get_schema_params(): # list of tuples: (filter, expected result) filters = [ (dja_filters.QueryParameterValidationFilter, []), - (backends.DjangoFilterBackend, [ - { - 'name': 'filter[id]', 'required': False, 'in': 'query', - 'description': 'id', 'schema': {'type': 'string'} - }, - { - 'name': 'filter[headline]', 'required': False, 'in': 'query', - 'description': 'headline', 'schema': {'type': 'string'} - }, - { - 'name': 'filter[headline.contains]', 'required': False, 'in': 'query', - 'description': 'headline__contains', 'schema': {'type': 'string'} - }, - { - 'name': 'filter[blog.name.contains]', 'required': False, 'in': 'query', - 'description': 'blog__name__contains', 'schema': {'type': 'string'} - }, - ]), - (dja_filters.OrderingFilter, [ - { - 'name': 'sort', 'required': False, 'in': 'query', - 'description': 'Which field to use when ordering the results.', - 'schema': {'type': 'string'} - } - ]), - (drf_filters.SearchFilter, [ - { - 'name': 'filter[search]', 'required': False, 'in': 'query', - 'description': 'A search term.', - 'schema': {'type': 'string'} - } - ]), + ( + backends.DjangoFilterBackend, + [ + { + "name": "filter[id]", + "required": False, + "in": "query", + "description": "id", + "schema": {"type": "string"}, + }, + { + "name": "filter[headline]", + "required": False, + "in": "query", + "description": "headline", + "schema": {"type": "string"}, + }, + { + "name": "filter[headline.contains]", + "required": False, + "in": "query", + "description": "headline__contains", + "schema": {"type": "string"}, + }, + { + "name": "filter[blog.name.contains]", + "required": False, + "in": "query", + "description": "blog__name__contains", + "schema": {"type": "string"}, + }, + ], + ), + ( + dja_filters.OrderingFilter, + [ + { + "name": "sort", + "required": False, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": {"type": "string"}, + } + ], + ), + ( + drf_filters.SearchFilter, + [ + { + "name": "filter[search]", + "required": False, + "in": "query", + "description": "A search term.", + "schema": {"type": "string"}, + } + ], + ), ] view = DummyEntryViewSet() @@ -71,7 +100,7 @@ def test_filters_get_schema_params(): continue # py35: the result list/dict ordering isn't guaranteed for res_item in result: - assert 'name' in res_item + assert "name" in res_item for exp_item in expected: - if res_item['name'] == exp_item['name']: + if res_item["name"] == exp_item["name"]: assert res_item == exp_item diff --git a/example/tests/unit/test_pagination.py b/example/tests/unit/test_pagination.py index aeb5f87e..45042939 100644 --- a/example/tests/unit/test_pagination.py +++ b/example/tests/unit/test_pagination.py @@ -21,7 +21,7 @@ class ExamplePagination(pagination.JsonApiLimitOffsetPagination): self.pagination = ExamplePagination() self.queryset = range(1, 101) - self.base_url = 'http://testserver/' + self.base_url = "http://testserver/" def paginate_queryset(self, request): return list(self.pagination.paginate_queryset(self.queryset, request)) @@ -31,7 +31,7 @@ def get_paginated_content(self, queryset): return response.data def get_test_request(self, arguments): - return Request(factory.get('/', arguments)) + return Request(factory.get("/", arguments)) def test_valid_offset_limit(self): """ @@ -44,34 +44,48 @@ def test_valid_offset_limit(self): next_offset = 15 prev_offset = 5 - request = self.get_test_request({ - self.pagination.limit_query_param: limit, - self.pagination.offset_query_param: offset - }) - base_url = replace_query_param(self.base_url, self.pagination.limit_query_param, limit) - last_url = replace_query_param(base_url, self.pagination.offset_query_param, last_offset) + request = self.get_test_request( + { + self.pagination.limit_query_param: limit, + self.pagination.offset_query_param: offset, + } + ) + base_url = replace_query_param( + self.base_url, self.pagination.limit_query_param, limit + ) + last_url = replace_query_param( + base_url, self.pagination.offset_query_param, last_offset + ) first_url = base_url - next_url = replace_query_param(base_url, self.pagination.offset_query_param, next_offset) - prev_url = replace_query_param(base_url, self.pagination.offset_query_param, prev_offset) + next_url = replace_query_param( + base_url, self.pagination.offset_query_param, next_offset + ) + prev_url = replace_query_param( + base_url, self.pagination.offset_query_param, prev_offset + ) queryset = self.paginate_queryset(request) content = self.get_paginated_content(queryset) next_offset = offset + limit expected_content = { - 'results': list(range(offset + 1, next_offset + 1)), - 'links': OrderedDict([ - ('first', first_url), - ('last', last_url), - ('next', next_url), - ('prev', prev_url), - ]), - 'meta': { - 'pagination': OrderedDict([ - ('count', count), - ('limit', limit), - ('offset', offset), - ]) - } + "results": list(range(offset + 1, next_offset + 1)), + "links": OrderedDict( + [ + ("first", first_url), + ("last", last_url), + ("next", next_url), + ("prev", prev_url), + ] + ), + "meta": { + "pagination": OrderedDict( + [ + ("count", count), + ("limit", limit), + ("offset", offset), + ] + ) + }, } assert queryset == list(range(offset + 1, next_offset + 1)) diff --git a/example/tests/unit/test_renderer_class_methods.py b/example/tests/unit/test_renderer_class_methods.py index 7a9230d3..c599abbd 100644 --- a/example/tests/unit/test_renderer_class_methods.py +++ b/example/tests/unit/test_renderer_class_methods.py @@ -11,34 +11,36 @@ class ResourceSerializer(serializers.ModelSerializer): version = serializers.SerializerMethodField() def get_version(self, obj): - return '1.0.0' + return "1.0.0" class Meta: - fields = ('username',) - meta_fields = ('version',) + fields = ("username",) + meta_fields = ("version",) model = get_user_model() def test_build_json_resource_obj(): resource = { - 'pk': 1, - 'username': 'Alice', + "pk": 1, + "username": "Alice", } - serializer = ResourceSerializer(data={'username': 'Alice'}) + serializer = ResourceSerializer(data={"username": "Alice"}) serializer.is_valid() resource_instance = serializer.save() output = { - 'type': 'user', - 'id': '1', - 'attributes': { - 'username': 'Alice' - }, + "type": "user", + "id": "1", + "attributes": {"username": "Alice"}, } - assert JSONRenderer.build_json_resource_obj( - serializer.fields, resource, resource_instance, 'user') == output + assert ( + JSONRenderer.build_json_resource_obj( + serializer.fields, resource, resource_instance, "user" + ) + == output + ) def test_can_override_methods(): @@ -46,20 +48,18 @@ def test_can_override_methods(): Make sure extract_attributes and extract_relationships can be overriden. """ resource = { - 'pk': 1, - 'username': 'Alice', + "pk": 1, + "username": "Alice", } - serializer = ResourceSerializer(data={'username': 'Alice'}) + serializer = ResourceSerializer(data={"username": "Alice"}) serializer.is_valid() resource_instance = serializer.save() output = { - 'type': 'user', - 'id': '1', - 'attributes': { - 'username': 'Alice' - }, + "type": "user", + "id": "1", + "attributes": {"username": "Alice"}, } class CustomRenderer(JSONRenderer): @@ -78,35 +78,37 @@ def extract_relationships(cls, fields, resource, resource_instance): fields, resource, resource_instance ) - assert CustomRenderer.build_json_resource_obj( - serializer.fields, resource, resource_instance, 'user') == output + assert ( + CustomRenderer.build_json_resource_obj( + serializer.fields, resource, resource_instance, "user" + ) + == output + ) assert CustomRenderer.extract_attributes_was_overriden assert CustomRenderer.extract_relationships_was_overriden def test_extract_attributes(): fields = { - 'id': serializers.Field(), - 'username': serializers.Field(), - 'deleted': serializers.ReadOnlyField(), - } - resource = {'id': 1, 'deleted': None, 'username': 'jerel'} - expected = { - 'username': 'jerel', - 'deleted': None + "id": serializers.Field(), + "username": serializers.Field(), + "deleted": serializers.ReadOnlyField(), } - assert sorted(JSONRenderer.extract_attributes(fields, resource)) == sorted(expected), ( - 'Regular fields should be extracted' - ) + resource = {"id": 1, "deleted": None, "username": "jerel"} + expected = {"username": "jerel", "deleted": None} + assert sorted(JSONRenderer.extract_attributes(fields, resource)) == sorted( + expected + ), "Regular fields should be extracted" assert sorted(JSONRenderer.extract_attributes(fields, {})) == sorted( - {'username': ''}), 'Should not extract read_only fields on empty serializer' + {"username": ""} + ), "Should not extract read_only fields on empty serializer" def test_extract_meta(): - serializer = ResourceSerializer(data={'username': 'jerel', 'version': '1.0.0'}) + serializer = ResourceSerializer(data={"username": "jerel", "version": "1.0.0"}) serializer.is_valid() expected = { - 'version': '1.0.0', + "version": "1.0.0", } assert JSONRenderer.extract_meta(serializer, serializer.data) == expected @@ -114,33 +116,27 @@ def test_extract_meta(): class ExtractRootMetaResourceSerializer(ResourceSerializer): def get_root_meta(self, resource, many): if many: - return { - 'foo': 'meta-many-value' - } + return {"foo": "meta-many-value"} else: - return { - 'foo': 'meta-value' - } + return {"foo": "meta-value"} class InvalidExtractRootMetaResourceSerializer(ResourceSerializer): def get_root_meta(self, resource, many): - return 'not a dict' + return "not a dict" def test_extract_root_meta(): serializer = ExtractRootMetaResourceSerializer() expected = { - 'foo': 'meta-value', + "foo": "meta-value", } assert JSONRenderer.extract_root_meta(serializer, {}) == expected def test_extract_root_meta_many(): serializer = ExtractRootMetaResourceSerializer(many=True) - expected = { - 'foo': 'meta-many-value' - } + expected = {"foo": "meta-many-value"} assert JSONRenderer.extract_root_meta(serializer, {}) == expected diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index 034cc45f..47d37b5b 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -11,40 +11,41 @@ # serializers class RelatedModelSerializer(serializers.ModelSerializer): - blog = serializers.ReadOnlyField(source='entry.blog') + blog = serializers.ReadOnlyField(source="entry.blog") class Meta: model = Comment - fields = ('id', 'blog') + fields = ("id", "blog") class DummyTestSerializer(serializers.ModelSerializer): - ''' + """ This serializer is a simple compound document serializer which includes only a single embedded relation - ''' + """ + related_models = RelatedModelSerializer( - source='comments', many=True, read_only=True) + source="comments", many=True, read_only=True + ) json_field = serializers.SerializerMethodField() def get_json_field(self, entry): - return {'JsonKey': 'JsonValue'} + return {"JsonKey": "JsonValue"} class Meta: model = Entry - fields = ('related_models', 'json_field') + fields = ("related_models", "json_field") class JSONAPIMeta: - included_resources = ('related_models',) + included_resources = ("related_models",) class EntryDRFSerializers(serializers.ModelSerializer): - class Meta: model = Entry - fields = ('headline', 'body_text') - read_only_fields = ('tags',) + fields = ("headline", "body_text") + read_only_fields = ("tags",) class CommentWithNestedFieldsSerializer(serializers.ModelSerializer): @@ -52,7 +53,7 @@ class CommentWithNestedFieldsSerializer(serializers.ModelSerializer): class Meta: model = Comment - exclude = ('created_at', 'modified_at', 'author') + exclude = ("created_at", "modified_at", "author") # fields = ('entry', 'body', 'author',) @@ -61,7 +62,7 @@ class AuthorWithNestedFieldsSerializer(serializers.ModelSerializer): class Meta: model = Author - fields = ('name', 'email', 'comments') + fields = ("name", "email", "comments") # views @@ -78,59 +79,54 @@ class ReadOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): class AuthorWithNestedFieldsViewSet(views.ModelViewSet): queryset = Author.objects.all() serializer_class = AuthorWithNestedFieldsSerializer - resource_name = 'authors' + resource_name = "authors" def render_dummy_test_serialized_view(view_class, instance): serializer = view_class.serializer_class(instance=instance) renderer = JSONRenderer() - return renderer.render( - serializer.data, - renderer_context={'view': view_class()}) + return renderer.render(serializer.data, renderer_context={"view": view_class()}) def test_simple_reverse_relation_included_renderer(): - ''' + """ Test renderer when a single reverse fk relation is passed. - ''' - rendered = render_dummy_test_serialized_view( - DummyTestViewSet, Entry()) + """ + rendered = render_dummy_test_serialized_view(DummyTestViewSet, Entry()) assert rendered def test_simple_reverse_relation_included_read_only_viewset(): - rendered = render_dummy_test_serialized_view( - ReadOnlyDummyTestViewSet, Entry()) + rendered = render_dummy_test_serialized_view(ReadOnlyDummyTestViewSet, Entry()) assert rendered def test_render_format_field_names(settings): """Test that json field is kept untouched.""" - settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' + settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" rendered = render_dummy_test_serialized_view(DummyTestViewSet, Entry()) result = json.loads(rendered.decode()) - assert result['data']['attributes']['json-field'] == {'JsonKey': 'JsonValue'} + assert result["data"]["attributes"]["json-field"] == {"JsonKey": "JsonValue"} def test_writeonly_not_in_response(): """Test that writeonly fields are not shown in list response""" class WriteonlyTestSerializer(serializers.ModelSerializer): - '''Serializer for testing the absence of write_only fields''' + """Serializer for testing the absence of write_only fields""" + comments = serializers.ResourceRelatedField( - many=True, - write_only=True, - queryset=Comment.objects.all() + many=True, write_only=True, queryset=Comment.objects.all() ) rating = serializers.IntegerField(write_only=True) class Meta: model = Entry - fields = ('comments', 'rating') + fields = ("comments", "rating") class WriteOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): queryset = Entry.objects.all() @@ -139,8 +135,8 @@ class WriteOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): rendered = render_dummy_test_serialized_view(WriteOnlyDummyTestViewSet, Entry()) result = json.loads(rendered.decode()) - assert 'rating' not in result['data']['attributes'] - assert 'relationships' not in result['data'] + assert "rating" not in result["data"]["attributes"] + assert "relationships" not in result["data"] def test_render_empty_relationship_reverse_lookup(): @@ -149,7 +145,7 @@ def test_render_empty_relationship_reverse_lookup(): class EmptyRelationshipSerializer(serializers.ModelSerializer): class Meta: model = Author - fields = ('bio', ) + fields = ("bio",) class EmptyRelationshipViewSet(views.ReadOnlyModelViewSet): queryset = Author.objects.all() @@ -157,9 +153,9 @@ class EmptyRelationshipViewSet(views.ReadOnlyModelViewSet): rendered = render_dummy_test_serialized_view(EmptyRelationshipViewSet, Author()) result = json.loads(rendered.decode()) - assert 'relationships' in result['data'] - assert 'bio' in result['data']['relationships'] - assert result['data']['relationships']['bio'] == {'data': None} + assert "relationships" in result["data"] + assert "bio" in result["data"]["relationships"] + assert result["data"]["relationships"]["bio"] == {"data": None} @pytest.mark.django_db @@ -167,32 +163,30 @@ def test_extract_relation_instance(comment): serializer = RelatedModelSerializer(instance=comment) got = JSONRenderer.extract_relation_instance( - field=serializer.fields['blog'], resource_instance=comment + field=serializer.fields["blog"], resource_instance=comment ) assert got == comment.entry.blog def test_render_serializer_as_attribute(db): # setting up - blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") entry = Entry.objects.create( blog=blog, - headline='headline', - body_text='body_text', + headline="headline", + body_text="body_text", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=3 + rating=3, ) - author = Author.objects.create(name='some_author', email='some_author@example.org') + author = Author.objects.create(name="some_author", email="some_author@example.org") entry.authors.add(author) Comment.objects.create( - entry=entry, - body='testing one two three', - author=Author.objects.first() + entry=entry, body="testing one two three", author=Author.objects.first() ) rendered = render_dummy_test_serialized_view(AuthorWithNestedFieldsViewSet, author) @@ -209,13 +203,13 @@ def test_render_serializer_as_attribute(db): { "id": 1, "entry": { - 'headline': 'headline', - 'body_text': 'body_text', + "headline": "headline", + "body_text": "body_text", }, - "body": "testing one two three" + "body": "testing one two three", } - ] - } + ], + }, } } assert expected == result diff --git a/example/tests/unit/test_serializer_method_field.py b/example/tests/unit/test_serializer_method_field.py index 89e18295..37e74ce6 100644 --- a/example/tests/unit/test_serializer_method_field.py +++ b/example/tests/unit/test_serializer_method_field.py @@ -13,28 +13,27 @@ class BlogSerializer(serializers.ModelSerializer): class Meta: model = Blog - fields = ['one_entry'] + fields = ["one_entry"] def get_one_entry(self, instance): return Entry(id=100) serializer = BlogSerializer(instance=Blog()) - assert serializer.data['one_entry']['id'] == '100' + assert serializer.data["one_entry"]["id"] == "100" def test_method_name_custom(): class BlogSerializer(serializers.ModelSerializer): one_entry = SerializerMethodResourceRelatedField( - model=Entry, - method_name='get_custom_entry' + model=Entry, method_name="get_custom_entry" ) class Meta: model = Blog - fields = ['one_entry'] + fields = ["one_entry"] def get_custom_entry(self, instance): return Entry(id=100) serializer = BlogSerializer(instance=Blog()) - assert serializer.data['one_entry']['id'] == '100' + assert serializer.data["one_entry"]["id"] == "100" diff --git a/example/tests/unit/test_settings.py b/example/tests/unit/test_settings.py index e6b82a24..666eae4a 100644 --- a/example/tests/unit/test_settings.py +++ b/example/tests/unit/test_settings.py @@ -13,5 +13,5 @@ def test_settings_default(): def test_settings_override(settings): - settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' - assert json_api_settings.FORMAT_FIELD_NAMES == 'dasherize' + settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" + assert json_api_settings.FORMAT_FIELD_NAMES == "dasherize" diff --git a/example/urls.py b/example/urls.py index 9b882ce5..800b9f79 100644 --- a/example/urls.py +++ b/example/urls.py @@ -19,70 +19,95 @@ EntryViewSet, NonPaginatedEntryViewSet, ProjectTypeViewset, - ProjectViewset + ProjectViewset, ) router = routers.DefaultRouter(trailing_slash=False) -router.register(r'blogs', BlogViewSet) -router.register(r'entries', EntryViewSet) -router.register(r'nopage-entries', NonPaginatedEntryViewSet, 'nopage-entry') -router.register(r'authors', AuthorViewSet) -router.register(r'comments', CommentViewSet) -router.register(r'companies', CompanyViewset) -router.register(r'projects', ProjectViewset) -router.register(r'project-types', ProjectTypeViewset) +router.register(r"blogs", BlogViewSet) +router.register(r"entries", EntryViewSet) +router.register(r"nopage-entries", NonPaginatedEntryViewSet, "nopage-entry") +router.register(r"authors", AuthorViewSet) +router.register(r"comments", CommentViewSet) +router.register(r"companies", CompanyViewset) +router.register(r"projects", ProjectViewset) +router.register(r"project-types", ProjectTypeViewset) urlpatterns = [ - url(r'^', include(router.urls)), - url(r'^entries/(?P[^/.]+)/suggested/$', - EntryViewSet.as_view({'get': 'list'}), - name='entry-suggested' - ), - url(r'entries/(?P[^/.]+)/blog$', - BlogViewSet.as_view({'get': 'retrieve'}), - name='entry-blog'), - url(r'entries/(?P[^/.]+)/comments$', - CommentViewSet.as_view({'get': 'list'}), - name='entry-comments'), - url(r'entries/(?P[^/.]+)/authors$', - AuthorViewSet.as_view({'get': 'list'}), - name='entry-authors'), - url(r'entries/(?P[^/.]+)/featured$', - EntryViewSet.as_view({'get': 'retrieve'}), - name='entry-featured'), - - url(r'^authors/(?P[^/.]+)/(?P\w+)/$', - AuthorViewSet.as_view({'get': 'retrieve_related'}), - name='author-related'), - - url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)$', + url(r"^", include(router.urls)), + url( + r"^entries/(?P[^/.]+)/suggested/$", + EntryViewSet.as_view({"get": "list"}), + name="entry-suggested", + ), + url( + r"entries/(?P[^/.]+)/blog$", + BlogViewSet.as_view({"get": "retrieve"}), + name="entry-blog", + ), + url( + r"entries/(?P[^/.]+)/comments$", + CommentViewSet.as_view({"get": "list"}), + name="entry-comments", + ), + url( + r"entries/(?P[^/.]+)/authors$", + AuthorViewSet.as_view({"get": "list"}), + name="entry-authors", + ), + url( + r"entries/(?P[^/.]+)/featured$", + EntryViewSet.as_view({"get": "retrieve"}), + name="entry-featured", + ), + url( + r"^authors/(?P[^/.]+)/(?P\w+)/$", + AuthorViewSet.as_view({"get": "retrieve_related"}), + name="author-related", + ), + url( + r"^entries/(?P[^/.]+)/relationships/(?P\w+)$", EntryRelationshipView.as_view(), - name='entry-relationships'), - url(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)$', + name="entry-relationships", + ), + url( + r"^blogs/(?P[^/.]+)/relationships/(?P\w+)$", BlogRelationshipView.as_view(), - name='blog-relationships'), - url(r'^comments/(?P[^/.]+)/relationships/(?P\w+)$', + name="blog-relationships", + ), + url( + r"^comments/(?P[^/.]+)/relationships/(?P\w+)$", CommentRelationshipView.as_view(), - name='comment-relationships'), - url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', + name="comment-relationships", + ), + url( + r"^authors/(?P[^/.]+)/relationships/(?P\w+)$", AuthorRelationshipView.as_view(), - name='author-relationships'), - path('openapi', get_schema_view( - title="Example API", - description="API for all things …", - version="1.0.0", - generator_class=SchemaGenerator - ), name='openapi-schema'), - path('swagger-ui/', TemplateView.as_view( - template_name='swagger-ui.html', - extra_context={'schema_url': 'openapi-schema'} - ), name='swagger-ui'), + name="author-relationships", + ), + path( + "openapi", + get_schema_view( + title="Example API", + description="API for all things …", + version="1.0.0", + generator_class=SchemaGenerator, + ), + name="openapi-schema", + ), + path( + "swagger-ui/", + TemplateView.as_view( + template_name="swagger-ui.html", + extra_context={"schema_url": "openapi-schema"}, + ), + name="swagger-ui", + ), ] if settings.DEBUG: import debug_toolbar urlpatterns = [ - url(r'^__debug__/', include(debug_toolbar.urls)), + url(r"^__debug__/", include(debug_toolbar.urls)), ] + urlpatterns diff --git a/example/urls_test.py b/example/urls_test.py index 44ae6f58..7e875936 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -18,73 +18,90 @@ NoFiltersetEntryViewSet, NonPaginatedEntryViewSet, ProjectTypeViewset, - ProjectViewset + ProjectViewset, ) router = routers.DefaultRouter(trailing_slash=False) -router.register(r'blogs', BlogViewSet) +router.register(r"blogs", BlogViewSet) # router to test default DRF blog functionalities -router.register(r'drf-blogs', DRFBlogViewSet, 'drf-entry-blog') -router.register(r'entries', EntryViewSet) +router.register(r"drf-blogs", DRFBlogViewSet, "drf-entry-blog") +router.register(r"entries", EntryViewSet) # these "flavors" of entries are used for various tests: -router.register(r'nopage-entries', NonPaginatedEntryViewSet, 'nopage-entry') -router.register(r'filterset-entries', FiltersetEntryViewSet, 'filterset-entry') -router.register(r'nofilterset-entries', NoFiltersetEntryViewSet, 'nofilterset-entry') -router.register(r'authors', AuthorViewSet) -router.register(r'comments', CommentViewSet) -router.register(r'companies', CompanyViewset) -router.register(r'projects', ProjectViewset) -router.register(r'project-types', ProjectTypeViewset) +router.register(r"nopage-entries", NonPaginatedEntryViewSet, "nopage-entry") +router.register(r"filterset-entries", FiltersetEntryViewSet, "filterset-entry") +router.register(r"nofilterset-entries", NoFiltersetEntryViewSet, "nofilterset-entry") +router.register(r"authors", AuthorViewSet) +router.register(r"comments", CommentViewSet) +router.register(r"companies", CompanyViewset) +router.register(r"projects", ProjectViewset) +router.register(r"project-types", ProjectTypeViewset) # for the old tests -router.register(r'identities', Identity) +router.register(r"identities", Identity) urlpatterns = [ # old tests - re_path(r'identities/default/(?P\d+)$', - GenericIdentity.as_view(), name='user-default'), - - - re_path(r'^entries/(?P[^/.]+)/blog$', - BlogViewSet.as_view({'get': 'retrieve'}), - name='entry-blog' - ), - re_path(r'^entries/(?P[^/.]+)/comments$', - CommentViewSet.as_view({'get': 'list'}), - name='entry-comments' - ), - re_path(r'^entries/(?P[^/.]+)/suggested/$', - EntryViewSet.as_view({'get': 'list'}), - name='entry-suggested' - ), - re_path(r'^drf-entries/(?P[^/.]+)/suggested/$', - DRFEntryViewSet.as_view({'get': 'list'}), - name='drf-entry-suggested' - ), - re_path(r'entries/(?P[^/.]+)/authors$', - AuthorViewSet.as_view({'get': 'list'}), - name='entry-authors'), - re_path(r'entries/(?P[^/.]+)/featured$', - EntryViewSet.as_view({'get': 'retrieve'}), - name='entry-featured'), - - re_path(r'^authors/(?P[^/.]+)/(?P\w+)/$', - AuthorViewSet.as_view({'get': 'retrieve_related'}), - name='author-related'), - - re_path(r'^entries/(?P[^/.]+)/relationships/(?P\w+)$', - EntryRelationshipView.as_view(), - name='entry-relationships'), - re_path(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)$', - BlogRelationshipView.as_view(), - name='blog-relationships'), - re_path(r'^comments/(?P[^/.]+)/relationships/(?P\w+)$', - CommentRelationshipView.as_view(), - name='comment-relationships'), - re_path(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', - AuthorRelationshipView.as_view(), - name='author-relationships'), + re_path( + r"identities/default/(?P\d+)$", + GenericIdentity.as_view(), + name="user-default", + ), + re_path( + r"^entries/(?P[^/.]+)/blog$", + BlogViewSet.as_view({"get": "retrieve"}), + name="entry-blog", + ), + re_path( + r"^entries/(?P[^/.]+)/comments$", + CommentViewSet.as_view({"get": "list"}), + name="entry-comments", + ), + re_path( + r"^entries/(?P[^/.]+)/suggested/$", + EntryViewSet.as_view({"get": "list"}), + name="entry-suggested", + ), + re_path( + r"^drf-entries/(?P[^/.]+)/suggested/$", + DRFEntryViewSet.as_view({"get": "list"}), + name="drf-entry-suggested", + ), + re_path( + r"entries/(?P[^/.]+)/authors$", + AuthorViewSet.as_view({"get": "list"}), + name="entry-authors", + ), + re_path( + r"entries/(?P[^/.]+)/featured$", + EntryViewSet.as_view({"get": "retrieve"}), + name="entry-featured", + ), + re_path( + r"^authors/(?P[^/.]+)/(?P\w+)/$", + AuthorViewSet.as_view({"get": "retrieve_related"}), + name="author-related", + ), + re_path( + r"^entries/(?P[^/.]+)/relationships/(?P\w+)$", + EntryRelationshipView.as_view(), + name="entry-relationships", + ), + re_path( + r"^blogs/(?P[^/.]+)/relationships/(?P\w+)$", + BlogRelationshipView.as_view(), + name="blog-relationships", + ), + re_path( + r"^comments/(?P[^/.]+)/relationships/(?P\w+)$", + CommentRelationshipView.as_view(), + name="comment-relationships", + ), + re_path( + r"^authors/(?P[^/.]+)/relationships/(?P\w+)$", + AuthorRelationshipView.as_view(), + name="author-relationships", + ), ] urlpatterns += router.urls diff --git a/example/utils.py b/example/utils.py index 65403038..1c9ef459 100644 --- a/example/utils.py +++ b/example/utils.py @@ -6,7 +6,7 @@ class BrowsableAPIRendererWithoutForms(BrowsableAPIRenderer): def get_context(self, *args, **kwargs): ctx = super().get_context(*args, **kwargs) - ctx['display_edit_forms'] = False + ctx["display_edit_forms"] = False return ctx def show_form_for_method(self, view, method, request, obj): diff --git a/example/views.py b/example/views.py index 99a54193..65bcb301 100644 --- a/example/views.py +++ b/example/views.py @@ -8,7 +8,10 @@ import rest_framework_json_api.parsers import rest_framework_json_api.renderers from rest_framework_json_api.django_filters import DjangoFilterBackend -from rest_framework_json_api.filters import OrderingFilter, QueryParameterValidationFilter +from rest_framework_json_api.filters import ( + OrderingFilter, + QueryParameterValidationFilter, +) from rest_framework_json_api.pagination import JsonApiPageNumberPagination from rest_framework_json_api.utils import format_drf_errors from rest_framework_json_api.views import ModelViewSet, RelationshipView @@ -25,7 +28,7 @@ EntryDRFSerializers, EntrySerializer, ProjectSerializer, - ProjectTypeSerializer + ProjectTypeSerializer, ) HTTP_422_UNPROCESSABLE_ENTITY = 422 @@ -36,7 +39,7 @@ class BlogViewSet(ModelViewSet): serializer_class = BlogSerializer def get_object(self): - entry_pk = self.kwargs.get('entry_pk', None) + entry_pk = self.kwargs.get("entry_pk", None) if entry_pk is not None: return Entry.objects.get(id=entry_pk).blog @@ -46,7 +49,7 @@ def get_object(self): class DRFBlogViewSet(ModelViewSet): queryset = Blog.objects.all() serializer_class = BlogDRFSerializer - lookup_url_kwarg = 'entry_pk' + lookup_url_kwarg = "entry_pk" def get_object(self): entry_pk = self.kwargs.get(self.lookup_url_kwarg, None) @@ -62,6 +65,7 @@ class JsonApiViewSet(ModelViewSet): within a class. It allows using DRF-jsonapi alongside vanilla DRF API views. """ + parser_classes = [ rest_framework_json_api.parsers.JSONParser, rest_framework.parsers.FormParser, @@ -92,14 +96,14 @@ class BlogCustomViewSet(JsonApiViewSet): class EntryViewSet(ModelViewSet): queryset = Entry.objects.all() - resource_name = 'posts' + resource_name = "posts" def get_serializer_class(self): return EntrySerializer def get_object(self): # Handle featured - entry_pk = self.kwargs.get('entry_pk', None) + entry_pk = self.kwargs.get("entry_pk", None) if entry_pk is not None: return Entry.objects.exclude(pk=entry_pk).first() @@ -109,7 +113,7 @@ def get_object(self): class DRFEntryViewSet(ModelViewSet): queryset = Entry.objects.all() serializer_class = EntryDRFSerializers - lookup_url_kwarg = 'entry_pk' + lookup_url_kwarg = "entry_pk" def get_object(self): # Handle featured @@ -128,30 +132,42 @@ class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination # override the default filter backends in order to test QueryParameterValidationFilter without # breaking older usage of non-standard query params like `page_size`. - filter_backends = (QueryParameterValidationFilter, OrderingFilter, - DjangoFilterBackend, SearchFilter) - ordering_fields = ('headline', 'body_text', 'blog__name', 'blog__id') - rels = ('exact', 'iexact', - 'contains', 'icontains', - 'gt', 'gte', 'lt', 'lte', - 'in', 'regex', 'isnull',) + filter_backends = ( + QueryParameterValidationFilter, + OrderingFilter, + DjangoFilterBackend, + SearchFilter, + ) + ordering_fields = ("headline", "body_text", "blog__name", "blog__id") + rels = ( + "exact", + "iexact", + "contains", + "icontains", + "gt", + "gte", + "lt", + "lte", + "in", + "regex", + "isnull", + ) filterset_fields = { - 'id': ('exact', 'in'), - 'headline': rels, - 'body_text': rels, - 'blog__name': rels, - 'blog__tagline': rels, + "id": ("exact", "in"), + "headline": rels, + "body_text": rels, + "blog__name": rels, + "blog__tagline": rels, } - search_fields = ('headline', 'body_text', 'blog__name', 'blog__tagline') + search_fields = ("headline", "body_text", "blog__name", "blog__tagline") class EntryFilter(filters.FilterSet): - bname = filters.CharFilter(field_name="blog__name", - lookup_expr="exact") + bname = filters.CharFilter(field_name="blog__name", lookup_expr="exact") authors__id = filters.ModelMultipleChoiceFilter( - field_name='authors', - to_field_name='id', + field_name="authors", + to_field_name="id", conjoined=True, # to "and" the ids queryset=Author.objects.all(), ) @@ -159,10 +175,10 @@ class EntryFilter(filters.FilterSet): class Meta: model = Entry fields = { - 'id': ('exact',), - 'headline': ('exact',), - 'body_text': ('exact',), - 'authors__id': ('in',), + "id": ("exact",), + "headline": ("exact",), + "body_text": ("exact",), + "authors__id": ("in",), } @@ -170,16 +186,21 @@ class FiltersetEntryViewSet(EntryViewSet): """ like above but use filterset_class instead of filterset_fields """ + pagination_class = NoPagination filterset_fields = None filterset_class = EntryFilter - filter_backends = (QueryParameterValidationFilter, DjangoFilterBackend,) + filter_backends = ( + QueryParameterValidationFilter, + DjangoFilterBackend, + ) class NoFiltersetEntryViewSet(EntryViewSet): """ like above but no filtersets """ + pagination_class = NoPagination filterset_fields = None filterset_class = None @@ -189,7 +210,8 @@ class AuthorViewSet(ModelViewSet): queryset = Author.objects.all() serializer_classes = { "list": AuthorListSerializer, - "retrieve": AuthorDetailSerializer} + "retrieve": AuthorDetailSerializer, + } serializer_class = AuthorSerializer # fallback def get_serializer_class(self): @@ -202,16 +224,14 @@ def get_serializer_class(self): class CommentViewSet(ModelViewSet): queryset = Comment.objects.all() serializer_class = CommentSerializer - select_for_includes = { - 'writer': ['author__bio'] - } + select_for_includes = {"writer": ["author__bio"]} prefetch_for_includes = { - '__all__': [], - 'author': ['author__bio', 'author__entries'], + "__all__": [], + "author": ["author__bio", "author__entries"], } def get_queryset(self, *args, **kwargs): - entry_pk = self.kwargs.get('entry_pk', None) + entry_pk = self.kwargs.get("entry_pk", None) if entry_pk is not None: return self.queryset.filter(entry_id=entry_pk) @@ -224,7 +244,7 @@ class CompanyViewset(ModelViewSet): class ProjectViewset(ModelViewSet): - queryset = Project.objects.all().order_by('pk') + queryset = Project.objects.all().order_by("pk") serializer_class = ProjectSerializer @@ -247,4 +267,4 @@ class CommentRelationshipView(RelationshipView): class AuthorRelationshipView(RelationshipView): queryset = Author.objects.all() - self_link_view_name = 'author-relationships' + self_link_view_name = "author-relationships" diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index f059f020..4c4f115d 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -__title__ = 'djangorestframework-jsonapi' -__version__ = '4.0.0' -__author__ = '' -__license__ = 'BSD' -__copyright__ = '' +__title__ = "djangorestframework-jsonapi" +__version__ = "4.0.0" +__author__ = "" +__license__ = "BSD" +__copyright__ = "" # Version synonym VERSION = __version__ diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 814a79f3..bb24756c 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -54,6 +54,7 @@ class DjangoFilterBackend(DjangoFilterBackend): keyword. The default is "search" unless overriden but it's used here just to make sure we don't complain about it being an invalid filter. """ + search_param = api_settings.SEARCH_PARAM # Make this regex check for 'filter' as well as 'filter[...]' @@ -63,7 +64,9 @@ class DjangoFilterBackend(DjangoFilterBackend): # regex `\w` matches [a-zA-Z0-9_]. # TODO: U+0080 and above allowed but not recommended. Leave them out for now.e # Also, ' ' (space) is allowed within a member name but not recommended. - filter_regex = re.compile(r'^filter(?P\[?)(?P[\w\.\-]*)(?P\]?$)') + filter_regex = re.compile( + r"^filter(?P\[?)(?P[\w\.\-]*)(?P\]?$)" + ) def _validate_filter(self, keys, filterset_class): """ @@ -74,7 +77,7 @@ def _validate_filter(self, keys, filterset_class): :raises ValidationError: if key not in FilterSet keys or no FilterSet. """ for k in keys: - if ((not filterset_class) or (k not in filterset_class.base_filters)): + if (not filterset_class) or (k not in filterset_class.base_filters): raise ValidationError("invalid filter[{}]".format(k)) def get_filterset(self, request, queryset, view): @@ -86,7 +89,7 @@ def get_filterset(self, request, queryset, view): # TODO: .base_filters vs. .filters attr (not always present) filterset_class = self.get_filterset_class(view, queryset) kwargs = self.get_filterset_kwargs(request, queryset, view) - self._validate_filter(kwargs.pop('filter_keys'), filterset_class) + self._validate_filter(kwargs.pop("filter_keys"), filterset_class) if filterset_class is None: return None return filterset_class(**kwargs) @@ -103,24 +106,29 @@ def get_filterset_kwargs(self, request, queryset, view): data = request.query_params.copy() for qp, val in request.query_params.lists(): m = self.filter_regex.match(qp) - if m and (not m.groupdict()['assoc'] or - m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'): + if m and ( + not m.groupdict()["assoc"] + or m.groupdict()["ldelim"] != "[" + or m.groupdict()["rdelim"] != "]" + ): raise ValidationError("invalid query parameter: {}".format(qp)) if m and qp != self.search_param: if not all(val): - raise ValidationError("missing value for query parameter {}".format(qp)) + raise ValidationError( + "missing value for query parameter {}".format(qp) + ) # convert jsonapi relationship path to Django ORM's __ notation - key = m.groupdict()['assoc'].replace('.', '__') + key = m.groupdict()["assoc"].replace(".", "__") # undo JSON_API_FORMAT_FIELD_NAMES conversion: - key = format_value(key, 'underscore') + key = format_value(key, "underscore") data.setlist(key, val) filter_keys.append(key) del data[qp] return { - 'data': data, - 'queryset': queryset, - 'request': request, - 'filter_keys': filter_keys, + "data": data, + "queryset": queryset, + "request": request, + "filter_keys": filter_keys, } def get_schema_operation_parameters(self, view): @@ -133,6 +141,6 @@ def get_schema_operation_parameters(self, view): """ result = super(DjangoFilterBackend, self).get_schema_operation_parameters(view) for res in result: - if 'name' in res: - res['name'] = 'filter[{}]'.format(res['name']).replace('__', '.') + if "name" in res: + res["name"] = "filter[{}]".format(res["name"]).replace("__", ".") return result diff --git a/rest_framework_json_api/exceptions.py b/rest_framework_json_api/exceptions.py index 938a0c77..05f56756 100644 --- a/rest_framework_json_api/exceptions.py +++ b/rest_framework_json_api/exceptions.py @@ -8,7 +8,8 @@ def rendered_with_json_api(view): from rest_framework_json_api.renderers import JSONRenderer - for renderer_class in getattr(view, 'renderer_classes', []): + + for renderer_class in getattr(view, "renderer_classes", []): if issubclass(renderer_class, JSONRenderer): return True return False @@ -29,7 +30,7 @@ def exception_handler(exc, context): return response # Use regular DRF format if not rendered by DRF JSON API and not uniform - is_json_api_view = rendered_with_json_api(context['view']) + is_json_api_view = rendered_with_json_api(context["view"]) is_uniform = json_api_settings.UNIFORM_EXCEPTIONS if not is_json_api_view and not is_uniform: return response @@ -46,4 +47,4 @@ def exception_handler(exc, context): class Conflict(exceptions.APIException): status_code = status.HTTP_409_CONFLICT - default_detail = _('Conflict.') + default_detail = _("Conflict.") diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index eafcdb76..06f7667e 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -18,9 +18,10 @@ class OrderingFilter(OrderingFilter): Also applies DJA format_value() to convert (e.g. camelcase) to underscore. (See JSON_API_FORMAT_FIELD_NAMES in docs/usage.md) """ + #: override :py:attr:`rest_framework.filters.OrderingFilter.ordering_param` #: with JSON:API-compliant query parameter name. - ordering_param = 'sort' + ordering_param = "sort" def remove_invalid_fields(self, queryset, fields, view, request): """ @@ -31,16 +32,21 @@ def remove_invalid_fields(self, queryset, fields, view, request): :raises ValidationError: if a sort field is invalid. """ valid_fields = [ - item[0] for item in self.get_valid_fields(queryset, view, - {'request': request}) + item[0] + for item in self.get_valid_fields(queryset, view, {"request": request}) ] bad_terms = [ - term for term in fields - if format_value(term.replace(".", "__").lstrip('-'), "underscore") not in valid_fields + term + for term in fields + if format_value(term.replace(".", "__").lstrip("-"), "underscore") + not in valid_fields ] if bad_terms: - raise ValidationError('invalid sort parameter{}: {}'.format( - ('s' if len(bad_terms) > 1 else ''), ','.join(bad_terms))) + raise ValidationError( + "invalid sort parameter{}: {}".format( + ("s" if len(bad_terms) > 1 else ""), ",".join(bad_terms) + ) + ) # this looks like it duplicates code above, but we want the ValidationError to report # the actual parameter supplied while we want the fields passed to the super() to # be correctly rewritten. @@ -48,14 +54,16 @@ def remove_invalid_fields(self, queryset, fields, view, request): underscore_fields = [] for item in fields: item_rewritten = item.replace(".", "__") - if item_rewritten.startswith('-'): + if item_rewritten.startswith("-"): underscore_fields.append( - '-' + format_value(item_rewritten.lstrip('-'), "underscore")) + "-" + format_value(item_rewritten.lstrip("-"), "underscore") + ) else: underscore_fields.append(format_value(item_rewritten, "underscore")) return super(OrderingFilter, self).remove_invalid_fields( - queryset, underscore_fields, view, request) + queryset, underscore_fields, view, request + ) class QueryParameterValidationFilter(BaseFilterBackend): @@ -68,9 +76,12 @@ class QueryParameterValidationFilter(BaseFilterBackend): override :py:attr:`query_regex` adding the new parameters. Make sure to comply with the rules at http://jsonapi.org/format/#query-parameters. """ + #: compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters: #: `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s - query_regex = re.compile(r'^(sort|include)$|^(?Pfilter|fields|page)(\[[\w\.\-]+\])?$') + query_regex = re.compile( + r"^(sort|include)$|^(?Pfilter|fields|page)(\[[\w\.\-]+\])?$" + ) def validate_query_params(self, request): """ @@ -84,10 +95,14 @@ def validate_query_params(self, request): for qp in request.query_params.keys(): m = self.query_regex.match(qp) if not m: - raise ValidationError('invalid query parameter: {}'.format(qp)) - if not m.group('type') == 'filter' and len(request.query_params.getlist(qp)) > 1: + raise ValidationError("invalid query parameter: {}".format(qp)) + if ( + not m.group("type") == "filter" + and len(request.query_params.getlist(qp)) > 1 + ): raise ValidationError( - 'repeated query parameter not allowed: {}'.format(qp)) + "repeated query parameter not allowed: {}".format(qp) + ) def filter_queryset(self, request, queryset, view): """ diff --git a/rest_framework_json_api/metadata.py b/rest_framework_json_api/metadata.py index ef3356fe..a48af532 100644 --- a/rest_framework_json_api/metadata.py +++ b/rest_framework_json_api/metadata.py @@ -17,56 +17,65 @@ class JSONAPIMetadata(SimpleMetadata): There are not any formalized standards for `OPTIONS` responses for us to base this on. """ - type_lookup = ClassLookupDict({ - serializers.Field: 'GenericField', - serializers.RelatedField: 'Relationship', - serializers.BooleanField: 'Boolean', - serializers.NullBooleanField: 'Boolean', - serializers.CharField: 'String', - serializers.URLField: 'URL', - serializers.EmailField: 'Email', - serializers.RegexField: 'Regex', - serializers.SlugField: 'Slug', - serializers.IntegerField: 'Integer', - serializers.FloatField: 'Float', - serializers.DecimalField: 'Decimal', - serializers.DateField: 'Date', - serializers.DateTimeField: 'DateTime', - serializers.TimeField: 'Time', - serializers.ChoiceField: 'Choice', - serializers.MultipleChoiceField: 'MultipleChoice', - serializers.FileField: 'File', - serializers.ImageField: 'Image', - serializers.ListField: 'List', - serializers.DictField: 'Dict', - serializers.Serializer: 'Serializer', - }) + + type_lookup = ClassLookupDict( + { + serializers.Field: "GenericField", + serializers.RelatedField: "Relationship", + serializers.BooleanField: "Boolean", + serializers.NullBooleanField: "Boolean", + serializers.CharField: "String", + serializers.URLField: "URL", + serializers.EmailField: "Email", + serializers.RegexField: "Regex", + serializers.SlugField: "Slug", + serializers.IntegerField: "Integer", + serializers.FloatField: "Float", + serializers.DecimalField: "Decimal", + serializers.DateField: "Date", + serializers.DateTimeField: "DateTime", + serializers.TimeField: "Time", + serializers.ChoiceField: "Choice", + serializers.MultipleChoiceField: "MultipleChoice", + serializers.FileField: "File", + serializers.ImageField: "Image", + serializers.ListField: "List", + serializers.DictField: "Dict", + serializers.Serializer: "Serializer", + } + ) try: - relation_type_lookup = ClassLookupDict({ - related.ManyToManyDescriptor: 'ManyToMany', - related.ReverseManyToOneDescriptor: 'OneToMany', - related.ForwardManyToOneDescriptor: 'ManyToOne', - }) + relation_type_lookup = ClassLookupDict( + { + related.ManyToManyDescriptor: "ManyToMany", + related.ReverseManyToOneDescriptor: "OneToMany", + related.ForwardManyToOneDescriptor: "ManyToOne", + } + ) except AttributeError: - relation_type_lookup = ClassLookupDict({ - related.ManyRelatedObjectsDescriptor: 'ManyToMany', - related.ReverseManyRelatedObjectsDescriptor: 'ManyToMany', - related.ForeignRelatedObjectsDescriptor: 'OneToMany', - related.ReverseSingleRelatedObjectDescriptor: 'ManyToOne', - }) + relation_type_lookup = ClassLookupDict( + { + related.ManyRelatedObjectsDescriptor: "ManyToMany", + related.ReverseManyRelatedObjectsDescriptor: "ManyToMany", + related.ForeignRelatedObjectsDescriptor: "OneToMany", + related.ReverseSingleRelatedObjectDescriptor: "ManyToOne", + } + ) def determine_metadata(self, request, view): metadata = OrderedDict() - metadata['name'] = view.get_view_name() - metadata['description'] = view.get_view_description() - metadata['renders'] = [renderer.media_type for renderer in view.renderer_classes] - metadata['parses'] = [parser.media_type for parser in view.parser_classes] - metadata['allowed_methods'] = view.allowed_methods - if hasattr(view, 'get_serializer'): + metadata["name"] = view.get_view_name() + metadata["description"] = view.get_view_description() + metadata["renders"] = [ + renderer.media_type for renderer in view.renderer_classes + ] + metadata["parses"] = [parser.media_type for parser in view.parser_classes] + metadata["allowed_methods"] = view.allowed_methods + if hasattr(view, "get_serializer"): actions = self.determine_actions(request, view) if actions: - metadata['actions'] = actions + metadata["actions"] = actions return metadata def get_serializer_info(self, serializer): @@ -74,7 +83,7 @@ def get_serializer_info(self, serializer): Given an instance of a serializer, return a dictionary of metadata about its fields. """ - if hasattr(serializer, 'child'): + if hasattr(serializer, "child"): # If this is a `ListSerializer` then we want to examine the # underlying child serializer instance instead. serializer = serializer.child @@ -82,10 +91,12 @@ def get_serializer_info(self, serializer): # Remove the URL field if present serializer.fields.pop(api_settings.URL_FIELD_NAME, None) - return OrderedDict([ - (format_value(field_name), self.get_field_info(field)) - for field_name, field in serializer.fields.items() - ]) + return OrderedDict( + [ + (format_value(field_name), self.get_field_info(field)) + for field_name, field in serializer.fields.items() + ] + ) def get_field_info(self, field): """ @@ -96,13 +107,13 @@ def get_field_info(self, field): serializer = field.parent if isinstance(field, serializers.ManyRelatedField): - field_info['type'] = self.type_lookup[field.child_relation] + field_info["type"] = self.type_lookup[field.child_relation] else: - field_info['type'] = self.type_lookup[field] + field_info["type"] = self.type_lookup[field] try: - serializer_model = getattr(serializer.Meta, 'model') - field_info['relationship_type'] = self.relation_type_lookup[ + serializer_model = getattr(serializer.Meta, "model") + field_info["relationship_type"] = self.relation_type_lookup[ getattr(serializer_model, field.field_name) ] except KeyError: @@ -110,40 +121,51 @@ def get_field_info(self, field): except AttributeError: pass else: - field_info['relationship_resource'] = get_related_resource_type(field) + field_info["relationship_resource"] = get_related_resource_type(field) - field_info['required'] = getattr(field, 'required', False) + field_info["required"] = getattr(field, "required", False) attrs = [ - 'read_only', 'write_only', 'label', 'help_text', - 'min_length', 'max_length', - 'min_value', 'max_value', 'initial' + "read_only", + "write_only", + "label", + "help_text", + "min_length", + "max_length", + "min_value", + "max_value", + "initial", ] for attr in attrs: value = getattr(field, attr, None) - if value is not None and value != '': + if value is not None and value != "": field_info[attr] = force_str(value, strings_only=True) - if getattr(field, 'child', None): - field_info['child'] = self.get_field_info(field.child) - elif getattr(field, 'fields', None): - field_info['children'] = self.get_serializer_info(field) + if getattr(field, "child", None): + field_info["child"] = self.get_field_info(field.child) + elif getattr(field, "fields", None): + field_info["children"] = self.get_serializer_info(field) if ( - not field_info.get('read_only') and - not field_info.get('relationship_resource') and - hasattr(field, 'choices') + not field_info.get("read_only") + and not field_info.get("relationship_resource") + and hasattr(field, "choices") ): - field_info['choices'] = [ + field_info["choices"] = [ { - 'value': choice_value, - 'display_name': force_str(choice_name, strings_only=True) + "value": choice_value, + "display_name": force_str(choice_name, strings_only=True), } for choice_value, choice_name in field.choices.items() ] - if hasattr(serializer, 'included_serializers') and 'relationship_resource' in field_info: - field_info['allows_include'] = field.field_name in serializer.included_serializers + if ( + hasattr(serializer, "included_serializers") + and "relationship_resource" in field_info + ): + field_info["allows_include"] = ( + field.field_name in serializer.included_serializers + ) return field_info diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index 2e57b937..468f684c 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -12,14 +12,15 @@ class JsonApiPageNumberPagination(PageNumberPagination): """ A json-api compatible pagination format. """ - page_query_param = 'page[number]' - page_size_query_param = 'page[size]' + + page_query_param = "page[number]" + page_size_query_param = "page[size]" max_page_size = 100 def build_link(self, index): if not index: return None - url = self.request and self.request.build_absolute_uri() or '' + url = self.request and self.request.build_absolute_uri() or "" return replace_query_param(url, self.page_query_param, index) def get_paginated_response(self, data): @@ -31,22 +32,28 @@ def get_paginated_response(self, data): if self.page.has_previous(): previous = self.page.previous_page_number() - return Response({ - 'results': data, - 'meta': { - 'pagination': OrderedDict([ - ('page', self.page.number), - ('pages', self.page.paginator.num_pages), - ('count', self.page.paginator.count), - ]) - }, - 'links': OrderedDict([ - ('first', self.build_link(1)), - ('last', self.build_link(self.page.paginator.num_pages)), - ('next', self.build_link(next)), - ('prev', self.build_link(previous)) - ]) - }) + return Response( + { + "results": data, + "meta": { + "pagination": OrderedDict( + [ + ("page", self.page.number), + ("pages", self.page.paginator.num_pages), + ("count", self.page.paginator.count), + ] + ) + }, + "links": OrderedDict( + [ + ("first", self.build_link(1)), + ("last", self.build_link(self.page.paginator.num_pages)), + ("next", self.build_link(next)), + ("prev", self.build_link(previous)), + ] + ), + } + ) class JsonApiLimitOffsetPagination(LimitOffsetPagination): @@ -59,8 +66,9 @@ class JsonApiLimitOffsetPagination(LimitOffsetPagination): http://api.example.org/accounts/?page[offset]=400&page[limit]=100 """ - limit_query_param = 'page[limit]' - offset_query_param = 'page[offset]' + + limit_query_param = "page[limit]" + offset_query_param = "page[offset]" max_limit = 100 def get_last_link(self): @@ -85,19 +93,25 @@ def get_first_link(self): return remove_query_param(url, self.offset_query_param) def get_paginated_response(self, data): - return Response({ - 'results': data, - 'meta': { - 'pagination': OrderedDict([ - ('count', self.count), - ('limit', self.limit), - ('offset', self.offset), - ]) - }, - 'links': OrderedDict([ - ('first', self.get_first_link()), - ('last', self.get_last_link()), - ('next', self.get_next_link()), - ('prev', self.get_previous_link()) - ]) - }) + return Response( + { + "results": data, + "meta": { + "pagination": OrderedDict( + [ + ("count", self.count), + ("limit", self.limit), + ("offset", self.offset), + ] + ) + }, + "links": OrderedDict( + [ + ("first", self.get_first_link()), + ("last", self.get_last_link()), + ("next", self.get_next_link()), + ("prev", self.get_previous_link()), + ] + ), + } + ) diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 88c4f522..e3315334 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -31,41 +31,44 @@ class JSONParser(parsers.JSONParser): We extract the attributes so that DRF serializers can work as normal. """ - media_type = 'application/vnd.api+json' + + media_type = "application/vnd.api+json" renderer_class = renderers.JSONRenderer @staticmethod def parse_attributes(data): - attributes = data.get('attributes') + attributes = data.get("attributes") uses_format_translation = json_api_settings.FORMAT_FIELD_NAMES if not attributes: return dict() elif uses_format_translation: # convert back to python/rest_framework's preferred underscore format - return utils.format_field_names(attributes, 'underscore') + return utils.format_field_names(attributes, "underscore") else: return attributes @staticmethod def parse_relationships(data): uses_format_translation = json_api_settings.FORMAT_FIELD_NAMES - relationships = data.get('relationships') + relationships = data.get("relationships") if not relationships: relationships = dict() elif uses_format_translation: # convert back to python/rest_framework's preferred underscore format - relationships = utils.format_field_names(relationships, 'underscore') + relationships = utils.format_field_names(relationships, "underscore") # Parse the relationships parsed_relationships = dict() for field_name, field_data in relationships.items(): - field_data = field_data.get('data') + field_data = field_data.get("data") if isinstance(field_data, dict) or field_data is None: parsed_relationships[field_name] = field_data elif isinstance(field_data, list): - parsed_relationships[field_name] = list(relation for relation in field_data) + parsed_relationships[field_name] = list( + relation for relation in field_data + ) return parsed_relationships @staticmethod @@ -75,9 +78,9 @@ def parse_metadata(result): it reads the `meta` content in the request body and returns it in a dictionary with a `_meta` top level key. """ - metadata = result.get('meta') + metadata = result.get("meta") if metadata: - return {'_meta': metadata} + return {"_meta": metadata} else: return {} @@ -89,79 +92,92 @@ def parse(self, stream, media_type=None, parser_context=None): stream, media_type=media_type, parser_context=parser_context ) - if not isinstance(result, dict) or 'data' not in result: - raise ParseError('Received document does not contain primary data') + if not isinstance(result, dict) or "data" not in result: + raise ParseError("Received document does not contain primary data") - data = result.get('data') - view = parser_context['view'] + data = result.get("data") + view = parser_context["view"] from rest_framework_json_api.views import RelationshipView + if isinstance(view, RelationshipView): # We skip parsing the object as JSONAPI Resource Identifier Object and not a regular # Resource Object if isinstance(data, list): for resource_identifier_object in data: if not ( - resource_identifier_object.get('id') and - resource_identifier_object.get('type') + resource_identifier_object.get("id") + and resource_identifier_object.get("type") ): raise ParseError( - 'Received data contains one or more malformed JSONAPI ' - 'Resource Identifier Object(s)' + "Received data contains one or more malformed JSONAPI " + "Resource Identifier Object(s)" ) - elif not (data.get('id') and data.get('type')): - raise ParseError('Received data is not a valid JSONAPI Resource Identifier Object') + elif not (data.get("id") and data.get("type")): + raise ParseError( + "Received data is not a valid JSONAPI Resource Identifier Object" + ) return data - request = parser_context.get('request') + request = parser_context.get("request") # Sanity check if not isinstance(data, dict): - raise ParseError('Received data is not a valid JSONAPI Resource Identifier Object') + raise ParseError( + "Received data is not a valid JSONAPI Resource Identifier Object" + ) # Check for inconsistencies - if request.method in ('PUT', 'POST', 'PATCH'): + if request.method in ("PUT", "POST", "PATCH"): resource_name = utils.get_resource_name( - parser_context, expand_polymorphic_types=True) + parser_context, expand_polymorphic_types=True + ) if isinstance(resource_name, str): - if data.get('type') != resource_name: + if data.get("type") != resource_name: raise exceptions.Conflict( "The resource object's type ({data_type}) is not the type that " "constitute the collection represented by the endpoint " "({resource_type}).".format( - data_type=data.get('type'), - resource_type=resource_name)) + data_type=data.get("type"), resource_type=resource_name + ) + ) else: - if data.get('type') not in resource_name: + if data.get("type") not in resource_name: raise exceptions.Conflict( "The resource object's type ({data_type}) is not the type that " "constitute the collection represented by the endpoint " "(one of [{resource_types}]).".format( - data_type=data.get('type'), - resource_types=", ".join(resource_name))) - if not data.get('id') and request.method in ('PATCH', 'PUT'): - raise ParseError("The resource identifier object must contain an 'id' member") - - if request.method in ('PATCH', 'PUT'): - lookup_url_kwarg = getattr(view, 'lookup_url_kwarg', None) or \ - getattr(view, 'lookup_field', None) - if lookup_url_kwarg and str(data.get('id')) != str(view.kwargs[lookup_url_kwarg]): + data_type=data.get("type"), + resource_types=", ".join(resource_name), + ) + ) + if not data.get("id") and request.method in ("PATCH", "PUT"): + raise ParseError( + "The resource identifier object must contain an 'id' member" + ) + + if request.method in ("PATCH", "PUT"): + lookup_url_kwarg = getattr(view, "lookup_url_kwarg", None) or getattr( + view, "lookup_field", None + ) + if lookup_url_kwarg and str(data.get("id")) != str( + view.kwargs[lookup_url_kwarg] + ): raise exceptions.Conflict( "The resource object's id ({data_id}) does not match url's " "lookup id ({url_id})".format( - data_id=data.get('id'), - url_id=view.kwargs[lookup_url_kwarg] + data_id=data.get("id"), url_id=view.kwargs[lookup_url_kwarg] ) ) # Construct the return data - serializer_class = getattr(view, 'serializer_class', None) - parsed_data = {'id': data.get('id')} if 'id' in data else {} + serializer_class = getattr(view, "serializer_class", None) + parsed_data = {"id": data.get("id")} if "id" in data else {} # `type` field needs to be allowed in none polymorphic serializers if serializer_class is not None: if issubclass(serializer_class, serializers.PolymorphicModelSerializer): - parsed_data['type'] = data.get('type') + parsed_data["type"] = data.get("type") parsed_data.update(self.parse_attributes(data)) parsed_data.update(self.parse_relationships(data)) parsed_data.update(self.parse_metadata(result)) diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 95df1d48..2924cd56 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -18,14 +18,14 @@ get_included_serializers, get_resource_type_from_instance, get_resource_type_from_queryset, - get_resource_type_from_serializer + get_resource_type_from_serializer, ) LINKS_PARAMS = [ - 'self_link_view_name', - 'related_link_view_name', - 'related_link_lookup_field', - 'related_link_url_kwarg' + "self_link_view_name", + "related_link_view_name", + "related_link_lookup_field", + "related_link_url_kwarg", ] @@ -52,7 +52,7 @@ class ManyRelatedFieldWithNoData(SkipDataMixin, DRFManyRelatedField): class HyperlinkedMixin(object): self_link_view_name = None related_link_view_name = None - related_link_lookup_field = 'pk' + related_link_lookup_field = "pk" def __init__(self, self_link_view_name=None, related_link_view_name=None, **kwargs): if self_link_view_name is not None: @@ -61,10 +61,10 @@ def __init__(self, self_link_view_name=None, related_link_view_name=None, **kwar self.related_link_view_name = related_link_view_name self.related_link_lookup_field = kwargs.pop( - 'related_link_lookup_field', self.related_link_lookup_field + "related_link_lookup_field", self.related_link_lookup_field ) self.related_link_url_kwarg = kwargs.pop( - 'related_link_url_kwarg', self.related_link_lookup_field + "related_link_url_kwarg", self.related_link_lookup_field ) # We include this simply for dependency injection in tests. @@ -91,7 +91,7 @@ def get_url(self, name, view_name, kwargs, request): url = self.reverse(view_name, kwargs=kwargs, request=request) except NoReverseMatch: msg = ( - 'Could not resolve URL for hyperlinked relationship using ' + "Could not resolve URL for hyperlinked relationship using " 'view name "%s".' ) raise ImproperlyConfigured(msg % view_name) @@ -101,18 +101,26 @@ def get_url(self, name, view_name, kwargs, request): return Hyperlink(url, name) - def get_links(self, obj=None, lookup_field='pk'): - request = self.context.get('request', None) - view = self.context.get('view', None) + def get_links(self, obj=None, lookup_field="pk"): + request = self.context.get("request", None) + view = self.context.get("view", None) return_data = OrderedDict() - kwargs = {lookup_field: getattr(obj, lookup_field) if obj else view.kwargs[lookup_field]} + kwargs = { + lookup_field: getattr(obj, lookup_field) + if obj + else view.kwargs[lookup_field] + } self_kwargs = kwargs.copy() - self_kwargs.update({ - 'related_field': self.field_name if self.field_name else self.parent.field_name - }) - self_link = self.get_url('self', self.self_link_view_name, self_kwargs, request) + self_kwargs.update( + { + "related_field": self.field_name + if self.field_name + else self.parent.field_name + } + ) + self_link = self.get_url("self", self.self_link_view_name, self_kwargs, request) # Assuming RelatedField will be declared in two ways: # 1. url(r'^authors/(?P[^/.]+)/(?P\w+)/$', @@ -120,22 +128,25 @@ def get_links(self, obj=None, lookup_field='pk'): # 2. url(r'^authors/(?P[^/.]+)/bio/$', # AuthorBioViewSet.as_view({'get': 'retrieve'})) # So, if related_link_url_kwarg == 'pk' it will add 'related_field' parameter to reverse() - if self.related_link_url_kwarg == 'pk': + if self.related_link_url_kwarg == "pk": related_kwargs = self_kwargs else: - related_kwargs = {self.related_link_url_kwarg: kwargs[self.related_link_lookup_field]} + related_kwargs = { + self.related_link_url_kwarg: kwargs[self.related_link_lookup_field] + } - related_link = self.get_url('related', self.related_link_view_name, related_kwargs, request) + related_link = self.get_url( + "related", self.related_link_view_name, related_kwargs, request + ) if self_link: - return_data.update({'self': self_link}) + return_data.update({"self": self_link}) if related_link: - return_data.update({'related': related_link}) + return_data.update({"related": related_link}) return return_data class HyperlinkedRelatedField(HyperlinkedMixin, SkipDataMixin, RelatedField): - @classmethod def many_init(cls, *args, **kwargs): """ @@ -155,7 +166,7 @@ def many_init(cls, *args, **kwargs): kwargs['child'] = cls() return CustomManyRelatedField(*args, **kwargs) """ - list_kwargs = {'child_relation': cls(*args, **kwargs)} + list_kwargs = {"child_relation": cls(*args, **kwargs)} for key in kwargs: if key in MANY_RELATION_KWARGS: list_kwargs[key] = kwargs[key] @@ -166,25 +177,27 @@ class ResourceRelatedField(HyperlinkedMixin, PrimaryKeyRelatedField): _skip_polymorphic_optimization = True self_link_view_name = None related_link_view_name = None - related_link_lookup_field = 'pk' + related_link_lookup_field = "pk" default_error_messages = { - 'required': _('This field is required.'), - 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), - 'incorrect_type': _( - 'Incorrect type. Expected resource identifier object, received {data_type}.' + "required": _("This field is required."), + "does_not_exist": _('Invalid pk "{pk_value}" - object does not exist.'), + "incorrect_type": _( + "Incorrect type. Expected resource identifier object, received {data_type}." ), - 'incorrect_relation_type': _( - 'Incorrect relation type. Expected {relation_type}, received {received_type}.' + "incorrect_relation_type": _( + "Incorrect relation type. Expected {relation_type}, received {received_type}." ), - 'missing_type': _('Invalid resource identifier object: missing \'type\' attribute'), - 'missing_id': _('Invalid resource identifier object: missing \'id\' attribute'), - 'no_match': _('Invalid hyperlink - No URL match.'), + "missing_type": _( + "Invalid resource identifier object: missing 'type' attribute" + ), + "missing_id": _("Invalid resource identifier object: missing 'id' attribute"), + "no_match": _("Invalid hyperlink - No URL match."), } def __init__(self, **kwargs): # check for a model class that was passed in for the relation type - model = kwargs.pop('model', None) + model = kwargs.pop("model", None) if model: self.model = model @@ -213,9 +226,9 @@ def to_internal_value(self, data): data = json.loads(data) except ValueError: # show a useful error if they send a `pk` instead of resource object - self.fail('incorrect_type', data_type=type(data).__name__) + self.fail("incorrect_type", data_type=type(data).__name__) if not isinstance(data, dict): - self.fail('incorrect_type', data_type=type(data).__name__) + self.fail("incorrect_type", data_type=type(data).__name__) expected_relation_type = get_resource_type_from_queryset(self.get_queryset()) serializer_resource_type = self.get_resource_type_from_included_serializer() @@ -223,23 +236,23 @@ def to_internal_value(self, data): if serializer_resource_type is not None: expected_relation_type = serializer_resource_type - if 'type' not in data: - self.fail('missing_type') + if "type" not in data: + self.fail("missing_type") - if 'id' not in data: - self.fail('missing_id') + if "id" not in data: + self.fail("missing_id") - if data['type'] != expected_relation_type: + if data["type"] != expected_relation_type: self.conflict( - 'incorrect_relation_type', + "incorrect_relation_type", relation_type=expected_relation_type, - received_type=data['type'] + received_type=data["type"], ) - return super(ResourceRelatedField, self).to_internal_value(data['id']) + return super(ResourceRelatedField, self).to_internal_value(data["id"]) def to_representation(self, value): - if getattr(self, 'pk_field', None) is not None: + if getattr(self, "pk_field", None) is not None: pk = self.pk_field.to_representation(value.pk) else: pk = value.pk @@ -248,7 +261,7 @@ def to_representation(self, value): if resource_type is None or not self._skip_polymorphic_optimization: resource_type = get_resource_type_from_instance(value) - return OrderedDict([('type', resource_type), ('id', str(pk))]) + return OrderedDict([("type", resource_type), ("id", str(pk))]) def get_resource_type_from_included_serializer(self): """ @@ -262,7 +275,7 @@ def get_resource_type_from_included_serializer(self): # accept both singular and plural versions of field_name field_names = [ inflection.singularize(field_name), - inflection.pluralize(field_name) + inflection.pluralize(field_name), ] includes = get_included_serializers(parent) for field in field_names: @@ -272,7 +285,7 @@ def get_resource_type_from_included_serializer(self): return None def get_parent_serializer(self): - if hasattr(self.parent, 'parent') and self.is_serializer(self.parent.parent): + if hasattr(self.parent, "parent") and self.is_serializer(self.parent.parent): return self.parent.parent elif self.is_serializer(self.parent): return self.parent @@ -292,13 +305,12 @@ def get_choices(self, cutoff=None): if cutoff is not None: queryset = queryset[:cutoff] - return OrderedDict([ - ( - json.dumps(self.to_representation(item)), - self.display_value(item) - ) - for item in queryset - ]) + return OrderedDict( + [ + (json.dumps(self.to_representation(item)), self.display_value(item)) + for item in queryset + ] + ) class PolymorphicResourceRelatedField(ResourceRelatedField): @@ -309,10 +321,15 @@ class PolymorphicResourceRelatedField(ResourceRelatedField): """ _skip_polymorphic_optimization = False - default_error_messages = dict(ResourceRelatedField.default_error_messages, **{ - 'incorrect_relation_type': _('Incorrect relation type. Expected one of [{relation_type}], ' - 'received {received_type}.'), - }) + default_error_messages = dict( + ResourceRelatedField.default_error_messages, + **{ + "incorrect_relation_type": _( + "Incorrect relation type. Expected one of [{relation_type}], " + "received {received_type}." + ), + } + ) def __init__(self, polymorphic_serializer, *args, **kwargs): self.polymorphic_serializer = polymorphic_serializer @@ -327,34 +344,37 @@ def to_internal_value(self, data): data = json.loads(data) except ValueError: # show a useful error if they send a `pk` instead of resource object - self.fail('incorrect_type', data_type=type(data).__name__) + self.fail("incorrect_type", data_type=type(data).__name__) if not isinstance(data, dict): - self.fail('incorrect_type', data_type=type(data).__name__) + self.fail("incorrect_type", data_type=type(data).__name__) - if 'type' not in data: - self.fail('missing_type') + if "type" not in data: + self.fail("missing_type") - if 'id' not in data: - self.fail('missing_id') + if "id" not in data: + self.fail("missing_id") expected_relation_types = self.polymorphic_serializer.get_polymorphic_types() - if data['type'] not in expected_relation_types: - self.conflict('incorrect_relation_type', relation_type=", ".join( - expected_relation_types), received_type=data['type']) + if data["type"] not in expected_relation_types: + self.conflict( + "incorrect_relation_type", + relation_type=", ".join(expected_relation_types), + received_type=data["type"], + ) - return super(ResourceRelatedField, self).to_internal_value(data['id']) + return super(ResourceRelatedField, self).to_internal_value(data["id"]) class SerializerMethodFieldBase(Field): def __init__(self, method_name=None, **kwargs): self.method_name = method_name - kwargs['source'] = '*' - kwargs['read_only'] = True + kwargs["source"] = "*" + kwargs["read_only"] = True super().__init__(**kwargs) def bind(self, field_name, parent): - default_method_name = 'get_{field_name}'.format(field_name=field_name) + default_method_name = "get_{field_name}".format(field_name=field_name) if self.method_name is None: self.method_name = default_method_name super().bind(field_name, parent) @@ -364,40 +384,46 @@ def get_attribute(self, instance): return serializer_method(instance) -class ManySerializerMethodResourceRelatedField(SerializerMethodFieldBase, ResourceRelatedField): +class ManySerializerMethodResourceRelatedField( + SerializerMethodFieldBase, ResourceRelatedField +): def __init__(self, child_relation=None, *args, **kwargs): - assert child_relation is not None, '`child_relation` is a required argument.' + assert child_relation is not None, "`child_relation` is a required argument." self.child_relation = child_relation super().__init__(**kwargs) - self.child_relation.bind(field_name='', parent=self) + self.child_relation.bind(field_name="", parent=self) def to_representation(self, value): return [self.child_relation.to_representation(item) for item in value] -class SerializerMethodResourceRelatedField(SerializerMethodFieldBase, ResourceRelatedField): +class SerializerMethodResourceRelatedField( + SerializerMethodFieldBase, ResourceRelatedField +): """ Allows us to use serializer method RelatedFields with return querysets """ - many_kwargs = [*MANY_RELATION_KWARGS, *LINKS_PARAMS, 'method_name', 'model'] + many_kwargs = [*MANY_RELATION_KWARGS, *LINKS_PARAMS, "method_name", "model"] many_cls = ManySerializerMethodResourceRelatedField @classmethod def many_init(cls, *args, **kwargs): - list_kwargs = {'child_relation': cls(**kwargs)} + list_kwargs = {"child_relation": cls(**kwargs)} for key in kwargs: if key in cls.many_kwargs: list_kwargs[key] = kwargs[key] return cls.many_cls(**list_kwargs) -class ManySerializerMethodHyperlinkedRelatedField(SkipDataMixin, - ManySerializerMethodResourceRelatedField): +class ManySerializerMethodHyperlinkedRelatedField( + SkipDataMixin, ManySerializerMethodResourceRelatedField +): pass -class SerializerMethodHyperlinkedRelatedField(SkipDataMixin, - SerializerMethodResourceRelatedField): +class SerializerMethodHyperlinkedRelatedField( + SkipDataMixin, SerializerMethodResourceRelatedField +): many_cls = ManySerializerMethodHyperlinkedRelatedField diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index a9b8dd82..4733288f 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -17,7 +17,11 @@ import rest_framework_json_api from rest_framework_json_api import utils -from rest_framework_json_api.relations import HyperlinkedMixin, ResourceRelatedField, SkipDataMixin +from rest_framework_json_api.relations import ( + HyperlinkedMixin, + ResourceRelatedField, + SkipDataMixin, +) class JSONRenderer(renderers.JSONRenderer): @@ -44,8 +48,8 @@ class JSONRenderer(renderers.JSONRenderer): } """ - media_type = 'application/vnd.api+json' - format = 'vnd.api+json' + media_type = "application/vnd.api+json" + format = "vnd.api+json" @classmethod def extract_attributes(cls, fields, resource): @@ -55,15 +59,13 @@ def extract_attributes(cls, fields, resource): data = OrderedDict() for field_name, field in iter(fields.items()): # ID is always provided in the root of JSON API so remove it from attributes - if field_name == 'id': + if field_name == "id": continue # don't output a key for write only fields if fields[field_name].write_only: continue # Skip fields with relations - if isinstance( - field, (relations.RelatedField, relations.ManyRelatedField) - ): + if isinstance(field, (relations.RelatedField, relations.ManyRelatedField)): continue # Skip read_only attribute fields when `resource` is an empty @@ -75,9 +77,7 @@ def extract_attributes(cls, fields, resource): if fields[field_name].read_only: continue - data.update({ - field_name: resource.get(field_name) - }) + data.update({field_name: resource.get(field_name)}) return utils.format_field_names(data) @@ -123,67 +123,74 @@ def extract_relationships(cls, fields, resource, resource_instance): relation_data = list() # Don't try to query an empty relation - relation_queryset = relation_instance \ - if relation_instance is not None else list() + relation_queryset = ( + relation_instance if relation_instance is not None else list() + ) for related_object in relation_queryset: relation_data.append( - OrderedDict([ - ('type', relation_type), - ('id', encoding.force_str(related_object.pk)) - ]) + OrderedDict( + [ + ("type", relation_type), + ("id", encoding.force_str(related_object.pk)), + ] + ) ) - data.update({field_name: { - 'links': { - "related": resource.get(field_name)}, - 'data': relation_data, - 'meta': { - 'count': len(relation_data) + data.update( + { + field_name: { + "links": {"related": resource.get(field_name)}, + "data": relation_data, + "meta": {"count": len(relation_data)}, + } } - }}) + ) continue relation_data = {} if isinstance(field, HyperlinkedMixin): - field_links = field.get_links(resource_instance, field.related_link_lookup_field) - relation_data.update({'links': field_links} if field_links else dict()) + field_links = field.get_links( + resource_instance, field.related_link_lookup_field + ) + relation_data.update({"links": field_links} if field_links else dict()) data.update({field_name: relation_data}) - if isinstance(field, (ResourceRelatedField, )): + if isinstance(field, (ResourceRelatedField,)): if not isinstance(field, SkipDataMixin): - relation_data.update({'data': resource.get(field_name)}) + relation_data.update({"data": resource.get(field_name)}) data.update({field_name: relation_data}) continue if isinstance( - field, (relations.PrimaryKeyRelatedField, relations.HyperlinkedRelatedField) + field, + (relations.PrimaryKeyRelatedField, relations.HyperlinkedRelatedField), ): resolved, relation = utils.get_relation_instance( - resource_instance, '%s_id' % source, field.parent + resource_instance, "%s_id" % source, field.parent ) if not resolved: continue relation_id = relation if resource.get(field_name) else None relation_data = { - 'data': ( - OrderedDict([ - ('type', relation_type), ('id', encoding.force_str(relation_id)) - ]) - if relation_id is not None else None) + "data": ( + OrderedDict( + [ + ("type", relation_type), + ("id", encoding.force_str(relation_id)), + ] + ) + if relation_id is not None + else None + ) } - if ( - isinstance(field, relations.HyperlinkedRelatedField) and - resource.get(field_name) - ): + if isinstance( + field, relations.HyperlinkedRelatedField + ) and resource.get(field_name): relation_data.update( - { - 'links': { - 'related': resource.get(field_name) - } - } + {"links": {"related": resource.get(field_name)}} ) data.update({field_name: relation_data}) continue @@ -199,25 +206,20 @@ def extract_relationships(cls, fields, resource, resource_instance): if isinstance(resource.get(field_name), Iterable): relation_data.update( - { - 'meta': {'count': len(resource.get(field_name))} - } + {"meta": {"count": len(resource.get(field_name))}} ) if isinstance(field.child_relation, ResourceRelatedField): # special case for ResourceRelatedField - relation_data.update( - {'data': resource.get(field_name)} - ) + relation_data.update({"data": resource.get(field_name)}) if isinstance(field.child_relation, HyperlinkedMixin): field_links = field.child_relation.get_links( resource_instance, - field.child_relation.related_link_lookup_field + field.child_relation.related_link_lookup_field, ) relation_data.update( - {'links': field_links} - if field_links else dict() + {"links": field_links} if field_links else dict() ) data.update({field_name: relation_data}) @@ -226,22 +228,28 @@ def extract_relationships(cls, fields, resource, resource_instance): relation_data = list() for nested_resource_instance in relation_instance: nested_resource_instance_type = ( - relation_type or - utils.get_resource_type_from_instance(nested_resource_instance) + relation_type + or utils.get_resource_type_from_instance( + nested_resource_instance + ) ) - relation_data.append(OrderedDict([ - ('type', nested_resource_instance_type), - ('id', encoding.force_str(nested_resource_instance.pk)) - ])) - data.update({ - field_name: { - 'data': relation_data, - 'meta': { - 'count': len(relation_data) + relation_data.append( + OrderedDict( + [ + ("type", nested_resource_instance_type), + ("id", encoding.force_str(nested_resource_instance.pk)), + ] + ) + ) + data.update( + { + field_name: { + "data": relation_data, + "meta": {"count": len(relation_data)}, } } - }) + ) continue return utils.format_field_names(data) @@ -263,8 +271,9 @@ def extract_relation_instance(cls, field, resource_instance): return None @classmethod - def extract_included(cls, fields, resource, resource_instance, included_resources, - included_cache): + def extract_included( + cls, fields, resource, resource_instance, included_resources, included_cache + ): """ Adds related data to the top level included key when the request includes ?include=example,example_field2 @@ -277,7 +286,9 @@ def extract_included(cls, fields, resource, resource_instance, included_resource context = current_serializer.context included_serializers = utils.get_included_serializers(current_serializer) included_resources = copy.copy(included_resources) - included_resources = [inflection.underscore(value) for value in included_resources] + included_resources = [ + inflection.underscore(value) for value in included_resources + ] for field_name, field in iter(fields.items()): # Skip URL field @@ -295,12 +306,12 @@ def extract_included(cls, fields, resource, resource_instance, included_resource except ValueError: # Skip fields not in requested included resources # If no child field, directly continue with the next field - if field_name not in [node.split('.')[0] for node in included_resources]: + if field_name not in [ + node.split(".")[0] for node in included_resources + ]: continue - relation_instance = cls.extract_relation_instance( - field, resource_instance - ) + relation_instance = cls.extract_relation_instance(field, resource_instance) if isinstance(relation_instance, Manager): relation_instance = relation_instance.all() @@ -315,11 +326,14 @@ def extract_included(cls, fields, resource, resource_instance, included_resource if relation_instance is None or not serializer_data: continue - many = field._kwargs.get('child_relation', None) is not None + many = field._kwargs.get("child_relation", None) is not None if isinstance(field, ResourceRelatedField) and not many: - already_included = serializer_data['type'] in included_cache and \ - serializer_data['id'] in included_cache[serializer_data['type']] + already_included = ( + serializer_data["type"] in included_cache + and serializer_data["id"] + in included_cache[serializer_data["type"]] + ) if already_included: continue @@ -328,9 +342,11 @@ def extract_included(cls, fields, resource, resource_instance, included_resource field = serializer_class(relation_instance, many=many, context=context) serializer_data = field.data - new_included_resources = [key.replace('%s.' % field_name, '', 1) - for key in included_resources - if field_name == key.split('.')[0]] + new_included_resources = [ + key.replace("%s." % field_name, "", 1) + for key in included_resources + if field_name == key.split(".")[0] + ] if isinstance(field, ListSerializer): serializer = field.child @@ -342,8 +358,10 @@ def extract_included(cls, fields, resource, resource_instance, included_resource serializer_resource = serializer_data[position] nested_resource_instance = relation_queryset[position] resource_type = ( - relation_type or - utils.get_resource_type_from_instance(nested_resource_instance) + relation_type + or utils.get_resource_type_from_instance( + nested_resource_instance + ) ) serializer_fields = utils.get_serializer_fields( serializer.__class__( @@ -355,10 +373,11 @@ def extract_included(cls, fields, resource, resource_instance, included_resource serializer_resource, nested_resource_instance, resource_type, - getattr(serializer, '_poly_force_type_resolution', False) + getattr(serializer, "_poly_force_type_resolution", False), ) - included_cache[new_item['type']][new_item['id']] = \ - utils.format_field_names(new_item) + included_cache[new_item["type"]][ + new_item["id"] + ] = utils.format_field_names(new_item) cls.extract_included( serializer_fields, serializer_resource, @@ -378,11 +397,11 @@ def extract_included(cls, fields, resource, resource_instance, included_resource serializer_data, relation_instance, relation_type, - getattr(field, '_poly_force_type_resolution', False) - ) - included_cache[new_item['type']][new_item['id']] = utils.format_field_names( - new_item + getattr(field, "_poly_force_type_resolution", False), ) + included_cache[new_item["type"]][ + new_item["id"] + ] = utils.format_field_names(new_item) cls.extract_included( serializer_fields, serializer_data, @@ -397,16 +416,14 @@ def extract_meta(cls, serializer, resource): Gathers the data from serializer fields specified in meta_fields and adds it to the meta object. """ - if hasattr(serializer, 'child'): - meta = getattr(serializer.child, 'Meta', None) + if hasattr(serializer, "child"): + meta = getattr(serializer.child, "Meta", None) else: - meta = getattr(serializer, 'Meta', None) - meta_fields = getattr(meta, 'meta_fields', []) + meta = getattr(serializer, "Meta", None) + meta_fields = getattr(meta, "meta_fields", []) data = OrderedDict() for field_name in meta_fields: - data.update({ - field_name: resource.get(field_name) - }) + data.update({field_name: resource.get(field_name)}) return data @classmethod @@ -415,20 +432,26 @@ def extract_root_meta(cls, serializer, resource): Calls a `get_root_meta` function on a serializer, if it exists. """ many = False - if hasattr(serializer, 'child'): + if hasattr(serializer, "child"): many = True serializer = serializer.child data = {} - if getattr(serializer, 'get_root_meta', None): + if getattr(serializer, "get_root_meta", None): json_api_meta = serializer.get_root_meta(resource, many) - assert isinstance(json_api_meta, dict), 'get_root_meta must return a dict' + assert isinstance(json_api_meta, dict), "get_root_meta must return a dict" data.update(json_api_meta) return data @classmethod - def build_json_resource_obj(cls, fields, resource, resource_instance, resource_name, - force_type_resolution=False): + def build_json_resource_obj( + cls, + fields, + resource, + resource_instance, + resource_name, + force_type_resolution=False, + ): """ Builds the resource object (type, id, attributes) and extracts relationships. """ @@ -436,28 +459,34 @@ def build_json_resource_obj(cls, fields, resource, resource_instance, resource_n if force_type_resolution: resource_name = utils.get_resource_type_from_instance(resource_instance) resource_data = [ - ('type', resource_name), - ('id', encoding.force_str(resource_instance.pk) if resource_instance else None), - ('attributes', cls.extract_attributes(fields, resource)), + ("type", resource_name), + ( + "id", + encoding.force_str(resource_instance.pk) if resource_instance else None, + ), + ("attributes", cls.extract_attributes(fields, resource)), ] relationships = cls.extract_relationships(fields, resource, resource_instance) if relationships: - resource_data.append(('relationships', relationships)) + resource_data.append(("relationships", relationships)) # Add 'self' link if field is present and valid - if api_settings.URL_FIELD_NAME in resource and \ - isinstance(fields[api_settings.URL_FIELD_NAME], relations.RelatedField): - resource_data.append(('links', {'self': resource[api_settings.URL_FIELD_NAME]})) + if api_settings.URL_FIELD_NAME in resource and isinstance( + fields[api_settings.URL_FIELD_NAME], relations.RelatedField + ): + resource_data.append( + ("links", {"self": resource[api_settings.URL_FIELD_NAME]}) + ) return OrderedDict(resource_data) - def render_relationship_view(self, data, accepted_media_type=None, renderer_context=None): + def render_relationship_view( + self, data, accepted_media_type=None, renderer_context=None + ): # Special case for RelationshipView view = renderer_context.get("view", None) - render_data = OrderedDict([ - ('data', data) - ]) + render_data = OrderedDict([("data", data)]) links = view.get_links() if links: - render_data.update({'links': links}), + render_data.update({"links": links}), return super(JSONRenderer, self).render( render_data, accepted_media_type, renderer_context ) @@ -478,20 +507,23 @@ def render(self, data, accepted_media_type=None, renderer_context=None): resource_name = utils.get_resource_name(renderer_context) # If this is an error response, skip the rest. - if resource_name == 'errors': + if resource_name == "errors": return self.render_errors(data, accepted_media_type, renderer_context) # if response.status_code is 204 then the data to be rendered must # be None - response = renderer_context.get('response', None) + response = renderer_context.get("response", None) if response is not None and response.status_code == 204: return super(JSONRenderer, self).render( None, accepted_media_type, renderer_context ) from rest_framework_json_api.views import RelationshipView + if isinstance(view, RelationshipView): - return self.render_relationship_view(data, accepted_media_type, renderer_context) + return self.render_relationship_view( + data, accepted_media_type, renderer_context + ) # If `resource_name` is set to None then render default as the dev # wants to build the output format manually. @@ -502,15 +534,15 @@ def render(self, data, accepted_media_type=None, renderer_context=None): json_api_data = data # initialize json_api_meta with pagination meta or an empty dict - json_api_meta = data.get('meta', {}) if isinstance(data, dict) else {} + json_api_meta = data.get("meta", {}) if isinstance(data, dict) else {} included_cache = defaultdict(dict) - if data and 'results' in data: + if data and "results" in data: serializer_data = data["results"] else: serializer_data = data - serializer = getattr(serializer_data, 'serializer', None) + serializer = getattr(serializer_data, "serializer", None) included_resources = utils.get_included_resources(request, serializer) @@ -519,66 +551,92 @@ def render(self, data, accepted_media_type=None, renderer_context=None): # Extract root meta for any type of serializer json_api_meta.update(self.extract_root_meta(serializer, serializer_data)) - if getattr(serializer, 'many', False): + if getattr(serializer, "many", False): json_api_data = list() for position in range(len(serializer_data)): resource = serializer_data[position] # Get current resource - resource_instance = serializer.instance[position] # Get current instance - - if isinstance(serializer.child, rest_framework_json_api. - serializers.PolymorphicModelSerializer): - resource_serializer_class = serializer.child.\ - get_polymorphic_serializer_for_instance(resource_instance)( - context=serializer.child.context - ) + resource_instance = serializer.instance[ + position + ] # Get current instance + + if isinstance( + serializer.child, + rest_framework_json_api.serializers.PolymorphicModelSerializer, + ): + resource_serializer_class = ( + serializer.child.get_polymorphic_serializer_for_instance( + resource_instance + )(context=serializer.child.context) + ) else: resource_serializer_class = serializer.child fields = utils.get_serializer_fields(resource_serializer_class) force_type_resolution = getattr( - resource_serializer_class, '_poly_force_type_resolution', False) + resource_serializer_class, "_poly_force_type_resolution", False + ) json_resource_obj = self.build_json_resource_obj( - fields, resource, resource_instance, resource_name, force_type_resolution + fields, + resource, + resource_instance, + resource_name, + force_type_resolution, ) meta = self.extract_meta(serializer, resource) if meta: - json_resource_obj.update({'meta': utils.format_field_names(meta)}) + json_resource_obj.update( + {"meta": utils.format_field_names(meta)} + ) json_api_data.append(json_resource_obj) self.extract_included( - fields, resource, resource_instance, included_resources, included_cache + fields, + resource, + resource_instance, + included_resources, + included_cache, ) else: fields = utils.get_serializer_fields(serializer) - force_type_resolution = getattr(serializer, '_poly_force_type_resolution', False) + force_type_resolution = getattr( + serializer, "_poly_force_type_resolution", False + ) resource_instance = serializer.instance json_api_data = self.build_json_resource_obj( - fields, serializer_data, resource_instance, resource_name, force_type_resolution + fields, + serializer_data, + resource_instance, + resource_name, + force_type_resolution, ) meta = self.extract_meta(serializer, serializer_data) if meta: - json_api_data.update({'meta': utils.format_field_names(meta)}) + json_api_data.update({"meta": utils.format_field_names(meta)}) self.extract_included( - fields, serializer_data, resource_instance, included_resources, included_cache + fields, + serializer_data, + resource_instance, + included_resources, + included_cache, ) # Make sure we render data in a specific order render_data = OrderedDict() - if isinstance(data, dict) and data.get('links'): - render_data['links'] = data.get('links') + if isinstance(data, dict) and data.get("links"): + render_data["links"] = data.get("links") # format the api root link list - if view.__class__ and view.__class__.__name__ == 'APIRoot': - render_data['data'] = None - render_data['links'] = json_api_data + if view.__class__ and view.__class__.__name__ == "APIRoot": + render_data["data"] = None + render_data["links"] = json_api_data else: - render_data['data'] = json_api_data + render_data["data"] = json_api_data if included_cache: if isinstance(json_api_data, list): @@ -587,22 +645,23 @@ def render(self, data, accepted_media_type=None, renderer_context=None): objects = [json_api_data] for object in objects: - obj_type = object.get('type') - obj_id = object.get('id') - if obj_type in included_cache and \ - obj_id in included_cache[obj_type]: + obj_type = object.get("type") + obj_id = object.get("id") + if obj_type in included_cache and obj_id in included_cache[obj_type]: del included_cache[obj_type][obj_id] if not included_cache[obj_type]: del included_cache[obj_type] if included_cache: - render_data['included'] = list() + render_data["included"] = list() for included_type in sorted(included_cache.keys()): for included_id in sorted(included_cache[included_type].keys()): - render_data['included'].append(included_cache[included_type][included_id]) + render_data["included"].append( + included_cache[included_type][included_id] + ) if json_api_meta: - render_data['meta'] = utils.format_field_names(json_api_meta) + render_data["meta"] = utils.format_field_names(json_api_meta) return super(JSONRenderer, self).render( render_data, accepted_media_type, renderer_context @@ -610,21 +669,21 @@ def render(self, data, accepted_media_type=None, renderer_context=None): class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer): - template = 'rest_framework_json_api/api.html' - includes_template = 'rest_framework_json_api/includes.html' + template = "rest_framework_json_api/api.html" + includes_template = "rest_framework_json_api/includes.html" def get_context(self, data, accepted_media_type, renderer_context): context = super(BrowsableAPIRenderer, self).get_context( data, accepted_media_type, renderer_context ) - view = renderer_context['view'] + view = renderer_context["view"] - context['includes_form'] = self.get_includes_form(view) + context["includes_form"] = self.get_includes_form(view) return context @classmethod - def _get_included_serializers(cls, serializer, prefix='', already_seen=None): + def _get_included_serializers(cls, serializer, prefix="", already_seen=None): if not already_seen: already_seen = set() @@ -634,12 +693,15 @@ def _get_included_serializers(cls, serializer, prefix='', already_seen=None): included_serializers = [] already_seen.add(serializer) - for include, included_serializer in utils.get_included_serializers(serializer).items(): - included_serializers.append(f'{prefix}{include}') + for include, included_serializer in utils.get_included_serializers( + serializer + ).items(): + included_serializers.append(f"{prefix}{include}") included_serializers.extend( cls._get_included_serializers( - included_serializer, f'{prefix}{include}.', - already_seen=already_seen + included_serializer, + f"{prefix}{include}.", + already_seen=already_seen, ) ) @@ -651,9 +713,9 @@ def get_includes_form(self, view): except AttributeError: return - if not hasattr(serializer_class, 'included_serializers'): + if not hasattr(serializer_class, "included_serializers"): return template = loader.get_template(self.includes_template) - context = {'elements': self._get_included_serializers(serializer_class)} + context = {"elements": self._get_included_serializers(serializer_class)} return template.render(context) diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index fe6b095e..b51f366f 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -14,109 +14,104 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): """ Extend DRF's SchemaGenerator to implement jsonapi-flavored generateschema command. """ + #: These JSONAPI component definitions are referenced by the generated OAS schema. #: If you need to add more or change these static component definitions, extend this dict. jsonapi_components = { - 'schemas': { - 'jsonapi': { - 'type': 'object', - 'description': "The server's implementation", - 'properties': { - 'version': {'type': 'string'}, - 'meta': {'$ref': '#/components/schemas/meta'} + "schemas": { + "jsonapi": { + "type": "object", + "description": "The server's implementation", + "properties": { + "version": {"type": "string"}, + "meta": {"$ref": "#/components/schemas/meta"}, }, - 'additionalProperties': False + "additionalProperties": False, }, - 'resource': { - 'type': 'object', - 'required': ['type', 'id'], - 'additionalProperties': False, - 'properties': { - 'type': { - '$ref': '#/components/schemas/type' - }, - 'id': { - '$ref': '#/components/schemas/id' - }, - 'attributes': { - 'type': 'object', + "resource": { + "type": "object", + "required": ["type", "id"], + "additionalProperties": False, + "properties": { + "type": {"$ref": "#/components/schemas/type"}, + "id": {"$ref": "#/components/schemas/id"}, + "attributes": { + "type": "object", # ... }, - 'relationships': { - 'type': 'object', + "relationships": { + "type": "object", # ... }, - 'links': { - '$ref': '#/components/schemas/links' - }, - 'meta': {'$ref': '#/components/schemas/meta'}, - } + "links": {"$ref": "#/components/schemas/links"}, + "meta": {"$ref": "#/components/schemas/meta"}, + }, }, - 'link': { - 'oneOf': [ + "link": { + "oneOf": [ { - 'description': "a string containing the link's URL", - 'type': 'string', - 'format': 'uri-reference' + "description": "a string containing the link's URL", + "type": "string", + "format": "uri-reference", }, { - 'type': 'object', - 'required': ['href'], - 'properties': { - 'href': { - 'description': "a string containing the link's URL", - 'type': 'string', - 'format': 'uri-reference' + "type": "object", + "required": ["href"], + "properties": { + "href": { + "description": "a string containing the link's URL", + "type": "string", + "format": "uri-reference", }, - 'meta': {'$ref': '#/components/schemas/meta'} - } - } + "meta": {"$ref": "#/components/schemas/meta"}, + }, + }, ] }, - 'links': { - 'type': 'object', - 'additionalProperties': {'$ref': '#/components/schemas/link'} + "links": { + "type": "object", + "additionalProperties": {"$ref": "#/components/schemas/link"}, }, - 'reltoone': { - 'description': "a singular 'to-one' relationship", - 'type': 'object', - 'properties': { - 'links': {'$ref': '#/components/schemas/relationshipLinks'}, - 'data': {'$ref': '#/components/schemas/relationshipToOne'}, - 'meta': {'$ref': '#/components/schemas/meta'} - } + "reltoone": { + "description": "a singular 'to-one' relationship", + "type": "object", + "properties": { + "links": {"$ref": "#/components/schemas/relationshipLinks"}, + "data": {"$ref": "#/components/schemas/relationshipToOne"}, + "meta": {"$ref": "#/components/schemas/meta"}, + }, }, - 'relationshipToOne': { - 'description': "reference to other resource in a to-one relationship", - 'anyOf': [ - {'$ref': '#/components/schemas/nulltype'}, - {'$ref': '#/components/schemas/linkage'} + "relationshipToOne": { + "description": "reference to other resource in a to-one relationship", + "anyOf": [ + {"$ref": "#/components/schemas/nulltype"}, + {"$ref": "#/components/schemas/linkage"}, ], }, - 'reltomany': { - 'description': "a multiple 'to-many' relationship", - 'type': 'object', - 'properties': { - 'links': {'$ref': '#/components/schemas/relationshipLinks'}, - 'data': {'$ref': '#/components/schemas/relationshipToMany'}, - 'meta': {'$ref': '#/components/schemas/meta'} - } + "reltomany": { + "description": "a multiple 'to-many' relationship", + "type": "object", + "properties": { + "links": {"$ref": "#/components/schemas/relationshipLinks"}, + "data": {"$ref": "#/components/schemas/relationshipToMany"}, + "meta": {"$ref": "#/components/schemas/meta"}, + }, }, - 'relationshipLinks': { - 'description': 'optional references to other resource objects', - 'type': 'object', - 'additionalProperties': True, - 'properties': { - 'self': {'$ref': '#/components/schemas/link'}, - 'related': {'$ref': '#/components/schemas/link'} - } + "relationshipLinks": { + "description": "optional references to other resource objects", + "type": "object", + "additionalProperties": True, + "properties": { + "self": {"$ref": "#/components/schemas/link"}, + "related": {"$ref": "#/components/schemas/link"}, + }, }, - 'relationshipToMany': { - 'description': "An array of objects each containing the " - "'type' and 'id' for to-many relationships", - 'type': 'array', - 'items': {'$ref': '#/components/schemas/linkage'}, - 'uniqueItems': True + "relationshipToMany": { + "description": "An array of objects each containing the " + "'type' and 'id' for to-many relationships", + "type": "array", + "items": {"$ref": "#/components/schemas/linkage"}, + "uniqueItems": True, }, # A RelationshipView uses a ResourceIdentifierObjectSerializer (hence the name # ResourceIdentifierObject returned by get_component_name()) which serializes type and @@ -124,159 +119,140 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): # toMany or toOne so offer both options since we are not iterating over all the # possible {related_field}'s but rather rendering one path schema which may represent # toMany and toOne relationships. - 'ResourceIdentifierObject': { - 'oneOf': [ - {'$ref': '#/components/schemas/relationshipToOne'}, - {'$ref': '#/components/schemas/relationshipToMany'} + "ResourceIdentifierObject": { + "oneOf": [ + {"$ref": "#/components/schemas/relationshipToOne"}, + {"$ref": "#/components/schemas/relationshipToMany"}, ] }, - 'linkage': { - 'type': 'object', - 'description': "the 'type' and 'id'", - 'required': ['type', 'id'], - 'properties': { - 'type': {'$ref': '#/components/schemas/type'}, - 'id': {'$ref': '#/components/schemas/id'}, - 'meta': {'$ref': '#/components/schemas/meta'} - } + "linkage": { + "type": "object", + "description": "the 'type' and 'id'", + "required": ["type", "id"], + "properties": { + "type": {"$ref": "#/components/schemas/type"}, + "id": {"$ref": "#/components/schemas/id"}, + "meta": {"$ref": "#/components/schemas/meta"}, + }, }, - 'pagination': { - 'type': 'object', - 'properties': { - 'first': {'$ref': '#/components/schemas/pageref'}, - 'last': {'$ref': '#/components/schemas/pageref'}, - 'prev': {'$ref': '#/components/schemas/pageref'}, - 'next': {'$ref': '#/components/schemas/pageref'}, - } + "pagination": { + "type": "object", + "properties": { + "first": {"$ref": "#/components/schemas/pageref"}, + "last": {"$ref": "#/components/schemas/pageref"}, + "prev": {"$ref": "#/components/schemas/pageref"}, + "next": {"$ref": "#/components/schemas/pageref"}, + }, }, - 'pageref': { - 'oneOf': [ - {'type': 'string', 'format': 'uri-reference'}, - {'$ref': '#/components/schemas/nulltype'} + "pageref": { + "oneOf": [ + {"type": "string", "format": "uri-reference"}, + {"$ref": "#/components/schemas/nulltype"}, ] }, - 'failure': { - 'type': 'object', - 'required': ['errors'], - 'properties': { - 'errors': {'$ref': '#/components/schemas/errors'}, - 'meta': {'$ref': '#/components/schemas/meta'}, - 'jsonapi': {'$ref': '#/components/schemas/jsonapi'}, - 'links': {'$ref': '#/components/schemas/links'} - } + "failure": { + "type": "object", + "required": ["errors"], + "properties": { + "errors": {"$ref": "#/components/schemas/errors"}, + "meta": {"$ref": "#/components/schemas/meta"}, + "jsonapi": {"$ref": "#/components/schemas/jsonapi"}, + "links": {"$ref": "#/components/schemas/links"}, + }, }, - 'errors': { - 'type': 'array', - 'items': {'$ref': '#/components/schemas/error'}, - 'uniqueItems': True + "errors": { + "type": "array", + "items": {"$ref": "#/components/schemas/error"}, + "uniqueItems": True, }, - 'error': { - 'type': 'object', - 'additionalProperties': False, - 'properties': { - 'id': {'type': 'string'}, - 'status': {'type': 'string'}, - 'links': {'$ref': '#/components/schemas/links'}, - 'code': {'type': 'string'}, - 'title': {'type': 'string'}, - 'detail': {'type': 'string'}, - 'source': { - 'type': 'object', - 'properties': { - 'pointer': { - 'type': 'string', - 'description': - "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " - "to the associated entity in the request document " - "[e.g. `/data` for a primary data object, or " - "`/data/attributes/title` for a specific attribute." + "error": { + "type": "object", + "additionalProperties": False, + "properties": { + "id": {"type": "string"}, + "status": {"type": "string"}, + "links": {"$ref": "#/components/schemas/links"}, + "code": {"type": "string"}, + "title": {"type": "string"}, + "detail": {"type": "string"}, + "source": { + "type": "object", + "properties": { + "pointer": { + "type": "string", + "description": "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " + "to the associated entity in the request document " + "[e.g. `/data` for a primary data object, or " + "`/data/attributes/title` for a specific attribute.", }, - 'parameter': { - 'type': 'string', - 'description': - "A string indicating which query parameter " - "caused the error." + "parameter": { + "type": "string", + "description": "A string indicating which query parameter " + "caused the error.", }, - 'meta': {'$ref': '#/components/schemas/meta'} - } - } - } - }, - 'onlymeta': { - 'additionalProperties': False, - 'properties': { - 'meta': {'$ref': '#/components/schemas/meta'} - } + "meta": {"$ref": "#/components/schemas/meta"}, + }, + }, + }, }, - 'meta': { - 'type': 'object', - 'additionalProperties': True + "onlymeta": { + "additionalProperties": False, + "properties": {"meta": {"$ref": "#/components/schemas/meta"}}, }, - 'datum': { - 'description': 'singular item', - 'properties': { - 'data': {'$ref': '#/components/schemas/resource'} - } + "meta": {"type": "object", "additionalProperties": True}, + "datum": { + "description": "singular item", + "properties": {"data": {"$ref": "#/components/schemas/resource"}}, }, - 'nulltype': { - 'type': 'object', - 'nullable': True, - 'default': None + "nulltype": {"type": "object", "nullable": True, "default": None}, + "type": { + "type": "string", + "description": "The [type]" + "(https://jsonapi.org/format/#document-resource-object-identification) " + "member is used to describe resource objects that share common attributes " + "and relationships.", }, - 'type': { - 'type': 'string', - 'description': - 'The [type]' - '(https://jsonapi.org/format/#document-resource-object-identification) ' - 'member is used to describe resource objects that share common attributes ' - 'and relationships.' - }, - 'id': { - 'type': 'string', - 'description': - "Each resource object’s type and id pair MUST " - "[identify]" - "(https://jsonapi.org/format/#document-resource-object-identification) " - "a single, unique resource." + "id": { + "type": "string", + "description": "Each resource object’s type and id pair MUST " + "[identify]" + "(https://jsonapi.org/format/#document-resource-object-identification) " + "a single, unique resource.", }, }, - 'parameters': { - 'include': { - 'name': 'include', - 'in': 'query', - 'description': '[list of included related resources]' - '(https://jsonapi.org/format/#fetching-includes)', - 'required': False, - 'style': 'form', - 'schema': { - 'type': 'string' - } + "parameters": { + "include": { + "name": "include", + "in": "query", + "description": "[list of included related resources]" + "(https://jsonapi.org/format/#fetching-includes)", + "required": False, + "style": "form", + "schema": {"type": "string"}, }, # TODO: deepObject not well defined/supported: # https://github.com/OAI/OpenAPI-Specification/issues/1706 - 'fields': { - 'name': 'fields', - 'in': 'query', - 'description': '[sparse fieldsets]' - '(https://jsonapi.org/format/#fetching-sparse-fieldsets).\n' - 'Use fields[\\]=field1,field2,...,fieldN', - 'required': False, - 'style': 'deepObject', - 'schema': { - 'type': 'object', + "fields": { + "name": "fields", + "in": "query", + "description": "[sparse fieldsets]" + "(https://jsonapi.org/format/#fetching-sparse-fieldsets).\n" + "Use fields[\\]=field1,field2,...,fieldN", + "required": False, + "style": "deepObject", + "schema": { + "type": "object", }, - 'explode': True + "explode": True, }, - 'sort': { - 'name': 'sort', - 'in': 'query', - 'description': '[list of fields to sort by]' - '(https://jsonapi.org/format/#fetching-sorting)', - 'required': False, - 'style': 'form', - 'schema': { - 'type': 'string' - } + "sort": { + "name": "sort", + "in": "query", + "description": "[list of fields to sort by]" + "(https://jsonapi.org/format/#fetching-sorting)", + "required": False, + "style": "form", + "schema": {"type": "string"}, }, }, } @@ -299,10 +275,14 @@ def get_schema(self, request=None, public=False): #: - 'action' copy of current view.action (list/fetch) as this gets reset for each request. expanded_endpoints = [] for path, method, view in view_endpoints: - if hasattr(view, 'action') and view.action == 'retrieve_related': - expanded_endpoints += self._expand_related(path, method, view, view_endpoints) + if hasattr(view, "action") and view.action == "retrieve_related": + expanded_endpoints += self._expand_related( + path, method, view, view_endpoints + ) else: - expanded_endpoints.append((path, method, view, getattr(view, 'action', None))) + expanded_endpoints.append( + (path, method, view, getattr(view, "action", None)) + ) for path, method, view, action in expanded_endpoints: if not self.has_view_permissions(path, method, view): @@ -312,7 +292,7 @@ def get_schema(self, request=None, public=False): # (to_many). This patches the view.action appropriately so that # view.schema.get_operation() "does the right thing" for fetch vs. list. current_action = None - if hasattr(view, 'action'): + if hasattr(view, "action"): current_action = view.action view.action = action operation = view.schema.get_operation(path, method) @@ -323,16 +303,19 @@ def get_schema(self, request=None, public=False): if components_schemas[k] == components[k]: continue warnings.warn( - 'Schema component "{}" has been overriden with a different value.'.format(k)) + 'Schema component "{}" has been overriden with a different value.'.format( + k + ) + ) components_schemas.update(components) - if hasattr(view, 'action'): + if hasattr(view, "action"): view.action = current_action # Normalise path for any provided mount url. - if path.startswith('/'): + if path.startswith("/"): path = path[1:] - path = urljoin(self.url or '/', path) + path = urljoin(self.url or "/", path) paths.setdefault(path, {}) paths[path][method.lower()] = operation @@ -340,9 +323,9 @@ def get_schema(self, request=None, public=False): self.check_duplicate_operation_id(paths) # Compile final schema, overriding stuff from super class. - schema['paths'] = paths - schema['components'] = self.jsonapi_components - schema['components']['schemas'].update(components_schemas) + schema["paths"] = paths + schema["components"] = self.jsonapi_components + schema["components"]["schemas"].update(components_schemas) return schema @@ -362,18 +345,25 @@ def _expand_related(self, path, method, view, view_endpoints): # It's not obvious if it's allowed to have both included_ and related_ serializers, # so just merge both dicts. serializers = {} - if hasattr(serializer, 'included_serializers'): + if hasattr(serializer, "included_serializers"): serializers = {**serializers, **serializer.included_serializers} - if hasattr(serializer, 'related_serializers'): + if hasattr(serializer, "related_serializers"): serializers = {**serializers, **serializer.related_serializers} related_fields = [fs for fs in serializers.items()] for field, related_serializer in related_fields: - related_view = self._find_related_view(view_endpoints, related_serializer, view) + related_view = self._find_related_view( + view_endpoints, related_serializer, view + ) if related_view: action = self._field_is_one_or_many(field, view) result.append( - (path.replace('{related_field}', field), method, related_view, action) + ( + path.replace("{related_field}", field), + method, + related_view, + action, + ) ) return result @@ -390,7 +380,9 @@ def _find_related_view(self, view_endpoints, related_serializer, parent_view): for path, method, view in view_endpoints: view_serializer = view.get_serializer() if not isinstance(related_serializer, type): - related_serializer_class = import_class_from_dotted_path(related_serializer) + related_serializer_class = import_class_from_dotted_path( + related_serializer + ) else: related_serializer_class = related_serializer if isinstance(view_serializer, related_serializer_class): @@ -401,17 +393,18 @@ def _find_related_view(self, view_endpoints, related_serializer, parent_view): def _field_is_one_or_many(self, field, view): serializer = view.get_serializer() if isinstance(serializer.fields[field], ManyRelatedField): - return 'list' + return "list" else: - return 'fetch' + return "fetch" class AutoSchema(drf_openapi.AutoSchema): """ Extend DRF's openapi.AutoSchema for JSONAPI serialization. """ + #: ignore all the media types and only generate a JSONAPI schema. - content_types = ['application/vnd.api+json'] + content_types = ["application/vnd.api+json"] def get_operation(self, path, method): """ @@ -421,31 +414,31 @@ def get_operation(self, path, method): - special handling for POST, PATCH, DELETE """ operation = {} - operation['operationId'] = self.get_operation_id(path, method) - operation['description'] = self.get_description(path, method) + operation["operationId"] = self.get_operation_id(path, method) + operation["description"] = self.get_description(path, method) parameters = [] parameters += self.get_path_parameters(path, method) # pagination, filters only apply to GET/HEAD of collections and items - if method in ['GET', 'HEAD']: + if method in ["GET", "HEAD"]: parameters += self._get_include_parameters(path, method) parameters += self._get_fields_parameters(path, method) parameters += self._get_sort_parameters(path, method) parameters += self.get_pagination_parameters(path, method) parameters += self.get_filter_parameters(path, method) - operation['parameters'] = parameters + operation["parameters"] = parameters # get request and response code schemas - if method == 'GET': + if method == "GET": if is_list_view(path, method, self.view): self._add_get_collection_response(operation) else: self._add_get_item_response(operation) - elif method == 'POST': + elif method == "POST": self._add_post_item_response(operation, path) - elif method == 'PATCH': + elif method == "PATCH": self._add_patch_item_response(operation, path) - elif method == 'DELETE': + elif method == "DELETE": # should only allow deleting a resource, not a collection # TODO: implement delete of a relationship in future release. self._add_delete_item_response(operation, path) @@ -457,9 +450,9 @@ def get_operation_id(self, path, method): used for the main path as well as such as related and relationships. This concatenates the (mapped) method name and path as the spec allows most any """ - method_name = getattr(self.view, 'action', method.lower()) + method_name = getattr(self.view, "action", method.lower()) if is_list_view(path, method, self.view): - action = 'List' + action = "List" elif method_name not in self.method_mapping: action = method_name else: @@ -470,7 +463,7 @@ def _get_include_parameters(self, path, method): """ includes parameter: https://jsonapi.org/format/#fetching-includes """ - return [{'$ref': '#/components/parameters/include'}] + return [{"$ref": "#/components/parameters/include"}] def _get_fields_parameters(self, path, method): """ @@ -490,20 +483,20 @@ def _get_fields_parameters(self, path, method): # world: # type: string # noqa F821 # explode: true - return [{'$ref': '#/components/parameters/fields'}] + return [{"$ref": "#/components/parameters/fields"}] def _get_sort_parameters(self, path, method): """ sort parameter: https://jsonapi.org/format/#fetching-sorting """ - return [{'$ref': '#/components/parameters/sort'}] + return [{"$ref": "#/components/parameters/sort"}] def _add_get_collection_response(self, operation): """ Add GET 200 response for a collection to operation """ - operation['responses'] = { - '200': self._get_toplevel_200_response(operation, collection=True) + operation["responses"] = { + "200": self._get_toplevel_200_response(operation, collection=True) } self._add_get_4xx_responses(operation) @@ -511,8 +504,8 @@ def _add_get_item_response(self, operation): """ add GET 200 response for an item to operation """ - operation['responses'] = { - '200': self._get_toplevel_200_response(operation, collection=False) + operation["responses"] = { + "200": self._get_toplevel_200_response(operation, collection=False) } self._add_get_4xx_responses(operation) @@ -525,58 +518,57 @@ def _get_toplevel_200_response(self, operation, collection=True): Uses a $ref to the components.schemas. component definition. """ if collection: - data = {'type': 'array', 'items': self._get_reference(self.view.get_serializer())} + data = { + "type": "array", + "items": self._get_reference(self.view.get_serializer()), + } else: data = self._get_reference(self.view.get_serializer()) return { - 'description': operation['operationId'], - 'content': { - 'application/vnd.api+json': { - 'schema': { - 'type': 'object', - 'required': ['data'], - 'properties': { - 'data': data, - 'included': { - 'type': 'array', - 'uniqueItems': True, - 'items': { - '$ref': '#/components/schemas/resource' - } + "description": operation["operationId"], + "content": { + "application/vnd.api+json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": data, + "included": { + "type": "array", + "uniqueItems": True, + "items": {"$ref": "#/components/schemas/resource"}, }, - 'links': { - 'description': 'Link members related to primary data', - 'allOf': [ - {'$ref': '#/components/schemas/links'}, - {'$ref': '#/components/schemas/pagination'} - ] + "links": { + "description": "Link members related to primary data", + "allOf": [ + {"$ref": "#/components/schemas/links"}, + {"$ref": "#/components/schemas/pagination"}, + ], }, - 'jsonapi': { - '$ref': '#/components/schemas/jsonapi' - } - } + "jsonapi": {"$ref": "#/components/schemas/jsonapi"}, + }, } } - } + }, } def _add_post_item_response(self, operation, path): """ add response for POST of an item to operation """ - operation['requestBody'] = self.get_request_body(path, 'POST') - operation['responses'] = { - '201': self._get_toplevel_200_response(operation, collection=False) + operation["requestBody"] = self.get_request_body(path, "POST") + operation["responses"] = { + "201": self._get_toplevel_200_response(operation, collection=False) } - operation['responses']['201']['description'] = ( - '[Created](https://jsonapi.org/format/#crud-creating-responses-201). ' - 'Assigned `id` and/or any other changes are in this response.' + operation["responses"]["201"]["description"] = ( + "[Created](https://jsonapi.org/format/#crud-creating-responses-201). " + "Assigned `id` and/or any other changes are in this response." ) self._add_async_response(operation) - operation['responses']['204'] = { - 'description': '[Created](https://jsonapi.org/format/#crud-creating-responses-204) ' - 'with the supplied `id`. No other changes from what was POSTed.' + operation["responses"]["204"] = { + "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) " + "with the supplied `id`. No other changes from what was POSTed." } self._add_post_4xx_responses(operation) @@ -584,9 +576,9 @@ def _add_patch_item_response(self, operation, path): """ Add PATCH response for an item to operation """ - operation['requestBody'] = self.get_request_body(path, 'PATCH') - operation['responses'] = { - '200': self._get_toplevel_200_response(operation, collection=False) + operation["requestBody"] = self.get_request_body(path, "PATCH") + operation["responses"] = { + "200": self._get_toplevel_200_response(operation, collection=False) } self._add_patch_4xx_responses(operation) @@ -596,7 +588,7 @@ def _add_delete_item_response(self, operation, path): """ # Only DELETE of relationships has a requestBody if isinstance(self.view, views.RelationshipView): - operation['requestBody'] = self.get_request_body(path, 'DELETE') + operation["requestBody"] = self.get_request_body(path, "DELETE") self._add_delete_responses(operation) def get_request_body(self, path, method): @@ -604,7 +596,7 @@ def get_request_body(self, path, method): A request body is required by jsonapi for POST, PATCH, and DELETE methods. """ serializer = self.get_serializer(path, method) - if not isinstance(serializer, (serializers.BaseSerializer, )): + if not isinstance(serializer, (serializers.BaseSerializer,)): return {} is_relationship = isinstance(self.view, views.RelationshipView) @@ -617,32 +609,35 @@ def get_request_body(self, path, method): # Another subclassed from base with required type/id but no required attributes (PATCH) if is_relationship: - item_schema = {'$ref': '#/components/schemas/ResourceIdentifierObject'} + item_schema = {"$ref": "#/components/schemas/ResourceIdentifierObject"} else: item_schema = self.map_serializer(serializer) - if method == 'POST': + if method == "POST": # 'type' and 'id' are both required for: # - all relationship operations # - regular PATCH or DELETE # Only 'type' is required for POST: system may assign the 'id'. - item_schema['required'] = ['type'] + item_schema["required"] = ["type"] - if 'properties' in item_schema and 'attributes' in item_schema['properties']: + if "properties" in item_schema and "attributes" in item_schema["properties"]: # No required attributes for PATCH - if method in ['PATCH', 'PUT'] and 'required' in item_schema['properties']['attributes']: - del item_schema['properties']['attributes']['required'] + if ( + method in ["PATCH", "PUT"] + and "required" in item_schema["properties"]["attributes"] + ): + del item_schema["properties"]["attributes"]["required"] # No read_only fields for request. - for name, schema in item_schema['properties']['attributes']['properties'].copy().items(): # noqa E501 - if 'readOnly' in schema: - del item_schema['properties']['attributes']['properties'][name] + for name, schema in ( + item_schema["properties"]["attributes"]["properties"].copy().items() + ): # noqa E501 + if "readOnly" in schema: + del item_schema["properties"]["attributes"]["properties"][name] return { - 'content': { + "content": { ct: { - 'schema': { - 'required': ['data'], - 'properties': { - 'data': item_schema - } + "schema": { + "required": ["data"], + "properties": {"data": item_schema}, } } for ct in self.content_types @@ -666,10 +661,14 @@ def map_serializer(self, serializer): if isinstance(field, serializers.HiddenField): continue if isinstance(field, serializers.RelatedField): - relationships[field.field_name] = {'$ref': '#/components/schemas/reltoone'} + relationships[field.field_name] = { + "$ref": "#/components/schemas/reltoone" + } continue if isinstance(field, serializers.ManyRelatedField): - relationships[field.field_name] = {'$ref': '#/components/schemas/reltomany'} + relationships[field.field_name] = { + "$ref": "#/components/schemas/reltomany" + } continue if field.required: @@ -677,47 +676,45 @@ def map_serializer(self, serializer): schema = self.map_field(field) if field.read_only: - schema['readOnly'] = True + schema["readOnly"] = True if field.write_only: - schema['writeOnly'] = True + schema["writeOnly"] = True if field.allow_null: - schema['nullable'] = True + schema["nullable"] = True if field.default and field.default != empty: - schema['default'] = field.default + schema["default"] = field.default if field.help_text: # Ensure django gettext_lazy is rendered correctly - schema['description'] = str(field.help_text) + schema["description"] = str(field.help_text) self.map_field_validators(field, schema) attributes[field.field_name] = schema result = { - 'type': 'object', - 'required': ['type', 'id'], - 'additionalProperties': False, - 'properties': { - 'type': {'$ref': '#/components/schemas/type'}, - 'id': {'$ref': '#/components/schemas/id'}, - 'links': { - 'type': 'object', - 'properties': { - 'self': {'$ref': '#/components/schemas/link'} - } - } - } + "type": "object", + "required": ["type", "id"], + "additionalProperties": False, + "properties": { + "type": {"$ref": "#/components/schemas/type"}, + "id": {"$ref": "#/components/schemas/id"}, + "links": { + "type": "object", + "properties": {"self": {"$ref": "#/components/schemas/link"}}, + }, + }, } if attributes: - result['properties']['attributes'] = { - 'type': 'object', - 'properties': attributes + result["properties"]["attributes"] = { + "type": "object", + "properties": attributes, } if required: - result['properties']['attributes']['required'] = required + result["properties"]["attributes"]["required"] = required if relationships: - result['properties']['relationships'] = { - 'type': 'object', - 'properties': relationships + result["properties"]["relationships"] = { + "type": "object", + "properties": relationships, } return result @@ -725,14 +722,14 @@ def _add_async_response(self, operation): """ Add async response to operation """ - operation['responses']['202'] = { - 'description': 'Accepted for [asynchronous processing]' - '(https://jsonapi.org/recommendations/#asynchronous-processing)', - 'content': { - 'application/vnd.api+json': { - 'schema': {'$ref': '#/components/schemas/datum'} + operation["responses"]["202"] = { + "description": "Accepted for [asynchronous processing]" + "(https://jsonapi.org/recommendations/#asynchronous-processing)", + "content": { + "application/vnd.api+json": { + "schema": {"$ref": "#/components/schemas/datum"} } - } + }, } def _failure_response(self, reason): @@ -740,28 +737,30 @@ def _failure_response(self, reason): Return failure response reason as the description """ return { - 'description': reason, - 'content': { - 'application/vnd.api+json': { - 'schema': {'$ref': '#/components/schemas/failure'} + "description": reason, + "content": { + "application/vnd.api+json": { + "schema": {"$ref": "#/components/schemas/failure"} } - } + }, } def _add_generic_failure_responses(self, operation): """ Add generic failure response(s) to operation """ - for code, reason in [('401', 'not authorized'), ]: - operation['responses'][code] = self._failure_response(reason) + for code, reason in [ + ("401", "not authorized"), + ]: + operation["responses"][code] = self._failure_response(reason) def _add_get_4xx_responses(self, operation): """ Add generic 4xx GET responses to operation """ self._add_generic_failure_responses(operation) - for code, reason in [('404', 'not found')]: - operation['responses'][code] = self._failure_response(reason) + for code, reason in [("404", "not found")]: + operation["responses"][code] = self._failure_response(reason) def _add_post_4xx_responses(self, operation): """ @@ -769,12 +768,21 @@ def _add_post_4xx_responses(self, operation): """ self._add_generic_failure_responses(operation) for code, reason in [ - ('403', '[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)'), - ('404', '[Related resource does not exist]' - '(https://jsonapi.org/format/#crud-creating-responses-404)'), - ('409', '[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)'), + ( + "403", + "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)", + ), + ( + "404", + "[Related resource does not exist]" + "(https://jsonapi.org/format/#crud-creating-responses-404)", + ), + ( + "409", + "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)", + ), ]: - operation['responses'][code] = self._failure_response(reason) + operation["responses"][code] = self._failure_response(reason) def _add_patch_4xx_responses(self, operation): """ @@ -782,37 +790,49 @@ def _add_patch_4xx_responses(self, operation): """ self._add_generic_failure_responses(operation) for code, reason in [ - ('403', '[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)'), - ('404', '[Related resource does not exist]' - '(https://jsonapi.org/format/#crud-updating-responses-404)'), - ('409', '[Conflict]([Conflict]' - '(https://jsonapi.org/format/#crud-updating-responses-409)'), + ( + "403", + "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)", + ), + ( + "404", + "[Related resource does not exist]" + "(https://jsonapi.org/format/#crud-updating-responses-404)", + ), + ( + "409", + "[Conflict]([Conflict]" + "(https://jsonapi.org/format/#crud-updating-responses-409)", + ), ]: - operation['responses'][code] = self._failure_response(reason) + operation["responses"][code] = self._failure_response(reason) def _add_delete_responses(self, operation): """ Add generic DELETE responses to operation """ # the 2xx statuses: - operation['responses'] = { - '200': { - 'description': '[OK](https://jsonapi.org/format/#crud-deleting-responses-200)', - 'content': { - 'application/vnd.api+json': { - 'schema': {'$ref': '#/components/schemas/onlymeta'} + operation["responses"] = { + "200": { + "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)", + "content": { + "application/vnd.api+json": { + "schema": {"$ref": "#/components/schemas/onlymeta"} } - } + }, } } self._add_async_response(operation) - operation['responses']['204'] = { - 'description': '[no content](https://jsonapi.org/format/#crud-deleting-responses-204)', + operation["responses"]["204"] = { + "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)", } # the 4xx errors: self._add_generic_failure_responses(operation) for code, reason in [ - ('404', '[Resource does not exist]' - '(https://jsonapi.org/format/#crud-deleting-responses-404)'), + ( + "404", + "[Resource does not exist]" + "(https://jsonapi.org/format/#crud-deleting-responses-404)", + ), ]: - operation['responses'][code] = self._failure_response(reason) + operation["responses"][code] = self._failure_response(reason) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 31f2a86b..50b546b6 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -12,45 +12,47 @@ get_included_serializers, get_resource_type_from_instance, get_resource_type_from_model, - get_resource_type_from_serializer + get_resource_type_from_serializer, ) class ResourceIdentifierObjectSerializer(BaseSerializer): default_error_messages = { - 'incorrect_model_type': _( - 'Incorrect model type. Expected {model_type}, received {received_type}.' + "incorrect_model_type": _( + "Incorrect model type. Expected {model_type}, received {received_type}." ), - 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), - 'incorrect_type': _('Incorrect type. Expected pk value, received {data_type}.'), + "does_not_exist": _('Invalid pk "{pk_value}" - object does not exist.'), + "incorrect_type": _("Incorrect type. Expected pk value, received {data_type}."), } model_class = None def __init__(self, *args, **kwargs): - self.model_class = kwargs.pop('model_class', self.model_class) + self.model_class = kwargs.pop("model_class", self.model_class) # this has no fields but assumptions are made elsewhere that self.fields exists. self.fields = {} super(ResourceIdentifierObjectSerializer, self).__init__(*args, **kwargs) def to_representation(self, instance): return { - 'type': get_resource_type_from_instance(instance), - 'id': str(instance.pk) + "type": get_resource_type_from_instance(instance), + "id": str(instance.pk), } def to_internal_value(self, data): - if data['type'] != get_resource_type_from_model(self.model_class): + if data["type"] != get_resource_type_from_model(self.model_class): self.fail( - 'incorrect_model_type', model_type=self.model_class, received_type=data['type'] + "incorrect_model_type", + model_type=self.model_class, + received_type=data["type"], ) - pk = data['id'] + pk = data["id"] try: return self.model_class.objects.get(pk=pk) except ObjectDoesNotExist: - self.fail('does_not_exist', pk_value=pk) + self.fail("does_not_exist", pk_value=pk) except (TypeError, ValueError): - self.fail('incorrect_type', data_type=type(data['pk']).__name__) + self.fail("incorrect_type", data_type=type(data["pk"]).__name__) class SparseFieldsetsMixin(object): @@ -62,26 +64,30 @@ class SparseFieldsetsMixin(object): def __init__(self, *args, **kwargs): super(SparseFieldsetsMixin, self).__init__(*args, **kwargs) - context = kwargs.get('context') - request = context.get('request') if context else None + context = kwargs.get("context") + request = context.get("request") if context else None if request: - sparse_fieldset_query_param = 'fields[{}]'.format( + sparse_fieldset_query_param = "fields[{}]".format( get_resource_type_from_serializer(self) ) try: param_name = next( - key for key in request.query_params if sparse_fieldset_query_param == key + key + for key in request.query_params + if sparse_fieldset_query_param == key ) except StopIteration: pass else: - fieldset = request.query_params.get(param_name).split(',') + fieldset = request.query_params.get(param_name).split(",") # iterate over a *copy* of self.fields' underlying OrderedDict, because we may # modify the original during the iteration. # self.fields is a `rest_framework.utils.serializer_helpers.BindingDict` for field_name, field in self.fields.fields.copy().items(): - if field_name == api_settings.URL_FIELD_NAME: # leave self link there + if ( + field_name == api_settings.URL_FIELD_NAME + ): # leave self link there continue if field_name not in fieldset: self.fields.pop(field_name) @@ -96,19 +102,19 @@ class IncludedResourcesValidationMixin(object): """ def __init__(self, *args, **kwargs): - context = kwargs.get('context') - request = context.get('request') if context else None - view = context.get('view') if context else None + context = kwargs.get("context") + request = context.get("request") if context else None + view = context.get("view") if context else None def validate_path(serializer_class, field_path, path): serializers = get_included_serializers(serializer_class) if serializers is None: - raise ParseError('This endpoint does not support the include parameter') + raise ParseError("This endpoint does not support the include parameter") this_field_name = inflection.underscore(field_path[0]) this_included_serializer = serializers.get(this_field_name) if this_included_serializer is None: raise ParseError( - 'This endpoint does not support the include parameter for path {}'.format( + "This endpoint does not support the include parameter for path {}".format( path ) ) @@ -120,10 +126,12 @@ def validate_path(serializer_class, field_path, path): if request and view: included_resources = get_included_resources(request) for included_field_name in included_resources: - included_field_path = included_field_name.split('.') + included_field_path = included_field_name.split(".") this_serializer_class = view.get_serializer_class() # lets validate the current path - validate_path(this_serializer_class, included_field_path, included_field_name) + validate_path( + this_serializer_class, included_field_path, included_field_name + ) super(IncludedResourcesValidationMixin, self).__init__(*args, **kwargs) @@ -135,8 +143,10 @@ class SerializerMetaclass(SerializerMetaclass): # If user imports serializer from here we can catch class definition and check # nested serializers for depricated use. class Serializer( - IncludedResourcesValidationMixin, SparseFieldsetsMixin, Serializer, - metaclass=SerializerMetaclass + IncludedResourcesValidationMixin, + SparseFieldsetsMixin, + Serializer, + metaclass=SerializerMetaclass, ): """ A `Serializer` is a model-less serializer class with additional @@ -156,8 +166,10 @@ class Serializer( class HyperlinkedModelSerializer( - IncludedResourcesValidationMixin, SparseFieldsetsMixin, HyperlinkedModelSerializer, - metaclass=SerializerMetaclass + IncludedResourcesValidationMixin, + SparseFieldsetsMixin, + HyperlinkedModelSerializer, + metaclass=SerializerMetaclass, ): """ A type of `ModelSerializer` that uses hyperlinked relationships instead @@ -173,8 +185,12 @@ class HyperlinkedModelSerializer( """ -class ModelSerializer(IncludedResourcesValidationMixin, SparseFieldsetsMixin, ModelSerializer, - metaclass=SerializerMetaclass): +class ModelSerializer( + IncludedResourcesValidationMixin, + SparseFieldsetsMixin, + ModelSerializer, + metaclass=SerializerMetaclass, +): """ A `ModelSerializer` is just a regular `Serializer`, except that: @@ -196,6 +212,7 @@ class ModelSerializer(IncludedResourcesValidationMixin, SparseFieldsetsMixin, Mo * A mixin class to enable sparse fieldsets is included * A mixin class to enable validation of included resources is included """ + serializer_related_field = ResourceRelatedField def get_field_names(self, declared_fields, info): @@ -203,7 +220,7 @@ def get_field_names(self, declared_fields, info): We override the parent to omit explicity defined meta fields (such as SerializerMethodFields) from the list of declared fields """ - meta_fields = getattr(self.Meta, 'meta_fields', []) + meta_fields = getattr(self.Meta, "meta_fields", []) declared = OrderedDict() for field_name in set(declared_fields.keys()): @@ -211,7 +228,7 @@ def get_field_names(self, declared_fields, info): if field_name not in meta_fields: declared[field_name] = field fields = super(ModelSerializer, self).get_field_names(declared, info) - return list(fields) + list(getattr(self.Meta, 'meta_fields', list())) + return list(fields) + list(getattr(self.Meta, "meta_fields", list())) class PolymorphicSerializerMetaclass(SerializerMetaclass): @@ -221,7 +238,9 @@ class PolymorphicSerializerMetaclass(SerializerMetaclass): """ def __new__(cls, name, bases, attrs): - new_class = super(PolymorphicSerializerMetaclass, cls).__new__(cls, name, bases, attrs) + new_class = super(PolymorphicSerializerMetaclass, cls).__new__( + cls, name, bases, attrs + ) # Ensure initialization is only performed for subclasses of PolymorphicModelSerializer # (excluding PolymorphicModelSerializer class itself). @@ -229,17 +248,21 @@ def __new__(cls, name, bases, attrs): if not parents: return new_class - polymorphic_serializers = getattr(new_class, 'polymorphic_serializers', None) + polymorphic_serializers = getattr(new_class, "polymorphic_serializers", None) if not polymorphic_serializers: raise NotImplementedError( - "A PolymorphicModelSerializer must define a `polymorphic_serializers` attribute.") + "A PolymorphicModelSerializer must define a `polymorphic_serializers` attribute." + ) serializer_to_model = { - serializer: serializer.Meta.model for serializer in polymorphic_serializers} + serializer: serializer.Meta.model for serializer in polymorphic_serializers + } model_to_serializer = { - serializer.Meta.model: serializer for serializer in polymorphic_serializers} + serializer.Meta.model: serializer for serializer in polymorphic_serializers + } type_to_serializer = { - get_resource_type_from_serializer(serializer): serializer for - serializer in polymorphic_serializers} + get_resource_type_from_serializer(serializer): serializer + for serializer in polymorphic_serializers + } new_class._poly_serializer_model_map = serializer_to_model new_class._poly_model_serializer_map = model_to_serializer new_class._poly_type_serializer_map = type_to_serializer @@ -252,21 +275,30 @@ def __new__(cls, name, bases, attrs): return new_class -class PolymorphicModelSerializer(ModelSerializer, metaclass=PolymorphicSerializerMetaclass): +class PolymorphicModelSerializer( + ModelSerializer, metaclass=PolymorphicSerializerMetaclass +): """ A serializer for polymorphic models. Useful for "lazy" parent models. Leaves should be represented with a regular serializer. """ + def get_fields(self): """ Return an exhaustive list of the polymorphic serializer fields. """ if self.instance not in (None, []): if not isinstance(self.instance, QuerySet): - serializer_class = self.get_polymorphic_serializer_for_instance(self.instance) - return serializer_class(self.instance, context=self.context).get_fields() + serializer_class = self.get_polymorphic_serializer_for_instance( + self.instance + ) + return serializer_class( + self.instance, context=self.context + ).get_fields() else: - raise Exception("Cannot get fields from a polymorphic serializer given a queryset") + raise Exception( + "Cannot get fields from a polymorphic serializer given a queryset" + ) return super(PolymorphicModelSerializer, self).get_fields() @classmethod @@ -281,7 +313,9 @@ def get_polymorphic_serializer_for_instance(cls, instance): except KeyError: raise NotImplementedError( "No polymorphic serializer has been found for model {}".format( - instance._meta.model.__name__)) + instance._meta.model.__name__ + ) + ) @classmethod def get_polymorphic_model_for_serializer(cls, serializer): @@ -294,7 +328,10 @@ def get_polymorphic_model_for_serializer(cls, serializer): return cls._poly_serializer_model_map[serializer] except KeyError: raise NotImplementedError( - "No polymorphic model has been found for serializer {}".format(serializer.__name__)) + "No polymorphic model has been found for serializer {}".format( + serializer.__name__ + ) + ) @classmethod def get_polymorphic_serializer_for_type(cls, obj_type): @@ -307,7 +344,8 @@ def get_polymorphic_serializer_for_type(cls, obj_type): return cls._poly_type_serializer_map[obj_type] except KeyError: raise NotImplementedError( - "No polymorphic serializer has been found for type {}".format(obj_type)) + "No polymorphic serializer has been found for type {}".format(obj_type) + ) @classmethod def get_polymorphic_model_for_type(cls, obj_type): @@ -317,7 +355,8 @@ def get_polymorphic_model_for_type(cls, obj_type): means that a serializer is missing in the class's `polymorphic_serializers` attribute. """ return cls.get_polymorphic_model_for_serializer( - cls.get_polymorphic_serializer_for_type(obj_type)) + cls.get_polymorphic_serializer_for_type(obj_type) + ) @classmethod def get_polymorphic_types(cls): @@ -331,21 +370,27 @@ def to_representation(self, instance): Retrieve the appropriate polymorphic serializer and use this to handle representation. """ serializer_class = self.get_polymorphic_serializer_for_instance(instance) - return serializer_class(instance, context=self.context).to_representation(instance) + return serializer_class(instance, context=self.context).to_representation( + instance + ) def to_internal_value(self, data): """ Ensure that the given type is one of the expected polymorphic types, then retrieve the appropriate polymorphic serializer and use this to handle internal value. """ - received_type = data.get('type') + received_type = data.get("type") expected_types = self.get_polymorphic_types() if received_type not in expected_types: raise Conflict( - 'Incorrect relation type. Expected on of [{expected_types}], ' - 'received {received_type}.'.format( - expected_types=', '.join(expected_types), received_type=received_type)) + "Incorrect relation type. Expected on of [{expected_types}], " + "received {received_type}.".format( + expected_types=", ".join(expected_types), + received_type=received_type, + ) + ) serializer_class = self.get_polymorphic_serializer_for_type(received_type) self.__class__ = serializer_class - return serializer_class(self.instance, data, context=self.context, - partial=self.partial).to_internal_value(data) + return serializer_class( + self.instance, data, context=self.context, partial=self.partial + ).to_internal_value(data) diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 1385630c..0384894c 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -7,13 +7,13 @@ from django.conf import settings from django.core.signals import setting_changed -JSON_API_SETTINGS_PREFIX = 'JSON_API_' +JSON_API_SETTINGS_PREFIX = "JSON_API_" DEFAULTS = { - 'FORMAT_FIELD_NAMES': False, - 'FORMAT_TYPES': False, - 'PLURALIZE_TYPES': False, - 'UNIFORM_EXCEPTIONS': False, + "FORMAT_FIELD_NAMES": False, + "FORMAT_TYPES": False, + "PLURALIZE_TYPES": False, + "UNIFORM_EXCEPTIONS": False, } @@ -31,7 +31,9 @@ def __getattr__(self, attr): if attr not in self.defaults: raise AttributeError("Invalid JSON API setting: '%s'" % attr) - value = getattr(self.user_settings, JSON_API_SETTINGS_PREFIX + attr, self.defaults[attr]) + value = getattr( + self.user_settings, JSON_API_SETTINGS_PREFIX + attr, self.defaults[attr] + ) # Cache the result setattr(self, attr, value) @@ -42,9 +44,9 @@ def __getattr__(self, attr): def reload_json_api_settings(*args, **kwargs): - django_setting = kwargs['setting'] - setting = django_setting.replace(JSON_API_SETTINGS_PREFIX, '') - value = kwargs['value'] + django_setting = kwargs["setting"] + setting = django_setting.replace(JSON_API_SETTINGS_PREFIX, "") + value = kwargs["value"] if setting in DEFAULTS.keys(): if value is not None: setattr(json_api_settings, setting, value) diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index b99de91a..41683303 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -8,7 +8,7 @@ from django.db.models import Manager from django.db.models.fields.related_descriptors import ( ManyToManyDescriptor, - ReverseManyToOneDescriptor + ReverseManyToOneDescriptor, ) from django.http import Http404 from django.utils import encoding @@ -20,7 +20,7 @@ from .settings import json_api_settings # Generic relation descriptor from django.contrib.contenttypes. -if 'django.contrib.contenttypes' not in settings.INSTALLED_APPS: # pragma: no cover +if "django.contrib.contenttypes" not in settings.INSTALLED_APPS: # pragma: no cover # Target application does not use contenttypes. Importing would cause errors. ReverseGenericManyToOneDescriptor = object() else: @@ -32,7 +32,8 @@ def get_resource_name(context, expand_polymorphic_types=False): Return the name of a resource. """ from rest_framework_json_api.serializers import PolymorphicModelSerializer - view = context.get('view') + + view = context.get("view") # Sanity check to make sure we have a view. if not view: @@ -45,18 +46,20 @@ def get_resource_name(context, expand_polymorphic_types=False): except (AttributeError, ValueError): pass else: - if code.startswith('4') or code.startswith('5'): - return 'errors' + if code.startswith("4") or code.startswith("5"): + return "errors" try: - resource_name = getattr(view, 'resource_name') + resource_name = getattr(view, "resource_name") except AttributeError: try: - if 'kwargs' in context and 'related_field' in context['kwargs']: + if "kwargs" in context and "related_field" in context["kwargs"]: serializer = view.get_related_serializer_class() else: serializer = view.get_serializer_class() - if expand_polymorphic_types and issubclass(serializer, PolymorphicModelSerializer): + if expand_polymorphic_types and issubclass( + serializer, PolymorphicModelSerializer + ): return serializer.get_polymorphic_types() else: return get_resource_type_from_serializer(serializer) @@ -78,15 +81,15 @@ def get_resource_name(context, expand_polymorphic_types=False): def get_serializer_fields(serializer): fields = None - if hasattr(serializer, 'child'): - fields = getattr(serializer.child, 'fields') - meta = getattr(serializer.child, 'Meta', None) - if hasattr(serializer, 'fields'): - fields = getattr(serializer, 'fields') - meta = getattr(serializer, 'Meta', None) + if hasattr(serializer, "child"): + fields = getattr(serializer.child, "fields") + meta = getattr(serializer.child, "Meta", None) + if hasattr(serializer, "fields"): + fields = getattr(serializer, "fields") + meta = getattr(serializer, "Meta", None) if fields is not None: - meta_fields = getattr(meta, 'meta_fields', {}) + meta_fields = getattr(meta, "meta_fields", {}) for field in meta_fields: try: fields.pop(field) @@ -118,15 +121,15 @@ def format_field_names(obj, format_type=None): def format_value(value, format_type=None): if format_type is None: format_type = json_api_settings.FORMAT_FIELD_NAMES - if format_type == 'dasherize': + if format_type == "dasherize": # inflection can't dasherize camelCase value = inflection.underscore(value) value = inflection.dasherize(value) - elif format_type == 'camelize': + elif format_type == "camelize": value = inflection.camelize(value, False) - elif format_type == 'capitalize': + elif format_type == "capitalize": value = inflection.camelize(value) - elif format_type == 'underscore': + elif format_type == "underscore": value = inflection.underscore(value) return value @@ -147,22 +150,24 @@ def format_resource_type(value, format_type=None, pluralize=None): def get_related_resource_type(relation): from rest_framework_json_api.serializers import PolymorphicModelSerializer + try: return get_resource_type_from_serializer(relation) except AttributeError: pass relation_model = None - if hasattr(relation, '_meta'): + if hasattr(relation, "_meta"): relation_model = relation._meta.model - elif hasattr(relation, 'model'): + elif hasattr(relation, "model"): # the model type was explicitly passed as a kwarg to ResourceRelatedField relation_model = relation.model - elif hasattr(relation, 'get_queryset') and relation.get_queryset() is not None: + elif hasattr(relation, "get_queryset") and relation.get_queryset() is not None: relation_model = relation.get_queryset().model elif ( - getattr(relation, 'many', False) and - hasattr(relation.child, 'Meta') and - hasattr(relation.child.Meta, 'model')): + getattr(relation, "many", False) + and hasattr(relation.child, "Meta") + and hasattr(relation.child.Meta, "model") + ): # For ManyToMany relationships, get the model from the child # serializer of the list serializer relation_model = relation.child.Meta.model @@ -171,20 +176,25 @@ def get_related_resource_type(relation): parent_model = None if isinstance(parent_serializer, PolymorphicModelSerializer): parent_model = parent_serializer.get_polymorphic_serializer_for_instance( - parent_serializer.instance).Meta.model - elif hasattr(parent_serializer, 'Meta'): - parent_model = getattr(parent_serializer.Meta, 'model', None) - elif hasattr(parent_serializer, 'parent') and hasattr(parent_serializer.parent, 'Meta'): - parent_model = getattr(parent_serializer.parent.Meta, 'model', None) + parent_serializer.instance + ).Meta.model + elif hasattr(parent_serializer, "Meta"): + parent_model = getattr(parent_serializer.Meta, "model", None) + elif hasattr(parent_serializer, "parent") and hasattr( + parent_serializer.parent, "Meta" + ): + parent_model = getattr(parent_serializer.parent.Meta, "model", None) if parent_model is not None: if relation.source: - if relation.source != '*': + if relation.source != "*": parent_model_relation = getattr(parent_model, relation.source) else: parent_model_relation = getattr(parent_model, relation.field_name) else: - parent_model_relation = getattr(parent_model, parent_serializer.field_name) + parent_model_relation = getattr( + parent_model, parent_serializer.field_name + ) parent_model_relation_type = type(parent_model_relation) if parent_model_relation_type is ReverseManyToOneDescriptor: @@ -196,7 +206,7 @@ def get_related_resource_type(relation): relation_model = parent_model_relation.field.model elif parent_model_relation_type is ReverseGenericManyToOneDescriptor: relation_model = parent_model_relation.rel.model - elif hasattr(parent_model_relation, 'field'): + elif hasattr(parent_model_relation, "field"): try: relation_model = parent_model_relation.field.remote_field.model except AttributeError: @@ -205,17 +215,16 @@ def get_related_resource_type(relation): return get_related_resource_type(parent_model_relation) if relation_model is None: - raise APIException(_('Could not resolve resource type for relation %s' % relation)) + raise APIException( + _("Could not resolve resource type for relation %s" % relation) + ) return get_resource_type_from_model(relation_model) def get_resource_type_from_model(model): - json_api_meta = getattr(model, 'JSONAPIMeta', None) - return getattr( - json_api_meta, - 'resource_name', - format_resource_type(model.__name__)) + json_api_meta = getattr(model, "JSONAPIMeta", None) + return getattr(json_api_meta, "resource_name", format_resource_type(model.__name__)) def get_resource_type_from_queryset(qs): @@ -223,7 +232,7 @@ def get_resource_type_from_queryset(qs): def get_resource_type_from_instance(instance): - if hasattr(instance, '_meta'): + if hasattr(instance, "_meta"): return get_resource_type_from_model(instance._meta.model) @@ -232,39 +241,41 @@ def get_resource_type_from_manager(manager): def get_resource_type_from_serializer(serializer): - json_api_meta = getattr(serializer, 'JSONAPIMeta', None) - meta = getattr(serializer, 'Meta', None) - if hasattr(json_api_meta, 'resource_name'): + json_api_meta = getattr(serializer, "JSONAPIMeta", None) + meta = getattr(serializer, "Meta", None) + if hasattr(json_api_meta, "resource_name"): return json_api_meta.resource_name - elif hasattr(meta, 'resource_name'): + elif hasattr(meta, "resource_name"): return meta.resource_name - elif hasattr(meta, 'model'): + elif hasattr(meta, "model"): return get_resource_type_from_model(meta.model) raise AttributeError() def get_included_resources(request, serializer=None): """ Build a list of included resources. """ - include_resources_param = request.query_params.get('include') if request else None + include_resources_param = request.query_params.get("include") if request else None if include_resources_param: - return include_resources_param.split(',') + return include_resources_param.split(",") else: return get_default_included_resources_from_serializer(serializer) def get_default_included_resources_from_serializer(serializer): - meta = getattr(serializer, 'JSONAPIMeta', None) - if meta is None and getattr(serializer, 'many', False): - meta = getattr(serializer.child, 'JSONAPIMeta', None) - return list(getattr(meta, 'included_resources', [])) + meta = getattr(serializer, "JSONAPIMeta", None) + if meta is None and getattr(serializer, "many", False): + meta = getattr(serializer.child, "JSONAPIMeta", None) + return list(getattr(meta, "included_resources", [])) def get_included_serializers(serializer): - included_serializers = copy.copy(getattr(serializer, 'included_serializers', dict())) + included_serializers = copy.copy( + getattr(serializer, "included_serializers", dict()) + ) for name, value in iter(included_serializers.items()): if not isinstance(value, type): - if value == 'self': + if value == "self": included_serializers[name] = ( serializer if isinstance(serializer, type) else serializer.__class__ ) @@ -281,7 +292,7 @@ def get_relation_instance(resource_instance, source, serializer): # if the field is not defined on the model then we check the serializer # and if no value is there we skip over the field completely serializer_method = getattr(serializer, source, None) - if serializer_method and hasattr(serializer_method, '__call__'): + if serializer_method and hasattr(serializer_method, "__call__"): relation_instance = serializer_method(resource_instance) else: return False, None @@ -315,12 +326,12 @@ def format_drf_errors(response, context, exc): # handle generic errors. ValidationError('test') in a view for example if isinstance(response.data, list): for message in response.data: - errors.extend(format_error_object(message, '/data', response)) + errors.extend(format_error_object(message, "/data", response)) # handle all errors thrown from serializers else: for field, error in response.data.items(): field = format_value(field) - pointer = '/data/attributes/{}'.format(field) + pointer = "/data/attributes/{}".format(field) if isinstance(exc, Http404) and isinstance(error, str): # 404 errors don't have a pointer errors.extend(format_error_object(error, None, response)) @@ -328,14 +339,14 @@ def format_drf_errors(response, context, exc): classes = inspect.getmembers(exceptions, inspect.isclass) # DRF sets the `field` to 'detail' for its own exceptions if isinstance(exc, tuple(x[1] for x in classes)): - pointer = '/data' + pointer = "/data" errors.extend(format_error_object(error, pointer, response)) elif isinstance(error, list): errors.extend(format_error_object(error, pointer, response)) else: errors.extend(format_error_object(error, pointer, response)) - context['view'].resource_name = 'errors' + context["view"].resource_name = "errors" response.data = errors return response @@ -347,41 +358,46 @@ def format_error_object(message, pointer, response): # as there is no required field in error object we check that all fields are string # except links and source which might be a dict - is_custom_error = all([ - isinstance(value, str) - for key, value in message.items() if key not in ['links', 'source'] - ]) + is_custom_error = all( + [ + isinstance(value, str) + for key, value in message.items() + if key not in ["links", "source"] + ] + ) if is_custom_error: - if 'source' not in message: - message['source'] = {} - message['source'] = { - 'pointer': pointer, + if "source" not in message: + message["source"] = {} + message["source"] = { + "pointer": pointer, } errors.append(message) else: for k, v in message.items(): - errors.extend(format_error_object(v, pointer + '/{}'.format(k), response)) + errors.extend( + format_error_object(v, pointer + "/{}".format(k), response) + ) elif isinstance(message, list): for num, error in enumerate(message): if isinstance(error, (list, dict)): - new_pointer = pointer + '/{}'.format(num) + new_pointer = pointer + "/{}".format(num) else: new_pointer = pointer if error: errors.extend(format_error_object(error, new_pointer, response)) else: error_obj = { - 'detail': message, - 'status': encoding.force_str(response.status_code), + "detail": message, + "status": encoding.force_str(response.status_code), } if pointer is not None: - error_obj['source'] = { - 'pointer': pointer, + error_obj["source"] = { + "pointer": pointer, } code = getattr(message, "code", None) if code is not None: - error_obj['code'] = code + error_obj["code"] = code errors.append(error_obj) return errors @@ -389,5 +405,5 @@ def format_error_object(message, pointer, response): def format_errors(data): if len(data) > 1 and isinstance(data, list): - data.sort(key=lambda x: x.get('source', {}).get('pointer', '')) - return {'errors': data} + data.sort(key=lambda x: x.get("source", {}).get("pointer", "")) + return {"errors": data} diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 7c874e7a..7a558cbf 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -6,7 +6,7 @@ ForwardManyToOneDescriptor, ManyToManyDescriptor, ReverseManyToOneDescriptor, - ReverseOneToOneDescriptor + ReverseOneToOneDescriptor, ) from django.db.models.manager import Manager from django.db.models.query import QuerySet @@ -26,7 +26,7 @@ Hyperlink, OrderedDict, get_included_resources, - get_resource_type_from_instance + get_resource_type_from_instance, ) @@ -54,16 +54,16 @@ class MyViewSet(viewsets.ModelViewSet): """ def get_select_related(self, include): - return getattr(self, 'select_for_includes', {}).get(include, None) + return getattr(self, "select_for_includes", {}).get(include, None) def get_prefetch_related(self, include): - return getattr(self, 'prefetch_for_includes', {}).get(include, None) + return getattr(self, "prefetch_for_includes", {}).get(include, None) def get_queryset(self, *args, **kwargs): qs = super(PreloadIncludesMixin, self).get_queryset(*args, **kwargs) included_resources = get_included_resources(self.request) - for included in included_resources + ['__all__']: + for included in included_resources + ["__all__"]: select_related = self.get_select_related(included) if select_related is not None: @@ -83,10 +83,10 @@ def get_queryset(self, *args, **kwargs): included_resources = get_included_resources(self.request) - for included in included_resources + ['__all__']: + for included in included_resources + ["__all__"]: # If include was not defined, trying to resolve it automatically included_model = None - levels = included.split('.') + levels = included.split(".") level_model = qs.model for level in levels: if not hasattr(level_model, level): @@ -94,11 +94,11 @@ def get_queryset(self, *args, **kwargs): field = getattr(level_model, level) field_class = field.__class__ - is_forward_relation = ( - issubclass(field_class, (ForwardManyToOneDescriptor, ManyToManyDescriptor)) + is_forward_relation = issubclass( + field_class, (ForwardManyToOneDescriptor, ManyToManyDescriptor) ) - is_reverse_relation = ( - issubclass(field_class, (ReverseManyToOneDescriptor, ReverseOneToOneDescriptor)) + is_reverse_relation = issubclass( + field_class, (ReverseManyToOneDescriptor, ReverseOneToOneDescriptor) ) if not (is_forward_relation or is_reverse_relation): break @@ -118,7 +118,7 @@ def get_queryset(self, *args, **kwargs): level_model = model_field.model if included_model is not None: - qs = qs.prefetch_related(included.replace('.', '__')) + qs = qs.prefetch_related(included.replace(".", "__")) return qs @@ -132,7 +132,7 @@ def retrieve_related(self, request, *args, **kwargs): serializer_kwargs = {} instance = self.get_related_instance() - if hasattr(instance, 'all'): + if hasattr(instance, "all"): instance = instance.all() if callable(instance): @@ -142,36 +142,41 @@ def retrieve_related(self, request, *args, **kwargs): return Response(data=None) if isinstance(instance, Iterable): - serializer_kwargs['many'] = True + serializer_kwargs["many"] = True serializer = self.get_related_serializer(instance, **serializer_kwargs) return Response(serializer.data) def get_related_serializer(self, instance, **kwargs): serializer_class = self.get_related_serializer_class() - kwargs.setdefault('context', self.get_serializer_context()) + kwargs.setdefault("context", self.get_serializer_context()) return serializer_class(instance, **kwargs) def get_related_serializer_class(self): parent_serializer_class = super(RelatedMixin, self).get_serializer_class() - if 'related_field' in self.kwargs: - field_name = self.kwargs['related_field'] + if "related_field" in self.kwargs: + field_name = self.kwargs["related_field"] # Try get the class from related_serializers - if hasattr(parent_serializer_class, 'related_serializers'): - _class = parent_serializer_class.related_serializers.get(field_name, None) + if hasattr(parent_serializer_class, "related_serializers"): + _class = parent_serializer_class.related_serializers.get( + field_name, None + ) if _class is None: raise NotFound - elif hasattr(parent_serializer_class, 'included_serializers'): - _class = parent_serializer_class.included_serializers.get(field_name, None) + elif hasattr(parent_serializer_class, "included_serializers"): + _class = parent_serializer_class.included_serializers.get( + field_name, None + ) if _class is None: raise NotFound else: - assert False, \ - 'Either "included_serializers" or "related_serializers" should be configured' + assert ( + False + ), 'Either "included_serializers" or "related_serializers" should be configured' if not isinstance(_class, type): return import_class_from_dotted_path(_class) @@ -180,7 +185,7 @@ def get_related_serializer_class(self): return parent_serializer_class def get_related_field_name(self): - return self.kwargs['related_field'] + return self.kwargs["related_field"] def get_related_instance(self): parent_obj = self.get_object() @@ -206,17 +211,16 @@ def get_related_instance(self): raise NotFound -class ModelViewSet(AutoPrefetchMixin, - PreloadIncludesMixin, - RelatedMixin, - viewsets.ModelViewSet): - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] +class ModelViewSet( + AutoPrefetchMixin, PreloadIncludesMixin, RelatedMixin, viewsets.ModelViewSet +): + http_method_names = ["get", "post", "patch", "delete", "head", "options"] -class ReadOnlyModelViewSet(AutoPrefetchMixin, - RelatedMixin, - viewsets.ReadOnlyModelViewSet): - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] +class ReadOnlyModelViewSet( + AutoPrefetchMixin, RelatedMixin, viewsets.ReadOnlyModelViewSet +): + http_method_names = ["get", "post", "patch", "delete", "head", "options"] class RelationshipView(generics.GenericAPIView): @@ -224,10 +228,10 @@ class RelationshipView(generics.GenericAPIView): self_link_view_name = None related_link_view_name = None field_name_mapping = {} - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] + http_method_names = ["get", "post", "patch", "delete", "head", "options"] def get_serializer_class(self): - if getattr(self, 'action', False) is None: + if getattr(self, "action", False) is None: return Serializer return self.serializer_class @@ -255,10 +259,10 @@ def get_url(self, name, view_name, kwargs, request): url = self.reverse(view_name, kwargs=kwargs, request=request) except NoReverseMatch: msg = ( - 'Could not resolve URL for hyperlinked relationship using ' + "Could not resolve URL for hyperlinked relationship using " 'view name "%s". You may have failed to include the related ' - 'model in your API, or incorrectly configured the ' - '`lookup_field` attribute on this field.' + "model in your API, or incorrectly configured the " + "`lookup_field` attribute on this field." ) raise ImproperlyConfigured(msg % view_name) @@ -269,15 +273,17 @@ def get_url(self, name, view_name, kwargs, request): def get_links(self): return_data = OrderedDict() - self_link = self.get_url('self', self.self_link_view_name, self.kwargs, self.request) + self_link = self.get_url( + "self", self.self_link_view_name, self.kwargs, self.request + ) related_kwargs = {self.lookup_field: self.kwargs.get(self.lookup_field)} related_link = self.get_url( - 'related', self.related_link_view_name, related_kwargs, self.request + "related", self.related_link_view_name, related_kwargs, self.request ) if self_link: - return_data.update({'self': self_link}) + return_data.update({"self": self_link}) if related_link: - return_data.update({'related': related_link}) + return_data.update({"related": related_link}) return return_data def get(self, request, *args, **kwargs): @@ -292,7 +298,7 @@ def remove_relationships(self, instance_manager, field): for obj in instance_manager.all(): setattr(obj, field_object.name, None) obj.save() - elif hasattr(instance_manager, 'clear'): + elif hasattr(instance_manager, "clear"): instance_manager.clear() else: instance_manager.all().delete() @@ -313,25 +319,33 @@ def patch(self, request, *args, **kwargs): # for to one if hasattr(related_instance_or_manager, "field"): related_instance_or_manager = self.remove_relationships( - instance_manager=related_instance_or_manager, field="field") + instance_manager=related_instance_or_manager, field="field" + ) # for to many else: related_instance_or_manager = self.remove_relationships( - instance_manager=related_instance_or_manager, field="target_field") + instance_manager=related_instance_or_manager, field="target_field" + ) # have to set bulk to False since data isn't saved yet class_name = related_instance_or_manager.__class__.__name__ - if class_name != 'ManyRelatedManager': + if class_name != "ManyRelatedManager": related_instance_or_manager.add(*serializer.validated_data, bulk=False) else: related_instance_or_manager.add(*serializer.validated_data) else: related_model_class = related_instance_or_manager.__class__ - serializer = self.get_serializer(data=request.data, model_class=related_model_class) + serializer = self.get_serializer( + data=request.data, model_class=related_model_class + ) serializer.is_valid(raise_exception=True) - setattr(parent_obj, self.get_related_field_name(), serializer.validated_data) + setattr( + parent_obj, self.get_related_field_name(), serializer.validated_data + ) parent_obj.save() - related_instance_or_manager = self.get_related_instance() # Refresh instance + related_instance_or_manager = ( + self.get_related_instance() + ) # Refresh instance result_serializer = self._instantiate_serializer(related_instance_or_manager) return Response(result_serializer.data) @@ -344,11 +358,13 @@ def post(self, request, *args, **kwargs): data=request.data, model_class=related_model_class, many=True ) serializer.is_valid(raise_exception=True) - if frozenset(serializer.validated_data) <= frozenset(related_instance_or_manager.all()): + if frozenset(serializer.validated_data) <= frozenset( + related_instance_or_manager.all() + ): return Response(status=204) related_instance_or_manager.add(*serializer.validated_data) else: - raise MethodNotAllowed('POST') + raise MethodNotAllowed("POST") result_serializer = self._instantiate_serializer(related_instance_or_manager) return Response(result_serializer.data) @@ -368,11 +384,11 @@ def delete(self, request, *args, **kwargs): related_instance_or_manager.remove(*serializer.validated_data) except AttributeError: raise Conflict( - 'This object cannot be removed from this relationship without being ' - 'added to another' + "This object cannot be removed from this relationship without being " + "added to another" ) else: - raise MethodNotAllowed('DELETE') + raise MethodNotAllowed("DELETE") result_serializer = self._instantiate_serializer(related_instance_or_manager) return Response(result_serializer.data) @@ -383,7 +399,7 @@ def get_related_instance(self): raise NotFound def get_related_field_name(self): - field_name = self.kwargs['related_field'] + field_name = self.kwargs["related_field"] if field_name in self.field_name_mapping: return self.field_name_mapping[field_name] return field_name @@ -398,7 +414,7 @@ def _instantiate_serializer(self, instance): return self.get_serializer(instance=instance, many=True) def get_resource_name(self): - if not hasattr(self, '_resource_name'): + if not hasattr(self, "_resource_name"): instance = getattr(self.get_object(), self.get_related_field_name()) self._resource_name = get_resource_type_from_instance(instance) return self._resource_name diff --git a/setup.py b/setup.py index 3bf1c728..a076a7a5 100755 --- a/setup.py +++ b/setup.py @@ -7,15 +7,15 @@ from setuptools import setup -needs_wheel = {'bdist_wheel'}.intersection(sys.argv) -wheel = ['wheel'] if needs_wheel else [] +needs_wheel = {"bdist_wheel"}.intersection(sys.argv) +wheel = ["wheel"] if needs_wheel else [] def read(*paths): """ Build a file path from paths and return the contents. """ - with open(os.path.join(*paths), 'r') as f: + with open(os.path.join(*paths), "r") as f: return f.read() @@ -23,7 +23,7 @@ def get_version(package): """ Return package version as listed in `__version__` in `init.py`. """ - init_py = open(os.path.join(package, '__init__.py')).read() + init_py = open(os.path.join(package, "__init__.py")).read() return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) @@ -31,9 +31,11 @@ def get_packages(package): """ Return root package and all sub-packages. """ - return [dirpath - for dirpath, dirnames, filenames in os.walk(package) - if os.path.exists(os.path.join(dirpath, '__init__.py'))] + return [ + dirpath + for dirpath, dirnames, filenames in os.walk(package) + if os.path.exists(os.path.join(dirpath, "__init__.py")) + ] def get_package_data(package): @@ -41,63 +43,67 @@ def get_package_data(package): Return all files under the root package, that are not in a package themselves. """ - walk = [(dirpath.replace(package + os.sep, '', 1), filenames) - for dirpath, dirnames, filenames in os.walk(package) - if not os.path.exists(os.path.join(dirpath, '__init__.py'))] + walk = [ + (dirpath.replace(package + os.sep, "", 1), filenames) + for dirpath, dirnames, filenames in os.walk(package) + if not os.path.exists(os.path.join(dirpath, "__init__.py")) + ] filepaths = [] for base, filenames in walk: - filepaths.extend([os.path.join(base, filename) - for filename in filenames]) + filepaths.extend([os.path.join(base, filename) for filename in filenames]) return {package: filepaths} -if sys.argv[-1] == 'publish': +if sys.argv[-1] == "publish": os.system("python setup.py sdist upload") os.system("python setup.py bdist_wheel upload") print("You probably want to also tag the version now:") - print(" git tag -a {0} -m 'version {0}'".format( - get_version('rest_framework_json_api'))) + print( + " git tag -a {0} -m 'version {0}'".format( + get_version("rest_framework_json_api") + ) + ) print(" git push --tags") sys.exit() setup( - name='djangorestframework-jsonapi', - version=get_version('rest_framework_json_api'), - url='https://github.com/django-json-api/django-rest-framework-json-api', - license='BSD', - description='A Django REST framework API adapter for the JSON API spec.', - long_description=read('README.rst'), - author='Jerel Unruh', - author_email='', - packages=get_packages('rest_framework_json_api'), - package_data=get_package_data('rest_framework_json_api'), + name="djangorestframework-jsonapi", + version=get_version("rest_framework_json_api"), + url="https://github.com/django-json-api/django-rest-framework-json-api", + license="BSD", + description="A Django REST framework API adapter for the JSON API spec.", + long_description=read("README.rst"), + author="Jerel Unruh", + author_email="", + packages=get_packages("rest_framework_json_api"), + package_data=get_package_data("rest_framework_json_api"), classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Web Environment', - 'Framework :: Django', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Software Development :: Libraries :: Application Frameworks', - 'Topic :: Software Development :: Libraries :: Python Modules', + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Topic :: Software Development :: Libraries :: Python Modules", ], install_requires=[ - 'inflection>=0.3.0', - 'djangorestframework>=3.12,<3.13', - 'django>=2.2,<3.2', + "inflection>=0.3.0", + "djangorestframework>=3.12,<3.13", + "django>=2.2,<3.2", ], extras_require={ - 'django-polymorphic': ['django-polymorphic>=2.0'], - 'django-filter': ['django-filter>=2.0'], - 'openapi': ['pyyaml>=5.3', 'uritemplate>=3.0.1'] + "django-polymorphic": ["django-polymorphic>=2.0"], + "django-filter": ["django-filter>=2.0"], + "openapi": ["pyyaml>=5.3", "uritemplate>=3.0.1"], }, setup_requires=wheel, python_requires=">=3.6", diff --git a/tests/models.py b/tests/models.py index e91911ce..3c3e6146 100644 --- a/tests/models.py +++ b/tests/models.py @@ -7,7 +7,7 @@ class DJAModel(models.Model): """ class Meta: - app_label = 'tests' + app_label = "tests" abstract = True @@ -23,7 +23,7 @@ class ManyToManyTarget(DJAModel): class ManyToManySource(DJAModel): name = models.CharField(max_length=100) - targets = models.ManyToManyField(ManyToManyTarget, related_name='sources') + targets = models.ManyToManyField(ManyToManyTarget, related_name="sources") # ForeignKey @@ -33,5 +33,6 @@ class ForeignKeyTarget(DJAModel): class ForeignKeySource(DJAModel): name = models.CharField(max_length=100) - target = models.ForeignKey(ForeignKeyTarget, related_name='sources', - on_delete=models.CASCADE) + target = models.ForeignKey( + ForeignKeyTarget, related_name="sources", on_delete=models.CASCADE + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8f272e1a..657f8160 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -12,7 +12,7 @@ format_value, get_included_serializers, get_related_resource_type, - get_resource_name + get_resource_name, ) from tests.models import ( BasicModel, @@ -20,7 +20,7 @@ ForeignKeySource, ForeignKeyTarget, ManyToManySource, - ManyToManyTarget + ManyToManyTarget, ) @@ -28,64 +28,79 @@ def test_get_resource_name_no_view(): assert get_resource_name({}) is None -@pytest.mark.parametrize("format_type,pluralize_type,output", [ - (False, False, 'APIView'), - (False, True, 'APIViews'), - ('dasherize', False, 'api-view'), - ('dasherize', True, 'api-views'), -]) +@pytest.mark.parametrize( + "format_type,pluralize_type,output", + [ + (False, False, "APIView"), + (False, True, "APIViews"), + ("dasherize", False, "api-view"), + ("dasherize", True, "api-views"), + ], +) def test_get_resource_name_from_view(settings, format_type, pluralize_type, output): settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type view = APIView() - context = {'view': view} + context = {"view": view} assert output == get_resource_name(context) -@pytest.mark.parametrize("format_type,pluralize_type", [ - (False, False), - (False, True), - ('dasherize', False), - ('dasherize', True), -]) -def test_get_resource_name_from_view_custom_resource_name(settings, format_type, pluralize_type): +@pytest.mark.parametrize( + "format_type,pluralize_type", + [ + (False, False), + (False, True), + ("dasherize", False), + ("dasherize", True), + ], +) +def test_get_resource_name_from_view_custom_resource_name( + settings, format_type, pluralize_type +): settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type view = APIView() - view.resource_name = 'custom' - context = {'view': view} - assert 'custom' == get_resource_name(context) - - -@pytest.mark.parametrize("format_type,pluralize_type,output", [ - (False, False, 'BasicModel'), - (False, True, 'BasicModels'), - ('dasherize', False, 'basic-model'), - ('dasherize', True, 'basic-models'), -]) + view.resource_name = "custom" + context = {"view": view} + assert "custom" == get_resource_name(context) + + +@pytest.mark.parametrize( + "format_type,pluralize_type,output", + [ + (False, False, "BasicModel"), + (False, True, "BasicModels"), + ("dasherize", False, "basic-model"), + ("dasherize", True, "basic-models"), + ], +) def test_get_resource_name_from_model(settings, format_type, pluralize_type, output): settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type view = APIView() view.model = BasicModel - context = {'view': view} + context = {"view": view} assert output == get_resource_name(context) -@pytest.mark.parametrize("format_type,pluralize_type,output", [ - (False, False, 'BasicModel'), - (False, True, 'BasicModels'), - ('dasherize', False, 'basic-model'), - ('dasherize', True, 'basic-models'), -]) -def test_get_resource_name_from_model_serializer_class(settings, format_type, - pluralize_type, output): +@pytest.mark.parametrize( + "format_type,pluralize_type,output", + [ + (False, False, "BasicModel"), + (False, True, "BasicModels"), + ("dasherize", False, "basic-model"), + ("dasherize", True, "basic-models"), + ], +) +def test_get_resource_name_from_model_serializer_class( + settings, format_type, pluralize_type, output +): class BasicModelSerializer(serializers.ModelSerializer): class Meta: - fields = ('text',) + fields = ("text",) model = BasicModel settings.JSON_API_FORMAT_TYPES = format_type @@ -93,22 +108,25 @@ class Meta: view = GenericAPIView() view.serializer_class = BasicModelSerializer - context = {'view': view} + context = {"view": view} assert output == get_resource_name(context) -@pytest.mark.parametrize("format_type,pluralize_type", [ - (False, False), - (False, True), - ('dasherize', False), - ('dasherize', True), -]) -def test_get_resource_name_from_model_serializer_class_custom_resource_name(settings, - format_type, - pluralize_type): +@pytest.mark.parametrize( + "format_type,pluralize_type", + [ + (False, False), + (False, True), + ("dasherize", False), + ("dasherize", True), + ], +) +def test_get_resource_name_from_model_serializer_class_custom_resource_name( + settings, format_type, pluralize_type +): class BasicModelSerializer(serializers.ModelSerializer): class Meta: - fields = ('text',) + fields = ("text",) model = BasicModel settings.JSON_API_FORMAT_TYPES = format_type @@ -116,22 +134,27 @@ class Meta: view = GenericAPIView() view.serializer_class = BasicModelSerializer - view.serializer_class.Meta.resource_name = 'custom' + view.serializer_class.Meta.resource_name = "custom" - context = {'view': view} - assert 'custom' == get_resource_name(context) + context = {"view": view} + assert "custom" == get_resource_name(context) -@pytest.mark.parametrize("format_type,pluralize_type", [ - (False, False), - (False, True), - ('dasherize', False), - ('dasherize', True), -]) -def test_get_resource_name_from_plain_serializer_class(settings, format_type, pluralize_type): +@pytest.mark.parametrize( + "format_type,pluralize_type", + [ + (False, False), + (False, True), + ("dasherize", False), + ("dasherize", True), + ], +) +def test_get_resource_name_from_plain_serializer_class( + settings, format_type, pluralize_type +): class PlainSerializer(serializers.Serializer): class Meta: - resource_name = 'custom' + resource_name = "custom" settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type @@ -139,61 +162,76 @@ class Meta: view = GenericAPIView() view.serializer_class = PlainSerializer - context = {'view': view} - assert 'custom' == get_resource_name(context) + context = {"view": view} + assert "custom" == get_resource_name(context) -@pytest.mark.parametrize("status_code", [ - status.HTTP_400_BAD_REQUEST, - status.HTTP_403_FORBIDDEN, - status.HTTP_500_INTERNAL_SERVER_ERROR -]) +@pytest.mark.parametrize( + "status_code", + [ + status.HTTP_400_BAD_REQUEST, + status.HTTP_403_FORBIDDEN, + status.HTTP_500_INTERNAL_SERVER_ERROR, + ], +) def test_get_resource_name_with_errors(status_code): view = APIView() - context = {'view': view} + context = {"view": view} view.response = Response(status=status_code) - assert 'errors' == get_resource_name(context) + assert "errors" == get_resource_name(context) -@pytest.mark.parametrize("format_type,output", [ - ('camelize', {'fullName': {'last-name': 'a', 'first-name': 'b'}}), - ('capitalize', {'FullName': {'last-name': 'a', 'first-name': 'b'}}), - ('dasherize', {'full-name': {'last-name': 'a', 'first-name': 'b'}}), - ('underscore', {'full_name': {'last-name': 'a', 'first-name': 'b'}}), -]) +@pytest.mark.parametrize( + "format_type,output", + [ + ("camelize", {"fullName": {"last-name": "a", "first-name": "b"}}), + ("capitalize", {"FullName": {"last-name": "a", "first-name": "b"}}), + ("dasherize", {"full-name": {"last-name": "a", "first-name": "b"}}), + ("underscore", {"full_name": {"last-name": "a", "first-name": "b"}}), + ], +) def test_format_field_names(settings, format_type, output): settings.JSON_API_FORMAT_FIELD_NAMES = format_type - value = {'full_name': {'last-name': 'a', 'first-name': 'b'}} + value = {"full_name": {"last-name": "a", "first-name": "b"}} assert format_field_names(value, format_type) == output -@pytest.mark.parametrize("format_type,output", [ - (None, 'first_name'), - ('camelize', 'firstName'), - ('capitalize', 'FirstName'), - ('dasherize', 'first-name'), - ('underscore', 'first_name') -]) +@pytest.mark.parametrize( + "format_type,output", + [ + (None, "first_name"), + ("camelize", "firstName"), + ("capitalize", "FirstName"), + ("dasherize", "first-name"), + ("underscore", "first_name"), + ], +) def test_format_value(settings, format_type, output): - assert format_value('first_name', format_type) == output + assert format_value("first_name", format_type) == output -@pytest.mark.parametrize("resource_type,pluralize,output", [ - (None, None, 'ResourceType'), - ('camelize', False, 'resourceType'), - ('camelize', True, 'resourceTypes'), -]) +@pytest.mark.parametrize( + "resource_type,pluralize,output", + [ + (None, None, "ResourceType"), + ("camelize", False, "resourceType"), + ("camelize", True, "resourceTypes"), + ], +) def test_format_resource_type(settings, resource_type, pluralize, output): - assert format_resource_type('ResourceType', resource_type, pluralize) == output + assert format_resource_type("ResourceType", resource_type, pluralize) == output -@pytest.mark.parametrize('model_class,field,output', [ - (ManyToManySource, 'targets', 'ManyToManyTarget'), - (ManyToManyTarget, 'sources', 'ManyToManySource'), - (ForeignKeySource, 'target', 'ForeignKeyTarget'), - (ForeignKeyTarget, 'sources', 'ForeignKeySource'), -]) +@pytest.mark.parametrize( + "model_class,field,output", + [ + (ManyToManySource, "targets", "ManyToManyTarget"), + (ManyToManyTarget, "sources", "ManyToManySource"), + (ForeignKeySource, "target", "ForeignKeyTarget"), + (ForeignKeyTarget, "sources", "ForeignKeySource"), + ], +) def test_get_related_resource_type(model_class, field, output): class RelatedResourceTypeSerializer(serializers.ModelSerializer): class Meta: @@ -212,29 +250,29 @@ class Meta: def test_get_included_serializers(): class IncludedSerializersModel(DJAModel): - self = models.ForeignKey('self', on_delete=models.CASCADE) + self = models.ForeignKey("self", on_delete=models.CASCADE) target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) other_target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) class Meta: - app_label = 'tests' + app_label = "tests" class IncludedSerializersSerializer(serializers.ModelSerializer): included_serializers = { - 'self': 'self', - 'target': ManyToManyTargetSerializer, - 'other_target': 'tests.test_utils.ManyToManyTargetSerializer' + "self": "self", + "target": ManyToManyTargetSerializer, + "other_target": "tests.test_utils.ManyToManyTargetSerializer", } class Meta: model = IncludedSerializersModel - fields = ('self', 'other_target', 'target') + fields = ("self", "other_target", "target") included_serializers = get_included_serializers(IncludedSerializersSerializer) expected_included_serializers = { - 'self': IncludedSerializersSerializer, - 'target': ManyToManyTargetSerializer, - 'other_target': ManyToManyTargetSerializer + "self": IncludedSerializersSerializer, + "target": ManyToManyTargetSerializer, + "other_target": ManyToManyTargetSerializer, } assert included_serializers == expected_included_serializers From c356806d9f8ad15669a049b6b4bb041a1c9ea482 Mon Sep 17 00:00:00 2001 From: Antonio Matinata Date: Thu, 3 Dec 2020 19:14:50 +0100 Subject: [PATCH 270/488] Replace dead filter links in documentation (#867) * Updated link to DRF OrderingFilter * Updated link to DRF SearchFilter Co-authored-by: mtnntn --- docs/usage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index afc8a7ac..415c492b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -135,7 +135,7 @@ simply don't use this filter backend. #### OrderingFilter `OrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses -DRF's [ordering filter](http://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#orderingfilter). +DRF's [ordering filter](https://www.django-rest-framework.org/api-guide/filtering/#orderingfilter). Per the JSON:API specification, "If the server does not support sorting as specified in the query parameter `sort`, it **MUST** return `400 Bad Request`." For example, for `?sort=abc,foo,def` where `foo` is a valid @@ -205,7 +205,7 @@ As this feature depends on `django-filter` you need to run #### SearchFilter To comply with JSON:API query parameter naming standards, DRF's -[SearchFilter](https://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#searchfilter) should +[SearchFilter](https://www.django-rest-framework.org/api-guide/filtering/#searchfilter) should be configured to use a `filter[_something_]` query parameter. This can be done by default by adding the SearchFilter to `REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']` and setting `REST_FRAMEWORK['SEARCH_PARAM']` or adding the `.search_param` attribute to a custom class derived from `SearchFilter`. If you do this and also From f26c6eceaa6104a95d2230d1906a236d6127c9b4 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 22 Nov 2020 21:48:48 +0100 Subject: [PATCH 271/488] Add pre-commit configuration --- .pre-commit-config.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..49f72fe2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: local + hooks: + - id: black + stages: [commit] + name: black + language: system + entry: black + types: [python] + - id: isort + stages: [commit] + name: isort + language: system + entry: isort + types: [python] + - id: flake8 + stages: [commit] + name: flake8 + language: system + entry: flake8 + types: [python] From acaec9508d3dd2f034d4e4dc9fd4fdde7592f08d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 22 Nov 2020 21:49:33 +0100 Subject: [PATCH 272/488] Incooperate pre-commit usage into CONTRIBUTING.md --- README.rst | 12 -------- docs/CONTRIBUTING.md | 65 ++++++++++++++++++++++++++++++-------------- 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/README.rst b/README.rst index 7ffbcc0e..3501e8a1 100644 --- a/README.rst +++ b/README.rst @@ -142,18 +142,6 @@ Browse to * http://localhost:8000/openapi for the schema view's OpenAPI specification document. -Running Tests and linting -^^^^^^^^^^^^^^^^^^^^^^^^^ - -It is recommended to create a virtualenv for testing. Assuming it is already -installed and activated: - -:: - - $ pip install -Ur requirements.txt - $ flake8 - $ pytest - ----- Usage ----- diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 5ab82991..5e09ef32 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,30 +1,53 @@ -Contributing -============ +# Contributing -DJA should be easy to contribute to. +Django REST Framework JSON API (aka DJA) should be easy to contribute to. If anything is unclear about how to contribute, please submit an issue on GitHub so that we can fix it! -How ---- +Before writing any code, have a conversation on a GitHub issue to see +if the proposed change makes sense for the project. -Before writing any code, -have a conversation on a GitHub issue -to see if the proposed change makes sense -for the project. +## Setup development environment -Fork DJA on [GitHub](https://github.com/django-json-api/django-rest-framework-json-api) and -[submit a Pull Request](https://help.github.com/articles/creating-a-pull-request/) -when you're ready. +### Clone -For maintainers ---------------- +To start developing on Django REST Framework JSON API you need to first clone the repository: -To upload a release (using version 1.2.3 as the example): + git clone https://github.com/django-json-api/django-rest-framework-json-api.git -```bash -(venv)$ python setup.py sdist bdist_wheel -(venv)$ twine upload dist/* -(venv)$ git tag -a v1.2.3 -m 'Release 1.2.3' -(venv)$ git push --tags -``` +### Testing + +To run tests clone the repository, and then: + + # Setup the virtual environment + python3 -m venv env + source env/bin/activate + pip install -r requirements.txt + + # Format code + black . + + # Run linting + flake8 + + # Run tests + pytest + +### Setup pre-commit + +pre-commit hooks is an additional option to check linting and formatting of code independent of +an editor before you commit your changes with git. + +To setup pre-commit hooks first create a testing environment as explained above before running below commands: + + pip install pre-commit + pre-commit install + +## For maintainers + +To upload a release (using version 1.2.3 as the example) first setup testing environment as above before running below commands: + + python setup.py sdist bdist_wheel + twine upload dist/* + git tag -a v1.2.3 -m 'Release 1.2.3' + git push --tags From 80fa5f4c54179c942e499b07115493623a5d6c08 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 3 Dec 2020 22:22:03 +0400 Subject: [PATCH 273/488] Document how to use tox --- docs/CONTRIBUTING.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 5e09ef32..c3f7d0f1 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -33,6 +33,13 @@ To run tests clone the repository, and then: # Run tests pytest +### Running against multiple environments + +You can also use the excellent [tox](https://tox.readthedocs.io/en/latest/) testing tool to run the tests against all supported versions of Python and Django. Install `tox` globally, and then simply run: + + tox + + ### Setup pre-commit pre-commit hooks is an additional option to check linting and formatting of code independent of From 3263061a486e7b6bbfff066620a605383714fdf6 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 7 Dec 2020 20:51:50 +0200 Subject: [PATCH 274/488] Scheduled biweekly dependency update for week 49 (#869) * Update sphinx from 3.3.0 to 3.3.1 * Update django-debug-toolbar from 3.1.1 to 3.2 * Update faker from 4.14.0 to 5.0.0 --- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index e8378b09..12dd5670 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.6.0 -Sphinx==3.3.0 +Sphinx==3.3.1 sphinx_rtd_theme==0.5.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 9508b0a3..66820c49 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ -django-debug-toolbar==3.1.1 +django-debug-toolbar==3.2 factory-boy==3.1.0 -Faker==4.14.0 +Faker==5.0.0 pytest==6.1.2 pytest-cov==2.10.1 pytest-django==4.1.0 From 3833271411c5199f782fc5c7842733bb44aadf9d Mon Sep 17 00:00:00 2001 From: Nathanael Gordon Date: Sat, 12 Dec 2020 04:44:15 +1100 Subject: [PATCH 275/488] Support many related fields on plain Serializers (#868) Support many related fields on plain Serializers Co-authored-by: Oliver Sauder --- CHANGELOG.md | 3 ++- rest_framework_json_api/utils.py | 5 +++++ tests/test_utils.py | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4613aa18..17a63357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] - TBD +## [Unreleased] ### Added @@ -17,6 +17,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Allow users to overwrite a view's `get_serializer_class()` method when using [related urls](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#related-urls) +* Correctly resolve the resource type of `ResourceRelatedField(many=True)` fields on plain serializers ## [4.0.0] - 2020-10-31 diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 41683303..f7ddac75 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -215,6 +215,11 @@ def get_related_resource_type(relation): return get_related_resource_type(parent_model_relation) if relation_model is None: + # For ManyRelatedFields on plain Serializers the resource_type + # cannot be determined from a model, so we must get it from the + # child_relation + if hasattr(relation, "child_relation"): + return get_related_resource_type(relation.child_relation) raise APIException( _("Could not resolve resource type for relation %s" % relation) ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 657f8160..a0e4773a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -243,6 +243,27 @@ class Meta: assert get_related_resource_type(field) == output +@pytest.mark.parametrize( + "related_field_kwargs,output", + [ + ({"queryset": BasicModel.objects}, "BasicModel"), + ({"queryset": BasicModel.objects, "model": BasicModel}, "BasicModel"), + ({"model": BasicModel, "read_only": True}, "BasicModel"), + ], +) +def test_get_related_resource_type_from_plain_serializer_class( + related_field_kwargs, output +): + class PlainRelatedResourceTypeSerializer(serializers.Serializer): + basic_models = serializers.ResourceRelatedField( + many=True, **related_field_kwargs + ) + + serializer = PlainRelatedResourceTypeSerializer() + field = serializer.fields["basic_models"] + assert get_related_resource_type(field) == output + + class ManyToManyTargetSerializer(serializers.ModelSerializer): class Meta: model = ManyToManyTarget From a59c96f239a299d8ea0b9a9eaf03a95e892824f8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 11 Dec 2020 22:22:03 +0400 Subject: [PATCH 276/488] Do not ignore F405 linting error Enforces proper imports --- example/settings/test.py | 2 +- rest_framework_json_api/serializers.py | 15 ++++++++++++++- setup.cfg | 5 +---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/example/settings/test.py b/example/settings/test.py index 5fa040d6..2b92b5b3 100644 --- a/example/settings/test.py +++ b/example/settings/test.py @@ -13,7 +13,7 @@ JSON_API_FORMAT_TYPES = "camelize" JSON_API_PLURALIZE_TYPES = True -REST_FRAMEWORK.update( +REST_FRAMEWORK.update( # noqa: F405 { # noqa "PAGE_SIZE": 1, } diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 50b546b6..56949818 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,9 +1,22 @@ +from collections import OrderedDict + import inflection from django.core.exceptions import ObjectDoesNotExist from django.db.models.query import QuerySet from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ParseError -from rest_framework.serializers import * # noqa: F403 + +# star import defined so `rest_framework_json_api.serializers` can be +# a simple drop in for `rest_framework.serializers` +from rest_framework.serializers import * # noqa: F401, F403 +from rest_framework.serializers import ( + BaseSerializer, + HyperlinkedModelSerializer, + ModelSerializer, + Serializer, + SerializerMetaclass, +) +from rest_framework.settings import api_settings from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.relations import ResourceRelatedField diff --git a/setup.cfg b/setup.cfg index 07b279c8..527ddd6b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,10 +10,7 @@ extend-ignore = # whitespace before ':' - disabled as not PEP8 compliant E203, # line too long (managed by black) - E501, - # usage of star imports - # TODO mark star imports directly in code to ignore this error - F405 + E501 exclude = build/lib, .eggs From 5a284a09b0c703f3caf9a37fa324d04ae4bbd00a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 12 Dec 2020 23:29:52 +0400 Subject: [PATCH 277/488] Add warning to docs on permission check with related urls --- docs/usage.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/usage.md b/docs/usage.md index 415c492b..0e724713 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -703,6 +703,14 @@ class OrderSerializer(serializers.HyperlinkedModelSerializer): } ``` +
+ Note: + Even though with related urls relations are served on different urls there are still served + by the same view. This means that the object permission check is performed on the parent object. + In other words when the parent object is accessible by the user the related object will be as well. +
+ + ### RelationshipView `rest_framework_json_api.views.RelationshipView` is used to build relationship views (see the From c503748c844a49e02a9b327c0c7abd3baf764cb0 Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Sun, 27 Dec 2020 12:48:22 -0600 Subject: [PATCH 278/488] Support formatting URL segments via new FORMAT_LINKS setting (#876) Fixes #790. --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/usage.md | 38 ++++++++++++++ rest_framework_json_api/relations.py | 11 ++--- rest_framework_json_api/settings.py | 1 + rest_framework_json_api/utils.py | 13 +++++ rest_framework_json_api/views.py | 4 +- tests/test_relations.py | 74 ++++++++++++++++++++++++++++ tests/test_utils.py | 16 ++++++ tests/test_views.py | 49 ++++++++++++++++++ 10 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 tests/test_relations.py create mode 100644 tests/test_views.py diff --git a/AUTHORS b/AUTHORS index d4c03b2c..11d8db8c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ Jason Housley Jerel Unruh Jonathan Senecal Joseba Mendivil +Kevin Partington Kieran Evans Léo S. Luc Cary diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a63357..858fd8d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Ability for the user to select `included_serializers` to apply when using `BrowsableAPI`, based on available `included_serializers` defined for the current endpoint. +* Ability for the user to format serializer properties in URL segments using the `JSON_API_FORMAT_LINKS` setting. ### Fixed diff --git a/docs/usage.md b/docs/usage.md index 415c492b..663d3b7f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -477,6 +477,44 @@ When set to pluralize: } ``` +#### Related URL segments + +Serializer properties in relationship and related resource URLs may be infected using the `JSON_API_FORMAT_LINKS` setting. + +``` python +JSON_API_FORMAT_LINKS = 'dasherize' +``` + +For example, with a serializer property `created_by` and with `'dasherize'` formatting: + +```json +{ + "data": { + "type": "comments", + "id": "1", + "attributes": { + "text": "Comments are fun!" + }, + "links": { + "self": "/comments/1" + }, + "relationships": { + "created_by": { + "links": { + "self": "/comments/1/relationships/created-by", + "related": "/comments/1/created-by" + } + } + } + }, + "links": { + "self": "/comments/1" + } +} +``` + +The relationship name is formatted by the `JSON_API_FORMAT_FIELD_NAMES` setting, but the URL segments are formatted by the `JSON_API_FORMAT_LINKS` setting. + ### Related fields #### ResourceRelatedField diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 2924cd56..c9dd765d 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -15,6 +15,7 @@ from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.utils import ( Hyperlink, + format_link_segment, get_included_serializers, get_resource_type_from_instance, get_resource_type_from_queryset, @@ -112,14 +113,10 @@ def get_links(self, obj=None, lookup_field="pk"): else view.kwargs[lookup_field] } + field_name = self.field_name if self.field_name else self.parent.field_name + self_kwargs = kwargs.copy() - self_kwargs.update( - { - "related_field": self.field_name - if self.field_name - else self.parent.field_name - } - ) + self_kwargs.update({"related_field": format_link_segment(field_name)}) self_link = self.get_url("self", self.self_link_view_name, self_kwargs, request) # Assuming RelatedField will be declared in two ways: diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 0384894c..e7cc3711 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -12,6 +12,7 @@ DEFAULTS = { "FORMAT_FIELD_NAMES": False, "FORMAT_TYPES": False, + "FORMAT_LINKS": False, "PLURALIZE_TYPES": False, "UNIFORM_EXCEPTIONS": False, } diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index f7ddac75..7b211207 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -148,6 +148,19 @@ def format_resource_type(value, format_type=None, pluralize=None): return inflection.pluralize(value) if pluralize else value +def format_link_segment(value, format_type=None): + """ + Takes a string value and returns it with formatted keys as set in `format_type` + or `JSON_API_FORMAT_LINKS`. + + :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' + """ + if format_type is None: + format_type = json_api_settings.FORMAT_LINKS + + return format_value(value, format_type) + + def get_related_resource_type(relation): from rest_framework_json_api.serializers import PolymorphicModelSerializer diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 7a558cbf..2f061cbb 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -25,6 +25,7 @@ from rest_framework_json_api.utils import ( Hyperlink, OrderedDict, + format_value, get_included_resources, get_resource_type_from_instance, ) @@ -185,7 +186,8 @@ def get_related_serializer_class(self): return parent_serializer_class def get_related_field_name(self): - return self.kwargs["related_field"] + field_name = self.kwargs["related_field"] + return format_value(field_name, "underscore") def get_related_instance(self): parent_obj = self.get_object() diff --git a/tests/test_relations.py b/tests/test_relations.py new file mode 100644 index 00000000..e9f3800b --- /dev/null +++ b/tests/test_relations.py @@ -0,0 +1,74 @@ +import pytest +from django.conf.urls import re_path +from rest_framework.routers import SimpleRouter + +from rest_framework_json_api.relations import HyperlinkedRelatedField +from rest_framework_json_api.views import ModelViewSet, RelationshipView + +from .models import BasicModel + + +@pytest.mark.urls(__name__) +@pytest.mark.parametrize( + "format_links,expected_url_segment", + [ + (None, "relatedField_name"), + ("dasherize", "related-field-name"), + ("camelize", "relatedFieldName"), + ("capitalize", "RelatedFieldName"), + ("underscore", "related_field_name"), + ], +) +def test_relationship_urls_respect_format_links( + settings, format_links, expected_url_segment +): + settings.JSON_API_FORMAT_LINKS = format_links + + model = BasicModel(text="Some text") + + field = HyperlinkedRelatedField( + self_link_view_name="basic-model-relationships", + related_link_view_name="basic-model-related", + read_only=True, + ) + field.field_name = "relatedField_name" + + expected = { + "self": f"/basic_models/{model.pk}/relationships/{expected_url_segment}/", + "related": f"/basic_models/{model.pk}/{expected_url_segment}/", + } + + actual = field.get_links(model) + + assert expected == actual + + +# Routing setup + + +class BasicModelViewSet(ModelViewSet): + class Meta: + model = BasicModel + + +class BasicModelRelationshipView(RelationshipView): + queryset = BasicModel.objects + + +router = SimpleRouter() +router.register(r"basic_models", BasicModelViewSet, basename="basic-model") + +urlpatterns = [ + re_path( + r"^basic_models/(?P[^/.]+)/(?P[^/.]+)/$", + BasicModelViewSet.as_view({"get": "retrieve_related"}), + name="basic-model-related", + ), + re_path( + r"^basic_models/(?P[^/.]+)/relationships/(?P[^/.]+)/$", + BasicModelRelationshipView.as_view(), + name="basic-model-relationships", + ), +] + +urlpatterns += router.urls diff --git a/tests/test_utils.py b/tests/test_utils.py index a0e4773a..449f515e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,6 +8,7 @@ from rest_framework_json_api import serializers from rest_framework_json_api.utils import ( format_field_names, + format_link_segment, format_resource_type, format_value, get_included_serializers, @@ -197,6 +198,21 @@ def test_format_field_names(settings, format_type, output): assert format_field_names(value, format_type) == output +@pytest.mark.parametrize( + "format_type,output", + [ + (None, "first_Name"), + ("camelize", "firstName"), + ("capitalize", "FirstName"), + ("dasherize", "first-name"), + ("underscore", "first_name"), + ], +) +def test_format_field_segment(settings, format_type, output): + settings.JSON_API_FORMAT_LINKS = format_type + assert format_link_segment("first_Name") == output + + @pytest.mark.parametrize( "format_type,output", [ diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 00000000..8241a10e --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,49 @@ +import pytest + +from rest_framework_json_api import serializers, views +from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.utils import format_value + +from .models import BasicModel + +related_model_field_name = "related_field_model" + + +@pytest.mark.parametrize( + "format_links", + [ + None, + "dasherize", + "camelize", + "capitalize", + "underscore", + ], +) +def test_get_related_field_name_handles_formatted_link_segments(format_links, rf): + url_segment = format_value(related_model_field_name, format_links) + + request = rf.get(f"/basic_models/1/{url_segment}") + + view = BasicModelFakeViewSet() + view.setup(request, related_field=url_segment) + + assert view.get_related_field_name() == related_model_field_name + + +class BasicModelSerializer(serializers.ModelSerializer): + related_model_field = ResourceRelatedField(queryset=BasicModel.objects) + + def __init__(self, *args, **kwargs): + # Intentionally setting field_name property to something that matches no format + self.related_model_field.field_name = related_model_field_name + super(BasicModelSerializer, self).__init(*args, **kwargs) + + class Meta: + model = BasicModel + + +class BasicModelFakeViewSet(views.ModelViewSet): + serializer_class = BasicModelSerializer + + def retrieve(self, request, *args, **kwargs): + pass From 5019b72a3bf555f05f1155049be71ca4244688c8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 28 Dec 2020 22:28:28 +0400 Subject: [PATCH 279/488] Use GitHub actions for CI instead of Travis --- .github/workflows/tests.yml | 50 +++++++++++++++++++++ .travis.yml | 88 ------------------------------------- README.rst | 5 ++- tox.ini | 17 +++++++ 4 files changed, 70 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..7e73beae --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,50 @@ +name: Tests +on: [push, pull_request] + +jobs: + test: + name: Run test + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.django-rest-framework == 'master' }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9"] + django: ["2.2", "3.0", "3.1"] + django-rest-framework: ["3.12", "master"] + 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 + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox + env: + DJANGO: ${{ matrix.django }} + DJANGO_REST_FRAMEWORK: ${{ matrix.django-rest-framework }} + check: + name: Run check + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + tox-env: ["black", "lint", "docs"] + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.6 + uses: actions/setup-python@v2 + with: + python-version: 3.6 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox + - name: Run lint + run: tox + env: + TOXENV: ${{ matrix.tox-env }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 65ea1cc8..00000000 --- a/.travis.yml +++ /dev/null @@ -1,88 +0,0 @@ -language: python -dist: xenial -sudo: required -cache: pip -# Favor explicit over implicit and use an explicit build matrix. -matrix: - allow_failures: - - env: TOXENV=py36-django22-drfmaster - - env: TOXENV=py37-django22-drfmaster - - env: TOXENV=py38-django22-drfmaster - - env: TOXENV=py39-django22-drfmaster - - env: TOXENV=py36-django30-drfmaster - - env: TOXENV=py37-django30-drfmaster - - env: TOXENV=py38-django30-drfmaster - - env: TOXENV=py39-django30-drfmaster - - env: TOXENV=py36-django31-drfmaster - - env: TOXENV=py37-django31-drfmaster - - env: TOXENV=py38-django31-drfmaster - - env: TOXENV=py39-django31-drfmaster - - include: - - python: 3.6 - env: TOXENV=black - - python: 3.6 - env: TOXENV=lint - - python: 3.6 - env: TOXENV=docs - - - python: 3.6 - env: TOXENV=py36-django22-drf312 - - python: 3.6 - env: TOXENV=py36-django22-drfmaster - - python: 3.6 - env: TOXENV=py36-django30-drf312 - - python: 3.6 - env: TOXENV=py36-django30-drfmaster - - python: 3.6 - env: TOXENV=py36-django31-drf312 - - python: 3.6 - env: TOXENV=py36-django31-drfmaster - - - python: 3.7 - env: TOXENV=py37-django22-drf312 - - python: 3.7 - env: TOXENV=py37-django22-drfmaster - - python: 3.7 - env: TOXENV=py37-django30-drf312 - - python: 3.7 - env: TOXENV=py37-django30-drfmaster - - python: 3.7 - env: TOXENV=py37-django31-drf312 - - python: 3.7 - env: TOXENV=py37-django31-drfmaster - - - python: 3.8 - env: TOXENV=py38-django22-drf312 - - python: 3.8 - env: TOXENV=py38-django22-drfmaster - - python: 3.8 - env: TOXENV=py38-django30-drf312 - - python: 3.8 - env: TOXENV=py38-django30-drfmaster - - python: 3.8 - env: TOXENV=py38-django31-drf312 - - python: 3.8 - env: TOXENV=py38-django31-drfmaster - - - python: 3.9 - env: TOXENV=py39-django22-drf312 - - python: 3.9 - env: TOXENV=py39-django22-drfmaster - - python: 3.9 - env: TOXENV=py39-django30-drf312 - - python: 3.9 - env: TOXENV=py39-django30-drfmaster - - python: 3.9 - env: TOXENV=py39-django31-drf312 - - python: 3.9 - env: TOXENV=py39-django31-drfmaster - - -install: - - pip install tox -script: - - tox -after_success: - - pip install codecov - - codecov -e TOXENV --required diff --git a/README.rst b/README.rst index 3501e8a1..86e20fd3 100644 --- a/README.rst +++ b/README.rst @@ -2,8 +2,9 @@ JSON API and Django Rest Framework ================================== -.. image:: https://travis-ci.com/django-json-api/django-rest-framework-json-api.svg?branch=master - :target: https://travis-ci.com/django-json-api/django-rest-framework-json-api +.. image:: hhttps://github.com/django-json-api/django-rest-framework-json-api/workflows/Tests/badge.svg + :alt: Tests + :target: https://github.com/django-json-api/django-rest-framework-json-api/actions .. image:: https://readthedocs.org/projects/django-rest-framework-json-api/badge/?version=latest :alt: Read the docs diff --git a/tox.ini b/tox.ini index dc808c01..dab1676c 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,23 @@ envlist = py{36,37,38,39}-django{22,30,31}-drf{312,master}, lint,docs +[gh-actions] +python = + 3.6: py36 + 3.7: py37 + 3.8: py38 + 3.9: py39 + +[gh-actions:env] +DJANGO = + 2.2: django22 + 3.0: django30 + 3.1: django31 + +DJANGO_REST_FRAMEWORK = + 3.12: drf312 + master: drfmaster + [testenv] deps = django22: Django>=2.2,<2.3 From d9cd0d362919b983d493964b6ee6e5d526c49d14 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 28 Dec 2020 23:25:36 +0400 Subject: [PATCH 280/488] Add codecov support --- .github/workflows/tests.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7e73beae..e3e786ae 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,10 @@ jobs: python-version: ["3.6", "3.7", "3.8", "3.9"] django: ["2.2", "3.0", "3.1"] django-rest-framework: ["3.12", "master"] + env: + PYTHON: ${{ matrix.python-version }} + DJANGO: ${{ matrix.django }} + DJANGO_REST_FRAMEWORK: ${{ matrix.django-rest-framework }} steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -24,9 +28,10 @@ jobs: pip install tox tox-gh-actions - name: Test with tox run: tox - env: - DJANGO: ${{ matrix.django }} - DJANGO_REST_FRAMEWORK: ${{ matrix.django-rest-framework }} + - name: Upload coverage report + uses: codecov/codecov-action@v1 + with: + env_vars: PYTHON,DJANGO,DJANGO_REST_FRAMEWORK check: name: Run check runs-on: ubuntu-latest From cfca644480adccabaf6eda7092b2397b5438c3eb Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Tue, 29 Dec 2020 04:01:58 -0600 Subject: [PATCH 281/488] Rename FORMAT_LINKS setting to FORMAT_RELATED_LINKS (#878) --- CHANGELOG.md | 2 +- docs/usage.md | 6 +++--- rest_framework_json_api/settings.py | 2 +- rest_framework_json_api/utils.py | 4 ++-- tests/test_relations.py | 8 ++++---- tests/test_utils.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 858fd8d9..13be06bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Ability for the user to select `included_serializers` to apply when using `BrowsableAPI`, based on available `included_serializers` defined for the current endpoint. -* Ability for the user to format serializer properties in URL segments using the `JSON_API_FORMAT_LINKS` setting. +* Ability for the user to format serializer properties in URL segments using the `JSON_API_FORMAT_RELATED_LINKS` setting. ### Fixed diff --git a/docs/usage.md b/docs/usage.md index ca0ccd22..81e52989 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -479,10 +479,10 @@ When set to pluralize: #### Related URL segments -Serializer properties in relationship and related resource URLs may be infected using the `JSON_API_FORMAT_LINKS` setting. +Serializer properties in relationship and related resource URLs may be infected using the `JSON_API_FORMAT_RELATED_LINKS` setting. ``` python -JSON_API_FORMAT_LINKS = 'dasherize' +JSON_API_FORMAT_RELATED_LINKS = 'dasherize' ``` For example, with a serializer property `created_by` and with `'dasherize'` formatting: @@ -513,7 +513,7 @@ For example, with a serializer property `created_by` and with `'dasherize'` form } ``` -The relationship name is formatted by the `JSON_API_FORMAT_FIELD_NAMES` setting, but the URL segments are formatted by the `JSON_API_FORMAT_LINKS` setting. +The relationship name is formatted by the `JSON_API_FORMAT_FIELD_NAMES` setting, but the URL segments are formatted by the `JSON_API_FORMAT_RELATED_LINKS` setting. ### Related fields diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index e7cc3711..0e790847 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -12,7 +12,7 @@ DEFAULTS = { "FORMAT_FIELD_NAMES": False, "FORMAT_TYPES": False, - "FORMAT_LINKS": False, + "FORMAT_RELATED_LINKS": False, "PLURALIZE_TYPES": False, "UNIFORM_EXCEPTIONS": False, } diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 7b211207..c87fa000 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -151,12 +151,12 @@ def format_resource_type(value, format_type=None, pluralize=None): def format_link_segment(value, format_type=None): """ Takes a string value and returns it with formatted keys as set in `format_type` - or `JSON_API_FORMAT_LINKS`. + or `JSON_API_FORMAT_RELATED_LINKS`. :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' """ if format_type is None: - format_type = json_api_settings.FORMAT_LINKS + format_type = json_api_settings.FORMAT_RELATED_LINKS return format_value(value, format_type) diff --git a/tests/test_relations.py b/tests/test_relations.py index e9f3800b..2565542f 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -10,7 +10,7 @@ @pytest.mark.urls(__name__) @pytest.mark.parametrize( - "format_links,expected_url_segment", + "format_related_links,expected_url_segment", [ (None, "relatedField_name"), ("dasherize", "related-field-name"), @@ -19,10 +19,10 @@ ("underscore", "related_field_name"), ], ) -def test_relationship_urls_respect_format_links( - settings, format_links, expected_url_segment +def test_relationship_urls_respect_format_related_links_setting( + settings, format_related_links, expected_url_segment ): - settings.JSON_API_FORMAT_LINKS = format_links + settings.JSON_API_FORMAT_RELATED_LINKS = format_related_links model = BasicModel(text="Some text") diff --git a/tests/test_utils.py b/tests/test_utils.py index 449f515e..f542ad7d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -209,7 +209,7 @@ def test_format_field_names(settings, format_type, output): ], ) def test_format_field_segment(settings, format_type, output): - settings.JSON_API_FORMAT_LINKS = format_type + settings.JSON_API_FORMAT_RELATED_LINKS = format_type assert format_link_segment("first_Name") == output From 6ea9e6a56b41a879557285884772e4899523740b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 2 Jan 2021 20:35:46 +0400 Subject: [PATCH 282/488] Calculate copyright date as it changes (#881) --- example/tests/test_serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index b3b1dc2d..ebadbedd 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -1,3 +1,4 @@ +from datetime import datetime from unittest import mock import pytest @@ -88,7 +89,7 @@ class Meta: [ ("name", "Some Blog"), ("tags", []), - ("copyright", 2020), + ("copyright", datetime.now().year), ("url", "http://testserver/blogs/1"), ] ), From 11368cafd374c3e5e13af5e39928051cb902fe49 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 2 Jan 2021 20:44:32 +0400 Subject: [PATCH 283/488] Adjust action badge image link to use correct svg (#880) Co-authored-by: Alan Crosswell --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 86e20fd3..2ab48598 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ JSON API and Django Rest Framework ================================== -.. image:: hhttps://github.com/django-json-api/django-rest-framework-json-api/workflows/Tests/badge.svg +.. image:: https://github.com/django-json-api/django-rest-framework-json-api/workflows/Tests/badge.svg :alt: Tests :target: https://github.com/django-json-api/django-rest-framework-json-api/actions From 236f0b016f12057eae1b4eff7fe5b61b6ae3b23e Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sat, 2 Jan 2021 18:59:02 +0200 Subject: [PATCH 284/488] Scheduled biweekly dependency update for week 51 (#875) * Update recommonmark from 0.6.0 to 0.7.1 * Update sphinx from 3.3.1 to 3.4.0 * Update faker from 5.0.0 to 5.0.2 * Update pytest from 6.1.2 to 6.2.1 Co-authored-by: Oliver Sauder Co-authored-by: Alan Crosswell --- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-testing.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 12dd5670..4099fc5f 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ -recommonmark==0.6.0 -Sphinx==3.3.1 +recommonmark==0.7.1 +Sphinx==3.4.0 sphinx_rtd_theme==0.5.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 66820c49..4c1bc8c4 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ django-debug-toolbar==3.2 factory-boy==3.1.0 -Faker==5.0.0 -pytest==6.1.2 +Faker==5.0.2 +pytest==6.2.1 pytest-cov==2.10.1 pytest-django==4.1.0 pytest-factoryboy==2.0.3 From 952e26b4d65ab13d0dcdd550d7d1aec8902e18ba Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 18 Jan 2021 18:11:05 +0200 Subject: [PATCH 285/488] Scheduled biweekly dependency update for week 03 (#884) * Update isort from 5.6.4 to 5.7.0 * Update sphinx from 3.4.0 to 3.4.3 * Update sphinx_rtd_theme from 0.5.0 to 0.5.1 * Update twine from 3.2.0 to 3.3.0 * Update factory-boy from 3.1.0 to 3.2.0 * Update faker from 5.0.2 to 5.6.1 * Update pytest-cov from 2.10.1 to 2.11.0 * Update pytest-factoryboy from 2.0.3 to 2.1.0 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index cdb8b514..db8fae19 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ black==20.8b1 flake8==3.8.4 flake8-isort==4.0.0 -isort==5.6.4 +isort==5.7.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 4099fc5f..607dfc7c 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==3.4.0 -sphinx_rtd_theme==0.5.0 +Sphinx==3.4.3 +sphinx_rtd_theme==0.5.1 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 7bc36909..889f179f 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.2.0 +twine==3.3.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 4c1bc8c4..5b0fc490 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2 -factory-boy==3.1.0 -Faker==5.0.2 +factory-boy==3.2.0 +Faker==5.6.1 pytest==6.2.1 -pytest-cov==2.10.1 +pytest-cov==2.11.0 pytest-django==4.1.0 -pytest-factoryboy==2.0.3 +pytest-factoryboy==2.1.0 snapshottest==0.6.0 From 5488b181a91554853c7e0aac8eea0f81a20ba761 Mon Sep 17 00:00:00 2001 From: Nikolai Date: Wed, 10 Feb 2021 21:40:23 +0300 Subject: [PATCH 286/488] Render meta_fields in included resources (#883) For consistency reasons the meta fields should also be rendered in included resources. --- AUTHORS | 1 + CHANGELOG.md | 1 + example/serializers.py | 11 +++++++ example/tests/integration/test_includes.py | 14 +++++++++ example/tests/test_format_keys.py | 1 + example/tests/test_serializers.py | 3 ++ .../tests/unit/test_renderer_class_methods.py | 25 +++++++++------- rest_framework_json_api/renderers.py | 29 +++++++++---------- 8 files changed, 60 insertions(+), 25 deletions(-) diff --git a/AUTHORS b/AUTHORS index 11d8db8c..c1a273c4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,6 +23,7 @@ Matt Layman Michael Haselton Mohammed Ali Zubair Nathanael Gordon +Nick Kozhenin Ola Tarkowska Oliver Sauder Raphael Cohen diff --git a/CHANGELOG.md b/CHANGELOG.md index 13be06bd..4f2a1a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ any parts of the framework not mentioned in the documentation should generally b * Allow users to overwrite a view's `get_serializer_class()` method when using [related urls](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#related-urls) * Correctly resolve the resource type of `ResourceRelatedField(many=True)` fields on plain serializers +* Render `meta_fields` in included resources ## [4.0.0] - 2020-10-31 diff --git a/example/serializers.py b/example/serializers.py index 7a923d4e..4d80c87c 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -251,6 +251,7 @@ class AuthorSerializer(serializers.ModelSerializer): write_only=True, help_text="help for defaults", ) + initials = serializers.SerializerMethodField() included_serializers = {"bio": AuthorBioSerializer, "type": AuthorTypeSerializer} related_serializers = { "bio": "example.serializers.AuthorBioSerializer", @@ -272,11 +273,16 @@ class Meta: "type", "secrets", "defaults", + "initials", ) + meta_fields = ("initials",) def get_first_entry(self, obj): return obj.entries.first() + def get_initials(self, obj): + return "".join([word[0] for word in obj.name.split(" ")]) + class AuthorListSerializer(AuthorSerializer): pass @@ -298,6 +304,7 @@ class Meta: class CommentSerializer(serializers.ModelSerializer): # testing remapping of related name writer = relations.ResourceRelatedField(source="author", read_only=True) + modified_days_ago = serializers.SerializerMethodField() included_serializers = { "entry": EntrySerializer, @@ -312,6 +319,10 @@ class Meta: "modified_at", ) # fields = ('entry', 'body', 'author',) + meta_fields = ("modified_days_ago",) + + def get_modified_days_ago(self, obj): + return (datetime.now() - obj.modified_at).days class ProjectTypeSerializer(serializers.ModelSerializer): diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index 0ee0d4fd..223645d2 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -261,3 +261,17 @@ def test_data_resource_not_included_again(single_comment, client): # The comment in the data attribute must not be included again. expected_comment_count -= 1 assert comment_count == expected_comment_count, "Comment count incorrect" + + +def test_meta_object_added_to_included_resources(single_entry, client): + response = client.get( + reverse("entry-detail", kwargs={"pk": single_entry.pk}) + "?include=comments" + ) + assert response.json()["included"][0].get("meta") + + response = client.get( + reverse("entry-detail", kwargs={"pk": single_entry.pk}) + + "?include=comments.author" + ) + assert response.json()["included"][0].get("meta") + assert response.json()["included"][1].get("meta") diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index 23a8a325..698a8bb1 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -63,5 +63,6 @@ def test_options_format_field_names(db, client): "comments", "secrets", "defaults", + "initials", } assert expected_keys == data["actions"]["POST"].keys() diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index ebadbedd..7550fd2c 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -195,6 +195,9 @@ def test_model_serializer_with_implicit_fields(self, comment, client): "data": {"type": "writers", "id": str(comment.author.pk)} }, }, + "meta": { + "modifiedDaysAgo": (datetime.now() - comment.modified_at).days + }, } } diff --git a/example/tests/unit/test_renderer_class_methods.py b/example/tests/unit/test_renderer_class_methods.py index c599abbd..51cc2481 100644 --- a/example/tests/unit/test_renderer_class_methods.py +++ b/example/tests/unit/test_renderer_class_methods.py @@ -3,6 +3,7 @@ from rest_framework_json_api import serializers from rest_framework_json_api.renderers import JSONRenderer +from rest_framework_json_api.utils import get_serializer_fields pytestmark = pytest.mark.django_db @@ -20,10 +21,7 @@ class Meta: def test_build_json_resource_obj(): - resource = { - "pk": 1, - "username": "Alice", - } + resource = {"username": "Alice", "version": "1.0.0"} serializer = ResourceSerializer(data={"username": "Alice"}) serializer.is_valid() @@ -33,11 +31,16 @@ def test_build_json_resource_obj(): "type": "user", "id": "1", "attributes": {"username": "Alice"}, + "meta": {"version": "1.0.0"}, } assert ( JSONRenderer.build_json_resource_obj( - serializer.fields, resource, resource_instance, "user" + get_serializer_fields(serializer), + resource, + resource_instance, + "user", + serializer, ) == output ) @@ -47,11 +50,8 @@ def test_can_override_methods(): """ Make sure extract_attributes and extract_relationships can be overriden. """ - resource = { - "pk": 1, - "username": "Alice", - } + resource = {"username": "Alice", "version": "1.0.0"} serializer = ResourceSerializer(data={"username": "Alice"}) serializer.is_valid() resource_instance = serializer.save() @@ -60,6 +60,7 @@ def test_can_override_methods(): "type": "user", "id": "1", "attributes": {"username": "Alice"}, + "meta": {"version": "1.0.0"}, } class CustomRenderer(JSONRenderer): @@ -80,7 +81,11 @@ def extract_relationships(cls, fields, resource, resource_instance): assert ( CustomRenderer.build_json_resource_obj( - serializer.fields, resource, resource_instance, "user" + get_serializer_fields(serializer), + resource, + resource_instance, + "user", + serializer, ) == output ) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 4733288f..e84c562b 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -373,11 +373,11 @@ def extract_included( serializer_resource, nested_resource_instance, resource_type, + serializer, getattr(serializer, "_poly_force_type_resolution", False), ) - included_cache[new_item["type"]][ - new_item["id"] - ] = utils.format_field_names(new_item) + included_cache[new_item["type"]][new_item["id"]] = new_item + cls.extract_included( serializer_fields, serializer_resource, @@ -397,11 +397,11 @@ def extract_included( serializer_data, relation_instance, relation_type, + field, getattr(field, "_poly_force_type_resolution", False), ) - included_cache[new_item["type"]][ - new_item["id"] - ] = utils.format_field_names(new_item) + included_cache[new_item["type"]][new_item["id"]] = new_item + cls.extract_included( serializer_fields, serializer_data, @@ -450,6 +450,7 @@ def build_json_resource_obj( resource, resource_instance, resource_name, + serializer, force_type_resolution=False, ): """ @@ -476,6 +477,11 @@ def build_json_resource_obj( resource_data.append( ("links", {"self": resource[api_settings.URL_FIELD_NAME]}) ) + + meta = cls.extract_meta(serializer, resource) + if meta: + resource_data.append(("meta", utils.format_field_names(meta))) + return OrderedDict(resource_data) def render_relationship_view( @@ -582,13 +588,9 @@ def render(self, data, accepted_media_type=None, renderer_context=None): resource, resource_instance, resource_name, + serializer, force_type_resolution, ) - meta = self.extract_meta(serializer, resource) - if meta: - json_resource_obj.update( - {"meta": utils.format_field_names(meta)} - ) json_api_data.append(json_resource_obj) self.extract_included( @@ -610,13 +612,10 @@ def render(self, data, accepted_media_type=None, renderer_context=None): serializer_data, resource_instance, resource_name, + serializer, force_type_resolution, ) - meta = self.extract_meta(serializer, serializer_data) - if meta: - json_api_data.update({"meta": utils.format_field_names(meta)}) - self.extract_included( fields, serializer_data, From aedc5d9d24a4699a30d9233e5701f7a82c170dcc Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 15 Feb 2021 22:30:50 +0400 Subject: [PATCH 287/488] Convert `ResourceRelatedField` test to pytest styled tests (#887) --- example/settings/dev.py | 1 + example/tests/test_relations.py | 113 ---------------------- tests/conftest.py | 22 +++++ tests/serializers.py | 44 +++++++++ tests/test_relations.py | 162 +++++++++++++++++++++++++++++++- tests/test_utils.py | 18 +--- 6 files changed, 229 insertions(+), 131 deletions(-) create mode 100644 tests/serializers.py diff --git a/example/settings/dev.py b/example/settings/dev.py index 2db2c259..12bed35c 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -27,6 +27,7 @@ "example", "debug_toolbar", "django_filters", + "tests", ] TEMPLATES = [ diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py index b83bbef4..6d1cf83b 100644 --- a/example/tests/test_relations.py +++ b/example/tests/test_relations.py @@ -6,130 +6,17 @@ from rest_framework.fields import SkipField from rest_framework.reverse import reverse -from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.relations import ( HyperlinkedRelatedField, ResourceRelatedField, SerializerMethodHyperlinkedRelatedField, ) -from rest_framework_json_api.utils import format_resource_type from . import TestBase from example.models import Author, Blog, Comment, Entry -from example.serializers import CommentSerializer from example.views import EntryViewSet -class TestResourceRelatedField(TestBase): - def setUp(self): - super(TestResourceRelatedField, self).setUp() - self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") - self.entry = Entry.objects.create( - blog=self.blog, - headline="headline", - body_text="body_text", - pub_date=timezone.now(), - mod_date=timezone.now(), - n_comments=0, - n_pingbacks=0, - rating=3, - ) - for i in range(1, 6): - name = "some_author{}".format(i) - self.entry.authors.add( - Author.objects.create(name=name, email="{}@example.org".format(name)) - ) - - self.comment = Comment.objects.create( - entry=self.entry, - body="testing one two three", - author=Author.objects.first(), - ) - - def test_data_in_correct_format_when_instantiated_with_blog_object(self): - serializer = BlogFKSerializer(instance={"blog": self.blog}) - - expected_data = {"type": format_resource_type("Blog"), "id": str(self.blog.id)} - - actual_data = serializer.data["blog"] - - self.assertEqual(actual_data, expected_data) - - def test_data_in_correct_format_when_instantiated_with_entry_object(self): - serializer = EntryFKSerializer(instance={"entry": self.entry}) - - expected_data = { - "type": format_resource_type("Entry"), - "id": str(self.entry.id), - } - - actual_data = serializer.data["entry"] - - self.assertEqual(actual_data, expected_data) - - def test_deserialize_primitive_data_blog(self): - serializer = BlogFKSerializer( - data={ - "blog": {"type": format_resource_type("Blog"), "id": str(self.blog.id)} - } - ) - - self.assertTrue(serializer.is_valid()) - self.assertEqual(serializer.validated_data["blog"], self.blog) - - def test_validation_fails_for_wrong_type(self): - with self.assertRaises(Conflict) as cm: - serializer = BlogFKSerializer( - data={"blog": {"type": "Entries", "id": str(self.blog.id)}} - ) - serializer.is_valid() - the_exception = cm.exception - self.assertEqual(the_exception.status_code, 409) - - def test_serialize_many_to_many_relation(self): - serializer = EntryModelSerializer(instance=self.entry) - - type_string = format_resource_type("Author") - author_pks = Author.objects.values_list("pk", flat=True) - expected_data = [{"type": type_string, "id": str(pk)} for pk in author_pks] - - self.assertEqual(serializer.data["authors"], expected_data) - - def test_deserialize_many_to_many_relation(self): - type_string = format_resource_type("Author") - author_pks = Author.objects.values_list("pk", flat=True) - authors = [{"type": type_string, "id": pk} for pk in author_pks] - - serializer = EntryModelSerializer(data={"authors": authors, "comments": []}) - - self.assertTrue(serializer.is_valid()) - self.assertEqual( - len(serializer.validated_data["authors"]), Author.objects.count() - ) - for author in serializer.validated_data["authors"]: - self.assertIsInstance(author, Author) - - def test_read_only(self): - serializer = EntryModelSerializer( - data={"authors": [], "comments": [{"type": "Comments", "id": 2}]} - ) - serializer.is_valid(raise_exception=True) - self.assertNotIn("comments", serializer.validated_data) - - def test_invalid_resource_id_object(self): - comment = { - "body": "testing 123", - "entry": {"type": "entry"}, - "author": {"id": "5"}, - } - serializer = CommentSerializer(data=comment) - assert not serializer.is_valid() - assert serializer.errors == { - "author": ["Invalid resource identifier object: missing 'type' attribute"], - "entry": ["Invalid resource identifier object: missing 'id' attribute"], - } - - class TestHyperlinkedFieldBase(TestBase): def setUp(self): super(TestHyperlinkedFieldBase, self).setUp() diff --git a/tests/conftest.py b/tests/conftest.py index 4942bf63..fbd3f453 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ import pytest +from tests.models import ForeignKeyTarget, ManyToManySource, ManyToManyTarget + @pytest.fixture(autouse=True) def use_rest_framework_json_api_defaults(settings): @@ -19,3 +21,23 @@ def use_rest_framework_json_api_defaults(settings): settings.JSON_API_FORMAT_FIELD_NAMES = False settings.JSON_API_FORMAT_TYPES = False settings.JSON_API_PLURALIZE_TYPES = False + + +@pytest.fixture +def foreign_key_target(db): + return ForeignKeyTarget.objects.create(name="Target") + + +@pytest.fixture +def many_to_many_source(db, many_to_many_targets): + source = ManyToManySource.objects.create(name="Source") + source.targets.add(*many_to_many_targets) + return source + + +@pytest.fixture +def many_to_many_targets(db): + return [ + ManyToManyTarget.objects.create(name="Target1"), + ManyToManyTarget.objects.create(name="Target2"), + ] diff --git a/tests/serializers.py b/tests/serializers.py new file mode 100644 index 00000000..8894a211 --- /dev/null +++ b/tests/serializers.py @@ -0,0 +1,44 @@ +from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.serializers import ModelSerializer +from tests.models import ( + BasicModel, + ForeignKeySource, + ForeignKeyTarget, + ManyToManySource, + ManyToManyTarget, +) + + +class BasicModelSerializer(ModelSerializer): + class Meta: + fields = ("text",) + model = BasicModel + + +class ForeignKeySourceSerializer(ModelSerializer): + target = ResourceRelatedField(queryset=ForeignKeyTarget.objects) + + class Meta: + model = ForeignKeySource + fields = ("target",) + + +class ManyToManySourceSerializer(ModelSerializer): + targets = ResourceRelatedField(many=True, queryset=ManyToManyTarget.objects) + + class Meta: + model = ManyToManySource + fields = ("targets",) + + +class ManyToManyTargetSerializer(ModelSerializer): + class Meta: + model = ManyToManyTarget + + +class ManyToManySourceReadOnlySerializer(ModelSerializer): + targets = ResourceRelatedField(many=True, read_only=True) + + class Meta: + model = ManyToManySource + fields = ("targets",) diff --git a/tests/test_relations.py b/tests/test_relations.py index 2565542f..4cc9833d 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -1,11 +1,17 @@ import pytest from django.conf.urls import re_path +from rest_framework import status from rest_framework.routers import SimpleRouter +from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.relations import HyperlinkedRelatedField from rest_framework_json_api.views import ModelViewSet, RelationshipView - -from .models import BasicModel +from tests.models import BasicModel +from tests.serializers import ( + ForeignKeySourceSerializer, + ManyToManySourceReadOnlySerializer, + ManyToManySourceSerializer, +) @pytest.mark.urls(__name__) @@ -43,6 +49,158 @@ def test_relationship_urls_respect_format_related_links_setting( assert expected == actual +@pytest.mark.django_db +class TestResourceRelatedField: + @pytest.mark.parametrize( + "format_type,pluralize_type,resource_type", + [ + (False, False, "ForeignKeyTarget"), + (False, True, "ForeignKeyTargets"), + ("dasherize", False, "foreign-key-target"), + ("dasherize", True, "foreign-key-targets"), + ], + ) + def test_serialize( + self, format_type, pluralize_type, resource_type, foreign_key_target, settings + ): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + serializer = ForeignKeySourceSerializer(instance={"target": foreign_key_target}) + expected = { + "type": resource_type, + "id": str(foreign_key_target.pk), + } + + assert serializer.data["target"] == expected + + @pytest.mark.parametrize( + "format_type,pluralize_type,resource_type", + [ + (False, False, "ForeignKeyTarget"), + (False, True, "ForeignKeyTargets"), + ("dasherize", False, "foreign-key-target"), + ("dasherize", True, "foreign-key-targets"), + ], + ) + def test_deserialize( + self, format_type, pluralize_type, resource_type, foreign_key_target, settings + ): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + serializer = ForeignKeySourceSerializer( + data={"target": {"type": resource_type, "id": str(foreign_key_target.pk)}} + ) + + assert serializer.is_valid() + assert serializer.validated_data["target"] == foreign_key_target + + @pytest.mark.parametrize( + "format_type,pluralize_type,resource_type", + [ + (False, False, "ForeignKeyTargets"), + (False, False, "Invalid"), + (False, False, "foreign-key-target"), + (False, True, "ForeignKeyTarget"), + ("dasherize", False, "ForeignKeyTarget"), + ("dasherize", True, "ForeignKeyTargets"), + ], + ) + def test_validation_fails_on_invalid_type( + self, format_type, pluralize_type, resource_type, foreign_key_target, settings + ): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + with pytest.raises(Conflict) as e: + serializer = ForeignKeySourceSerializer( + data={ + "target": {"type": resource_type, "id": str(foreign_key_target.pk)} + } + ) + serializer.is_valid() + assert e.value.status_code == status.HTTP_409_CONFLICT + + @pytest.mark.parametrize( + "format_type,pluralize_type,resource_type", + [ + (False, False, "ManyToManyTarget"), + (False, True, "ManyToManyTargets"), + ("dasherize", False, "many-to-many-target"), + ("dasherize", True, "many-to-many-targets"), + ], + ) + def test_serialize_many_to_many_relation( + self, + format_type, + pluralize_type, + resource_type, + many_to_many_source, + many_to_many_targets, + settings, + ): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + serializer = ManyToManySourceSerializer(instance=many_to_many_source) + expected = [ + {"type": resource_type, "id": str(target.pk)} + for target in many_to_many_targets + ] + assert serializer.data["targets"] == expected + + @pytest.mark.parametrize( + "format_type,pluralize_type,resource_type", + [ + (False, False, "ManyToManyTarget"), + (False, True, "ManyToManyTargets"), + ("dasherize", False, "many-to-many-target"), + ("dasherize", True, "many-to-many-targets"), + ], + ) + @pytest.mark.parametrize( + "serializer_class", + [ManyToManySourceSerializer, ManyToManySourceReadOnlySerializer], + ) + def test_deserialize_many_to_many_relation( + self, + format_type, + pluralize_type, + resource_type, + serializer_class, + many_to_many_targets, + settings, + ): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + targets = [ + {"type": resource_type, "id": target.pk} for target in many_to_many_targets + ] + serializer = ManyToManySourceSerializer(data={"targets": targets}) + assert serializer.is_valid() + assert serializer.validated_data["targets"] == many_to_many_targets + + @pytest.mark.parametrize( + "resource_identifier,error", + [ + ( + {"type": "ForeignKeyTarget"}, + "Invalid resource identifier object: missing 'id' attribute", + ), + ( + {"id": "1234"}, + "Invalid resource identifier object: missing 'type' attribute", + ), + ], + ) + def test_invalid_resource_id_object(self, resource_identifier, error): + serializer = ForeignKeySourceSerializer(data={"target": resource_identifier}) + assert not serializer.is_valid() + assert serializer.errors == {"target": [error]} + + # Routing setup diff --git a/tests/test_utils.py b/tests/test_utils.py index f542ad7d..40f9d391 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -23,6 +23,7 @@ ManyToManySource, ManyToManyTarget, ) +from tests.serializers import BasicModelSerializer, ManyToManyTargetSerializer def test_get_resource_name_no_view(): @@ -99,11 +100,6 @@ def test_get_resource_name_from_model(settings, format_type, pluralize_type, out def test_get_resource_name_from_model_serializer_class( settings, format_type, pluralize_type, output ): - class BasicModelSerializer(serializers.ModelSerializer): - class Meta: - fields = ("text",) - model = BasicModel - settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type @@ -125,11 +121,6 @@ class Meta: def test_get_resource_name_from_model_serializer_class_custom_resource_name( settings, format_type, pluralize_type ): - class BasicModelSerializer(serializers.ModelSerializer): - class Meta: - fields = ("text",) - model = BasicModel - settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type @@ -280,11 +271,6 @@ class PlainRelatedResourceTypeSerializer(serializers.Serializer): assert get_related_resource_type(field) == output -class ManyToManyTargetSerializer(serializers.ModelSerializer): - class Meta: - model = ManyToManyTarget - - def test_get_included_serializers(): class IncludedSerializersModel(DJAModel): self = models.ForeignKey("self", on_delete=models.CASCADE) @@ -298,7 +284,7 @@ class IncludedSerializersSerializer(serializers.ModelSerializer): included_serializers = { "self": "self", "target": ManyToManyTargetSerializer, - "other_target": "tests.test_utils.ManyToManyTargetSerializer", + "other_target": "tests.serializers.ManyToManyTargetSerializer", } class Meta: From 843e1fb471ed19befa6957b5e3573144e7119c11 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 8 Mar 2021 22:06:11 +0400 Subject: [PATCH 288/488] Release 4.1.0 (#892) --- CHANGELOG.md | 2 +- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f2a1a6e..aedc3597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [4.1.0] = 2021-03-10 ### Added diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 4c4f115d..3ce910ef 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = "djangorestframework-jsonapi" -__version__ = "4.0.0" +__version__ = "4.1.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From 1de9d1875d5a9c6232d841eb17fb6e0c0ebe9f6c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 8 Mar 2021 22:13:05 +0400 Subject: [PATCH 289/488] Adjust date of version 4.1.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aedc3597..c64e4a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [4.1.0] = 2021-03-10 +## [4.1.0] - 2021-03-08 ### Added From 3224db1da59e502ced70a81df0fe95a860d8fe92 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 8 Mar 2021 22:52:03 +0400 Subject: [PATCH 290/488] Convert HyperlinkedRelatedField tests to pytest style (#889) --- example/tests/test_relations.py | 195 -------------------------------- tests/conftest.py | 12 +- tests/test_relations.py | 134 ++++++++++++++++------ 3 files changed, 109 insertions(+), 232 deletions(-) delete mode 100644 example/tests/test_relations.py diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py deleted file mode 100644 index 6d1cf83b..00000000 --- a/example/tests/test_relations.py +++ /dev/null @@ -1,195 +0,0 @@ -from __future__ import absolute_import - -from django.test.client import RequestFactory -from django.utils import timezone -from rest_framework import serializers -from rest_framework.fields import SkipField -from rest_framework.reverse import reverse - -from rest_framework_json_api.relations import ( - HyperlinkedRelatedField, - ResourceRelatedField, - SerializerMethodHyperlinkedRelatedField, -) - -from . import TestBase -from example.models import Author, Blog, Comment, Entry -from example.views import EntryViewSet - - -class TestHyperlinkedFieldBase(TestBase): - def setUp(self): - super(TestHyperlinkedFieldBase, self).setUp() - self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") - self.entry = Entry.objects.create( - blog=self.blog, - headline="headline", - body_text="body_text", - pub_date=timezone.now(), - mod_date=timezone.now(), - n_comments=0, - n_pingbacks=0, - rating=3, - ) - self.comment = Comment.objects.create( - entry=self.entry, - body="testing one two three", - ) - - self.request = RequestFactory().get( - reverse("entry-detail", kwargs={"pk": self.entry.pk}) - ) - self.view = EntryViewSet( - request=self.request, kwargs={"entry_pk": self.entry.id} - ) - - -class TestHyperlinkedRelatedField(TestHyperlinkedFieldBase): - def test_single_hyperlinked_related_field(self): - field = HyperlinkedRelatedField( - related_link_view_name="entry-blog", - related_link_url_kwarg="entry_pk", - self_link_view_name="entry-relationships", - read_only=True, - ) - field._context = {"request": self.request, "view": self.view} - field.field_name = "blog" - - self.assertRaises(NotImplementedError, field.to_representation, self.entry) - self.assertRaises(SkipField, field.get_attribute, self.entry) - - links_expected = { - "self": "http://testserver/entries/{}/relationships/blog".format( - self.entry.pk - ), - "related": "http://testserver/entries/{}/blog".format(self.entry.pk), - } - got = field.get_links(self.entry) - self.assertEqual(got, links_expected) - - def test_many_hyperlinked_related_field(self): - field = HyperlinkedRelatedField( - related_link_view_name="entry-comments", - related_link_url_kwarg="entry_pk", - self_link_view_name="entry-relationships", - read_only=True, - many=True, - ) - field._context = {"request": self.request, "view": self.view} - field.field_name = "comments" - - self.assertRaises( - NotImplementedError, field.to_representation, self.entry.comments.all() - ) - self.assertRaises(SkipField, field.get_attribute, self.entry) - - links_expected = { - "self": "http://testserver/entries/{}/relationships/comments".format( - self.entry.pk - ), - "related": "http://testserver/entries/{}/comments".format(self.entry.pk), - } - got = field.child_relation.get_links(self.entry) - self.assertEqual(got, links_expected) - - -class TestSerializerMethodHyperlinkedRelatedField(TestHyperlinkedFieldBase): - def test_single_serializer_method_hyperlinked_related_field(self): - serializer = EntryModelSerializerWithHyperLinks( - instance=self.entry, context={"request": self.request, "view": self.view} - ) - field = serializer.fields["blog"] - - self.assertRaises(NotImplementedError, field.to_representation, self.entry) - self.assertRaises(SkipField, field.get_attribute, self.entry) - - expected = { - "self": "http://testserver/entries/{}/relationships/blog".format( - self.entry.pk - ), - "related": "http://testserver/entries/{}/blog".format(self.entry.pk), - } - got = field.get_links(self.entry) - self.assertEqual(got, expected) - - def test_many_serializer_method_hyperlinked_related_field(self): - serializer = EntryModelSerializerWithHyperLinks( - instance=self.entry, context={"request": self.request, "view": self.view} - ) - field = serializer.fields["comments"] - - self.assertRaises(NotImplementedError, field.to_representation, self.entry) - self.assertRaises(SkipField, field.get_attribute, self.entry) - - expected = { - "self": "http://testserver/entries/{}/relationships/comments".format( - self.entry.pk - ), - "related": "http://testserver/entries/{}/comments".format(self.entry.pk), - } - got = field.get_links(self.entry) - self.assertEqual(got, expected) - - def test_get_blog(self): - serializer = EntryModelSerializerWithHyperLinks(instance=self.entry) - got = serializer.get_blog(self.entry) - expected = self.entry.blog - - self.assertEqual(got, expected) - - def test_get_comments(self): - serializer = EntryModelSerializerWithHyperLinks(instance=self.entry) - got = serializer.get_comments(self.entry) - expected = self.entry.comments.all() - - self.assertListEqual(list(got), list(expected)) - - -class BlogResourceRelatedField(ResourceRelatedField): - def get_queryset(self): - return Blog.objects - - -class BlogFKSerializer(serializers.Serializer): - blog = BlogResourceRelatedField() - - -class EntryFKSerializer(serializers.Serializer): - entry = ResourceRelatedField(queryset=Entry.objects) - - -class EntryModelSerializer(serializers.ModelSerializer): - authors = ResourceRelatedField(many=True, queryset=Author.objects) - comments = ResourceRelatedField(many=True, read_only=True) - - class Meta: - model = Entry - fields = ("authors", "comments") - - -class EntryModelSerializerWithHyperLinks(serializers.ModelSerializer): - blog = SerializerMethodHyperlinkedRelatedField( - related_link_view_name="entry-blog", - related_link_url_kwarg="entry_pk", - self_link_view_name="entry-relationships", - many=True, - ) - comments = SerializerMethodHyperlinkedRelatedField( - related_link_view_name="entry-comments", - related_link_url_kwarg="entry_pk", - self_link_view_name="entry-relationships", - many=True, - ) - - class Meta: - model = Entry - fields = ( - "blog", - "comments", - ) - - def get_blog(self, obj): - return obj.blog - - def get_comments(self, obj): - return obj.comments.all() diff --git a/tests/conftest.py b/tests/conftest.py index fbd3f453..22be93a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,11 @@ import pytest -from tests.models import ForeignKeyTarget, ManyToManySource, ManyToManyTarget +from tests.models import ( + BasicModel, + ForeignKeyTarget, + ManyToManySource, + ManyToManyTarget, +) @pytest.fixture(autouse=True) @@ -23,6 +28,11 @@ def use_rest_framework_json_api_defaults(settings): settings.JSON_API_PLURALIZE_TYPES = False +@pytest.fixture +def model(db): + return BasicModel.objects.create(text="Model") + + @pytest.fixture def foreign_key_target(db): return ForeignKeyTarget.objects.create(name="Target") diff --git a/tests/test_relations.py b/tests/test_relations.py index 4cc9833d..d66c602f 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -1,10 +1,16 @@ import pytest from django.conf.urls import re_path from rest_framework import status +from rest_framework.fields import SkipField from rest_framework.routers import SimpleRouter +from rest_framework.serializers import Serializer from rest_framework_json_api.exceptions import Conflict -from rest_framework_json_api.relations import HyperlinkedRelatedField +from rest_framework_json_api.relations import ( + HyperlinkedRelatedField, + SerializerMethodHyperlinkedRelatedField, +) +from rest_framework_json_api.utils import format_link_segment from rest_framework_json_api.views import ModelViewSet, RelationshipView from tests.models import BasicModel from tests.serializers import ( @@ -14,41 +20,6 @@ ) -@pytest.mark.urls(__name__) -@pytest.mark.parametrize( - "format_related_links,expected_url_segment", - [ - (None, "relatedField_name"), - ("dasherize", "related-field-name"), - ("camelize", "relatedFieldName"), - ("capitalize", "RelatedFieldName"), - ("underscore", "related_field_name"), - ], -) -def test_relationship_urls_respect_format_related_links_setting( - settings, format_related_links, expected_url_segment -): - settings.JSON_API_FORMAT_RELATED_LINKS = format_related_links - - model = BasicModel(text="Some text") - - field = HyperlinkedRelatedField( - self_link_view_name="basic-model-relationships", - related_link_view_name="basic-model-related", - read_only=True, - ) - field.field_name = "relatedField_name" - - expected = { - "self": f"/basic_models/{model.pk}/relationships/{expected_url_segment}/", - "related": f"/basic_models/{model.pk}/{expected_url_segment}/", - } - - actual = field.get_links(model) - - assert expected == actual - - @pytest.mark.django_db class TestResourceRelatedField: @pytest.mark.parametrize( @@ -201,6 +172,97 @@ def test_invalid_resource_id_object(self, resource_identifier, error): assert serializer.errors == {"target": [error]} +class TestHyperlinkedRelatedField: + @pytest.fixture + def instance(self): + # dummy instance + return object() + + @pytest.fixture + def serializer(self): + class HyperlinkedRelatedFieldSerializer(Serializer): + single = HyperlinkedRelatedField( + self_link_view_name="basic-model-relationships", + related_link_view_name="basic-model-related", + read_only=True, + ) + many = HyperlinkedRelatedField( + self_link_view_name="basic-model-relationships", + related_link_view_name="basic-model-related", + read_only=True, + many=True, + ) + single_serializer_method = SerializerMethodHyperlinkedRelatedField( + self_link_view_name="basic-model-relationships", + related_link_view_name="basic-model-related", + read_only=True, + ) + many_serializer_method = SerializerMethodHyperlinkedRelatedField( + self_link_view_name="basic-model-relationships", + related_link_view_name="basic-model-related", + read_only=True, + many=True, + ) + + def get_single_serializer_method(self, obj): # pragma: no cover + raise NotImplementedError + + def get_many_serializer_method(self, obj): # pragma: no cover + raise NotImplementedError + + return HyperlinkedRelatedFieldSerializer() + + @pytest.fixture( + params=["single", "many", "single_serializer_method", "many_serializer_method"] + ) + def field(self, serializer, request): + field = serializer.fields[request.param] + field.field_name = request.param + return field + + def test_get_attribute(self, model, field): + with pytest.raises(SkipField): + field.get_attribute(model) + + def test_to_representation(self, model, field): + with pytest.raises(NotImplementedError): + field.to_representation(model) + + @pytest.mark.urls(__name__) + @pytest.mark.parametrize( + "format_related_links", + [ + None, + "dasherize", + "camelize", + "capitalize", + "underscore", + ], + ) + def test_get_links( + self, + format_related_links, + field, + settings, + model, + ): + settings.JSON_API_FORMAT_RELATED_LINKS = format_related_links + + link_segment = format_link_segment(field.field_name) + + expected = { + "self": f"/basic_models/{model.pk}/relationships/{link_segment}/", + "related": f"/basic_models/{model.pk}/{link_segment}/", + } + + if hasattr(field, "child_relation"): + # many case + field = field.child_relation + + actual = field.get_links(model) + assert expected == actual + + # Routing setup From 1444a67ba4c268cde477a7a04c72e553f475f309 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 10 Mar 2021 22:38:14 +0400 Subject: [PATCH 291/488] Add basis code quality analysis (#895) Adding this for a test run whether it gives any helpful information. If not we can also remove it again. --- .github/workflows/codeql-analysis.yml | 67 +++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..89e9f974 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,67 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '33 5 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 From e32ff43c05b4990b7c72c3862e3b7cc72bb0e3f8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 17 Mar 2021 00:24:44 +0400 Subject: [PATCH 292/488] Create security policy (#893) * Add security policy * Update SECURITY.md Replace invalid email address * Update SECURITY.md Add missing space * Update CONTRIBUTING.md Clarify add maintainer description. --- SECURITY.md | 9 +++++++++ docs/CONTRIBUTING.md | 12 ++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..ef73aad3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +## Reporting a Vulnerability + +If you believe you've found something in Django REST Framework JSON API which has security implications, please **do not raise the issue in a public forum**. + +Send a description of the issue via email to [rest-framework-security@googlegroups.com][security-mail]. The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. + +[security-mail]: mailto:rest-framework-security@googlegroups.com diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index c3f7d0f1..be1d0499 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -52,9 +52,21 @@ To setup pre-commit hooks first create a testing environment as explained above ## For maintainers +### Create release + To upload a release (using version 1.2.3 as the example) first setup testing environment as above before running below commands: python setup.py sdist bdist_wheel twine upload dist/* git tag -a v1.2.3 -m 'Release 1.2.3' git push --tags + + +### Add maintainer + +In case a new maintainer joins our team we need to consider to what of following services we want to add them too: + +* [Github organization](https://github.com/django-json-api) +* [Read the Docs project](https://django-rest-framework-json-api.readthedocs.io/) +* [PyPi project](https://pypi.org/project/djangorestframework-jsonapi/) +* [Google Groups security mailing list](https://groups.google.com/g/rest-framework-jsonapi-security) From 0cc77508784d970cc9a4c55e73959fdf8e052073 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 16 Mar 2021 22:37:46 +0200 Subject: [PATCH 293/488] Scheduled biweekly dependency update for week 11 (#896) * Update flake8 from 3.8.4 to 3.9.0 * Update sphinx from 3.4.3 to 3.5.2 * Update pyyaml from 5.3.1 to 5.4.1 * Update faker from 5.6.1 to 6.6.0 * Update pytest from 6.2.1 to 6.2.2 * Update pytest-cov from 2.11.0 to 2.11.1 Co-authored-by: Alan Crosswell --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index db8fae19..dced259a 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ black==20.8b1 -flake8==3.8.4 +flake8==3.9.0 flake8-isort==4.0.0 isort==5.7.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 607dfc7c..e904918e 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==3.4.3 +Sphinx==3.5.2 sphinx_rtd_theme==0.5.1 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index e5ceb15f..15e87ee5 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ django-filter==2.4.0 django-polymorphic==3.0.0 -pyyaml==5.3.1 +pyyaml==5.4.1 uritemplate==3.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 5b0fc490..f76c6686 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2 factory-boy==3.2.0 -Faker==5.6.1 -pytest==6.2.1 -pytest-cov==2.11.0 +Faker==6.6.0 +pytest==6.2.2 +pytest-cov==2.11.1 pytest-django==4.1.0 pytest-factoryboy==2.1.0 snapshottest==0.6.0 From 0892e3a8a4dbad9630d70e2b78e18b242a8b057d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 20 Mar 2021 19:49:52 +0400 Subject: [PATCH 294/488] Convert parsers test to pytest style (#898) --- example/tests/test_parsers.py | 145 ---------------------------------- tests/conftest.py | 6 ++ tests/test_parsers.py | 130 ++++++++++++++++++++++++++++++ tests/test_relations.py | 8 +- tests/test_views.py | 117 +++++++++++++++++++-------- tests/views.py | 10 +++ 6 files changed, 234 insertions(+), 182 deletions(-) delete mode 100644 example/tests/test_parsers.py create mode 100644 tests/test_parsers.py create mode 100644 tests/views.py diff --git a/example/tests/test_parsers.py b/example/tests/test_parsers.py deleted file mode 100644 index b83f70a7..00000000 --- a/example/tests/test_parsers.py +++ /dev/null @@ -1,145 +0,0 @@ -import json -from io import BytesIO - -from django.test import TestCase, override_settings -from django.urls import path, reverse -from rest_framework import status, views -from rest_framework.exceptions import ParseError -from rest_framework.response import Response -from rest_framework.test import APITestCase - -from rest_framework_json_api import serializers -from rest_framework_json_api.parsers import JSONParser -from rest_framework_json_api.renderers import JSONRenderer - - -class TestJSONParser(TestCase): - def setUp(self): - class MockRequest(object): - def __init__(self): - self.method = "GET" - - request = MockRequest() - - self.parser_context = {"request": request, "kwargs": {}, "view": "BlogViewSet"} - - data = { - "data": { - "id": 123, - "type": "Blog", - "attributes": {"json-value": {"JsonKey": "JsonValue"}}, - }, - "meta": {"random_key": "random_value"}, - } - - self.string = json.dumps(data) - - @override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize") - def test_parse_include_metadata_format_field_names(self): - parser = JSONParser() - - stream = BytesIO(self.string.encode("utf-8")) - data = parser.parse(stream, None, self.parser_context) - - self.assertEqual(data["_meta"], {"random_key": "random_value"}) - self.assertEqual(data["json_value"], {"JsonKey": "JsonValue"}) - - def test_parse_invalid_data(self): - parser = JSONParser() - - string = json.dumps([]) - stream = BytesIO(string.encode("utf-8")) - - with self.assertRaises(ParseError): - parser.parse(stream, None, self.parser_context) - - def test_parse_invalid_data_key(self): - parser = JSONParser() - - string = json.dumps( - { - "data": [ - { - "id": 123, - "type": "Blog", - "attributes": {"json-value": {"JsonKey": "JsonValue"}}, - } - ] - } - ) - stream = BytesIO(string.encode("utf-8")) - - with self.assertRaises(ParseError): - parser.parse(stream, None, self.parser_context) - - -class DummyDTO: - def __init__(self, response_dict): - for k, v in response_dict.items(): - setattr(self, k, v) - - @property - def pk(self): - return self.id if hasattr(self, "id") else None - - -class DummySerializer(serializers.Serializer): - body = serializers.CharField() - id = serializers.IntegerField() - - -class DummyAPIView(views.APIView): - parser_classes = [JSONParser] - renderer_classes = [JSONRenderer] - resource_name = "dummy" - - def patch(self, request, *args, **kwargs): - serializer = DummySerializer(DummyDTO(request.data)) - return Response(status=status.HTTP_200_OK, data=serializer.data) - - -urlpatterns = [ - path("repeater", DummyAPIView.as_view(), name="repeater"), -] - - -class TestParserOnAPIView(APITestCase): - def setUp(self): - class MockRequest(object): - def __init__(self): - self.method = "PATCH" - - request = MockRequest() - # To be honest view string isn't resolved into actual view - self.parser_context = {"request": request, "kwargs": {}, "view": "DummyAPIView"} - - self.data = { - "data": { - "id": 123, - "type": "strs", - "attributes": {"body": "hello"}, - } - } - - self.string = json.dumps(self.data) - - def test_patch_doesnt_raise_attribute_error(self): - parser = JSONParser() - - stream = BytesIO(self.string.encode("utf-8")) - - data = parser.parse(stream, None, self.parser_context) - - assert data["id"] == 123 - assert data["body"] == "hello" - - @override_settings(ROOT_URLCONF=__name__) - def test_patch_request(self): - url = reverse("repeater") - data = self.data - data["data"]["type"] = "dummy" - response = self.client.patch(url, data=data) - data = response.json() - - assert data["data"]["id"] == str(123) - assert data["data"]["attributes"]["body"] == "hello" diff --git a/tests/conftest.py b/tests/conftest.py index 22be93a1..ebdf5348 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import pytest +from rest_framework.test import APIClient from tests.models import ( BasicModel, @@ -51,3 +52,8 @@ def many_to_many_targets(db): ManyToManyTarget.objects.create(name="Target1"), ManyToManyTarget.objects.create(name="Target2"), ] + + +@pytest.fixture +def client(): + return APIClient() diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 00000000..907d1eb6 --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,130 @@ +import json +from io import BytesIO + +import pytest +from rest_framework.exceptions import ParseError + +from rest_framework_json_api.parsers import JSONParser +from rest_framework_json_api.utils import format_value +from tests.views import BasicModelViewSet + + +class TestJSONParser: + @pytest.fixture + def parser(self): + return JSONParser() + + @pytest.fixture + def parse(self, parser, parser_context): + def parse_wrapper(data): + stream = BytesIO(json.dumps(data).encode("utf-8")) + return parser.parse(stream, None, parser_context) + + return parse_wrapper + + @pytest.fixture + def parser_context(self, rf): + return {"request": rf.post("/"), "kwargs": {}, "view": BasicModelViewSet()} + + @pytest.mark.parametrize( + "format_field_names", + [ + None, + "dasherize", + "camelize", + "capitalize", + "underscore", + ], + ) + def test_parse_formats_field_names( + self, + settings, + format_field_names, + parse, + ): + settings.JSON_API_FORMAT_FIELD_NAMES = format_field_names + + data = { + "data": { + "id": "123", + "type": "BasicModel", + "attributes": { + format_value("test_attribute", format_field_names): "test-value" + }, + "relationships": { + format_value("test_relationship", format_field_names): { + "data": {"type": "TestRelationship", "id": "123"} + } + }, + } + } + + result = parse(data) + assert result == { + "id": "123", + "test_attribute": "test-value", + "test_relationship": {"id": "123", "type": "TestRelationship"}, + } + + def test_parse_extracts_meta(self, parse): + data = { + "data": { + "type": "BasicModel", + }, + "meta": {"random_key": "random_value"}, + } + + result = parse(data) + assert result["_meta"] == data["meta"] + + def test_parse_preserves_json_value_field_names(self, settings, parse): + settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" + + data = { + "data": { + "type": "BasicModel", + "attributes": {"json-value": {"JsonKey": "JsonValue"}}, + }, + } + + result = parse(data) + assert result["json_value"] == {"JsonKey": "JsonValue"} + + def test_parse_raises_error_on_empty_data(self, parse): + data = [] + + with pytest.raises(ParseError) as excinfo: + parse(data) + assert "Received document does not contain primary data" == str(excinfo.value) + + def test_parse_fails_on_list_of_objects(self, parse): + data = { + "data": [ + { + "type": "BasicModel", + "attributes": {"json-value": {"JsonKey": "JsonValue"}}, + } + ], + } + + with pytest.raises(ParseError) as excinfo: + parse(data) + + assert "Received data is not a valid JSONAPI Resource Identifier Object" == str( + excinfo.value + ) + + def test_parse_fails_when_id_is_missing_on_patch(self, rf, parse, parser_context): + parser_context["request"] = rf.patch("/") + data = { + "data": { + "type": "BasicModel", + }, + } + + with pytest.raises(ParseError) as excinfo: + parse(data) + + assert "The resource identifier object must contain an 'id' member" == str( + excinfo.value + ) diff --git a/tests/test_relations.py b/tests/test_relations.py index d66c602f..1baafdd0 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -11,13 +11,14 @@ SerializerMethodHyperlinkedRelatedField, ) from rest_framework_json_api.utils import format_link_segment -from rest_framework_json_api.views import ModelViewSet, RelationshipView +from rest_framework_json_api.views import RelationshipView from tests.models import BasicModel from tests.serializers import ( ForeignKeySourceSerializer, ManyToManySourceReadOnlySerializer, ManyToManySourceSerializer, ) +from tests.views import BasicModelViewSet @pytest.mark.django_db @@ -266,11 +267,6 @@ def test_get_links( # Routing setup -class BasicModelViewSet(ModelViewSet): - class Meta: - model = BasicModel - - class BasicModelRelationshipView(RelationshipView): queryset = BasicModel.objects diff --git a/tests/test_views.py b/tests/test_views.py index 8241a10e..e419ea0f 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,49 +1,104 @@ import pytest +from django.urls import path, reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView -from rest_framework_json_api import serializers, views +from rest_framework_json_api import serializers +from rest_framework_json_api.parsers import JSONParser from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.renderers import JSONRenderer from rest_framework_json_api.utils import format_value +from rest_framework_json_api.views import ModelViewSet +from tests.models import BasicModel -from .models import BasicModel -related_model_field_name = "related_field_model" +class TestModelViewSet: + @pytest.mark.parametrize( + "format_links", + [ + None, + "dasherize", + "camelize", + "capitalize", + "underscore", + ], + ) + def test_get_related_field_name_handles_formatted_link_segments( + self, format_links, rf + ): + # use field name which actually gets formatted + related_model_field_name = "related_field_model" + class RelatedFieldNameSerializer(serializers.ModelSerializer): + related_model_field = ResourceRelatedField(queryset=BasicModel.objects) -@pytest.mark.parametrize( - "format_links", - [ - None, - "dasherize", - "camelize", - "capitalize", - "underscore", - ], -) -def test_get_related_field_name_handles_formatted_link_segments(format_links, rf): - url_segment = format_value(related_model_field_name, format_links) + def __init__(self, *args, **kwargs): + self.related_model_field.field_name = related_model_field_name + super().__init(*args, **kwargs) - request = rf.get(f"/basic_models/1/{url_segment}") + class Meta: + model = BasicModel - view = BasicModelFakeViewSet() - view.setup(request, related_field=url_segment) + class RelatedFieldNameView(ModelViewSet): + serializer_class = RelatedFieldNameSerializer - assert view.get_related_field_name() == related_model_field_name + url_segment = format_value(related_model_field_name, format_links) + request = rf.get(f"/basic_models/1/{url_segment}") -class BasicModelSerializer(serializers.ModelSerializer): - related_model_field = ResourceRelatedField(queryset=BasicModel.objects) + view = RelatedFieldNameView() + view.setup(request, related_field=url_segment) - def __init__(self, *args, **kwargs): - # Intentionally setting field_name property to something that matches no format - self.related_model_field.field_name = related_model_field_name - super(BasicModelSerializer, self).__init(*args, **kwargs) + assert view.get_related_field_name() == related_model_field_name - class Meta: - model = BasicModel +class TestAPIView: + @pytest.mark.urls(__name__) + def test_patch(self, client): + data = { + "data": { + "id": 123, + "type": "custom", + "attributes": {"body": "hello"}, + } + } -class BasicModelFakeViewSet(views.ModelViewSet): - serializer_class = BasicModelSerializer + url = reverse("custom") - def retrieve(self, request, *args, **kwargs): - pass + response = client.patch(url, data=data) + result = response.json() + + assert result["data"]["id"] == str(123) + assert result["data"]["type"] == "custom" + assert result["data"]["attributes"]["body"] == "hello" + + +class CustomModel: + def __init__(self, response_dict): + for k, v in response_dict.items(): + setattr(self, k, v) + + @property + def pk(self): + return self.id if hasattr(self, "id") else None + + +class CustomModelSerializer(serializers.Serializer): + body = serializers.CharField() + id = serializers.IntegerField() + + +class CustomAPIView(APIView): + parser_classes = [JSONParser] + renderer_classes = [JSONRenderer] + resource_name = "custom" + + def patch(self, request, *args, **kwargs): + serializer = CustomModelSerializer(CustomModel(request.data)) + return Response(status=status.HTTP_200_OK, data=serializer.data) + + +urlpatterns = [ + path("custom", CustomAPIView.as_view(), name="custom"), +] diff --git a/tests/views.py b/tests/views.py new file mode 100644 index 00000000..e7046a52 --- /dev/null +++ b/tests/views.py @@ -0,0 +1,10 @@ +from rest_framework_json_api.views import ModelViewSet +from tests.models import BasicModel +from tests.serializers import BasicModelSerializer + + +class BasicModelViewSet(ModelViewSet): + serializer_class = BasicModelSerializer + + class Meta: + model = BasicModel From 51daed1a738aadb1536a6a3cbe787cbd88daaf05 Mon Sep 17 00:00:00 2001 From: Jeppe Fihl-Pearson Date: Wed, 7 Apr 2021 19:04:41 +0100 Subject: [PATCH 295/488] Add Django 3.2 support (#908) Co-authored-by: Oliver Sauder --- .github/workflows/tests.yml | 2 +- AUTHORS | 1 + CHANGELOG.md | 6 ++++++ README.rst | 2 +- docs/getting-started.md | 2 +- setup.cfg | 2 ++ setup.py | 2 +- tox.ini | 4 +++- 8 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e3e786ae..a48a7915 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: fail-fast: false matrix: python-version: ["3.6", "3.7", "3.8", "3.9"] - django: ["2.2", "3.0", "3.1"] + django: ["2.2", "3.0", "3.1", "3.2"] django-rest-framework: ["3.12", "master"] env: PYTHON: ${{ matrix.python-version }} diff --git a/AUTHORS b/AUTHORS index c1a273c4..543274a8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -12,6 +12,7 @@ Felix Viernickel Greg Aker Jamie Bliss Jason Housley +Jeppe Fihl-Pearson Jerel Unruh Jonathan Senecal Joseba Mendivil diff --git a/CHANGELOG.md b/CHANGELOG.md index c64e4a4c..b816f47d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Added + +* Added support for Django 3.2. + ## [4.1.0] - 2021-03-08 ### Added diff --git a/README.rst b/README.rst index 2ab48598..03f9d52a 100644 --- a/README.rst +++ b/README.rst @@ -89,7 +89,7 @@ Requirements ------------ 1. Python (3.6, 3.7, 3.8, 3.9) -2. Django (2.2, 3.0, 3.1) +2. Django (2.2, 3.0, 3.1, 3.2) 3. Django REST Framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index c305c801..5e374b30 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.6, 3.7, 3.8, 3.9) -2. Django (2.2, 3.0, 3.1) +2. Django (2.2, 3.0, 3.1, 3.2) 3. Django REST Framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. diff --git a/setup.cfg b/setup.cfg index 527ddd6b..83f6fa37 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,6 +58,8 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning + # Django Debug Toolbar currently (2021-04-07) specifies default_app_config which is deprecated in Django 3.2: + ignore:'debug_toolbar' defines default_app_config = 'debug_toolbar.apps.DebugToolbarConfig'. Django now detects this configuration automatically. You can remove default_app_config.:PendingDeprecationWarning testpaths = example tests diff --git a/setup.py b/setup.py index a076a7a5..8cce9a5d 100755 --- a/setup.py +++ b/setup.py @@ -98,7 +98,7 @@ def get_package_data(package): install_requires=[ "inflection>=0.3.0", "djangorestframework>=3.12,<3.13", - "django>=2.2,<3.2", + "django>=2.2,<3.3", ], extras_require={ "django-polymorphic": ["django-polymorphic>=2.0"], diff --git a/tox.ini b/tox.ini index dab1676c..f4160e5a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{36,37,38,39}-django{22,30,31}-drf{312,master}, + py{36,37,38,39}-django{22,30,31,32}-drf{312,master}, lint,docs [gh-actions] @@ -15,6 +15,7 @@ DJANGO = 2.2: django22 3.0: django30 3.1: django31 + 3.2: django32 DJANGO_REST_FRAMEWORK = 3.12: drf312 @@ -25,6 +26,7 @@ deps = django22: Django>=2.2,<2.3 django30: Django>=3.0,<3.1 django31: Django>=3.1,<3.2 + django32: Django>=3.2,<3.3 drf312: djangorestframework>=3.12,<3.13 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt From 450aa5ce49d90e75647d3c88cebd57b30b5d1470 Mon Sep 17 00:00:00 2001 From: Safa Alfulaij Date: Fri, 16 Apr 2021 22:42:35 +0300 Subject: [PATCH 296/488] Allow get_serializer_class to be overwritten for related urls without defining serializer_class fallback (#904) Co-authored-by: Oliver Sauder --- CHANGELOG.md | 4 ++++ example/tests/test_views.py | 7 +++---- example/views.py | 16 +++++++--------- rest_framework_json_api/views.py | 2 +- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b816f47d..c78a1b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Django 3.2. +### Fixed + +* Allow `get_serializer_class` to be overwritten when using related urls without defining `serializer_class` fallback + ## [4.1.0] - 2021-03-08 ### Added diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 4b494b52..16b51622 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -417,12 +417,11 @@ def test_get_related_serializer_class_many(self): def test_get_serializer_comes_from_included_serializers(self): kwargs = {"pk": self.author.id, "related_field": "type"} view = self._get_view(kwargs) - related_serializers = view.serializer_class.related_serializers - delattr(view.serializer_class, "related_serializers") + related_serializers = view.get_serializer_class().related_serializers + delattr(view.get_serializer_class(), "related_serializers") got = view.get_related_serializer_class() self.assertEqual(got, AuthorTypeSerializer) - - view.serializer_class.related_serializers = related_serializers + view.get_serializer_class().related_serializers = related_serializers def test_get_related_serializer_class_raises_error(self): kwargs = {"pk": self.author.id, "related_field": "unknown"} diff --git a/example/views.py b/example/views.py index 65bcb301..6a1b15a6 100644 --- a/example/views.py +++ b/example/views.py @@ -208,17 +208,15 @@ class NoFiltersetEntryViewSet(EntryViewSet): class AuthorViewSet(ModelViewSet): queryset = Author.objects.all() - serializer_classes = { - "list": AuthorListSerializer, - "retrieve": AuthorDetailSerializer, - } - serializer_class = AuthorSerializer # fallback def get_serializer_class(self): - try: - return self.serializer_classes.get(self.action, self.serializer_class) - except AttributeError: - return self.serializer_class + serializer_classes = { + "list": AuthorListSerializer, + "retrieve": AuthorDetailSerializer, + } + + action = getattr(self, "action", "") + return serializer_classes.get(action, AuthorSerializer) class CommentViewSet(ModelViewSet): diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 2f061cbb..3df27d1f 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -154,7 +154,7 @@ def get_related_serializer(self, instance, **kwargs): return serializer_class(instance, **kwargs) def get_related_serializer_class(self): - parent_serializer_class = super(RelatedMixin, self).get_serializer_class() + parent_serializer_class = self.get_serializer_class() if "related_field" in self.kwargs: field_name = self.kwargs["related_field"] From 5e2f60d1b68b393b932f895a629e7d4c64553e98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Apr 2021 00:16:06 +0400 Subject: [PATCH 297/488] Bump django-debug-toolbar from 3.2 to 3.2.1 in /requirements (#915) Bumps [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar) from 3.2 to 3.2.1. - [Release notes](https://github.com/jazzband/django-debug-toolbar/releases) - [Changelog](https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst) - [Commits](https://github.com/jazzband/django-debug-toolbar/compare/3.2...3.2.1) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index f76c6686..2fcda6fa 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,4 +1,4 @@ -django-debug-toolbar==3.2 +django-debug-toolbar==3.2.1 factory-boy==3.2.0 Faker==6.6.0 pytest==6.2.2 From d5aef4aecea9de0c2d4b09d9b5b3e4fd32072c0a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 19 Apr 2021 18:49:59 +0200 Subject: [PATCH 298/488] Scheduled biweekly dependency update for week 16 (#927) * Update flake8 from 3.9.0 to 3.9.1 * Update isort from 5.7.0 to 5.8.0 * Update sphinx from 3.5.2 to 3.5.4 * Update sphinx_rtd_theme from 0.5.1 to 0.5.2 * Update twine from 3.3.0 to 3.4.1 * Update faker from 6.6.0 to 8.1.0 * Update pytest from 6.2.2 to 6.2.3 * Update pytest-django from 4.1.0 to 4.2.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index dced259a..d572c8ea 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ black==20.8b1 -flake8==3.9.0 +flake8==3.9.1 flake8-isort==4.0.0 -isort==5.7.0 +isort==5.8.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index e904918e..1d4da97e 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==3.5.2 -sphinx_rtd_theme==0.5.1 +Sphinx==3.5.4 +sphinx_rtd_theme==0.5.2 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 889f179f..7e9e1799 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.3.0 +twine==3.4.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 2fcda6fa..e8c83a71 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2.1 factory-boy==3.2.0 -Faker==6.6.0 -pytest==6.2.2 +Faker==8.1.0 +pytest==6.2.3 pytest-cov==2.11.1 -pytest-django==4.1.0 +pytest-django==4.2.0 pytest-factoryboy==2.1.0 snapshottest==0.6.0 From a25e387a86af8914facdc320b6f28cdd486f32af Mon Sep 17 00:00:00 2001 From: Safa Alfulaij Date: Tue, 20 Apr 2021 17:34:03 +0300 Subject: [PATCH 299/488] Support tags in OAS schema (#928) * Suuport openapi tags * Update openapi tests * Update CHANGELOG.md * Update AUTHORS Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 1 + example/tests/snapshots/snap_test_openapi.py | 25 ++++++++++++++++---- rest_framework_json_api/schemas/openapi.py | 1 + 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index 543274a8..500dc3c6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -31,6 +31,7 @@ Raphael Cohen René Kälin Roberto Barreda Rohith PR +Safa AlFulaij santiavenda Sergey Kolomenkin Stas S. diff --git a/CHANGELOG.md b/CHANGELOG.md index c78a1b12..19b4ac01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Django 3.2. +* Added support for tags in OAS schema ### Fixed diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py index 7c0b3b98..f89fb442 100644 --- a/example/tests/snapshots/snap_test_openapi.py +++ b/example/tests/snapshots/snap_test_openapi.py @@ -121,7 +121,10 @@ }, "description": "not found" } - } + }, + "tags": [ + "authors" + ] }""" snapshots[ @@ -227,7 +230,10 @@ }, "description": "not found" } - } + }, + "tags": [ + "authors" + ] }""" snapshots[ @@ -411,7 +417,10 @@ }, "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" } - } + }, + "tags": [ + "authors" + ] }""" snapshots[ @@ -589,7 +598,10 @@ }, "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" } - } + }, + "tags": [ + "authors" + ] }""" snapshots[ @@ -652,5 +664,8 @@ }, "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" } - } + }, + "tags": [ + "authors" + ] }""" diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index b51f366f..77a0d4ed 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -427,6 +427,7 @@ def get_operation(self, path, method): parameters += self.get_pagination_parameters(path, method) parameters += self.get_filter_parameters(path, method) operation["parameters"] = parameters + operation["tags"] = self.get_tags(path, method) # get request and response code schemas if method == "GET": From 36a60a33b10165c97e942b505e75c9708639fd22 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 23 Apr 2021 18:20:20 +0400 Subject: [PATCH 300/488] Preserve field names when no formatting is configured (#909) This way it is possible to have field names which do not follow the python underscore convention. --- CHANGELOG.md | 6 ++ .../django_filters/backends.py | 5 +- rest_framework_json_api/filters.py | 10 +-- rest_framework_json_api/metadata.py | 4 +- rest_framework_json_api/parsers.py | 28 ++---- rest_framework_json_api/utils.py | 85 +++++++++++++++---- rest_framework_json_api/views.py | 4 +- tests/test_parsers.py | 2 +- tests/test_relations.py | 2 +- tests/test_utils.py | 80 ++++++++++++++++- tests/test_views.py | 10 ++- 11 files changed, 179 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b4ac01..b977ac07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Allow `get_serializer_class` to be overwritten when using related urls without defining `serializer_class` fallback +* Preserve field names when no formatting is configured. + +### Deprecated + +* Deprecated default `format_type` argument of `rest_framework_json_api.utils.format_value`. Use `rest_framework_json_api.utils.format_field_name` or specify specifc `format_type` instead. +* Deprecated `format_type` argument of `rest_framework_json_api.utils.format_link_segment`. Use `format_value` instead. ## [4.1.0] - 2021-03-08 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index bb24756c..3906308c 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -4,7 +4,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings -from rest_framework_json_api.utils import format_value +from rest_framework_json_api.utils import undo_format_field_name class DjangoFilterBackend(DjangoFilterBackend): @@ -119,8 +119,7 @@ def get_filterset_kwargs(self, request, queryset, view): ) # convert jsonapi relationship path to Django ORM's __ notation key = m.groupdict()["assoc"].replace(".", "__") - # undo JSON_API_FORMAT_FIELD_NAMES conversion: - key = format_value(key, "underscore") + key = undo_format_field_name(key) data.setlist(key, val) filter_keys.append(key) del data[qp] diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index 06f7667e..95056666 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -3,7 +3,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.filters import BaseFilterBackend, OrderingFilter -from rest_framework_json_api.utils import format_value +from rest_framework_json_api.utils import undo_format_field_name class OrderingFilter(OrderingFilter): @@ -15,7 +15,7 @@ class OrderingFilter(OrderingFilter): :py:class:`rest_framework.filters.OrderingFilter` with :py:attr:`~rest_framework.filters.OrderingFilter.ordering_param` = "sort" - Also applies DJA format_value() to convert (e.g. camelcase) to underscore. + Also supports undo of field name formatting (See JSON_API_FORMAT_FIELD_NAMES in docs/usage.md) """ @@ -38,7 +38,7 @@ def remove_invalid_fields(self, queryset, fields, view, request): bad_terms = [ term for term in fields - if format_value(term.replace(".", "__").lstrip("-"), "underscore") + if undo_format_field_name(term.replace(".", "__").lstrip("-")) not in valid_fields ] if bad_terms: @@ -56,10 +56,10 @@ def remove_invalid_fields(self, queryset, fields, view, request): item_rewritten = item.replace(".", "__") if item_rewritten.startswith("-"): underscore_fields.append( - "-" + format_value(item_rewritten.lstrip("-"), "underscore") + "-" + undo_format_field_name(item_rewritten.lstrip("-")) ) else: - underscore_fields.append(format_value(item_rewritten, "underscore")) + underscore_fields.append(undo_format_field_name(item_rewritten)) return super(OrderingFilter, self).remove_invalid_fields( queryset, underscore_fields, view, request diff --git a/rest_framework_json_api/metadata.py b/rest_framework_json_api/metadata.py index a48af532..6b88e578 100644 --- a/rest_framework_json_api/metadata.py +++ b/rest_framework_json_api/metadata.py @@ -7,7 +7,7 @@ from rest_framework.settings import api_settings from rest_framework.utils.field_mapping import ClassLookupDict -from rest_framework_json_api.utils import format_value, get_related_resource_type +from rest_framework_json_api.utils import format_field_name, get_related_resource_type class JSONAPIMetadata(SimpleMetadata): @@ -93,7 +93,7 @@ def get_serializer_info(self, serializer): return OrderedDict( [ - (format_value(field_name), self.get_field_info(field)) + (format_field_name(field_name), self.get_field_info(field)) for field_name, field in serializer.fields.items() ] ) diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index e3315334..433bcb32 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -4,8 +4,8 @@ from rest_framework import parsers from rest_framework.exceptions import ParseError -from . import exceptions, renderers, serializers, utils -from .settings import json_api_settings +from rest_framework_json_api import exceptions, renderers, serializers +from rest_framework_json_api.utils import get_resource_name, undo_format_field_names class JSONParser(parsers.JSONParser): @@ -37,27 +37,13 @@ class JSONParser(parsers.JSONParser): @staticmethod def parse_attributes(data): - attributes = data.get("attributes") - uses_format_translation = json_api_settings.FORMAT_FIELD_NAMES - - if not attributes: - return dict() - elif uses_format_translation: - # convert back to python/rest_framework's preferred underscore format - return utils.format_field_names(attributes, "underscore") - else: - return attributes + attributes = data.get("attributes") or dict() + return undo_format_field_names(attributes) @staticmethod def parse_relationships(data): - uses_format_translation = json_api_settings.FORMAT_FIELD_NAMES - relationships = data.get("relationships") - - if not relationships: - relationships = dict() - elif uses_format_translation: - # convert back to python/rest_framework's preferred underscore format - relationships = utils.format_field_names(relationships, "underscore") + relationships = data.get("relationships") or dict() + relationships = undo_format_field_names(relationships) # Parse the relationships parsed_relationships = dict() @@ -130,7 +116,7 @@ def parse(self, stream, media_type=None, parser_context=None): # Check for inconsistencies if request.method in ("PUT", "POST", "PATCH"): - resource_name = utils.get_resource_name( + resource_name = get_resource_name( parser_context, expand_polymorphic_types=True ) if isinstance(resource_name, str): diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index c87fa000..f8c6833c 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -1,6 +1,7 @@ import copy import inspect import operator +import warnings from collections import OrderedDict import inflection @@ -118,8 +119,76 @@ def format_field_names(obj, format_type=None): return obj +def undo_format_field_names(obj): + """ + Takes a dict and undo format field names to underscore which is the Python convention + but only in case `JSON_API_FORMAT_FIELD_NAMES` is actually configured. + """ + if json_api_settings.FORMAT_FIELD_NAMES: + return format_field_names(obj, "underscore") + + return obj + + +def format_field_name(field_name): + """ + Takes a field name and returns it with formatted keys as set in + `JSON_API_FORMAT_FIELD_NAMES` + """ + return format_value(field_name, json_api_settings.FORMAT_FIELD_NAMES) + + +def undo_format_field_name(field_name): + """ + Takes a string and undos format field name to underscore which is the Python convention + but only in case `JSON_API_FORMAT_FIELD_NAMES` is actually configured. + """ + if json_api_settings.FORMAT_FIELD_NAMES: + return format_value(field_name, "underscore") + + return field_name + + +def format_link_segment(value, format_type=None): + """ + Takes a string value and returns it with formatted keys as set in `format_type` + or `JSON_API_FORMAT_RELATED_LINKS`. + + :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' + """ + if format_type is None: + format_type = json_api_settings.FORMAT_RELATED_LINKS + else: + warnings.warn( + DeprecationWarning( + "Using `format_type` argument is deprecated." + "Use `format_value` instead." + ) + ) + + return format_value(value, format_type) + + +def undo_format_link_segment(value): + """ + Takes a link segment and undos format link segment to underscore which is the Python convention + but only in case `JSON_API_FORMAT_RELATED_LINKS` is actually configured. + """ + + if json_api_settings.FORMAT_RELATED_LINKS: + return format_value(value, "underscore") + + return value + + def format_value(value, format_type=None): if format_type is None: + warnings.warn( + DeprecationWarning( + "Using `format_value` without passing on `format_type` argument is deprecated." + "Use `format_field_name` instead." + ) + ) format_type = json_api_settings.FORMAT_FIELD_NAMES if format_type == "dasherize": # inflection can't dasherize camelCase @@ -142,25 +211,11 @@ def format_resource_type(value, format_type=None, pluralize=None): pluralize = json_api_settings.PLURALIZE_TYPES if format_type: - # format_type will never be None here so we can use format_value value = format_value(value, format_type) return inflection.pluralize(value) if pluralize else value -def format_link_segment(value, format_type=None): - """ - Takes a string value and returns it with formatted keys as set in `format_type` - or `JSON_API_FORMAT_RELATED_LINKS`. - - :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' - """ - if format_type is None: - format_type = json_api_settings.FORMAT_RELATED_LINKS - - return format_value(value, format_type) - - def get_related_resource_type(relation): from rest_framework_json_api.serializers import PolymorphicModelSerializer @@ -348,7 +403,7 @@ def format_drf_errors(response, context, exc): # handle all errors thrown from serializers else: for field, error in response.data.items(): - field = format_value(field) + field = format_field_name(field) pointer = "/data/attributes/{}".format(field) if isinstance(exc, Http404) and isinstance(error, str): # 404 errors don't have a pointer diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 3df27d1f..de1c2f25 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -25,9 +25,9 @@ from rest_framework_json_api.utils import ( Hyperlink, OrderedDict, - format_value, get_included_resources, get_resource_type_from_instance, + undo_format_link_segment, ) @@ -187,7 +187,7 @@ def get_related_serializer_class(self): def get_related_field_name(self): field_name = self.kwargs["related_field"] - return format_value(field_name, "underscore") + return undo_format_link_segment(field_name) def get_related_instance(self): parent_obj = self.get_object() diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 907d1eb6..03770970 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -29,7 +29,7 @@ def parser_context(self, rf): @pytest.mark.parametrize( "format_field_names", [ - None, + False, "dasherize", "camelize", "capitalize", diff --git a/tests/test_relations.py b/tests/test_relations.py index 1baafdd0..630dd9c8 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -233,7 +233,7 @@ def test_to_representation(self, model, field): @pytest.mark.parametrize( "format_related_links", [ - None, + False, "dasherize", "camelize", "capitalize", diff --git a/tests/test_utils.py b/tests/test_utils.py index 40f9d391..00bf0836 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,6 +7,7 @@ from rest_framework_json_api import serializers from rest_framework_json_api.utils import ( + format_field_name, format_field_names, format_link_segment, format_resource_type, @@ -14,6 +15,9 @@ get_included_serializers, get_related_resource_type, get_resource_name, + undo_format_field_name, + undo_format_field_names, + undo_format_link_segment, ) from tests.models import ( BasicModel, @@ -176,6 +180,7 @@ def test_get_resource_name_with_errors(status_code): @pytest.mark.parametrize( "format_type,output", [ + (False, {"full_name": {"last-name": "a", "first-name": "b"}}), ("camelize", {"fullName": {"last-name": "a", "first-name": "b"}}), ("capitalize", {"FullName": {"last-name": "a", "first-name": "b"}}), ("dasherize", {"full-name": {"last-name": "a", "first-name": "b"}}), @@ -192,22 +197,86 @@ def test_format_field_names(settings, format_type, output): @pytest.mark.parametrize( "format_type,output", [ - (None, "first_Name"), + (False, {"fullName": "Test Name"}), + ("camelize", {"full_name": "Test Name"}), + ], +) +def test_undo_format_field_names(settings, format_type, output): + settings.JSON_API_FORMAT_FIELD_NAMES = format_type + + value = {"fullName": "Test Name"} + assert undo_format_field_names(value) == output + + +@pytest.mark.parametrize( + "format_type,output", + [ + (False, "full_name"), + ("camelize", "fullName"), + ("capitalize", "FullName"), + ("dasherize", "full-name"), + ("underscore", "full_name"), + ], +) +def test_format_field_name(settings, format_type, output): + settings.JSON_API_FORMAT_FIELD_NAMES = format_type + + field_name = "full_name" + assert format_field_name(field_name) == output + + +@pytest.mark.parametrize( + "format_type,output", + [ + (False, "fullName"), + ("camelize", "full_name"), + ], +) +def test_undo_format_field_name(settings, format_type, output): + settings.JSON_API_FORMAT_FIELD_NAMES = format_type + + field_name = "fullName" + assert undo_format_field_name(field_name) == output + + +@pytest.mark.parametrize( + "format_type,output", + [ + (False, "first_Name"), ("camelize", "firstName"), ("capitalize", "FirstName"), ("dasherize", "first-name"), ("underscore", "first_name"), ], ) -def test_format_field_segment(settings, format_type, output): +def test_format_link_segment(settings, format_type, output): settings.JSON_API_FORMAT_RELATED_LINKS = format_type assert format_link_segment("first_Name") == output +def test_format_link_segment_deprecates_format_type_argument(): + with pytest.deprecated_call(): + assert "first-name" == format_link_segment("first_name", "dasherize") + + +@pytest.mark.parametrize( + "format_links,output", + [ + (False, "fullName"), + ("camelize", "full_name"), + ], +) +def test_undo_format_link_segment(settings, format_links, output): + settings.JSON_API_FORMAT_RELATED_LINKS = format_links + + link_segment = "fullName" + assert undo_format_link_segment(link_segment) == output + + @pytest.mark.parametrize( "format_type,output", [ - (None, "first_name"), + (False, "first_name"), ("camelize", "firstName"), ("capitalize", "FirstName"), ("dasherize", "first-name"), @@ -218,6 +287,11 @@ def test_format_value(settings, format_type, output): assert format_value("first_name", format_type) == output +def test_format_value_deprecates_default_format_type_argument(): + with pytest.deprecated_call(): + assert "first_name" == format_value("first_name") + + @pytest.mark.parametrize( "resource_type,pluralize,output", [ diff --git a/tests/test_views.py b/tests/test_views.py index e419ea0f..f8967982 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -8,7 +8,7 @@ from rest_framework_json_api.parsers import JSONParser from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.renderers import JSONRenderer -from rest_framework_json_api.utils import format_value +from rest_framework_json_api.utils import format_link_segment from rest_framework_json_api.views import ModelViewSet from tests.models import BasicModel @@ -17,7 +17,7 @@ class TestModelViewSet: @pytest.mark.parametrize( "format_links", [ - None, + False, "dasherize", "camelize", "capitalize", @@ -25,8 +25,10 @@ class TestModelViewSet: ], ) def test_get_related_field_name_handles_formatted_link_segments( - self, format_links, rf + self, settings, format_links, rf ): + settings.JSON_API_FORMAT_RELATED_LINKS = format_links + # use field name which actually gets formatted related_model_field_name = "related_field_model" @@ -43,7 +45,7 @@ class Meta: class RelatedFieldNameView(ModelViewSet): serializer_class = RelatedFieldNameSerializer - url_segment = format_value(related_model_field_name, format_links) + url_segment = format_link_segment(related_model_field_name) request = rf.get(f"/basic_models/1/{url_segment}") From 7d2970a4e8652952801c4af070fd5faae37f24d6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 23 Apr 2021 18:27:27 +0400 Subject: [PATCH 301/488] Avoided error when using include query parameter on related urls (#914) This is a regression from version 4.1.0. Co-authored-by: Alan Crosswell --- CHANGELOG.md | 4 ++++ example/tests/integration/test_browsable_api.py | 11 +++++++++++ example/tests/test_views.py | 11 +++++++++-- rest_framework_json_api/renderers.py | 5 ++++- rest_framework_json_api/serializers.py | 5 ++++- 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b977ac07..ab9ec4f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ any parts of the framework not mentioned in the documentation should generally b * Deprecated default `format_type` argument of `rest_framework_json_api.utils.format_value`. Use `rest_framework_json_api.utils.format_field_name` or specify specifc `format_type` instead. * Deprecated `format_type` argument of `rest_framework_json_api.utils.format_link_segment`. Use `format_value` instead. +### Fixed + +* Avoided error when using `include` query parameter on related urls (a regression since 4.1.0) + ## [4.1.0] - 2021-03-08 ### Added diff --git a/example/tests/integration/test_browsable_api.py b/example/tests/integration/test_browsable_api.py index 9156eb92..14ebe3fb 100644 --- a/example/tests/integration/test_browsable_api.py +++ b/example/tests/integration/test_browsable_api.py @@ -18,6 +18,17 @@ def test_browsable_api_with_included_serializers(single_entry, client): ) +def test_browsable_api_on_related_url(author, client): + url = reverse("author-related", kwargs={"pk": author.pk, "related_field": "bio"}) + response = client.get(url, data={"format": "api"}) + content = str(response.content) + assert response.status_code == 200 + assert re.search(r"JSON:API includes", content) + assert re.search( + r']* value="metadata"', content + ) + + def test_browsable_api_with_no_included_serializers(client): response = client.get(reverse("projecttype-list", kwargs={"format": "api"})) content = str(response.content) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 16b51622..71e2e2db 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -432,7 +432,7 @@ def test_retrieve_related_single_reverse_lookup(self): url = reverse( "author-related", kwargs={"pk": self.author.pk, "related_field": "bio"} ) - resp = self.client.get(url) + resp = self.client.get(url, data={"include": "metadata"}) expected = { "data": { "type": "authorBios", @@ -447,7 +447,14 @@ def test_retrieve_related_single_reverse_lookup(self): }, }, "attributes": {"body": str(self.author.bio.body)}, - } + }, + "included": [ + { + "attributes": {"body": str(self.author.bio.metadata.body)}, + "id": str(self.author.bio.metadata.id), + "type": "authorBioMetadata", + } + ], } self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json(), expected) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index e84c562b..b50c2b1a 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -708,7 +708,10 @@ def _get_included_serializers(cls, serializer, prefix="", already_seen=None): def get_includes_form(self, view): try: - serializer_class = view.get_serializer_class() + if "related_field" in view.kwargs: + serializer_class = view.get_related_serializer_class() + else: + serializer_class = view.get_serializer_class() except AttributeError: return diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 56949818..a73a5d47 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -140,7 +140,10 @@ def validate_path(serializer_class, field_path, path): included_resources = get_included_resources(request) for included_field_name in included_resources: included_field_path = included_field_name.split(".") - this_serializer_class = view.get_serializer_class() + if "related_field" in view.kwargs: + this_serializer_class = view.get_related_serializer_class() + else: + this_serializer_class = view.get_serializer_class() # lets validate the current path validate_path( this_serializer_class, included_field_path, included_field_name From a5df955794ad344ab599550a0cd433d5eefbcd75 Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Thu, 29 Apr 2021 13:53:38 -0500 Subject: [PATCH 302/488] Properly support formatting of link segments in related urls (#897) --- CHANGELOG.md | 7 +++++++ docs/usage.md | 9 +++++++-- example/tests/test_views.py | 25 ++++++++++++++++++++++++- example/urls_test.py | 6 +++--- rest_framework_json_api/views.py | 4 +++- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab9ec4f8..32799366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ any parts of the framework not mentioned in the documentation should generally b * Allow `get_serializer_class` to be overwritten when using related urls without defining `serializer_class` fallback * Preserve field names when no formatting is configured. +* Properly support `JSON_API_FORMAT_RELATED_LINKS` setting in related urls. In case you want to use `dasherize` for formatting links make sure that your url pattern matches dashes as well like following example: + ``` + url(r'^orders/(?P[^/.]+)/(?P[-\w]+)/$', + OrderViewSet.as_view({'get': 'retrieve_related'}), + name='order-related'), + ``` + ### Deprecated diff --git a/docs/usage.md b/docs/usage.md index 81e52989..79a2d918 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -515,6 +515,11 @@ For example, with a serializer property `created_by` and with `'dasherize'` form The relationship name is formatted by the `JSON_API_FORMAT_FIELD_NAMES` setting, but the URL segments are formatted by the `JSON_API_FORMAT_RELATED_LINKS` setting. +
+ Note: + When using this setting make sure that your url pattern matches the formatted url segement. +
+ ### Related fields #### ResourceRelatedField @@ -702,7 +707,7 @@ All you need is just add to `urls.py`: url(r'^orders/(?P[^/.]+)/$', OrderViewSet.as_view({'get': 'retrieve'}), name='order-detail'), -url(r'^orders/(?P[^/.]+)/(?P\w+)/$', +url(r'^orders/(?P[^/.]+)/(?P[-\w]+)/$', OrderViewSet.as_view({'get': 'retrieve_related'}), name='order-related'), ``` @@ -775,7 +780,7 @@ The urlconf would need to contain a route like the following: ```python url( - regex=r'^orders/(?P[^/.]+)/relationships/(?P[^/.]+)$', + regex=r'^orders/(?P[^/.]+)/relationships/(?P[-/w]+)$', view=OrderRelationshipView.as_view(), name='order-relationships' ) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 71e2e2db..d976ed56 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -1,7 +1,7 @@ import json from datetime import datetime -from django.test import RequestFactory +from django.test import RequestFactory, override_settings from django.utils import timezone from rest_framework import status from rest_framework.decorators import action @@ -92,6 +92,18 @@ def test_get_blog_relationship_entry_set(self): assert response.data == expected_data + @override_settings(JSON_API_FORMAT_RELATED_LINKS="dasherize") + def test_get_blog_relationship_entry_set_with_formatted_link(self): + response = self.client.get( + "/blogs/{}/relationships/entry-set".format(self.blog.id) + ) + expected_data = [ + {"type": format_resource_type("Entry"), "id": str(self.first_entry.id)}, + {"type": format_resource_type("Entry"), "id": str(self.second_entry.id)}, + ] + + assert response.data == expected_data + def test_put_entry_relationship_blog_returns_405(self): url = "/entries/{}/relationships/blog".format(self.first_entry.id) response = self.client.put(url, data={}) @@ -507,6 +519,17 @@ def test_retrieve_related_None(self): self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json(), {"data": None}) + @override_settings(JSON_API_FORMAT_RELATED_LINKS="dasherize") + def test_retrieve_related_with_formatted_link(self): + first_entry = EntryFactory(authors=(self.author,)) + + kwargs = {"pk": self.author.pk, "related_field": "first-entry"} + url = reverse("author-related", kwargs=kwargs) + resp = self.client.get(url) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["data"]["id"], str(first_entry.id)) + class TestValidationErrorResponses(TestBase): def test_if_returns_error_on_empty_post(self): diff --git a/example/urls_test.py b/example/urls_test.py index 7e875936..0219ac51 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -78,17 +78,17 @@ name="entry-featured", ), re_path( - r"^authors/(?P[^/.]+)/(?P\w+)/$", + r"^authors/(?P[^/.]+)/(?P[-\w]+)/$", AuthorViewSet.as_view({"get": "retrieve_related"}), name="author-related", ), re_path( - r"^entries/(?P[^/.]+)/relationships/(?P\w+)$", + r"^entries/(?P[^/.]+)/relationships/(?P[\-\w]+)$", EntryRelationshipView.as_view(), name="entry-relationships", ), re_path( - r"^blogs/(?P[^/.]+)/relationships/(?P\w+)$", + r"^blogs/(?P[^/.]+)/relationships/(?P[^/.]+)$", BlogRelationshipView.as_view(), name="blog-relationships", ), diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index de1c2f25..84ec509e 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -157,7 +157,7 @@ def get_related_serializer_class(self): parent_serializer_class = self.get_serializer_class() if "related_field" in self.kwargs: - field_name = self.kwargs["related_field"] + field_name = self.get_related_field_name() # Try get the class from related_serializers if hasattr(parent_serializer_class, "related_serializers"): @@ -402,6 +402,8 @@ def get_related_instance(self): def get_related_field_name(self): field_name = self.kwargs["related_field"] + field_name = undo_format_link_segment(field_name) + if field_name in self.field_name_mapping: return self.field_name_mapping[field_name] return field_name From fbb4b033a483b7edb62de3377c83a046a55f69b2 Mon Sep 17 00:00:00 2001 From: Safa Alfulaij Date: Sun, 2 May 2021 21:58:42 +0300 Subject: [PATCH 303/488] Prefetch default included resources (#900) Co-authored-by: Oliver Sauder --- CHANGELOG.md | 1 + example/tests/test_performance.py | 18 ++++++++++++++++-- rest_framework_json_api/serializers.py | 2 +- rest_framework_json_api/views.py | 8 ++++++-- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32799366..c62b5383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ any parts of the framework not mentioned in the documentation should generally b OrderViewSet.as_view({'get': 'retrieve_related'}), name='order-related'), ``` +* Ensure default `included_resources` are considered when calculating prefetches. ### Deprecated diff --git a/example/tests/test_performance.py b/example/tests/test_performance.py index e42afada..cae11ed4 100644 --- a/example/tests/test_performance.py +++ b/example/tests/test_performance.py @@ -1,7 +1,7 @@ from django.utils import timezone from rest_framework.test import APITestCase -from example.factories import CommentFactory +from example.factories import CommentFactory, EntryFactory from example.models import Author, Blog, Comment, Entry @@ -36,6 +36,7 @@ def setUp(self): ) self.comment = Comment.objects.create(entry=self.first_entry) CommentFactory.create_batch(50) + EntryFactory.create_batch(50) def test_query_count_no_includes(self): """We expect a simple list view to issue only two queries. @@ -49,7 +50,7 @@ def test_query_count_no_includes(self): self.assertEqual(len(response.data["results"]), 25) def test_query_count_include_author(self): - """We expect a list view with an include have three queries: + """We expect a list view with an include have five queries: 1. Primary resource COUNT query 2. Primary resource SELECT @@ -70,3 +71,16 @@ def test_query_select_related_entry(self): with self.assertNumQueries(2): response = self.client.get("/comments?include=writer&page[size]=25") self.assertEqual(len(response.data["results"]), 25) + + def test_query_prefetch_uses_included_resources(self): + """We expect a list view with `included_resources` to have three queries: + + 1. Primary resource COUNT query + 2. Primary resource SELECT + 3. Comments prefetched + """ + with self.assertNumQueries(3): + response = self.client.get( + "/entries?fields[entries]=comments&page[size]=25" + ) + self.assertEqual(len(response.data["results"]), 25) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index a73a5d47..5ca773d0 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -137,7 +137,7 @@ def validate_path(serializer_class, field_path, path): validate_path(this_included_serializer, new_included_field_path, path) if request and view: - included_resources = get_included_resources(request) + included_resources = get_included_resources(request, self) for included_field_name in included_resources: included_field_path = included_field_name.split(".") if "related_field" in view.kwargs: diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 84ec509e..8369cec9 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -63,7 +63,9 @@ def get_prefetch_related(self, include): def get_queryset(self, *args, **kwargs): qs = super(PreloadIncludesMixin, self).get_queryset(*args, **kwargs) - included_resources = get_included_resources(self.request) + included_resources = get_included_resources( + self.request, self.get_serializer_class() + ) for included in included_resources + ["__all__"]: select_related = self.get_select_related(included) @@ -82,7 +84,9 @@ def get_queryset(self, *args, **kwargs): """ This mixin adds automatic prefetching for OneToOne and ManyToMany fields. """ qs = super(AutoPrefetchMixin, self).get_queryset(*args, **kwargs) - included_resources = get_included_resources(self.request) + included_resources = get_included_resources( + self.request, self.get_serializer_class() + ) for included in included_resources + ["__all__"]: # If include was not defined, trying to resolve it automatically From 16a3d6b02f15b84151f73826ea9071acd3abd708 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 4 May 2021 20:21:22 +0200 Subject: [PATCH 304/488] Scheduled biweekly dependency update for week 18 (#939) * Update black from 20.8b1 to 21.4b2 * Update faker from 8.1.0 to 8.1.2 Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-testing.txt | 2 +- rest_framework_json_api/utils.py | 2 +- rest_framework_json_api/views.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index d572c8ea..8395b9e1 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==20.8b1 +black==21.4b2 flake8==3.9.1 flake8-isort==4.0.0 isort==5.8.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index e8c83a71..7887a137 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ django-debug-toolbar==3.2.1 factory-boy==3.2.0 -Faker==8.1.0 +Faker==8.1.2 pytest==6.2.3 pytest-cov==2.11.1 pytest-django==4.2.0 diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index f8c6833c..ac31979a 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -326,7 +326,7 @@ def get_resource_type_from_serializer(serializer): def get_included_resources(request, serializer=None): - """ Build a list of included resources. """ + """Build a list of included resources.""" include_resources_param = request.query_params.get("include") if request else None if include_resources_param: return include_resources_param.split(",") diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 8369cec9..e457009b 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -81,7 +81,7 @@ def get_queryset(self, *args, **kwargs): class AutoPrefetchMixin(object): def get_queryset(self, *args, **kwargs): - """ This mixin adds automatic prefetching for OneToOne and ManyToMany fields. """ + """This mixin adds automatic prefetching for OneToOne and ManyToMany fields.""" qs = super(AutoPrefetchMixin, self).get_queryset(*args, **kwargs) included_resources = get_included_resources( From 0fcede4890abf833cd3ca948aaa0bf103cba2745 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 12 May 2021 22:45:11 +0400 Subject: [PATCH 305/488] Release 4.2.0 (#937) --- CHANGELOG.md | 9 +++------ rest_framework_json_api/__init__.py | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c62b5383..e7f43647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [4.2.0] - 2021-05-03 ### Added @@ -26,16 +26,13 @@ any parts of the framework not mentioned in the documentation should generally b name='order-related'), ``` * Ensure default `included_resources` are considered when calculating prefetches. - +* Avoided error when using `include` query parameter on related urls (a regression since 4.1.0) ### Deprecated * Deprecated default `format_type` argument of `rest_framework_json_api.utils.format_value`. Use `rest_framework_json_api.utils.format_field_name` or specify specifc `format_type` instead. -* Deprecated `format_type` argument of `rest_framework_json_api.utils.format_link_segment`. Use `format_value` instead. +* Deprecated `format_type` argument of `rest_framework_json_api.utils.format_link_segment`. Use `rest_framework_json_api.utils.format_value` instead. -### Fixed - -* Avoided error when using `include` query parameter on related urls (a regression since 4.1.0) ## [4.1.0] - 2021-03-08 diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 3ce910ef..d166001f 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = "djangorestframework-jsonapi" -__version__ = "4.1.0" +__version__ = "4.2.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From 71065026aa5ea58359f38c6195041e19a7d6aeea Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 12 May 2021 22:48:18 +0400 Subject: [PATCH 306/488] Set correct date of release 4.2.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f43647..d7155df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [4.2.0] - 2021-05-03 +## [4.2.0] - 2021-05-12 ### Added From af1c804340f97b3e6ca9f147d1eba78295622e28 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 17 May 2021 21:11:59 +0400 Subject: [PATCH 307/488] Use correct security email address (#934) Not sure how this happened but the DRF security email address was used instead of our newly created one. Co-authored-by: Alan Crosswell --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index ef73aad3..78781946 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,6 @@ If you believe you've found something in Django REST Framework JSON API which has security implications, please **do not raise the issue in a public forum**. -Send a description of the issue via email to [rest-framework-security@googlegroups.com][security-mail]. The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. +Send a description of the issue via email to [rest-framework-jsonapi-security@googlegroups.com][security-mail]. The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. -[security-mail]: mailto:rest-framework-security@googlegroups.com +[security-mail]: mailto:rest-framework-jsonapi-security@googlegroups.com From 2eea7725e85d908740671352951cbf854f28bc3f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 17 May 2021 21:15:10 +0400 Subject: [PATCH 308/488] Added initial issue templates (#941) Co-authored-by: Alan Crosswell --- .github/ISSUE_TEMPLATE/bug_report.md | 15 +++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..7cb238e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,15 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +## Description of the Bug Report + +## Checklist + +- [ ] Certain that this is a bug (if unsure or you have a question use [discussions](https://github.com/django-json-api/django-rest-framework-json-api/discussions) instead) +- [ ] Code snippet or unit test added to reproduce bug diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..99156b88 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Please only raise a feature request if you've been advised to do so after discussion. + Thanks! +title: '' +labels: enhancement +assignees: '' + +--- + +## Description of feature request + +## Checklist + +- [ ] Raised initially as discussion #... +- [ ] This cannot be dealt with as a third party library. (We prefer new functionality to be in the form of third party libraries where feasible.) +- [ ] I have reduced the issue to the simplest possible case. From 858119e0949bb813077c15a906b24dd073f09147 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 28 May 2021 21:01:41 +0200 Subject: [PATCH 309/488] Use PreloadIncludesMixin for ReadOnlyModelViewSet (#946) --- AUTHORS | 1 + CHANGELOG.md | 6 ++++ docs/usage.md | 2 +- example/migrations/0009_labresults_author.py | 25 ++++++++++++++ example/models.py | 7 ++++ example/serializers.py | 4 ++- example/tests/test_performance.py | 34 +++++++++++++++++++- example/urls.py | 2 ++ example/urls_test.py | 2 ++ example/views.py | 27 ++++++++++++++-- rest_framework_json_api/views.py | 2 +- 11 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 example/migrations/0009_labresults_author.py diff --git a/AUTHORS b/AUTHORS index 500dc3c6..8d2af17d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -14,6 +14,7 @@ Jamie Bliss Jason Housley Jeppe Fihl-Pearson Jerel Unruh +Jonas Metzener Jonathan Senecal Joseba Mendivil Kevin Partington diff --git a/CHANGELOG.md b/CHANGELOG.md index d7155df3..508da91f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## Unreleased + +### Fixed + +* Include `PreloadIncludesMixin` in `ReadOnlyModelViewSet` to enable the usage of [performance utilities](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#performance-improvements) on read only views (regression since 2.8.0) + ## [4.2.0] - 2021-05-12 ### Added diff --git a/docs/usage.md b/docs/usage.md index 79a2d918..1e45976b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -942,7 +942,7 @@ class QuestSerializer(serializers.ModelSerializer): Be aware that using included resources without any form of prefetching **WILL HURT PERFORMANCE** as it will introduce m\*(n+1) queries. -A viewset helper was therefore designed to automatically preload data when possible. Such is automatically available when subclassing `ModelViewSet`. +A viewset helper was therefore designed to automatically preload data when possible. Such is automatically available when subclassing `ModelViewSet` or `ReadOnlyModelViewSet`. It also allows to define custom `select_related` and `prefetch_related` for each requested `include` when needed in special cases: diff --git a/example/migrations/0009_labresults_author.py b/example/migrations/0009_labresults_author.py new file mode 100644 index 00000000..6365d01c --- /dev/null +++ b/example/migrations/0009_labresults_author.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.3 on 2021-05-26 03:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("example", "0008_labresults"), + ] + + operations = [ + migrations.AddField( + model_name="labresults", + name="author", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="lab_results", + to="example.author", + ), + ), + ] diff --git a/example/models.py b/example/models.py index 47537b57..c3785c27 100644 --- a/example/models.py +++ b/example/models.py @@ -161,6 +161,13 @@ class LabResults(models.Model): ) date = models.DateField() measurements = models.TextField() + author = models.ForeignKey( + Author, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="lab_results", + ) class Company(models.Model): diff --git a/example/serializers.py b/example/serializers.py index 4d80c87c..64444e9e 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -356,9 +356,11 @@ class Meta: class LabResultsSerializer(serializers.ModelSerializer): + included_serializers = {"author": AuthorSerializer} + class Meta: model = LabResults - fields = ("date", "measurements") + fields = ("date", "measurements", "author") class ProjectSerializer(serializers.PolymorphicModelSerializer): diff --git a/example/tests/test_performance.py b/example/tests/test_performance.py index cae11ed4..ec4a81b2 100644 --- a/example/tests/test_performance.py +++ b/example/tests/test_performance.py @@ -1,8 +1,11 @@ +from datetime import date, timedelta +from random import randint + from django.utils import timezone from rest_framework.test import APITestCase from example.factories import CommentFactory, EntryFactory -from example.models import Author, Blog, Comment, Entry +from example.models import Author, Blog, Comment, Entry, LabResults, ResearchProject class PerformanceTestCase(APITestCase): @@ -84,3 +87,32 @@ def test_query_prefetch_uses_included_resources(self): "/entries?fields[entries]=comments&page[size]=25" ) self.assertEqual(len(response.data["results"]), 25) + + def test_query_prefetch_read_only(self): + """We expect a read only list view with an include have five queries: + + 1. Primary resource COUNT query + 2. Primary resource SELECT + 3. Authors prefetched + 4. Author types prefetched + 5. Entries prefetched + """ + project = ResearchProject.objects.create( + topic="Mars Mission", supervisor="Elon Musk" + ) + + LabResults.objects.bulk_create( + [ + LabResults( + research_project=project, + date=date.today() + timedelta(days=i), + measurements=randint(0, 10000), + author=self.author, + ) + for i in range(20) + ] + ) + + with self.assertNumQueries(5): + response = self.client.get("/lab-results?include=author&page[size]=25") + self.assertEqual(len(response.data["results"]), 20) diff --git a/example/urls.py b/example/urls.py index 800b9f79..867f1bd7 100644 --- a/example/urls.py +++ b/example/urls.py @@ -17,6 +17,7 @@ CompanyViewset, EntryRelationshipView, EntryViewSet, + LabResultViewSet, NonPaginatedEntryViewSet, ProjectTypeViewset, ProjectViewset, @@ -32,6 +33,7 @@ router.register(r"companies", CompanyViewset) router.register(r"projects", ProjectViewset) router.register(r"project-types", ProjectTypeViewset) +router.register(r"lab-results", LabResultViewSet) urlpatterns = [ url(r"^", include(router.urls)), diff --git a/example/urls_test.py b/example/urls_test.py index 0219ac51..5ee06a23 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -15,6 +15,7 @@ EntryRelationshipView, EntryViewSet, FiltersetEntryViewSet, + LabResultViewSet, NoFiltersetEntryViewSet, NonPaginatedEntryViewSet, ProjectTypeViewset, @@ -36,6 +37,7 @@ router.register(r"companies", CompanyViewset) router.register(r"projects", ProjectViewset) router.register(r"project-types", ProjectTypeViewset) +router.register(r"lab-results", LabResultViewSet) # for the old tests router.register(r"identities", Identity) diff --git a/example/views.py b/example/views.py index 6a1b15a6..0b35d4e4 100644 --- a/example/views.py +++ b/example/views.py @@ -14,9 +14,22 @@ ) from rest_framework_json_api.pagination import JsonApiPageNumberPagination from rest_framework_json_api.utils import format_drf_errors -from rest_framework_json_api.views import ModelViewSet, RelationshipView +from rest_framework_json_api.views import ( + ModelViewSet, + ReadOnlyModelViewSet, + RelationshipView, +) -from example.models import Author, Blog, Comment, Company, Entry, Project, ProjectType +from example.models import ( + Author, + Blog, + Comment, + Company, + Entry, + LabResults, + Project, + ProjectType, +) from example.serializers import ( AuthorDetailSerializer, AuthorListSerializer, @@ -27,6 +40,7 @@ CompanySerializer, EntryDRFSerializers, EntrySerializer, + LabResultsSerializer, ProjectSerializer, ProjectTypeSerializer, ) @@ -266,3 +280,12 @@ class CommentRelationshipView(RelationshipView): class AuthorRelationshipView(RelationshipView): queryset = Author.objects.all() self_link_view_name = "author-relationships" + + +class LabResultViewSet(ReadOnlyModelViewSet): + queryset = LabResults.objects.all() + serializer_class = LabResultsSerializer + prefetch_for_includes = { + "__all__": [], + "author": ["author__bio", "author__entries"], + } diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index e457009b..bb6f09bb 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -224,7 +224,7 @@ class ModelViewSet( class ReadOnlyModelViewSet( - AutoPrefetchMixin, RelatedMixin, viewsets.ReadOnlyModelViewSet + AutoPrefetchMixin, PreloadIncludesMixin, RelatedMixin, viewsets.ReadOnlyModelViewSet ): http_method_names = ["get", "post", "patch", "delete", "head", "options"] From 74d2ff855ccdaff55681a8dbce5d808b0e6d6516 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 13 Jun 2021 21:32:39 +0200 Subject: [PATCH 310/488] Scheduled biweekly dependency update for week 23 (#950) * Update black from 21.4b2 to 21.5b2 * Update flake8 from 3.9.1 to 3.9.2 * Update sphinx from 3.5.4 to 4.0.2 * Update faker from 8.1.2 to 8.5.1 * Update pytest from 6.2.3 to 6.2.4 * Update pytest-cov from 2.11.1 to 2.12.1 * Update pytest-django from 4.2.0 to 4.4.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 8395b9e1..550ec311 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==21.4b2 -flake8==3.9.1 +black==21.5b2 +flake8==3.9.2 flake8-isort==4.0.0 isort==5.8.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 1d4da97e..8246714f 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==3.5.4 +Sphinx==4.0.2 sphinx_rtd_theme==0.5.2 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 7887a137..1eabec55 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2.1 factory-boy==3.2.0 -Faker==8.1.2 -pytest==6.2.3 -pytest-cov==2.11.1 -pytest-django==4.2.0 +Faker==8.5.1 +pytest==6.2.4 +pytest-cov==2.12.1 +pytest-django==4.4.0 pytest-factoryboy==2.1.0 snapshottest==0.6.0 From b49aa6e9a4ed317446861c1d035fd0e4ae84e142 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 6 Jul 2021 22:57:09 +0400 Subject: [PATCH 311/488] Remove invalid validation of default included_resources (#956) --- CHANGELOG.md | 3 ++- example/serializers.py | 3 +++ example/tests/test_serializers.py | 20 +++++++++++++++++++- rest_framework_json_api/serializers.py | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 508da91f..48a5bd7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## Unreleased +## [Unreleased] ### Fixed * Include `PreloadIncludesMixin` in `ReadOnlyModelViewSet` to enable the usage of [performance utilities](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#performance-improvements) on read only views (regression since 2.8.0) +* Remove invalid validation of default `included_resources` (regression since 4.2.0) ## [4.2.0] - 2021-05-12 diff --git a/example/serializers.py b/example/serializers.py index 64444e9e..43415243 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -321,6 +321,9 @@ class Meta: # fields = ('entry', 'body', 'author',) meta_fields = ("modified_days_ago",) + class JSONAPIMeta: + included_resources = ("writer",) + def get_modified_days_ago(self, obj): return (datetime.now() - obj.modified_at).days diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index 7550fd2c..9cfe6db9 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -198,7 +198,25 @@ def test_model_serializer_with_implicit_fields(self, comment, client): "meta": { "modifiedDaysAgo": (datetime.now() - comment.modified_at).days }, - } + }, + "included": [ + { + "attributes": { + "email": comment.author.email, + "name": comment.author.name, + }, + "id": str(comment.author.pk), + "relationships": { + "bio": { + "data": { + "id": str(comment.author.bio.pk), + "type": "authorBios", + } + } + }, + "type": "writers", + } + ], } response = client.get(reverse("comment-detail", kwargs={"pk": comment.pk})) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 5ca773d0..a73a5d47 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -137,7 +137,7 @@ def validate_path(serializer_class, field_path, path): validate_path(this_included_serializer, new_included_field_path, path) if request and view: - included_resources = get_included_resources(request, self) + included_resources = get_included_resources(request) for included_field_name in included_resources: included_field_path = included_field_name.split(".") if "related_field" in view.kwargs: From cb023fc5f6f94aa532746e6c153efcf79e336367 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 6 Jul 2021 23:16:15 +0400 Subject: [PATCH 312/488] Release 4.2.1 (#958) --- CHANGELOG.md | 6 +++--- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a5bd7a..3206cfa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [4.2.1] - 2021-07-06 ### Fixed -* Include `PreloadIncludesMixin` in `ReadOnlyModelViewSet` to enable the usage of [performance utilities](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#performance-improvements) on read only views (regression since 2.8.0) -* Remove invalid validation of default `included_resources` (regression since 4.2.0) +* Included `PreloadIncludesMixin` in `ReadOnlyModelViewSet` to enable the usage of [performance utilities](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#performance-improvements) on read only views (regression since 2.8.0) +* Removed invalid validation of default `included_resources` (regression since 4.2.0) ## [4.2.0] - 2021-05-12 diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index d166001f..8f8e5dd7 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = "djangorestframework-jsonapi" -__version__ = "4.2.0" +__version__ = "4.2.1" __author__ = "" __license__ = "BSD" __copyright__ = "" From 684063b4a878afd834c712ef2ea1cb8b8dffe2bf Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 16 Jul 2021 17:42:12 +0400 Subject: [PATCH 313/488] Resolve warning of inconsistent result when call LabResults view (#959) As LabResults is used in a test with pagination, a default ordering is required to omit warning about unpredictable order. --- example/migrations/0010_auto_20210714_0809.py | 17 +++++++++++++++++ example/models.py | 3 +++ 2 files changed, 20 insertions(+) create mode 100644 example/migrations/0010_auto_20210714_0809.py diff --git a/example/migrations/0010_auto_20210714_0809.py b/example/migrations/0010_auto_20210714_0809.py new file mode 100644 index 00000000..de36ba20 --- /dev/null +++ b/example/migrations/0010_auto_20210714_0809.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.9 on 2021-07-14 08:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("example", "0009_labresults_author"), + ] + + operations = [ + migrations.AlterModelOptions( + name="labresults", + options={"ordering": ("id",)}, + ), + ] diff --git a/example/models.py b/example/models.py index c3785c27..18b965a3 100644 --- a/example/models.py +++ b/example/models.py @@ -169,6 +169,9 @@ class LabResults(models.Model): related_name="lab_results", ) + class Meta: + ordering = ("id",) + class Company(models.Model): name = models.CharField(max_length=100) From ca627a98c46b298e2ebdfbed1e5e483068ac857c Mon Sep 17 00:00:00 2001 From: Safa Alfulaij Date: Sun, 18 Jul 2021 22:06:40 +0300 Subject: [PATCH 314/488] Load included and related serializers in meta class (#926) --- CHANGELOG.md | 11 ++++- rest_framework_json_api/relations.py | 3 +- rest_framework_json_api/renderers.py | 8 ++-- rest_framework_json_api/schemas/openapi.py | 9 +--- rest_framework_json_api/serializers.py | 54 ++++++++++++++++++++-- rest_framework_json_api/utils.py | 20 +++----- rest_framework_json_api/views.py | 3 -- tests/test_serializers.py | 35 ++++++++++++++ tests/test_utils.py | 14 ++++-- 9 files changed, 118 insertions(+), 39 deletions(-) create mode 100644 tests/test_serializers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3206cfa8..d9eaf504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Changed + +* Moved resolving of `included_serialzers` and `related_serializers` classes to serializer's meta class. + +### Deprecated + +* Deprecated `get_included_serializers(serializer)` function under `rest_framework_json_api.utils`. Use `serializer.included_serializers` instead. + ## [4.2.1] - 2021-07-06 ### Fixed @@ -40,7 +50,6 @@ any parts of the framework not mentioned in the documentation should generally b * Deprecated default `format_type` argument of `rest_framework_json_api.utils.format_value`. Use `rest_framework_json_api.utils.format_field_name` or specify specifc `format_type` instead. * Deprecated `format_type` argument of `rest_framework_json_api.utils.format_link_segment`. Use `rest_framework_json_api.utils.format_value` instead. - ## [4.1.0] - 2021-03-08 ### Added diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index c9dd765d..605f7c1c 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -16,7 +16,6 @@ from rest_framework_json_api.utils import ( Hyperlink, format_link_segment, - get_included_serializers, get_resource_type_from_instance, get_resource_type_from_queryset, get_resource_type_from_serializer, @@ -274,7 +273,7 @@ def get_resource_type_from_included_serializer(self): inflection.singularize(field_name), inflection.pluralize(field_name), ] - includes = get_included_serializers(parent) + includes = getattr(parent, "included_serializers", dict()) for field in field_names: if field in includes.keys(): return get_resource_type_from_serializer(includes[field]) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index b50c2b1a..3c649331 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -284,7 +284,9 @@ def extract_included( current_serializer = fields.serializer context = current_serializer.context - included_serializers = utils.get_included_serializers(current_serializer) + included_serializers = getattr( + current_serializer, "included_serializers", dict() + ) included_resources = copy.copy(included_resources) included_resources = [ inflection.underscore(value) for value in included_resources @@ -692,8 +694,8 @@ def _get_included_serializers(cls, serializer, prefix="", already_seen=None): included_serializers = [] already_seen.add(serializer) - for include, included_serializer in utils.get_included_serializers( - serializer + for include, included_serializer in getattr( + serializer, "included_serializers", dict() ).items(): included_serializers.append(f"{prefix}{include}") included_serializers.extend( diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 77a0d4ed..efe14914 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -1,7 +1,6 @@ import warnings from urllib.parse import urljoin -from django.utils.module_loading import import_string as import_class_from_dotted_path from rest_framework.fields import empty from rest_framework.relations import ManyRelatedField from rest_framework.schemas import openapi as drf_openapi @@ -379,13 +378,7 @@ def _find_related_view(self, view_endpoints, related_serializer, parent_view): """ for path, method, view in view_endpoints: view_serializer = view.get_serializer() - if not isinstance(related_serializer, type): - related_serializer_class = import_class_from_dotted_path( - related_serializer - ) - else: - related_serializer_class = related_serializer - if isinstance(view_serializer, related_serializer_class): + if isinstance(view_serializer, related_serializer): return view return None diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index a73a5d47..bc60f193 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,8 +1,10 @@ from collections import OrderedDict +from collections.abc import Mapping import inflection from django.core.exceptions import ObjectDoesNotExist from django.db.models.query import QuerySet +from django.utils.module_loading import import_string as import_class_from_dotted_path from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ParseError @@ -22,7 +24,6 @@ from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.utils import ( get_included_resources, - get_included_serializers, get_resource_type_from_instance, get_resource_type_from_model, get_resource_type_from_serializer, @@ -120,7 +121,7 @@ def __init__(self, *args, **kwargs): view = context.get("view") if context else None def validate_path(serializer_class, field_path, path): - serializers = get_included_serializers(serializer_class) + serializers = getattr(serializer_class, "included_serializers", None) if serializers is None: raise ParseError("This endpoint does not support the include parameter") this_field_name = inflection.underscore(field_path[0]) @@ -152,8 +153,55 @@ def validate_path(serializer_class, field_path, path): super(IncludedResourcesValidationMixin, self).__init__(*args, **kwargs) +class LazySerializersDict(Mapping): + """ + A dictionary of serializers which lazily import dotted class path and self. + """ + + def __init__(self, parent, serializers): + self.parent = parent + self.serializers = serializers + + def __getitem__(self, key): + value = self.serializers[key] + if not isinstance(value, type): + if value == "self": + value = self.parent + else: + value = import_class_from_dotted_path(value) + self.serializers[key] = value + + return value + + def __iter__(self): + return iter(self.serializers) + + def __len__(self): + return len(self.serializers) + + def __repr__(self): + return dict.__repr__(self.serializers) + + class SerializerMetaclass(SerializerMetaclass): - pass + def __new__(cls, name, bases, attrs): + serializer = super().__new__(cls, name, bases, attrs) + + if attrs.get("included_serializers", None): + setattr( + serializer, + "included_serializers", + LazySerializersDict(serializer, attrs["included_serializers"]), + ) + + if attrs.get("related_serializers", None): + setattr( + serializer, + "related_serializers", + LazySerializersDict(serializer, attrs["related_serializers"]), + ) + + return serializer # If user imports serializer from here we can catch class definition and check diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index ac31979a..19c72809 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -1,4 +1,3 @@ -import copy import inspect import operator import warnings @@ -13,7 +12,6 @@ ) from django.http import Http404 from django.utils import encoding -from django.utils.module_loading import import_string as import_class_from_dotted_path from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions from rest_framework.exceptions import APIException @@ -342,20 +340,14 @@ def get_default_included_resources_from_serializer(serializer): def get_included_serializers(serializer): - included_serializers = copy.copy( - getattr(serializer, "included_serializers", dict()) + warnings.warn( + DeprecationWarning( + "Using of `get_included_serializers(serializer)` function is deprecated." + "Use `serializer.included_serializers` instead." + ) ) - for name, value in iter(included_serializers.items()): - if not isinstance(value, type): - if value == "self": - included_serializers[name] = ( - serializer if isinstance(serializer, type) else serializer.__class__ - ) - else: - included_serializers[name] = import_class_from_dotted_path(value) - - return included_serializers + return getattr(serializer, "included_serializers", dict()) def get_relation_instance(resource_instance, source, serializer): diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index bb6f09bb..6b739582 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -11,7 +11,6 @@ from django.db.models.manager import Manager from django.db.models.query import QuerySet from django.urls import NoReverseMatch -from django.utils.module_loading import import_string as import_class_from_dotted_path from rest_framework import generics, viewsets from rest_framework.exceptions import MethodNotAllowed, NotFound from rest_framework.fields import get_attribute @@ -183,8 +182,6 @@ def get_related_serializer_class(self): False ), 'Either "included_serializers" or "related_serializers" should be configured' - if not isinstance(_class, type): - return import_class_from_dotted_path(_class) return _class return parent_serializer_class diff --git a/tests/test_serializers.py b/tests/test_serializers.py new file mode 100644 index 00000000..6726fa8c --- /dev/null +++ b/tests/test_serializers.py @@ -0,0 +1,35 @@ +from django.db import models + +from rest_framework_json_api import serializers +from tests.models import DJAModel, ManyToManyTarget +from tests.serializers import ManyToManyTargetSerializer + + +def test_get_included_serializers(): + class IncludedSerializersModel(DJAModel): + self = models.ForeignKey("self", on_delete=models.CASCADE) + target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) + other_target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) + + class Meta: + app_label = "tests" + + class IncludedSerializersSerializer(serializers.ModelSerializer): + included_serializers = { + "self": "self", + "target": ManyToManyTargetSerializer, + "other_target": "tests.serializers.ManyToManyTargetSerializer", + } + + class Meta: + model = IncludedSerializersModel + fields = ("self", "other_target", "target") + + included_serializers = IncludedSerializersSerializer.included_serializers + expected_included_serializers = { + "self": IncludedSerializersSerializer, + "target": ManyToManyTargetSerializer, + "other_target": ManyToManyTargetSerializer, + } + + assert included_serializers == expected_included_serializers diff --git a/tests/test_utils.py b/tests/test_utils.py index 00bf0836..43c12dcf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -346,7 +346,7 @@ class PlainRelatedResourceTypeSerializer(serializers.Serializer): def test_get_included_serializers(): - class IncludedSerializersModel(DJAModel): + class DeprecatedIncludedSerializersModel(DJAModel): self = models.ForeignKey("self", on_delete=models.CASCADE) target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) other_target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) @@ -354,7 +354,7 @@ class IncludedSerializersModel(DJAModel): class Meta: app_label = "tests" - class IncludedSerializersSerializer(serializers.ModelSerializer): + class DeprecatedIncludedSerializersSerializer(serializers.ModelSerializer): included_serializers = { "self": "self", "target": ManyToManyTargetSerializer, @@ -362,12 +362,16 @@ class IncludedSerializersSerializer(serializers.ModelSerializer): } class Meta: - model = IncludedSerializersModel + model = DeprecatedIncludedSerializersModel fields = ("self", "other_target", "target") - included_serializers = get_included_serializers(IncludedSerializersSerializer) + with pytest.deprecated_call(): + included_serializers = get_included_serializers( + DeprecatedIncludedSerializersSerializer + ) + expected_included_serializers = { - "self": IncludedSerializersSerializer, + "self": DeprecatedIncludedSerializersSerializer, "target": ManyToManyTargetSerializer, "other_target": ManyToManyTargetSerializer, } From 4c62f1d664375c1df1d3a52d12790403530f6183 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 27 Jul 2021 01:44:37 +0400 Subject: [PATCH 315/488] Resolved invalid example app browse to list in README (#961) --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 03f9d52a..4cc41c5c 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,7 @@ installed and activated: $ django-admin runserver --settings=example.settings Browse to + * http://localhost:8000 for the list of available collections (in a non-JSONAPI format!), * http://localhost:8000/swagger-ui/ for a Swagger user interface to the dynamic schema view, or * http://localhost:8000/openapi for the schema view's OpenAPI specification document. From d20247fd8d3d4e1dca8482fa218e192c91ca40e0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 27 Jul 2021 01:47:08 +0400 Subject: [PATCH 316/488] Documented how to add DJA to installed apps (#963) This setting is now needed for browsable api and openapi schema. There might be more features coming needing this and it is also common practice to do this for django apps. Co-authored-by: Alan Crosswell --- README.rst | 18 ++++++++++++++---- docs/getting-started.md | 14 ++++++++++++-- docs/usage.md | 2 +- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 4cc41c5c..9cfb0e5f 100644 --- a/README.rst +++ b/README.rst @@ -100,8 +100,7 @@ Generally Python and Django series are supported till the official end of life. Installation ------------ -From PyPI -^^^^^^^^^ +Install using ``pip``... :: @@ -112,8 +111,7 @@ From PyPI $ pip install djangorestframework-jsonapi['openapi'] -From Source -^^^^^^^^^^^ +or from source... :: @@ -122,6 +120,18 @@ From Source $ pip install -e . +and add ``rest_framework_json_api`` to your ``INSTALLED_APPS`` setting below ``rest_framework``. + +:: + + INSTALLED_APPS = [ + ... + 'rest_framework', + 'rest_framework_json_api', + ... + ] + + Running the example app ^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/getting-started.md b/docs/getting-started.md index 5e374b30..debef71b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -61,7 +61,7 @@ Generally Python and Django series are supported till the official end of life. ## Installation -From PyPI +Install using `pip`... pip install djangorestframework-jsonapi # for optional package integrations @@ -69,11 +69,21 @@ From PyPI pip install djangorestframework-jsonapi['django-polymorphic'] pip install djangorestframework-jsonapi['openapi'] -From Source +or from source... git clone https://github.com/django-json-api/django-rest-framework-json-api.git cd django-rest-framework-json-api && pip install -e . + +and add `rest_framework_json_api` to your `INSTALLED_APPS` setting below `rest_framework`. + + INSTALLED_APPS = [ + ... + 'rest_framework', + 'rest_framework_json_api', + ... + ] + ## Running the example app git clone https://github.com/django-json-api/django-rest-framework-json-api.git diff --git a/docs/usage.md b/docs/usage.md index 1e45976b..e6109713 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -998,7 +998,7 @@ The `prefetch_related` case will issue 4 queries, but they will be small and fas ## Generating an OpenAPI Specification (OAS) 3.0 schema document -DRF >= 3.12 has a [new OAS schema functionality](https://www.django-rest-framework.org/api-guide/schemas/) to generate an +DRF has a [OAS schema functionality](https://www.django-rest-framework.org/api-guide/schemas/) to generate an [OAS 3.0 schema](https://www.openapis.org/) as a YAML or JSON file. DJA extends DRF's schema support to generate an OAS schema in the JSON:API format. From 5d25e2f91be7584dbe707cd8f0664895114c47e3 Mon Sep 17 00:00:00 2001 From: MsMansiDhruv <38699181+MsMansiDhruv@users.noreply.github.com> Date: Sun, 15 Aug 2021 00:17:29 +0530 Subject: [PATCH 317/488] Unified usage of JSON:API abbreviation (#945) --- AUTHORS | 1 + CHANGELOG.md | 12 +++++++---- README.rst | 14 ++++++------- SECURITY.md | 2 +- docs/CONTRIBUTING.md | 4 ++-- docs/conf.py | 16 +++++++-------- docs/getting-started.md | 6 +++--- docs/index.rst | 4 ++-- docs/usage.md | 12 +++++------ example/tests/test_filters.py | 2 +- example/tests/test_generic_viewset.py | 2 +- requirements.txt | 2 +- .../django_filters/backends.py | 6 +++--- rest_framework_json_api/exceptions.py | 6 +++--- rest_framework_json_api/filters.py | 2 +- rest_framework_json_api/pagination.py | 2 +- rest_framework_json_api/parsers.py | 10 +++++----- rest_framework_json_api/renderers.py | 6 +++--- rest_framework_json_api/schemas/openapi.py | 20 +++++++++---------- rest_framework_json_api/serializers.py | 4 ++-- rest_framework_json_api/settings.py | 6 +++--- setup.py | 2 +- tests/test_parsers.py | 5 +++-- 23 files changed, 75 insertions(+), 71 deletions(-) diff --git a/AUTHORS b/AUTHORS index 8d2af17d..03da87a8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -21,6 +21,7 @@ Kevin Partington Kieran Evans Léo S. Luc Cary +Mansi Dhruv Matt Layman Michael Haselton Mohammed Ali Zubair diff --git a/CHANGELOG.md b/CHANGELOG.md index d9eaf504..c8c5e9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Fixed + +* Adjusted error messages to correctly use capitial "JSON:API" abbreviation as used in the specification. + ### Changed * Moved resolving of `included_serialzers` and `related_serializers` classes to serializer's meta class. @@ -104,10 +108,10 @@ This is the last release supporting Django 1.11, Django 2.1, Django REST Framewo ### Added * Added support for serializing nested serializers as attribute json value introducing setting `JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE` - * Note: As keys of nested serializers are not json:api spec field names they are not inflected by format field names option. -* Added `rest_framework_json_api.serializer.Serializer` class to support initial JSON API views without models. + * Note: As keys of nested serializers are not JSON:API spec field names they are not inflected by format field names option. +* Added `rest_framework_json_api.serializer.Serializer` class to support initial JSON:API views without models. * Note that serializers derived from this class need to define `resource_name` in their `Meta` class. - * This fix might be a **BREAKING CHANGE** if you use `rest_framework_json_api.serializers.Serializer` for non json:api spec views (usually `APIView`). You need to change those serializers classes to use `rest_framework.serializers.Serializer` instead. + * This fix might be a **BREAKING CHANGE** if you use `rest_framework_json_api.serializers.Serializer` for non JSON:API spec views (usually `APIView`). You need to change those serializers classes to use `rest_framework.serializers.Serializer` instead. ### Fixed @@ -202,7 +206,7 @@ This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, D * Don't swallow `filter[]` params when there are several * Fix DeprecationWarning regarding collections.abc import in Python 3.7 * Allow OPTIONS request to be used on RelationshipView -* Remove non-JSONAPI methods (PUT and TRACE) from ModelViewSet and RelationshipView. +* Remove non-JSON:API methods (PUT and TRACE) from ModelViewSet and RelationshipView. This fix might be a **BREAKING CHANGE** if your clients are incorrectly using PUT instead of PATCH. * Avoid validation error for missing fields on a PATCH request using polymorphic serializers diff --git a/README.rst b/README.rst index 9cfb0e5f..b52977a8 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ ================================== -JSON API and Django Rest Framework +JSON:API and Django Rest Framework ================================== .. image:: https://github.com/django-json-api/django-rest-framework-json-api/workflows/Tests/badge.svg @@ -18,7 +18,7 @@ JSON API and Django Rest Framework Overview -------- -**JSON API support for Django REST Framework** +**JSON:API support for Django REST Framework** * Documentation: https://django-rest-framework-json-api.readthedocs.org/ * Format specification: http://jsonapi.org/format/ @@ -38,7 +38,7 @@ By default, Django REST Framework will produce a response like:: } -However, for an ``identity`` model in JSON API format the response should look +However, for an ``identity`` model in JSON:API format the response should look like the following:: { @@ -67,9 +67,9 @@ like the following:: Goals ----- -As a Django REST Framework JSON API (short DJA) we are trying to address following goals: +As a Django REST Framework JSON:API (short DJA) we are trying to address following goals: -1. Support the `JSON API`_ spec to compliance +1. Support the `JSON:API`_ spec to compliance 2. Be as compatible with `Django REST Framework`_ as possible @@ -81,7 +81,7 @@ As a Django REST Framework JSON API (short DJA) we are trying to address followi 5. Be performant -.. _JSON API: http://jsonapi.org +.. _JSON:API: http://jsonapi.org .. _Django REST Framework: https://www.django-rest-framework.org/ ------------ @@ -149,7 +149,7 @@ installed and activated: Browse to -* http://localhost:8000 for the list of available collections (in a non-JSONAPI format!), +* http://localhost:8000 for the list of available collections (in a non-JSON:API format!), * http://localhost:8000/swagger-ui/ for a Swagger user interface to the dynamic schema view, or * http://localhost:8000/openapi for the schema view's OpenAPI specification document. diff --git a/SECURITY.md b/SECURITY.md index 78781946..c30001ac 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a Vulnerability -If you believe you've found something in Django REST Framework JSON API which has security implications, please **do not raise the issue in a public forum**. +If you believe you've found something in Django REST Framework JSON:API which has security implications, please **do not raise the issue in a public forum**. Send a description of the issue via email to [rest-framework-jsonapi-security@googlegroups.com][security-mail]. The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index be1d0499..df054335 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Django REST Framework JSON API (aka DJA) should be easy to contribute to. +Django REST Framework JSON:API (aka DJA) should be easy to contribute to. If anything is unclear about how to contribute, please submit an issue on GitHub so that we can fix it! @@ -11,7 +11,7 @@ if the proposed change makes sense for the project. ### Clone -To start developing on Django REST Framework JSON API you need to first clone the repository: +To start developing on Django REST Framework JSON:API you need to first clone the repository: git clone https://github.com/django-json-api/django-rest-framework-json-api.git diff --git a/docs/conf.py b/docs/conf.py index 0b8caa69..9d4e12ae 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# Django REST Framework JSON API documentation build configuration file, created by +# Django REST Framework JSON:API documentation build configuration file, created by # sphinx-quickstart on Fri Jul 24 23:31:15 2015. # # This file is execfile()d with the current directory set to its @@ -59,10 +59,10 @@ master_doc = "index" # General information about the project. -project = "Django REST Framework JSON API" +project = "Django REST Framework JSON:API" year = datetime.date.today().year -copyright = "{}, Django REST Framework JSON API contributors".format(year) -author = "Django REST Framework JSON API contributors" +copyright = "{}, Django REST Framework JSON:API contributors".format(year) +author = "Django REST Framework JSON:API contributors" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -244,8 +244,8 @@ ( master_doc, "DjangoRESTFrameworkJSONAPI.tex", - "Django REST Framework JSON API Documentation", - "Django REST Framework JSON API contributors", + "Django REST Framework JSON:API Documentation", + "Django REST Framework JSON:API contributors", "manual", ), ] @@ -279,7 +279,7 @@ ( master_doc, "djangorestframeworkjsonapi", - "Django REST Framework JSON API Documentation", + "Django REST Framework JSON:API Documentation", [author], 1, ) @@ -298,7 +298,7 @@ ( master_doc, "DjangoRESTFrameworkJSONAPI", - "Django REST Framework JSON API Documentation", + "Django REST Framework JSON:API Documentation", author, "DjangoRESTFrameworkJSONAPI", "One line description of project.", diff --git a/docs/getting-started.md b/docs/getting-started.md index debef71b..75522ddf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,7 +1,7 @@ # Getting Started -*Note: this package is named Django REST Framework JSON API to follow the naming +*Note: this package is named Django REST Framework JSON:API to follow the naming convention of other Django REST Framework packages. Since that's quite a bit to say or type this package will be referred to as DJA elsewhere in these docs.* @@ -20,7 +20,7 @@ By default, Django REST Framework produces a response like: ``` -However, for the same `identity` model in JSON API format the response should look +However, for the same `identity` model in JSON:API format the response should look like the following: ``` js { @@ -97,7 +97,7 @@ and add `rest_framework_json_api` to your `INSTALLED_APPS` setting below `rest_f Browse to -* [http://localhost:8000](http://localhost:8000) for the list of available collections (in a non-JSONAPI format!), +* [http://localhost:8000](http://localhost:8000) for the list of available collections (in a non-JSON:API format!), * [http://localhost:8000/swagger-ui/](http://localhost:8000/swagger-ui/) for a Swagger user interface to the dynamic schema view, or * [http://localhost:8000/openapi](http://localhost:8000/openapi) for the schema view's OpenAPI specification document. diff --git a/docs/index.rst b/docs/index.rst index b18b8b6e..ee2afd7a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,9 +1,9 @@ -.. Django REST Framework JSON API documentation master file, created by +.. Django REST Framework JSON:API documentation master file, created by sphinx-quickstart on Fri Jul 24 23:31:15 2015. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to Django REST Framework JSON API +Welcome to Django REST Framework JSON:API ========================================================== Contents: diff --git a/docs/usage.md b/docs/usage.md index e6109713..bf77f3dd 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -240,9 +240,9 @@ class MyViewset(ModelViewSet): ### Exception handling For the `exception_handler` class, if the optional `JSON_API_UNIFORM_EXCEPTIONS` is set to True, -all exceptions will respond with the JSON API [error format](http://jsonapi.org/format/#error-objects). +all exceptions will respond with the JSON:API [error format](http://jsonapi.org/format/#error-objects). -When `JSON_API_UNIFORM_EXCEPTIONS` is False (the default), non-JSON API views will respond +When `JSON_API_UNIFORM_EXCEPTIONS` is False (the default), non-JSON:API views will respond with the normal DRF error format. ### Performance Testing @@ -312,8 +312,7 @@ multiple endpoints. Setting the `resource_name` on views may result in a differe ### Inflecting object and relation keys -This package includes the ability (off by default) to automatically convert [json -api field names](http://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the python/rest_framework's preferred underscore to +This package includes the ability (off by default) to automatically convert [JSON:API field names](http://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the python/rest_framework's preferred underscore to a format of your choice. To hook this up include the following setting in your project settings: @@ -524,8 +523,7 @@ The relationship name is formatted by the `JSON_API_FORMAT_FIELD_NAMES` setting, #### ResourceRelatedField -Because of the additional structure needed to represent relationships in JSON -API, this package provides the `ResourceRelatedField` for serializers, which +Because of the additional structure needed to represent relationships in JSON:API, this package provides the `ResourceRelatedField` for serializers, which works similarly to `PrimaryKeyRelatedField`. By default, `rest_framework_json_api.serializers.ModelSerializer` will use this for related fields automatically. It can be instantiated explicitly as in the @@ -896,7 +894,7 @@ Related links will be created automatically when using the Relationship View. ### Included -JSON API can include additional resources in a single network request. +JSON:API can include additional resources in a single network request. The specification refers to this feature as [Compound Documents](http://jsonapi.org/format/#document-compound-documents). Compound Documents can reduce the number of network requests diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 68ad1452..10d8886a 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -104,7 +104,7 @@ def test_sort_underscore(self): def test_sort_related(self): """ - test sort via related field using jsonapi path `.` and django orm `__` notation. + test sort via related field using JSON:API path `.` and django orm `__` notation. ORM relations must be predefined in the View's .ordering_fields attr """ for datum in ("blog__id", "blog.id"): diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index a07eb610..c8a28f24 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -55,7 +55,7 @@ def test_ember_expected_renderer(self): def test_default_validation_exceptions(self): """ - Default validation exceptions should conform to json api spec + Default validation exceptions should conform to JSON:API spec """ expected = { "errors": [ diff --git a/requirements.txt b/requirements.txt index f1ba742f..ba98a85d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -# The base set of requirements for Django REST framework JSON API is actually +# The base set of requirements for Django REST framework JSON:API is actually # fairly small, but for the purposes of development and testing # there are a number of packages that are useful to install. diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 3906308c..09e64bc2 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -11,7 +11,7 @@ class DjangoFilterBackend(DjangoFilterBackend): """ A Django-style ORM filter implementation, using `django-filter`. - This is not part of the jsonapi standard per-se, other than the requirement + This is not part of the JSON:API standard per-se, other than the requirement to use the `filter` keyword: This is an optional implementation of style of filtering in which each filter is an ORM expression as implemented by DjangoFilterBackend and seems to be in alignment with an interpretation of @@ -50,7 +50,7 @@ class DjangoFilterBackend(DjangoFilterBackend): the name of the query parameter for searching to make sure it doesn't conflict with a field name defined in the filterset. The recommended value is: `search_param="filter[search]"` but just make sure it's - `filter[]` to comply with the jsonapi spec requirement to use the filter + `filter[]` to comply with the JSON:API spec requirement to use the filter keyword. The default is "search" unless overriden but it's used here just to make sure we don't complain about it being an invalid filter. """ @@ -117,7 +117,7 @@ def get_filterset_kwargs(self, request, queryset, view): raise ValidationError( "missing value for query parameter {}".format(qp) ) - # convert jsonapi relationship path to Django ORM's __ notation + # convert JSON:API relationship path to Django ORM's __ notation key = m.groupdict()["assoc"].replace(".", "__") key = undo_format_field_name(key) data.setlist(key, val) diff --git a/rest_framework_json_api/exceptions.py b/rest_framework_json_api/exceptions.py index 05f56756..b13ccad5 100644 --- a/rest_framework_json_api/exceptions.py +++ b/rest_framework_json_api/exceptions.py @@ -29,16 +29,16 @@ def exception_handler(exc, context): if not response: return response - # Use regular DRF format if not rendered by DRF JSON API and not uniform + # Use regular DRF format if not rendered by DRF JSON:API and not uniform is_json_api_view = rendered_with_json_api(context["view"]) is_uniform = json_api_settings.UNIFORM_EXCEPTIONS if not is_json_api_view and not is_uniform: return response - # Convert to DRF JSON API error format + # Convert to DRF JSON:API error format response = utils.format_drf_errors(response, context, exc) - # Add top-level 'errors' object when not rendered by DRF JSON API + # Add top-level 'errors' object when not rendered by DRF JSON:API if not is_json_api_view: response.data = utils.format_errors(response.data) diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index 95056666..3862057a 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -90,7 +90,7 @@ def validate_query_params(self, request): :raises ValidationError: if not. """ - # TODO: For jsonapi error object conformance, must set jsonapi errors "parameter" for + # TODO: For JSON:API error object conformance, must set JSON:API errors "parameter" for # the ValidationError. This requires extending DRF/DJA Exceptions. for qp in request.query_params.keys(): m = self.query_regex.match(qp) diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index 468f684c..6cbd744c 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -10,7 +10,7 @@ class JsonApiPageNumberPagination(PageNumberPagination): """ - A json-api compatible pagination format. + A JSON:API compatible pagination format. """ page_query_param = "page[number]" diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 433bcb32..434e2925 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -13,7 +13,7 @@ class JSONParser(parsers.JSONParser): Similar to `JSONRenderer`, the `JSONParser` you may override the following methods if you need highly custom parsing control. - A JSON API client will send a payload that looks like this: + A JSON:API client will send a payload that looks like this: .. code:: json @@ -87,7 +87,7 @@ def parse(self, stream, media_type=None, parser_context=None): from rest_framework_json_api.views import RelationshipView if isinstance(view, RelationshipView): - # We skip parsing the object as JSONAPI Resource Identifier Object and not a regular + # We skip parsing the object as JSON:API Resource Identifier Object and not a regular # Resource Object if isinstance(data, list): for resource_identifier_object in data: @@ -96,12 +96,12 @@ def parse(self, stream, media_type=None, parser_context=None): and resource_identifier_object.get("type") ): raise ParseError( - "Received data contains one or more malformed JSONAPI " + "Received data contains one or more malformed JSON:API " "Resource Identifier Object(s)" ) elif not (data.get("id") and data.get("type")): raise ParseError( - "Received data is not a valid JSONAPI Resource Identifier Object" + "Received data is not a valid JSON:API Resource Identifier Object" ) return data @@ -111,7 +111,7 @@ def parse(self, stream, media_type=None, parser_context=None): # Sanity check if not isinstance(data, dict): raise ParseError( - "Received data is not a valid JSONAPI Resource Identifier Object" + "Received data is not a valid JSON:API Resource Identifier Object" ) # Check for inconsistencies diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 3c649331..de9e3c32 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -29,7 +29,7 @@ class JSONRenderer(renderers.JSONRenderer): The `JSONRenderer` exposes a number of methods that you may override if you need highly custom rendering control. - Render a JSON response per the JSON API spec: + Render a JSON response per the JSON:API spec: .. code-block:: json @@ -54,11 +54,11 @@ class JSONRenderer(renderers.JSONRenderer): @classmethod def extract_attributes(cls, fields, resource): """ - Builds the `attributes` object of the JSON API resource object. + Builds the `attributes` object of the JSON:API resource object. """ data = OrderedDict() for field_name, field in iter(fields.items()): - # ID is always provided in the root of JSON API so remove it from attributes + # ID is always provided in the root of JSON:API so remove it from attributes if field_name == "id": continue # don't output a key for write only fields diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index efe14914..2dff2e10 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -11,10 +11,10 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): """ - Extend DRF's SchemaGenerator to implement jsonapi-flavored generateschema command. + Extend DRF's SchemaGenerator to implement JSON:API flavored generateschema command. """ - #: These JSONAPI component definitions are referenced by the generated OAS schema. + #: These JSON:API component definitions are referenced by the generated OAS schema. #: If you need to add more or change these static component definitions, extend this dict. jsonapi_components = { "schemas": { @@ -258,7 +258,7 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): def get_schema(self, request=None, public=False): """ - Generate a JSONAPI OpenAPI schema. + Generate a JSON:API OpenAPI schema. Overrides upstream DRF's get_schema. """ # TODO: avoid copying so much of upstream get_schema() @@ -393,15 +393,15 @@ def _field_is_one_or_many(self, field, view): class AutoSchema(drf_openapi.AutoSchema): """ - Extend DRF's openapi.AutoSchema for JSONAPI serialization. + Extend DRF's openapi.AutoSchema for JSON:API serialization. """ - #: ignore all the media types and only generate a JSONAPI schema. + #: ignore all the media types and only generate a JSON:API schema. content_types = ["application/vnd.api+json"] def get_operation(self, path, method): """ - JSONAPI adds some standard fields to the API response that are not in upstream DRF: + JSON:API adds some standard fields to the API response that are not in upstream DRF: - some that only apply to GET/HEAD methods. - collections - special handling for POST, PATCH, DELETE @@ -505,7 +505,7 @@ def _add_get_item_response(self, operation): def _get_toplevel_200_response(self, operation, collection=True): """ - return top-level JSONAPI GET 200 response + return top-level JSON:API GET 200 response :param collection: True for collections; False for individual items. @@ -587,7 +587,7 @@ def _add_delete_item_response(self, operation, path): def get_request_body(self, path, method): """ - A request body is required by jsonapi for POST, PATCH, and DELETE methods. + A request body is required by JSON:API for POST, PATCH, and DELETE methods. """ serializer = self.get_serializer(path, method) if not isinstance(serializer, (serializers.BaseSerializer,)): @@ -595,7 +595,7 @@ def get_request_body(self, path, method): is_relationship = isinstance(self.view, views.RelationshipView) # DRF uses a $ref to the component schema definition, but this - # doesn't work for jsonapi due to the different required fields based on + # doesn't work for JSON:API due to the different required fields based on # the method, so make those changes and inline another copy of the schema. # TODO: A future improvement could make this DRYer with multiple component schemas: # A base schema for each viewset that has no required fields @@ -640,7 +640,7 @@ def get_request_body(self, path, method): def map_serializer(self, serializer): """ - Custom map_serializer that serializes the schema using the jsonapi spec. + Custom map_serializer that serializes the schema using the JSON:API spec. Non-attributes like related and identity fields, are move to 'relationships' and 'links'. """ # TODO: remove attributes, etc. for relationshipView?? diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index bc60f193..b0a85df7 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -214,9 +214,9 @@ class Serializer( ): """ A `Serializer` is a model-less serializer class with additional - support for json:api spec features. + support for JSON:API spec features. - As in json:api specification a type is always required you need to + As in JSON:API specification a type is always required you need to make sure that you define `resource_name` in your `Meta` class when deriving from this class. diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 0e790847..fd59c80c 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -1,6 +1,6 @@ """ This module provides the `json_api_settings` object that is used to access -JSON API REST framework settings, checking for user settings first, then falling back to +JSON:API REST framework settings, checking for user settings first, then falling back to the defaults. """ @@ -20,7 +20,7 @@ class JSONAPISettings(object): """ - A settings object that allows json api settings to be access as + A settings object that allows JSON:API settings to be access as properties. """ @@ -30,7 +30,7 @@ def __init__(self, user_settings=settings, defaults=DEFAULTS): def __getattr__(self, attr): if attr not in self.defaults: - raise AttributeError("Invalid JSON API setting: '%s'" % attr) + raise AttributeError("Invalid JSON:API setting: '%s'" % attr) value = getattr( self.user_settings, JSON_API_SETTINGS_PREFIX + attr, self.defaults[attr] diff --git a/setup.py b/setup.py index 8cce9a5d..eff9d026 100755 --- a/setup.py +++ b/setup.py @@ -72,7 +72,7 @@ def get_package_data(package): version=get_version("rest_framework_json_api"), url="https://github.com/django-json-api/django-rest-framework-json-api", license="BSD", - description="A Django REST framework API adapter for the JSON API spec.", + description="A Django REST framework API adapter for the JSON:API spec.", long_description=read("README.rst"), author="Jerel Unruh", author_email="", diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 03770970..5dd9036e 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -110,8 +110,9 @@ def test_parse_fails_on_list_of_objects(self, parse): with pytest.raises(ParseError) as excinfo: parse(data) - assert "Received data is not a valid JSONAPI Resource Identifier Object" == str( - excinfo.value + assert ( + "Received data is not a valid JSON:API Resource Identifier Object" + == str(excinfo.value) ) def test_parse_fails_when_id_is_missing_on_patch(self, rf, parse, parser_context): From 50841f704f9b7063f21f027f815010df7161e50d Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 16 Aug 2021 18:42:54 +0200 Subject: [PATCH 318/488] Scheduled biweekly dependency update for week 33 (#970) * Update black from 21.5b2 to 21.7b0 * Update isort from 5.8.0 to 5.9.3 * Update sphinx from 4.0.2 to 4.1.2 * Update twine from 3.4.1 to 3.4.2 * Update django-debug-toolbar from 3.2.1 to 3.2.2 * Update faker from 8.5.1 to 8.11.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 550ec311..9326aaec 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==21.5b2 +black==21.7b0 flake8==3.9.2 flake8-isort==4.0.0 -isort==5.8.0 +isort==5.9.3 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 8246714f..2835e77e 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.0.2 +Sphinx==4.1.2 sphinx_rtd_theme==0.5.2 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 7e9e1799..1a95714a 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.4.1 +twine==3.4.2 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 1eabec55..e793221b 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ -django-debug-toolbar==3.2.1 +django-debug-toolbar==3.2.2 factory-boy==3.2.0 -Faker==8.5.1 +Faker==8.11.0 pytest==6.2.4 pytest-cov==2.12.1 pytest-django==4.4.0 From e1feedc60ae2ed2c0b71c51227f30ba55d1c9dbe Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Fri, 10 Sep 2021 13:29:20 +0600 Subject: [PATCH 319/488] Clean up python2 related code crafts (#974) --- AUTHORS | 1 + rest_framework_json_api/relations.py | 14 +++++++------- rest_framework_json_api/renderers.py | 18 +++++------------- rest_framework_json_api/serializers.py | 16 +++++++--------- rest_framework_json_api/settings.py | 2 +- rest_framework_json_api/views.py | 12 ++++++------ 6 files changed, 27 insertions(+), 36 deletions(-) diff --git a/AUTHORS b/AUTHORS index 03da87a8..cb038bd4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -2,6 +2,7 @@ Adam Wróbel Adam Ziolkowski Alan Crosswell Anton Shutik +Asif Saif Uddin Beni Keller Boris Pleshakov Charlie Allatson diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 605f7c1c..f6e91cfe 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -29,14 +29,14 @@ ] -class SkipDataMixin(object): +class SkipDataMixin: """ This workaround skips "data" rendering for relationships in order to save some sql queries and improve performance """ def __init__(self, *args, **kwargs): - super(SkipDataMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def get_attribute(self, instance): raise SkipField @@ -49,7 +49,7 @@ class ManyRelatedFieldWithNoData(SkipDataMixin, DRFManyRelatedField): pass -class HyperlinkedMixin(object): +class HyperlinkedMixin: self_link_view_name = None related_link_view_name = None related_link_lookup_field = "pk" @@ -72,7 +72,7 @@ def __init__(self, self_link_view_name=None, related_link_view_name=None, **kwar # implicit `self` argument to be passed. self.reverse = reverse - super(HyperlinkedMixin, self).__init__(**kwargs) + super().__init__(**kwargs) def get_url(self, name, view_name, kwargs, request): """ @@ -197,7 +197,7 @@ def __init__(self, **kwargs): if model: self.model = model - super(ResourceRelatedField, self).__init__(**kwargs) + super().__init__(**kwargs) def use_pk_only_optimization(self): # We need the real object to determine its type... @@ -245,7 +245,7 @@ def to_internal_value(self, data): received_type=data["type"], ) - return super(ResourceRelatedField, self).to_internal_value(data["id"]) + return super().to_internal_value(data["id"]) def to_representation(self, value): if getattr(self, "pk_field", None) is not None: @@ -329,7 +329,7 @@ class PolymorphicResourceRelatedField(ResourceRelatedField): def __init__(self, polymorphic_serializer, *args, **kwargs): self.polymorphic_serializer = polymorphic_serializer - super(PolymorphicResourceRelatedField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def use_pk_only_optimization(self): return False diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index de9e3c32..e86c0c0b 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -495,12 +495,10 @@ def render_relationship_view( links = view.get_links() if links: render_data.update({"links": links}), - return super(JSONRenderer, self).render( - render_data, accepted_media_type, renderer_context - ) + return super().render(render_data, accepted_media_type, renderer_context) def render_errors(self, data, accepted_media_type=None, renderer_context=None): - return super(JSONRenderer, self).render( + return super().render( utils.format_errors(data), accepted_media_type, renderer_context ) @@ -522,9 +520,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): # be None response = renderer_context.get("response", None) if response is not None and response.status_code == 204: - return super(JSONRenderer, self).render( - None, accepted_media_type, renderer_context - ) + return super().render(None, accepted_media_type, renderer_context) from rest_framework_json_api.views import RelationshipView @@ -536,9 +532,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): # If `resource_name` is set to None then render default as the dev # wants to build the output format manually. if resource_name is None or resource_name is False: - return super(JSONRenderer, self).render( - data, accepted_media_type, renderer_context - ) + return super().render(data, accepted_media_type, renderer_context) json_api_data = data # initialize json_api_meta with pagination meta or an empty dict @@ -664,9 +658,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): if json_api_meta: render_data["meta"] = utils.format_field_names(json_api_meta) - return super(JSONRenderer, self).render( - render_data, accepted_media_type, renderer_context - ) + return super().render(render_data, accepted_media_type, renderer_context) class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer): diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index b0a85df7..ce80874a 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -45,7 +45,7 @@ def __init__(self, *args, **kwargs): self.model_class = kwargs.pop("model_class", self.model_class) # this has no fields but assumptions are made elsewhere that self.fields exists. self.fields = {} - super(ResourceIdentifierObjectSerializer, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def to_representation(self, instance): return { @@ -69,7 +69,7 @@ def to_internal_value(self, data): self.fail("incorrect_type", data_type=type(data["pk"]).__name__) -class SparseFieldsetsMixin(object): +class SparseFieldsetsMixin: """ A serializer mixin that adds support for sparse fieldsets through `fields` query parameter. @@ -77,7 +77,7 @@ class SparseFieldsetsMixin(object): """ def __init__(self, *args, **kwargs): - super(SparseFieldsetsMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) context = kwargs.get("context") request = context.get("request") if context else None @@ -107,7 +107,7 @@ def __init__(self, *args, **kwargs): self.fields.pop(field_name) -class IncludedResourcesValidationMixin(object): +class IncludedResourcesValidationMixin: """ A serializer mixin that adds validation of `include` query parameter to support compound documents. @@ -150,7 +150,7 @@ def validate_path(serializer_class, field_path, path): this_serializer_class, included_field_path, included_field_name ) - super(IncludedResourcesValidationMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) class LazySerializersDict(Mapping): @@ -302,9 +302,7 @@ class PolymorphicSerializerMetaclass(SerializerMetaclass): """ def __new__(cls, name, bases, attrs): - new_class = super(PolymorphicSerializerMetaclass, cls).__new__( - cls, name, bases, attrs - ) + new_class = super().__new__(cls, name, bases, attrs) # Ensure initialization is only performed for subclasses of PolymorphicModelSerializer # (excluding PolymorphicModelSerializer class itself). @@ -363,7 +361,7 @@ def get_fields(self): raise Exception( "Cannot get fields from a polymorphic serializer given a queryset" ) - return super(PolymorphicModelSerializer, self).get_fields() + return super().get_fields() @classmethod def get_polymorphic_serializer_for_instance(cls, instance): diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index fd59c80c..00a28fe5 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -18,7 +18,7 @@ } -class JSONAPISettings(object): +class JSONAPISettings: """ A settings object that allows JSON:API settings to be access as properties. diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 6b739582..c53864a0 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -30,7 +30,7 @@ ) -class PreloadIncludesMixin(object): +class PreloadIncludesMixin: """ This mixin provides a helper attributes to select or prefetch related models based on the include specified in the URL. @@ -60,7 +60,7 @@ def get_prefetch_related(self, include): return getattr(self, "prefetch_for_includes", {}).get(include, None) def get_queryset(self, *args, **kwargs): - qs = super(PreloadIncludesMixin, self).get_queryset(*args, **kwargs) + qs = super().get_queryset(*args, **kwargs) included_resources = get_included_resources( self.request, self.get_serializer_class() @@ -78,10 +78,10 @@ def get_queryset(self, *args, **kwargs): return qs -class AutoPrefetchMixin(object): +class AutoPrefetchMixin: def get_queryset(self, *args, **kwargs): """This mixin adds automatic prefetching for OneToOne and ManyToMany fields.""" - qs = super(AutoPrefetchMixin, self).get_queryset(*args, **kwargs) + qs = super().get_queryset(*args, **kwargs) included_resources = get_included_resources( self.request, self.get_serializer_class() @@ -127,7 +127,7 @@ def get_queryset(self, *args, **kwargs): return qs -class RelatedMixin(object): +class RelatedMixin: """ This mixin handles all related entities, whose Serializers are declared in "related_serializers" """ @@ -239,7 +239,7 @@ def get_serializer_class(self): return self.serializer_class def __init__(self, **kwargs): - super(RelationshipView, self).__init__(**kwargs) + super().__init__(**kwargs) # We include this simply for dependency injection in tests. # We can't add it as a class attributes or it would expect an # implicit `self` argument to be passed. From 7202f42ee61ec7443cea28ae006be4eccda9841d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 18 Sep 2021 05:07:17 +0400 Subject: [PATCH 320/488] Add missing tests folder to MAINIFEST.in (#975) Removed obsolete exclude as there is already a global exclude with those extensions. --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 44e3d86c..9102318a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,7 +2,7 @@ include AUTHORS include LICENSE include README.rst recursive-include example * -recursive-exclude example *.pyc *.pyo +recursive-include tests * global-exclude __pycache__ global-exclude *.py[co] From f8209c0eadbb6d6ca55a83e8a7887e2b10f5f2ae Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 18 Sep 2021 05:16:27 +0400 Subject: [PATCH 321/488] Use consistent naming for Django REST framework (#977) Co-authored-by: Alan Crosswell --- CHANGELOG.md | 16 ++++++++-------- README.rst | 22 +++++++++++----------- SECURITY.md | 2 +- docs/CONTRIBUTING.md | 4 ++-- docs/conf.py | 16 ++++++++-------- docs/getting-started.md | 12 ++++++------ docs/index.rst | 4 ++-- rest_framework_json_api/settings.py | 2 +- 8 files changed, 39 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c5e9d1..4d14d29f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), +Note that in line with [Django REST framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. ## [Unreleased] @@ -103,7 +103,7 @@ This release is not backwards compatible. For easy migration best upgrade first ## [3.2.0] - 2020-08-26 -This is the last release supporting Django 1.11, Django 2.1, Django REST Framework 3.10, Django REST Framework 3.11 and Python 3.5. +This is the last release supporting Django 1.11, Django 2.1, Django REST framework 3.10, Django REST framework 3.11 and Python 3.5. ### Added @@ -171,7 +171,7 @@ This release is not backwards compatible. For easy migration best upgrade first * Removed support for Python 2.7 and 3.4. * Removed support for Django Filter 1.1. * Removed obsolete dependency six. -* Removed support for Django REST Framework <=3.9. +* Removed support for Django REST framework <=3.9. * Removed support for Django 2.0. * Removed obsolete mixins `MultipleIDMixin` and `PrefetchForIncludesHelperMixin` * Removed obsolete settings `JSON_API_FORMAT_KEYS`, `JSON_API_FORMAT_RELATION_KEYS` and @@ -188,7 +188,7 @@ This release is not backwards compatible. For easy migration best upgrade first ## [2.8.0] - 2019-06-13 -This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, Django REST Framework <=3.9 and Django 2.0. +This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, Django REST framework <=3.9 and Django 2.0. ### Added @@ -265,7 +265,7 @@ This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, D * Add new pagination classes based on JSON:API query parameter *recommendations*: * `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination`. See [usage docs](docs/usage.md#pagination). * Add `ReadOnlyModelViewSet` extension with prefetch mixins -* Add support for Django REST Framework 3.8.x +* Add support for Django REST framework 3.8.x * Introduce `JSON_API_FORMAT_FIELD_NAMES` option replacing `JSON_API_FORMAT_KEYS` but in comparison preserving values from being formatted as attributes can contain any [json value](http://jsonapi.org/format/#document-resource-object-attributes). * Allow overwriting of `get_queryset()` in custom `ResourceRelatedField` @@ -293,13 +293,13 @@ This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, D ### Added -* Add support for Django REST Framework 3.7.x. +* Add support for Django REST framework 3.7.x. * Add support for Django 2.0. ### Removed * Drop support for Django 1.8 - 1.10 (EOL) -* Drop support for Django REST Framework < 3.6.3 +* Drop support for Django REST framework < 3.6.3 (3.6.3 is the first to support Django 1.11) * Drop support for Python 3.3 (EOL) @@ -326,7 +326,7 @@ This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, D ### Added -* Add support for Django REST Framework 3.5 and 3.6 +* Add support for Django REST framework 3.5 and 3.6 * Add support for Django 1.11 * Add support for Python 3.6 diff --git a/README.rst b/README.rst index b52977a8..856b4058 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ ================================== -JSON:API and Django Rest Framework +JSON:API and Django REST framework ================================== .. image:: https://github.com/django-json-api/django-rest-framework-json-api/workflows/Tests/badge.svg @@ -18,13 +18,13 @@ JSON:API and Django Rest Framework Overview -------- -**JSON:API support for Django REST Framework** +**JSON:API support for Django REST framework** * Documentation: https://django-rest-framework-json-api.readthedocs.org/ * Format specification: http://jsonapi.org/format/ -By default, Django REST Framework will produce a response like:: +By default, Django REST framework will produce a response like:: { "count": 20, @@ -67,13 +67,13 @@ like the following:: Goals ----- -As a Django REST Framework JSON:API (short DJA) we are trying to address following goals: +As a Django REST framework JSON:API (short DJA) we are trying to address following goals: 1. Support the `JSON:API`_ spec to compliance -2. Be as compatible with `Django REST Framework`_ as possible +2. Be as compatible with `Django REST framework`_ as possible - e.g. issues in Django REST Framework should be fixed upstream and not worked around in DJA + e.g. issues in Django REST framework should be fixed upstream and not worked around in DJA 3. Have sane defaults to be as easy to pick up as possible @@ -82,7 +82,7 @@ As a Django REST Framework JSON:API (short DJA) we are trying to address followi 5. Be performant .. _JSON:API: http://jsonapi.org -.. _Django REST Framework: https://www.django-rest-framework.org/ +.. _Django REST framework: https://www.django-rest-framework.org/ ------------ Requirements @@ -90,11 +90,11 @@ Requirements 1. Python (3.6, 3.7, 3.8, 3.9) 2. Django (2.2, 3.0, 3.1, 3.2) -3. Django REST Framework (3.12) +3. Django REST framework (3.12) -We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. +We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. -Generally Python and Django series are supported till the official end of life. For Django REST Framework the last two series are supported. +Generally Python and Django series are supported till the official end of life. For Django REST framework the last two series are supported. ------------ Installation @@ -160,7 +160,7 @@ Usage ``rest_framework_json_api`` assumes you are using class-based views in Django -Rest Framework. +REST framework. Settings diff --git a/SECURITY.md b/SECURITY.md index c30001ac..e524f6a5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a Vulnerability -If you believe you've found something in Django REST Framework JSON:API which has security implications, please **do not raise the issue in a public forum**. +If you believe you've found something in Django REST framework JSON:API which has security implications, please **do not raise the issue in a public forum**. Send a description of the issue via email to [rest-framework-jsonapi-security@googlegroups.com][security-mail]. The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index df054335..2aa87cfe 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Django REST Framework JSON:API (aka DJA) should be easy to contribute to. +Django REST framework JSON:API (aka DJA) should be easy to contribute to. If anything is unclear about how to contribute, please submit an issue on GitHub so that we can fix it! @@ -11,7 +11,7 @@ if the proposed change makes sense for the project. ### Clone -To start developing on Django REST Framework JSON:API you need to first clone the repository: +To start developing on Django REST framework JSON:API you need to first clone the repository: git clone https://github.com/django-json-api/django-rest-framework-json-api.git diff --git a/docs/conf.py b/docs/conf.py index 9d4e12ae..d36b32c1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# Django REST Framework JSON:API documentation build configuration file, created by +# Django REST framework JSON:API documentation build configuration file, created by # sphinx-quickstart on Fri Jul 24 23:31:15 2015. # # This file is execfile()d with the current directory set to its @@ -59,10 +59,10 @@ master_doc = "index" # General information about the project. -project = "Django REST Framework JSON:API" +project = "Django REST framework JSON:API" year = datetime.date.today().year -copyright = "{}, Django REST Framework JSON:API contributors".format(year) -author = "Django REST Framework JSON:API contributors" +copyright = "{}, Django REST framework JSON:API contributors".format(year) +author = "Django REST framework JSON:API contributors" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -244,8 +244,8 @@ ( master_doc, "DjangoRESTFrameworkJSONAPI.tex", - "Django REST Framework JSON:API Documentation", - "Django REST Framework JSON:API contributors", + "Django REST framework JSON:API Documentation", + "Django REST framework JSON:API contributors", "manual", ), ] @@ -279,7 +279,7 @@ ( master_doc, "djangorestframeworkjsonapi", - "Django REST Framework JSON:API Documentation", + "Django REST framework JSON:API Documentation", [author], 1, ) @@ -298,7 +298,7 @@ ( master_doc, "DjangoRESTFrameworkJSONAPI", - "Django REST Framework JSON:API Documentation", + "Django REST framework JSON:API Documentation", author, "DjangoRESTFrameworkJSONAPI", "One line description of project.", diff --git a/docs/getting-started.md b/docs/getting-started.md index 75522ddf..9351905b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,11 +1,11 @@ # Getting Started -*Note: this package is named Django REST Framework JSON:API to follow the naming -convention of other Django REST Framework packages. Since that's quite a bit +*Note: this package is named Django REST framework JSON:API to follow the naming +convention of other Django REST framework packages. Since that's quite a bit to say or type this package will be referred to as DJA elsewhere in these docs.* -By default, Django REST Framework produces a response like: +By default, Django REST framework produces a response like: ``` js { "count": 20, @@ -53,11 +53,11 @@ like the following: 1. Python (3.6, 3.7, 3.8, 3.9) 2. Django (2.2, 3.0, 3.1, 3.2) -3. Django REST Framework (3.12) +3. Django REST framework (3.12) -We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. +We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. -Generally Python and Django series are supported till the official end of life. For Django REST Framework the last two series are supported. +Generally Python and Django series are supported till the official end of life. For Django REST framework the last two series are supported. ## Installation diff --git a/docs/index.rst b/docs/index.rst index ee2afd7a..a1219bc0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,9 +1,9 @@ -.. Django REST Framework JSON:API documentation master file, created by +.. Django REST framework JSON:API documentation master file, created by sphinx-quickstart on Fri Jul 24 23:31:15 2015. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to Django REST Framework JSON:API +Welcome to Django REST framework JSON:API ========================================================== Contents: diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 00a28fe5..0800e901 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -1,6 +1,6 @@ """ This module provides the `json_api_settings` object that is used to access -JSON:API REST framework settings, checking for user settings first, then falling back to +Django REST framework JSON:API settings, checking for user settings first, then falling back to the defaults. """ From a6644ebfc580e28700967bb5c88f1d3515eafd59 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 23 Sep 2021 21:27:11 +0400 Subject: [PATCH 322/488] Align overwriting of get_queryset in example app and documentation (#979) Fixes #487 --- docs/usage.md | 6 +++--- example/views.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index bf77f3dd..02d9fbd2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -620,14 +620,14 @@ class LineItemViewSet(viewsets.ModelViewSet): serializer_class = LineItemSerializer def get_queryset(self): - queryset = super(LineItemViewSet, self).get_queryset() + queryset = super().get_queryset() # if this viewset is accessed via the 'order-lineitems-list' route, # it wll have been passed the `order_pk` kwarg and the queryset # needs to be filtered accordingly; if it was accessed via the # unnested '/lineitems' route, the queryset should include all LineItems - if 'order_pk' in self.kwargs: - order_pk = self.kwargs['order_pk'] + order_pk = self.kwargs.get('order_pk') + if order_pk is not None: queryset = queryset.filter(order__pk=order_pk) return queryset diff --git a/example/views.py b/example/views.py index 0b35d4e4..64d31780 100644 --- a/example/views.py +++ b/example/views.py @@ -243,11 +243,13 @@ class CommentViewSet(ModelViewSet): } def get_queryset(self, *args, **kwargs): + queryset = super().get_queryset() + entry_pk = self.kwargs.get("entry_pk", None) if entry_pk is not None: - return self.queryset.filter(entry_id=entry_pk) + queryset = queryset.filter(entry_id=entry_pk) - return super(CommentViewSet, self).get_queryset() + return queryset class CompanyViewset(ModelViewSet): From 29971b45aba199c290b0a7e789421642c8f95d30 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 23 Sep 2021 22:16:21 +0400 Subject: [PATCH 323/488] Allow parser context to be None (#981) Default parameter of `parser_context` was already set to `None` but would had always raised an error. DRF parser allows `parser_context` to be `None` to so brings DJA to use the same API. --- CHANGELOG.md | 1 + rest_framework_json_api/parsers.py | 10 +++++---- tests/test_parsers.py | 36 ++++++++++++++++++++---------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d14d29f..a3011d2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Adjusted error messages to correctly use capitial "JSON:API" abbreviation as used in the specification. +* Avoid error when `parser_context` is `None` while parsing. ### Changed diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 434e2925..4c04fd52 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -82,7 +82,8 @@ def parse(self, stream, media_type=None, parser_context=None): raise ParseError("Received document does not contain primary data") data = result.get("data") - view = parser_context["view"] + parser_context = parser_context or {} + view = parser_context.get("view") from rest_framework_json_api.views import RelationshipView @@ -107,6 +108,7 @@ def parse(self, stream, media_type=None, parser_context=None): return data request = parser_context.get("request") + method = request and request.method # Sanity check if not isinstance(data, dict): @@ -115,7 +117,7 @@ def parse(self, stream, media_type=None, parser_context=None): ) # Check for inconsistencies - if request.method in ("PUT", "POST", "PATCH"): + if method in ("PUT", "POST", "PATCH"): resource_name = get_resource_name( parser_context, expand_polymorphic_types=True ) @@ -138,12 +140,12 @@ def parse(self, stream, media_type=None, parser_context=None): resource_types=", ".join(resource_name), ) ) - if not data.get("id") and request.method in ("PATCH", "PUT"): + if not data.get("id") and method in ("PATCH", "PUT"): raise ParseError( "The resource identifier object must contain an 'id' member" ) - if request.method in ("PATCH", "PUT"): + if method in ("PATCH", "PUT"): lookup_url_kwarg = getattr(view, "lookup_url_kwarg", None) or getattr( view, "lookup_field", None ) diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 5dd9036e..f1207757 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -15,8 +15,8 @@ def parser(self): return JSONParser() @pytest.fixture - def parse(self, parser, parser_context): - def parse_wrapper(data): + def parse(self, parser): + def parse_wrapper(data, parser_context): stream = BytesIO(json.dumps(data).encode("utf-8")) return parser.parse(stream, None, parser_context) @@ -41,6 +41,7 @@ def test_parse_formats_field_names( settings, format_field_names, parse, + parser_context, ): settings.JSON_API_FORMAT_FIELD_NAMES = format_field_names @@ -59,14 +60,14 @@ def test_parse_formats_field_names( } } - result = parse(data) + result = parse(data, parser_context) assert result == { "id": "123", "test_attribute": "test-value", "test_relationship": {"id": "123", "type": "TestRelationship"}, } - def test_parse_extracts_meta(self, parse): + def test_parse_extracts_meta(self, parse, parser_context): data = { "data": { "type": "BasicModel", @@ -74,10 +75,21 @@ def test_parse_extracts_meta(self, parse): "meta": {"random_key": "random_value"}, } - result = parse(data) + result = parse(data, parser_context) assert result["_meta"] == data["meta"] - def test_parse_preserves_json_value_field_names(self, settings, parse): + def test_parse_with_default_arguments(self, parse): + data = { + "data": { + "type": "BasicModel", + }, + } + result = parse(data, None) + assert result == {} + + def test_parse_preserves_json_value_field_names( + self, settings, parse, parser_context + ): settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" data = { @@ -87,17 +99,17 @@ def test_parse_preserves_json_value_field_names(self, settings, parse): }, } - result = parse(data) + result = parse(data, parser_context) assert result["json_value"] == {"JsonKey": "JsonValue"} - def test_parse_raises_error_on_empty_data(self, parse): + def test_parse_raises_error_on_empty_data(self, parse, parser_context): data = [] with pytest.raises(ParseError) as excinfo: - parse(data) + parse(data, parser_context) assert "Received document does not contain primary data" == str(excinfo.value) - def test_parse_fails_on_list_of_objects(self, parse): + def test_parse_fails_on_list_of_objects(self, parse, parser_context): data = { "data": [ { @@ -108,7 +120,7 @@ def test_parse_fails_on_list_of_objects(self, parse): } with pytest.raises(ParseError) as excinfo: - parse(data) + parse(data, parser_context) assert ( "Received data is not a valid JSON:API Resource Identifier Object" @@ -124,7 +136,7 @@ def test_parse_fails_when_id_is_missing_on_patch(self, rf, parse, parser_context } with pytest.raises(ParseError) as excinfo: - parse(data) + parse(data, parser_context) assert "The resource identifier object must contain an 'id' member" == str( excinfo.value From 4f596d762e794a8368d9b47220eef822a8af6ff2 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 4 Oct 2021 13:41:12 -0500 Subject: [PATCH 324/488] Scheduled biweekly dependency update for week 40 (#988) * Update black from 21.7b0 to 21.9b0 * Update sphinx from 4.1.2 to 4.2.0 * Update sphinx_rtd_theme from 0.5.2 to 1.0.0 * Update django-filter from 2.4.0 to 21.1 * Update faker from 8.11.0 to 8.14.1 * Update pytest from 6.2.4 to 6.2.5 * Update pytest-cov from 2.12.1 to 3.0.0 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 9326aaec..4e7090cb 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==21.7b0 +black==21.9b0 flake8==3.9.2 flake8-isort==4.0.0 isort==5.9.3 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 2835e77e..d24d7101 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.1.2 -sphinx_rtd_theme==0.5.2 +Sphinx==4.2.0 +sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 15e87ee5..4d543da3 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==2.4.0 +django-filter==21.1 django-polymorphic==3.0.0 pyyaml==5.4.1 uritemplate==3.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index e793221b..a64d5b2c 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2.2 factory-boy==3.2.0 -Faker==8.11.0 -pytest==6.2.4 -pytest-cov==2.12.1 +Faker==8.14.1 +pytest==6.2.5 +pytest-cov==3.0.0 pytest-django==4.4.0 pytest-factoryboy==2.1.0 snapshottest==0.6.0 From 414547a34cac2ae3a143cc2578b320e3d05d8d85 Mon Sep 17 00:00:00 2001 From: Swaraj Baral Date: Wed, 6 Oct 2021 01:18:30 +0530 Subject: [PATCH 325/488] Changed all external links to https (#989) --- AUTHORS | 1 + CHANGELOG.md | 6 ++--- README.rst | 16 ++++++------- docs/Makefile | 2 +- docs/getting-started.md | 12 +++++----- docs/make.bat | 2 +- docs/usage.md | 24 +++++++++---------- .../django_filters/backends.py | 6 ++--- rest_framework_json_api/filters.py | 6 ++--- 9 files changed, 38 insertions(+), 37 deletions(-) diff --git a/AUTHORS b/AUTHORS index cb038bd4..c3865471 100644 --- a/AUTHORS +++ b/AUTHORS @@ -38,6 +38,7 @@ Safa AlFulaij santiavenda Sergey Kolomenkin Stas S. +Swaraj Baral Tim Selman Tom Glowka Ulrich Schuster diff --git a/CHANGELOG.md b/CHANGELOG.md index a3011d2e..97ef872d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Note that in line with [Django REST framework policy](http://www.django-rest-framework.org/topics/release-notes/), +Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. ## [Unreleased] @@ -240,7 +240,7 @@ This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, D * Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](https://www.django-rest-framework.org/api-guide/testing/#configuration) * Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields) * Add related urls support. See [usage docs](docs/usage.md#related-urls) -* Add optional [jsonapi-style](http://jsonapi.org/format/) filter backends. See [usage docs](docs/usage.md#filter-backends) +* Add optional [jsonapi-style](https://jsonapi.org/format/) filter backends. See [usage docs](docs/usage.md#filter-backends) ### Deprecated @@ -268,7 +268,7 @@ This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, D * Add `ReadOnlyModelViewSet` extension with prefetch mixins * Add support for Django REST framework 3.8.x * Introduce `JSON_API_FORMAT_FIELD_NAMES` option replacing `JSON_API_FORMAT_KEYS` but in comparison preserving - values from being formatted as attributes can contain any [json value](http://jsonapi.org/format/#document-resource-object-attributes). + values from being formatted as attributes can contain any [json value](https://jsonapi.org/format/#document-resource-object-attributes). * Allow overwriting of `get_queryset()` in custom `ResourceRelatedField` ### Deprecated diff --git a/README.rst b/README.rst index 856b4058..c3e661e3 100644 --- a/README.rst +++ b/README.rst @@ -21,15 +21,15 @@ Overview **JSON:API support for Django REST framework** * Documentation: https://django-rest-framework-json-api.readthedocs.org/ -* Format specification: http://jsonapi.org/format/ +* Format specification: https://jsonapi.org/format/ By default, Django REST framework will produce a response like:: { "count": 20, - "next": "http://example.com/api/1.0/identities/?page=3", - "previous": "http://example.com/api/1.0/identities/?page=1", + "next": "https://example.com/api/1.0/identities/?page=3", + "previous": "https://example.com/api/1.0/identities/?page=1", "results": [{ "id": 3, "username": "john", @@ -43,9 +43,9 @@ like the following:: { "links": { - "prev": "http://example.com/api/1.0/identities", - "self": "http://example.com/api/1.0/identities?page=2", - "next": "http://example.com/api/1.0/identities?page=3", + "prev": "https://example.com/api/1.0/identities", + "self": "https://example.com/api/1.0/identities?page=2", + "next": "https://example.com/api/1.0/identities?page=3", }, "data": [{ "type": "identities", @@ -81,7 +81,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi 5. Be performant -.. _JSON:API: http://jsonapi.org +.. _JSON:API: https://jsonapi.org .. _Django REST framework: https://www.django-rest-framework.org/ ------------ @@ -202,4 +202,4 @@ override ``settings.REST_FRAMEWORK`` This package provides much more including automatic inflection of JSON keys, extra top level data (using nested serializers), relationships, links, paginators, filters, and handy shortcuts. -Read more at http://django-rest-framework-json-api.readthedocs.org/ +Read more at https://django-rest-framework-json-api.readthedocs.org/ diff --git a/docs/Makefile b/docs/Makefile index fc44e850..ba724aee 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -9,7 +9,7 @@ BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/) endif # Internal variables. diff --git a/docs/getting-started.md b/docs/getting-started.md index 9351905b..51780af2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -9,8 +9,8 @@ By default, Django REST framework produces a response like: ``` js { "count": 20, - "next": "http://example.com/api/1.0/identities/?page=3", - "previous": "http://example.com/api/1.0/identities/?page=1", + "next": "https://example.com/api/1.0/identities/?page=3", + "previous": "https://example.com/api/1.0/identities/?page=1", "results": [{ "id": 3, "username": "john", @@ -25,10 +25,10 @@ like the following: ``` js { "links": { - "first": "http://example.com/api/1.0/identities", - "last": "http://example.com/api/1.0/identities?page=5", - "next": "http://example.com/api/1.0/identities?page=3", - "prev": "http://example.com/api/1.0/identities", + "first": "https://example.com/api/1.0/identities", + "last": "https://example.com/api/1.0/identities?page=5", + "next": "https://example.com/api/1.0/identities?page=3", + "prev": "https://example.com/api/1.0/identities", }, "data": [{ "type": "identities", diff --git a/docs/make.bat b/docs/make.bat index ed3e42a0..c2ddc7bb 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -65,7 +65,7 @@ if errorlevel 9009 ( echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ + echo.https://sphinx-doc.org/ exit /b 1 ) diff --git a/docs/usage.md b/docs/usage.md index 02d9fbd2..d0bd07f4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -3,7 +3,7 @@ The DJA package implements a custom renderer, parser, exception handler, query filter backends, and pagination. To get started enable the pieces in `settings.py` that you want to use. -Many features of the [JSON:API](http://jsonapi.org/format) format standard have been implemented using +Many features of the [JSON:API](https://jsonapi.org/format) format standard have been implemented using Mixin classes in `serializers.py`. The easiest way to make use of those features is to import ModelSerializer variants from `rest_framework_json_api` instead of the usual `rest_framework` @@ -116,7 +116,7 @@ is used. This can help the client identify misspelled query parameters, for exam If you want to change the list of valid query parameters, override the `.query_regex` attribute: ```python -# compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters +# compiled regex that matches the allowed https://jsonapi.org/format/#query-parameters # `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') ``` @@ -134,7 +134,7 @@ simply don't use this filter backend. #### OrderingFilter -`OrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses +`OrderingFilter` implements the [JSON:API `sort`](https://jsonapi.org/format/#fetching-sorting) and uses DRF's [ordering filter](https://www.django-rest-framework.org/api-guide/filtering/#orderingfilter). Per the JSON:API specification, "If the server does not support sorting as specified in the query parameter `sort`, @@ -159,14 +159,14 @@ If you want to silently ignore bad sort fields, just use `rest_framework.filters #### DjangoFilterBackend -`DjangoFilterBackend` implements a Django ORM-style [JSON:API `filter`](http://jsonapi.org/format/#fetching-filtering) +`DjangoFilterBackend` implements a Django ORM-style [JSON:API `filter`](https://jsonapi.org/format/#fetching-filtering) using the [django-filter](https://django-filter.readthedocs.io/) package. This filter is not part of the JSON:API standard per-se, other than the requirement to use the `filter` keyword: It is an optional implementation of a style of filtering in which each filter is an ORM expression as implemented by `DjangoFilterBackend` and seems to be in alignment with an interpretation of the -[JSON:API _recommendations_](http://jsonapi.org/recommendations/#filtering), including relationship +[JSON:API _recommendations_](https://jsonapi.org/recommendations/#filtering), including relationship chaining. Filters can be: @@ -240,7 +240,7 @@ class MyViewset(ModelViewSet): ### Exception handling For the `exception_handler` class, if the optional `JSON_API_UNIFORM_EXCEPTIONS` is set to True, -all exceptions will respond with the JSON:API [error format](http://jsonapi.org/format/#error-objects). +all exceptions will respond with the JSON:API [error format](https://jsonapi.org/format/#error-objects). When `JSON_API_UNIFORM_EXCEPTIONS` is False (the default), non-JSON:API views will respond with the normal DRF error format. @@ -312,7 +312,7 @@ multiple endpoints. Setting the `resource_name` on views may result in a differe ### Inflecting object and relation keys -This package includes the ability (off by default) to automatically convert [JSON:API field names](http://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the python/rest_framework's preferred underscore to +This package includes the ability (off by default) to automatically convert [JSON:API field names](https://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the python/rest_framework's preferred underscore to a format of your choice. To hook this up include the following setting in your project settings: @@ -551,7 +551,7 @@ class OrderSerializer(serializers.ModelSerializer): ``` -In the [JSON:API spec](http://jsonapi.org/format/#document-resource-objects), +In the [JSON:API spec](https://jsonapi.org/format/#document-resource-objects), relationship objects contain links to related objects. To make this work on a serializer we need to tell the `ResourceRelatedField` about the corresponding view. Use the `HyperlinkedModelSerializer` and instantiate @@ -755,12 +755,12 @@ class OrderSerializer(serializers.HyperlinkedModelSerializer): ### RelationshipView `rest_framework_json_api.views.RelationshipView` is used to build relationship views (see the -[JSON:API spec](http://jsonapi.org/format/#fetching-relationships)). +[JSON:API spec](https://jsonapi.org/format/#fetching-relationships)). The `self` link on a relationship object should point to the corresponding relationship view. The relationship view is fairly simple because it only serializes -[Resource Identifier Objects](http://jsonapi.org/format/#document-resource-identifier-objects) +[Resource Identifier Objects](https://jsonapi.org/format/#document-resource-identifier-objects) rather than full resource objects. In most cases the following is sufficient: ```python @@ -896,7 +896,7 @@ Related links will be created automatically when using the Relationship View. JSON:API can include additional resources in a single network request. The specification refers to this feature as -[Compound Documents](http://jsonapi.org/format/#document-compound-documents). +[Compound Documents](https://jsonapi.org/format/#document-compound-documents). Compound Documents can reduce the number of network requests which can lead to a better performing web application. To accomplish this, @@ -1058,7 +1058,7 @@ class MySchemaGenerator(JSONAPISchemaGenerator): } } schema['servers'] = [ - {'url': 'https://localhost/v1', 'description': 'local docker'}, + {'url': 'http://localhost/v1', 'description': 'local docker'}, {'url': 'http://localhost:8000/v1', 'description': 'local dev'}, {'url': 'https://api.example.com/v1', 'description': 'demo server'}, {'url': '{serverURL}', 'description': 'provide your server URL', diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 09e64bc2..72ae873e 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -15,7 +15,7 @@ class DjangoFilterBackend(DjangoFilterBackend): to use the `filter` keyword: This is an optional implementation of style of filtering in which each filter is an ORM expression as implemented by DjangoFilterBackend and seems to be in alignment with an interpretation of - http://jsonapi.org/recommendations/#filtering, including relationship + https://jsonapi.org/recommendations/#filtering, including relationship chaining. It also returns a 400 error for invalid filters. Filters can be: @@ -58,8 +58,8 @@ class DjangoFilterBackend(DjangoFilterBackend): search_param = api_settings.SEARCH_PARAM # Make this regex check for 'filter' as well as 'filter[...]' - # See http://jsonapi.org/format/#document-member-names for allowed characters - # and http://jsonapi.org/format/#document-member-names-reserved-characters for reserved + # See https://jsonapi.org/format/#document-member-names for allowed characters + # and https://jsonapi.org/format/#document-member-names-reserved-characters for reserved # characters (for use in paths, lists or as delimiters). # regex `\w` matches [a-zA-Z0-9_]. # TODO: U+0080 and above allowed but not recommended. Leave them out for now.e diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index 3862057a..d5dd337c 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -8,7 +8,7 @@ class OrderingFilter(OrderingFilter): """ - A backend filter that implements http://jsonapi.org/format/#fetching-sorting and + A backend filter that implements https://jsonapi.org/format/#fetching-sorting and raises a 400 error if any sort field is invalid. If you prefer *not* to report 400 errors for invalid sort fields, just use @@ -74,10 +74,10 @@ class QueryParameterValidationFilter(BaseFilterBackend): If you want to add some additional non-standard query parameters, override :py:attr:`query_regex` adding the new parameters. Make sure to comply with - the rules at http://jsonapi.org/format/#query-parameters. + the rules at https://jsonapi.org/format/#query-parameters. """ - #: compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters: + #: compiled regex that matches the allowed https://jsonapi.org/format/#query-parameters: #: `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s query_regex = re.compile( r"^(sort|include)$|^(?Pfilter|fields|page)(\[[\w\.\-]+\])?$" From e68a0f277fbf2e28f34c2ab3a9bff7dac25a1c7e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 6 Oct 2021 16:42:20 +0400 Subject: [PATCH 326/488] Check for reserved field names in serializer meta class (#987) Fixes #518 Fixes #710 This is to avoid an incomprehensible exception during runtime when either meta or results is used as a field name. Co-authored-by: Alan Crosswell --- CHANGELOG.md | 3 ++- rest_framework_json_api/serializers.py | 19 +++++++++++++++++++ tests/test_serializers.py | 15 +++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ef872d..30ef91ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed -* Adjusted error messages to correctly use capitial "JSON:API" abbreviation as used in the specification. +* Adjusted error messages to correctly use capital "JSON:API" abbreviation as used in the specification. * Avoid error when `parser_context` is `None` while parsing. +* Raise comprehensible error when reserved field names `meta` and `results` are used. ### Changed diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index ce80874a..ee6d15b6 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -184,6 +184,25 @@ def __repr__(self): class SerializerMetaclass(SerializerMetaclass): + + _reserved_field_names = {"meta", "results"} + + @classmethod + def _get_declared_fields(cls, bases, attrs): + fields = super()._get_declared_fields(bases, attrs) + + found_reserved_field_names = cls._reserved_field_names.intersection( + fields.keys() + ) + if found_reserved_field_names: + raise AttributeError( + f"Serializer class {attrs['__module__']}.{attrs['__qualname__']} uses " + f"following reserved field name(s) which is not allowed: " + f"{', '.join(sorted(found_reserved_field_names))}" + ) + + return fields + def __new__(cls, name, bases, attrs): serializer = super().__new__(cls, name, bases, attrs) diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 6726fa8c..505111ca 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,3 +1,4 @@ +import pytest from django.db import models from rest_framework_json_api import serializers @@ -33,3 +34,17 @@ class Meta: } assert included_serializers == expected_included_serializers + + +def test_reserved_field_names(): + with pytest.raises(AttributeError) as e: + + class ReservedFieldNamesSerializer(serializers.Serializer): + meta = serializers.CharField() + results = serializers.CharField() + + assert str(e.value) == ( + "Serializer class tests.test_serializers.test_reserved_field_names.." + "ReservedFieldNamesSerializer uses following reserved field name(s) which is " + "not allowed: meta, results" + ) From 081619296ac87fbd740bddf7b053e0a989df9a59 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 6 Oct 2021 17:22:06 +0400 Subject: [PATCH 327/488] Move pull request template to github directory (#991) This file is part of the github workflow and not the documentation so for consistency reason be moved to `.github` to be accompanied with the issue template. --- {docs => .github}/pull_request_template.md | 0 docs/conf.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename {docs => .github}/pull_request_template.md (100%) diff --git a/docs/pull_request_template.md b/.github/pull_request_template.md similarity index 100% rename from docs/pull_request_template.md rename to .github/pull_request_template.md diff --git a/docs/conf.py b/docs/conf.py index d36b32c1..cd9266ce 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -88,7 +88,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ["_build", "pull_request_template.md"] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all # documents. From ee679ee72a9b845811639e60ff745ded4238a6fc Mon Sep 17 00:00:00 2001 From: Mehdy Khoshnoody Date: Thu, 7 Oct 2021 21:31:15 +0330 Subject: [PATCH 328/488] Use relationships in the error object pointer when the field is actually a relationship (#986) --- AUTHORS | 1 + CHANGELOG.md | 1 + example/tests/snapshots/snap_test_errors.py | 11 +++++++ example/tests/test_errors.py | 32 +++++++++++++++++---- rest_framework_json_api/renderers.py | 10 ++----- rest_framework_json_api/utils.py | 23 +++++++++++++-- 6 files changed, 63 insertions(+), 15 deletions(-) diff --git a/AUTHORS b/AUTHORS index c3865471..84e52286 100644 --- a/AUTHORS +++ b/AUTHORS @@ -24,6 +24,7 @@ Léo S. Luc Cary Mansi Dhruv Matt Layman +Mehdy Khoshnoody Michael Haselton Mohammed Ali Zubair Nathanael Gordon diff --git a/CHANGELOG.md b/CHANGELOG.md index 30ef91ed..217ebb14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ any parts of the framework not mentioned in the documentation should generally b * Adjusted error messages to correctly use capital "JSON:API" abbreviation as used in the specification. * Avoid error when `parser_context` is `None` while parsing. * Raise comprehensible error when reserved field names `meta` and `results` are used. +* Use `relationships` in the error object `pointer` when the field is actually a relationship. ### Changed diff --git a/example/tests/snapshots/snap_test_errors.py b/example/tests/snapshots/snap_test_errors.py index 528b831b..dd5ca4f8 100644 --- a/example/tests/snapshots/snap_test_errors.py +++ b/example/tests/snapshots/snap_test_errors.py @@ -44,6 +44,17 @@ ] } +snapshots["test_relationship_errors_has_correct_pointers 1"] = { + "errors": [ + { + "code": "incorrect_type", + "detail": "Incorrect type. Expected resource identifier object, received str.", + "source": {"pointer": "/data/relationships/author"}, + "status": "400", + } + ] +} + snapshots["test_second_level_array_error 1"] = { "errors": [ { diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py index 93cdb235..6b322d72 100644 --- a/example/tests/test_errors.py +++ b/example/tests/test_errors.py @@ -1,11 +1,11 @@ import pytest from django.test import override_settings from django.urls import path, reverse -from rest_framework import views +from rest_framework import generics from rest_framework_json_api import serializers -from example.models import Blog +from example.models import Author, Blog # serializers @@ -30,6 +30,9 @@ class EntrySerializer(serializers.Serializer): comment = CommentSerializer(required=False) headline = serializers.CharField(allow_null=True, required=True) body_text = serializers.CharField() + author = serializers.ResourceRelatedField( + queryset=Author.objects.all(), required=False + ) def validate(self, attrs): body_text = attrs["body_text"] @@ -40,13 +43,12 @@ def validate(self, attrs): # view -class DummyTestView(views.APIView): +class DummyTestView(generics.CreateAPIView): serializer_class = EntrySerializer resource_name = "entries" - def post(self, request, *args, **kwargs): - serializer = self.serializer_class(data=request.data) - serializer.is_valid(raise_exception=True) + def get_serializer_context(self): + return {} urlpatterns = [ @@ -191,3 +193,21 @@ def test_many_third_level_dict_errors(client, some_blog, snapshot): } snapshot.assert_match(perform_error_test(client, data)) + + +def test_relationship_errors_has_correct_pointers(client, some_blog, snapshot): + data = { + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + }, + "relationships": { + "author": {"data": {"id": "INVALID_ID", "type": "authors"}} + }, + } + } + + snapshot.assert_match(perform_error_test(client, data)) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index e86c0c0b..b84666db 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -65,7 +65,7 @@ def extract_attributes(cls, fields, resource): if fields[field_name].write_only: continue # Skip fields with relations - if isinstance(field, (relations.RelatedField, relations.ManyRelatedField)): + if utils.is_relationship_field(field): continue # Skip read_only attribute fields when `resource` is an empty @@ -105,9 +105,7 @@ def extract_relationships(cls, fields, resource, resource_instance): continue # Skip fields without relations - if not isinstance( - field, (relations.RelatedField, relations.ManyRelatedField) - ): + if not utils.is_relationship_field(field): continue source = field.source @@ -298,9 +296,7 @@ def extract_included( continue # Skip fields without relations - if not isinstance( - field, (relations.RelatedField, relations.ManyRelatedField) - ): + if not utils.is_relationship_field(field): continue try: diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 19c72809..65cbc645 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -13,7 +13,7 @@ from django.http import Http404 from django.utils import encoding from django.utils.translation import gettext_lazy as _ -from rest_framework import exceptions +from rest_framework import exceptions, relations from rest_framework.exceptions import APIException from .settings import json_api_settings @@ -368,6 +368,10 @@ def get_relation_instance(resource_instance, source, serializer): return True, relation_instance +def is_relationship_field(field): + return isinstance(field, (relations.RelatedField, relations.ManyRelatedField)) + + class Hyperlink(str): """ A string like object that additionally has an associated name. @@ -394,9 +398,24 @@ def format_drf_errors(response, context, exc): errors.extend(format_error_object(message, "/data", response)) # handle all errors thrown from serializers else: + # Avoid circular deps + from rest_framework import generics + + has_serializer = isinstance(context["view"], generics.GenericAPIView) + if has_serializer: + serializer = context["view"].get_serializer() + fields = get_serializer_fields(serializer) or dict() + relationship_fields = [ + name for name, field in fields.items() if is_relationship_field(field) + ] + for field, error in response.data.items(): field = format_field_name(field) - pointer = "/data/attributes/{}".format(field) + pointer = None + # pointer can be determined only if there's a serializer. + if has_serializer: + rel = "relationships" if field in relationship_fields else "attributes" + pointer = "/data/{}/{}".format(rel, field) if isinstance(exc, Http404) and isinstance(error, str): # 404 errors don't have a pointer errors.extend(format_error_object(error, None, response)) From e17ea5790df909ef67b4f0cfc5ae8d10ab066573 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 8 Oct 2021 18:07:27 +0400 Subject: [PATCH 329/488] Verified in all fields whether reserved field names are used (#992) In meta class only specifically declared fields in serializer are accessible. In case of `ModelSerializer` fields may be just on the model or if a user has overwritten what fields are being returned during runtime DJA won't notice. So there does not seem to be another way then simply hooking into the `get_fields` method to check for reserved field names. --- rest_framework_json_api/serializers.py | 43 ++++++++++++++------------ tests/test_serializers.py | 2 ++ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index ee6d15b6..00cd3b38 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -153,6 +153,27 @@ def validate_path(serializer_class, field_path, path): super().__init__(*args, **kwargs) +class ReservedFieldNamesMixin: + """Ensures that reserved field names are not used and an error raised instead.""" + + _reserved_field_names = {"meta", "results"} + + def get_fields(self): + fields = super().get_fields() + + found_reserved_field_names = self._reserved_field_names.intersection( + fields.keys() + ) + if found_reserved_field_names: + raise AttributeError( + f"Serializer class {self.__class__.__module__}.{self.__class__.__qualname__} " + f"uses following reserved field name(s) which is not allowed: " + f"{', '.join(sorted(found_reserved_field_names))}" + ) + + return fields + + class LazySerializersDict(Mapping): """ A dictionary of serializers which lazily import dotted class path and self. @@ -184,25 +205,6 @@ def __repr__(self): class SerializerMetaclass(SerializerMetaclass): - - _reserved_field_names = {"meta", "results"} - - @classmethod - def _get_declared_fields(cls, bases, attrs): - fields = super()._get_declared_fields(bases, attrs) - - found_reserved_field_names = cls._reserved_field_names.intersection( - fields.keys() - ) - if found_reserved_field_names: - raise AttributeError( - f"Serializer class {attrs['__module__']}.{attrs['__qualname__']} uses " - f"following reserved field name(s) which is not allowed: " - f"{', '.join(sorted(found_reserved_field_names))}" - ) - - return fields - def __new__(cls, name, bases, attrs): serializer = super().__new__(cls, name, bases, attrs) @@ -228,6 +230,7 @@ def __new__(cls, name, bases, attrs): class Serializer( IncludedResourcesValidationMixin, SparseFieldsetsMixin, + ReservedFieldNamesMixin, Serializer, metaclass=SerializerMetaclass, ): @@ -251,6 +254,7 @@ class Serializer( class HyperlinkedModelSerializer( IncludedResourcesValidationMixin, SparseFieldsetsMixin, + ReservedFieldNamesMixin, HyperlinkedModelSerializer, metaclass=SerializerMetaclass, ): @@ -271,6 +275,7 @@ class HyperlinkedModelSerializer( class ModelSerializer( IncludedResourcesValidationMixin, SparseFieldsetsMixin, + ReservedFieldNamesMixin, ModelSerializer, metaclass=SerializerMetaclass, ): diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 505111ca..2c433c2a 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -43,6 +43,8 @@ class ReservedFieldNamesSerializer(serializers.Serializer): meta = serializers.CharField() results = serializers.CharField() + ReservedFieldNamesSerializer().fields + assert str(e.value) == ( "Serializer class tests.test_serializers.test_reserved_field_names.." "ReservedFieldNamesSerializer uses following reserved field name(s) which is " From 06c3ef4c03e1a329c158cdde19b504ee1b9dcf0b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 11 Oct 2021 20:56:16 +0400 Subject: [PATCH 330/488] Deprecated usage of `type` field name (#993) This support was added in #376 but only for non polymorphic fields. However as per specification [0] `type` must not be a field name and therefore must be forbidden in DJA as well. Some dependents might depend on being allowed to have a field name `type` so deprecating it now and remove it in next major version. [0] https://jsonapi.org/format/#document-resource-object-fields --- CHANGELOG.md | 1 + example/tests/test_model_viewsets.py | 26 +++++++++++++++----------- rest_framework_json_api/parsers.py | 2 +- rest_framework_json_api/serializers.py | 13 +++++++++++++ setup.cfg | 4 ++++ tests/test_serializers.py | 9 +++++++++ 6 files changed, 43 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 217ebb14..db78a996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Deprecated * Deprecated `get_included_serializers(serializer)` function under `rest_framework_json_api.utils`. Use `serializer.included_serializers` instead. +* Deprecated support for field name `type` as it may not be used according to the [JSON:API spec](https://jsonapi.org/format/#document-resource-object-fields). ## [4.2.1] - 2021-07-06 diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index 9d4a610f..a5bbcc00 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -223,17 +223,21 @@ def test_patch_allow_field_type(author, author_type_factory, client): """ Verify that type field may be updated. """ - author_type = author_type_factory() - url = reverse("author-detail", args=[author.id]) - - data = { - "data": { - "id": author.id, - "type": "authors", - "relationships": {"data": {"id": author_type.id, "type": "author-type"}}, + # TODO remove in next major version 5.0.0 see serializers.ReservedFieldNamesMixin + with pytest.deprecated_call(): + author_type = author_type_factory() + url = reverse("author-detail", args=[author.id]) + + data = { + "data": { + "id": author.id, + "type": "authors", + "relationships": { + "data": {"id": author_type.id, "type": "author-type"} + }, + } } - } - response = client.patch(url, data=data) + response = client.patch(url, data=data) - assert response.status_code == 200 + assert response.status_code == 200 diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 4c04fd52..93767a07 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -162,7 +162,7 @@ def parse(self, stream, media_type=None, parser_context=None): # Construct the return data serializer_class = getattr(view, "serializer_class", None) parsed_data = {"id": data.get("id")} if "id" in data else {} - # `type` field needs to be allowed in none polymorphic serializers + # TODO remove in next major version 5.0.0 see serializers.ReservedFieldNamesMixin if serializer_class is not None: if issubclass(serializer_class, serializers.PolymorphicModelSerializer): parsed_data["type"] = data.get("type") diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 00cd3b38..d2ec5b53 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,3 +1,4 @@ +import warnings from collections import OrderedDict from collections.abc import Mapping @@ -171,6 +172,18 @@ def get_fields(self): f"{', '.join(sorted(found_reserved_field_names))}" ) + if "type" in fields: + # see https://jsonapi.org/format/#document-resource-object-fields + warnings.warn( + DeprecationWarning( + f"Field name 'type' found in serializer class " + f"{self.__class__.__module__}.{self.__class__.__qualname__} " + f"which is not allowed according to the JSON:API spec and " + f"won't be supported anymore in the next major DJA release. " + f"Rename 'type' field to something else. " + ) + ) + return fields diff --git a/setup.cfg b/setup.cfg index 83f6fa37..31e61331 100644 --- a/setup.cfg +++ b/setup.cfg @@ -60,6 +60,10 @@ filterwarnings = error::PendingDeprecationWarning # Django Debug Toolbar currently (2021-04-07) specifies default_app_config which is deprecated in Django 3.2: ignore:'debug_toolbar' defines default_app_config = 'debug_toolbar.apps.DebugToolbarConfig'. Django now detects this configuration automatically. You can remove default_app_config.:PendingDeprecationWarning + # TODO remove in next major version of DJA 5.0.0 + # this deprecation warning filter needs to be added as AuthorSerializer is used in + # too many tests which introduced the type field name in tests + ignore:Field name 'type' testpaths = example tests diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 2c433c2a..70bb140f 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -50,3 +50,12 @@ class ReservedFieldNamesSerializer(serializers.Serializer): "ReservedFieldNamesSerializer uses following reserved field name(s) which is " "not allowed: meta, results" ) + + +def test_serializer_fields_deprecated_field_name_type(): + with pytest.deprecated_call(): + + class TypeFieldNameSerializer(serializers.Serializer): + type = serializers.CharField() + + TypeFieldNameSerializer().fields From 7d633f03973d1e0f02e196889fdb652160b9e18a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 18 Oct 2021 17:36:09 +0400 Subject: [PATCH 331/488] Bump to newer codecov action (#996) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a48a7915..a38766c7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,7 +29,7 @@ jobs: - name: Test with tox run: tox - name: Upload coverage report - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v2 with: env_vars: PYTHON,DJANGO,DJANGO_REST_FRAMEWORK check: From c763b978ff553d1388849b756453724bec15d1c2 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 19 Oct 2021 12:14:20 -0500 Subject: [PATCH 332/488] Scheduled biweekly dependency update for week 42 (#997) * Update flake8 from 3.9.2 to 4.0.1 * Update flake8-isort from 4.0.0 to 4.1.1 * Update pyyaml from 5.4.1 to 6.0 * Update uritemplate from 3.0.1 to 4.1.1 * Update faker from 8.14.1 to 9.3.1 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-optionals.txt | 4 ++-- requirements/requirements-testing.txt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 4e7090cb..55628500 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ black==21.9b0 -flake8==3.9.2 -flake8-isort==4.0.0 +flake8==4.0.1 +flake8-isort==4.1.1 isort==5.9.3 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 4d543da3..1db26c07 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ django-filter==21.1 django-polymorphic==3.0.0 -pyyaml==5.4.1 -uritemplate==3.0.1 +pyyaml==6.0 +uritemplate==4.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index a64d5b2c..5ac6e9c9 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ django-debug-toolbar==3.2.2 factory-boy==3.2.0 -Faker==8.14.1 +Faker==9.3.1 pytest==6.2.5 pytest-cov==3.0.0 pytest-django==4.4.0 From c4337f15f59811c4e72f33cf6eb3a4667cb4b197 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Thu, 21 Oct 2021 00:21:00 +0600 Subject: [PATCH 333/488] clean up python 2 code (#976) --- docs/conf.py | 2 +- example/api/resources/identity.py | 2 +- example/factories.py | 2 -- example/migrations/0001_initial.py | 4 ---- example/migrations/0002_taggeditem.py | 4 ---- example/migrations/0003_polymorphics.py | 4 ---- example/migrations/0004_auto_20171011_0631.py | 4 ---- example/models.py | 3 --- example/serializers.py | 4 ++-- example/tests/__init__.py | 2 +- example/tests/snapshots/snap_test_errors.py | 4 ---- example/tests/snapshots/snap_test_openapi.py | 4 ---- example/tests/test_format_keys.py | 2 +- example/tests/test_generic_validation.py | 2 +- example/tests/test_model_viewsets.py | 2 +- example/tests/test_serializers.py | 2 +- example/tests/unit/test_filter_schema_params.py | 2 +- example/tests/unit/test_renderer_class_methods.py | 6 ++---- example/tests/unit/test_serializer_method_field.py | 2 -- example/views.py | 10 +++++----- rest_framework_json_api/__init__.py | 2 -- rest_framework_json_api/django_filters/backends.py | 2 +- rest_framework_json_api/filters.py | 4 +--- rest_framework_json_api/parsers.py | 2 +- rest_framework_json_api/renderers.py | 4 +--- rest_framework_json_api/serializers.py | 2 +- setup.py | 3 +-- 27 files changed, 23 insertions(+), 63 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index cd9266ce..9038cd2b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- +# # # Django REST framework JSON:API documentation build configuration file, created by # sphinx-quickstart on Fri Jul 24 23:31:15 2015. diff --git a/example/api/resources/identity.py b/example/api/resources/identity.py index a291ba4f..12705553 100644 --- a/example/api/resources/identity.py +++ b/example/api/resources/identity.py @@ -33,7 +33,7 @@ def posts(self, request): @action(detail=True) def manual_resource_name(self, request, *args, **kwargs): self.resource_name = "data" - return super(Identity, self).retrieve(request, args, kwargs) + return super().retrieve(request, args, kwargs) @action(detail=True) def validation(self, request, *args, **kwargs): diff --git a/example/factories.py b/example/factories.py index 0de561f6..96b85cad 100644 --- a/example/factories.py +++ b/example/factories.py @@ -1,5 +1,3 @@ -# -*- encoding: utf-8 -*- - import factory from faker import Factory as FakerFactory diff --git a/example/migrations/0001_initial.py b/example/migrations/0001_initial.py index 35e01afe..0161cd49 100644 --- a/example/migrations/0001_initial.py +++ b/example/migrations/0001_initial.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.5 on 2016-05-02 08:26 -from __future__ import unicode_literals - import django.db.models.deletion from django.db import migrations, models diff --git a/example/migrations/0002_taggeditem.py b/example/migrations/0002_taggeditem.py index 3a57d22f..0abf49e0 100644 --- a/example/migrations/0002_taggeditem.py +++ b/example/migrations/0002_taggeditem.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.5 on 2017-02-01 08:34 -from __future__ import unicode_literals - import django.db.models.deletion from django.db import migrations, models diff --git a/example/migrations/0003_polymorphics.py b/example/migrations/0003_polymorphics.py index 46919dbb..1bae8491 100644 --- a/example/migrations/0003_polymorphics.py +++ b/example/migrations/0003_polymorphics.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.1 on 2017-05-17 14:49 -from __future__ import unicode_literals - import django.db.models.deletion from django.db import migrations, models diff --git a/example/migrations/0004_auto_20171011_0631.py b/example/migrations/0004_auto_20171011_0631.py index b1035dfe..fa597a29 100644 --- a/example/migrations/0004_auto_20171011_0631.py +++ b/example/migrations/0004_auto_20171011_0631.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2017-10-11 06:31 -from __future__ import unicode_literals - import django.db.models.deletion from django.db import migrations, models diff --git a/example/models.py b/example/models.py index 18b965a3..63d93e33 100644 --- a/example/models.py +++ b/example/models.py @@ -1,6 +1,3 @@ -# -*- encoding: utf-8 -*- -from __future__ import unicode_literals - from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.db import models diff --git a/example/serializers.py b/example/serializers.py index 43415243..0e1022cc 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -83,7 +83,7 @@ class Meta: class EntrySerializer(serializers.ModelSerializer): def __init__(self, *args, **kwargs): - super(EntrySerializer, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # to make testing more concise we'll only output the # `featured` field when it's requested via `include` request = kwargs.get("context", {}).get("request") @@ -379,7 +379,7 @@ class Meta: class CurrentProjectRelatedField(relations.PolymorphicResourceRelatedField): def get_attribute(self, instance): - obj = super(CurrentProjectRelatedField, self).get_attribute(instance) + obj = super().get_attribute(instance) is_art = self.field_name == "current_art_project" and isinstance( obj, ArtProject diff --git a/example/tests/__init__.py b/example/tests/__init__.py index daca2310..94a86486 100644 --- a/example/tests/__init__.py +++ b/example/tests/__init__.py @@ -11,7 +11,7 @@ def setUp(self): """ Create those users """ - super(TestBase, self).setUp() + super().setUp() self.create_users() def create_user(self, username, email, password="pw", first_name="", last_name=""): diff --git a/example/tests/snapshots/snap_test_errors.py b/example/tests/snapshots/snap_test_errors.py index dd5ca4f8..09bc25ef 100644 --- a/example/tests/snapshots/snap_test_errors.py +++ b/example/tests/snapshots/snap_test_errors.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - from snapshottest import Snapshot snapshots = Snapshot() diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py index f89fb442..a0e6e78e 100644 --- a/example/tests/snapshots/snap_test_openapi.py +++ b/example/tests/snapshots/snap_test_openapi.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - from snapshottest import Snapshot snapshots = Snapshot() diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index 698a8bb1..8d99eec7 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -14,7 +14,7 @@ class FormatKeysSetTests(TestBase): list_url = reverse("user-list") def setUp(self): - super(FormatKeysSetTests, self).setUp() + super().setUp() self.detail_url = reverse("user-detail", kwargs={"pk": self.miles.pk}) def test_camelization(self): diff --git a/example/tests/test_generic_validation.py b/example/tests/test_generic_validation.py index 99d2d09a..1c3561e7 100644 --- a/example/tests/test_generic_validation.py +++ b/example/tests/test_generic_validation.py @@ -9,7 +9,7 @@ class GenericValidationTest(TestBase): """ def setUp(self): - super(GenericValidationTest, self).setUp() + super().setUp() self.url = reverse("user-validation", kwargs={"pk": self.miles.pk}) def test_generic_validation_error(self): diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index a5bbcc00..c58b5131 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -19,7 +19,7 @@ class ModelViewSetTests(TestBase): list_url = reverse("user-list") def setUp(self): - super(ModelViewSetTests, self).setUp() + super().setUp() self.detail_url = reverse("user-detail", kwargs={"pk": self.miles.pk}) def test_key_in_list_result(self): diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index 9cfe6db9..8f14fed8 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -179,7 +179,7 @@ def test_deserialize_many(self): print(serializer.data) -class TestModelSerializer(object): +class TestModelSerializer: def test_model_serializer_with_implicit_fields(self, comment, client): expected = { "data": { diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py index 9c78dc61..f50304ac 100644 --- a/example/tests/unit/test_filter_schema_params.py +++ b/example/tests/unit/test_filter_schema_params.py @@ -22,7 +22,7 @@ class DummyEntryViewSet(EntryViewSet): def __init__(self, **kwargs): # dummy up self.request since PreloadIncludesMixin expects it to be defined self.request = None - super(DummyEntryViewSet, self).__init__(**kwargs) + super().__init__(**kwargs) def test_filters_get_schema_params(): diff --git a/example/tests/unit/test_renderer_class_methods.py b/example/tests/unit/test_renderer_class_methods.py index 51cc2481..6e7c9ea1 100644 --- a/example/tests/unit/test_renderer_class_methods.py +++ b/example/tests/unit/test_renderer_class_methods.py @@ -70,14 +70,12 @@ class CustomRenderer(JSONRenderer): @classmethod def extract_attributes(cls, fields, resource): cls.extract_attributes_was_overriden = True - return super(CustomRenderer, cls).extract_attributes(fields, resource) + return super().extract_attributes(fields, resource) @classmethod def extract_relationships(cls, fields, resource, resource_instance): cls.extract_relationships_was_overriden = True - return super(CustomRenderer, cls).extract_relationships( - fields, resource, resource_instance - ) + return super().extract_relationships(fields, resource, resource_instance) assert ( CustomRenderer.build_json_resource_obj( diff --git a/example/tests/unit/test_serializer_method_field.py b/example/tests/unit/test_serializer_method_field.py index 37e74ce6..1be56f47 100644 --- a/example/tests/unit/test_serializer_method_field.py +++ b/example/tests/unit/test_serializer_method_field.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import - from rest_framework import serializers from rest_framework_json_api.relations import SerializerMethodResourceRelatedField diff --git a/example/views.py b/example/views.py index 64d31780..27ec6c2e 100644 --- a/example/views.py +++ b/example/views.py @@ -57,7 +57,7 @@ def get_object(self): if entry_pk is not None: return Entry.objects.get(id=entry_pk).blog - return super(BlogViewSet, self).get_object() + return super().get_object() class DRFBlogViewSet(ModelViewSet): @@ -70,7 +70,7 @@ def get_object(self): if entry_pk is not None: return Entry.objects.get(id=entry_pk).blog - return super(DRFBlogViewSet, self).get_object() + return super().get_object() class JsonApiViewSet(ModelViewSet): @@ -98,7 +98,7 @@ def handle_exception(self, exc): exc.status_code = HTTP_422_UNPROCESSABLE_ENTITY # exception handler can't be set on class so you have to # override the error response in this method - response = super(JsonApiViewSet, self).handle_exception(exc) + response = super().handle_exception(exc) context = self.get_exception_handler_context() return format_drf_errors(response, context, exc) @@ -121,7 +121,7 @@ def get_object(self): if entry_pk is not None: return Entry.objects.exclude(pk=entry_pk).first() - return super(EntryViewSet, self).get_object() + return super().get_object() class DRFEntryViewSet(ModelViewSet): @@ -135,7 +135,7 @@ def get_object(self): if entry_pk is not None: return Entry.objects.exclude(pk=entry_pk).first() - return super(DRFEntryViewSet, self).get_object() + return super().get_object() class NoPagination(JsonApiPageNumberPagination): diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 8f8e5dd7..6add109f 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - __title__ = "djangorestframework-jsonapi" __version__ = "4.2.1" __author__ = "" diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 72ae873e..61518e1c 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -138,7 +138,7 @@ def get_schema_operation_parameters(self, view): This is basically the reverse of `get_filterset_kwargs` above. """ - result = super(DjangoFilterBackend, self).get_schema_operation_parameters(view) + result = super().get_schema_operation_parameters(view) for res in result: if "name" in res: res["name"] = "filter[{}]".format(res["name"]).replace("__", ".") diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index d5dd337c..ba3794cb 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -61,9 +61,7 @@ def remove_invalid_fields(self, queryset, fields, view, request): else: underscore_fields.append(undo_format_field_name(item_rewritten)) - return super(OrderingFilter, self).remove_invalid_fields( - queryset, underscore_fields, view, request - ) + return super().remove_invalid_fields(queryset, underscore_fields, view, request) class QueryParameterValidationFilter(BaseFilterBackend): diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 93767a07..61824749 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -74,7 +74,7 @@ def parse(self, stream, media_type=None, parser_context=None): """ Parses the incoming bytestream as JSON and returns the resulting data """ - result = super(JSONParser, self).parse( + result = super().parse( stream, media_type=media_type, parser_context=parser_context ) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index b84666db..db297870 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -662,9 +662,7 @@ class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer): includes_template = "rest_framework_json_api/includes.html" def get_context(self, data, accepted_media_type, renderer_context): - context = super(BrowsableAPIRenderer, self).get_context( - data, accepted_media_type, renderer_context - ) + context = super().get_context(data, accepted_media_type, renderer_context) view = renderer_context["view"] context["includes_form"] = self.get_includes_form(view) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index d2ec5b53..0b4a8dfd 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -328,7 +328,7 @@ def get_field_names(self, declared_fields, info): field = declared_fields[field_name] if field_name not in meta_fields: declared[field_name] = field - fields = super(ModelSerializer, self).get_field_names(declared, info) + fields = super().get_field_names(declared, info) return list(fields) + list(getattr(self.Meta, "meta_fields", list())) diff --git a/setup.py b/setup.py index eff9d026..08712129 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ -#!/usr/bin/env python -from __future__ import print_function +#!/usr/bin/env python3 import os import re From 2d00b013e6e7c7f3c55641fd1fdc97f0bfe8ca4e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 27 Oct 2021 21:30:55 +0400 Subject: [PATCH 334/488] Add codecov configuration (#1002) * Ensure that PR covers 100% of diff * Disable project coverage as it may be misleading * Disable github comment to avoid notifications --- .codecov.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .codecov.yml diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 00000000..78f7b94d --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,7 @@ +coverage: + status: + patch: + default: + target: 100% + project: false +comment: false From f32e8232c2dced3da2c2f9713500ae74f4e3ac8f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 10 Nov 2021 20:25:16 +0400 Subject: [PATCH 335/488] Migrate to syrupy snapshot testing (#1005) --- example/tests/__snapshots__/test_errors.ambr | 133 ++++ example/tests/__snapshots__/test_openapi.ambr | 669 ++++++++++++++++++ example/tests/snapshots/__init__.py | 0 example/tests/snapshots/snap_test_errors.py | 107 --- example/tests/snapshots/snap_test_openapi.py | 667 ----------------- example/tests/test_errors.py | 18 +- example/tests/test_openapi.py | 10 +- requirements/requirements-testing.txt | 2 +- 8 files changed, 817 insertions(+), 789 deletions(-) create mode 100644 example/tests/__snapshots__/test_errors.ambr create mode 100644 example/tests/__snapshots__/test_openapi.ambr delete mode 100644 example/tests/snapshots/__init__.py delete mode 100644 example/tests/snapshots/snap_test_errors.py delete mode 100644 example/tests/snapshots/snap_test_openapi.py diff --git a/example/tests/__snapshots__/test_errors.ambr b/example/tests/__snapshots__/test_errors.ambr new file mode 100644 index 00000000..d025fd4e --- /dev/null +++ b/example/tests/__snapshots__/test_errors.ambr @@ -0,0 +1,133 @@ +# name: test_first_level_attribute_error + { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/headline', + }, + 'status': '400', + }, + ], + } +--- +# name: test_first_level_custom_attribute_error + { + 'errors': [ + { + 'detail': 'Too short', + 'source': { + 'pointer': '/data/attributes/body-text', + }, + 'title': 'Too Short title', + }, + ], + } +--- +# name: test_many_third_level_dict_errors + { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/attachment/data', + }, + 'status': '400', + }, + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/body', + }, + 'status': '400', + }, + ], + } +--- +# name: test_relationship_errors_has_correct_pointers + { + 'errors': [ + { + 'code': 'incorrect_type', + 'detail': 'Incorrect type. Expected resource identifier object, received str.', + 'source': { + 'pointer': '/data/relationships/author', + }, + 'status': '400', + }, + ], + } +--- +# name: test_second_level_array_error + { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/body', + }, + 'status': '400', + }, + ], + } +--- +# name: test_second_level_dict_error + { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comment/body', + }, + 'status': '400', + }, + ], + } +--- +# name: test_third_level_array_error + { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/attachments/0/data', + }, + 'status': '400', + }, + ], + } +--- +# name: test_third_level_custom_array_error + { + 'errors': [ + { + 'code': 'invalid', + 'detail': 'Too short data', + 'source': { + 'pointer': '/data/attributes/comments/0/attachments/0/data', + }, + 'status': '400', + }, + ], + } +--- +# name: test_third_level_dict_error + { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/attachment/data', + }, + 'status': '400', + }, + ], + } +--- diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr new file mode 100644 index 00000000..28044c74 --- /dev/null +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -0,0 +1,669 @@ +# name: test_delete_request + ' + { + "description": "", + "operationId": "destroy/authors/{id}", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/onlymeta" + } + } + }, + "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)" + }, + "202": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/datum" + } + } + }, + "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" + }, + "204": { + "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" + } + }, + "tags": [ + "authors" + ] + } + ' +--- +# name: test_patch_request + ' + { + "description": "", + "operationId": "update/authors/{id}", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "defaults": { + "default": "default", + "description": "help for defaults", + "maxLength": 20, + "minLength": 3, + "type": "string", + "writeOnly": true + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "name": { + "maxLength": 50, + "type": "string" + } + }, + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "properties": { + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationships": { + "properties": { + "bio": { + "$ref": "#/components/schemas/reltoone" + }, + "comments": { + "$ref": "#/components/schemas/reltomany" + }, + "entries": { + "$ref": "#/components/schemas/reltomany" + }, + "first_entry": { + "$ref": "#/components/schemas/reltoone" + }, + "type": { + "$ref": "#/components/schemas/reltoone" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "update/authors/{id}" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "403": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-updating-responses-404)" + }, + "409": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" + } + }, + "tags": [ + "authors" + ] + } + ' +--- +# name: test_path_with_id_parameter + ' + { + "description": "", + "operationId": "retrieve/authors/{id}/", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/include" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "$ref": "#/components/parameters/sort" + }, + { + "description": "Which field to use when ordering the results.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search term.", + "in": "query", + "name": "filter[search]", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/AuthorDetail" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "retrieve/authors/{id}/" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not found" + } + }, + "tags": [ + "authors" + ] + } + ' +--- +# name: test_path_without_parameters + ' + { + "description": "", + "operationId": "List/authors/", + "parameters": [ + { + "$ref": "#/components/parameters/include" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "$ref": "#/components/parameters/sort" + }, + { + "description": "A page number within the paginated result set.", + "in": "query", + "name": "page[number]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Number of results to return per page.", + "in": "query", + "name": "page[size]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Which field to use when ordering the results.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search term.", + "in": "query", + "name": "filter[search]", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AuthorList" + }, + "type": "array" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "List/authors/" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not found" + } + }, + "tags": [ + "authors" + ] + } + ' +--- +# name: test_post_request + ' + { + "description": "", + "operationId": "create/authors/", + "parameters": [], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "defaults": { + "default": "default", + "description": "help for defaults", + "maxLength": 20, + "minLength": 3, + "type": "string", + "writeOnly": true + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "name": { + "maxLength": 50, + "type": "string" + } + }, + "required": [ + "name", + "email" + ], + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "properties": { + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationships": { + "properties": { + "bio": { + "$ref": "#/components/schemas/reltoone" + }, + "comments": { + "$ref": "#/components/schemas/reltomany" + }, + "entries": { + "$ref": "#/components/schemas/reltomany" + }, + "first_entry": { + "$ref": "#/components/schemas/reltoone" + }, + "type": { + "$ref": "#/components/schemas/reltoone" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "201": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-201). Assigned `id` and/or any other changes are in this response." + }, + "202": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/datum" + } + } + }, + "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" + }, + "204": { + "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) with the supplied `id`. No other changes from what was POSTed." + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "403": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-creating-responses-404)" + }, + "409": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" + } + }, + "tags": [ + "authors" + ] + } + ' +--- diff --git a/example/tests/snapshots/__init__.py b/example/tests/snapshots/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/example/tests/snapshots/snap_test_errors.py b/example/tests/snapshots/snap_test_errors.py deleted file mode 100644 index 09bc25ef..00000000 --- a/example/tests/snapshots/snap_test_errors.py +++ /dev/null @@ -1,107 +0,0 @@ -from snapshottest import Snapshot - -snapshots = Snapshot() - -snapshots["test_first_level_attribute_error 1"] = { - "errors": [ - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/headline"}, - "status": "400", - } - ] -} - -snapshots["test_first_level_custom_attribute_error 1"] = { - "errors": [ - { - "detail": "Too short", - "source": {"pointer": "/data/attributes/body-text"}, - "title": "Too Short title", - } - ] -} - -snapshots["test_many_third_level_dict_errors 1"] = { - "errors": [ - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/comments/0/attachment/data"}, - "status": "400", - }, - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/comments/0/body"}, - "status": "400", - }, - ] -} - -snapshots["test_relationship_errors_has_correct_pointers 1"] = { - "errors": [ - { - "code": "incorrect_type", - "detail": "Incorrect type. Expected resource identifier object, received str.", - "source": {"pointer": "/data/relationships/author"}, - "status": "400", - } - ] -} - -snapshots["test_second_level_array_error 1"] = { - "errors": [ - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/comments/0/body"}, - "status": "400", - } - ] -} - -snapshots["test_second_level_dict_error 1"] = { - "errors": [ - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/comment/body"}, - "status": "400", - } - ] -} - -snapshots["test_third_level_array_error 1"] = { - "errors": [ - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/comments/0/attachments/0/data"}, - "status": "400", - } - ] -} - -snapshots["test_third_level_custom_array_error 1"] = { - "errors": [ - { - "code": "invalid", - "detail": "Too short data", - "source": {"pointer": "/data/attributes/comments/0/attachments/0/data"}, - "status": "400", - } - ] -} - -snapshots["test_third_level_dict_error 1"] = { - "errors": [ - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/comments/0/attachment/data"}, - "status": "400", - } - ] -} diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py deleted file mode 100644 index a0e6e78e..00000000 --- a/example/tests/snapshots/snap_test_openapi.py +++ /dev/null @@ -1,667 +0,0 @@ -from snapshottest import Snapshot - -snapshots = Snapshot() - -snapshots[ - "test_path_without_parameters 1" -] = """{ - "description": "", - "operationId": "List/authors/", - "parameters": [ - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "$ref": "#/components/parameters/sort" - }, - { - "description": "A page number within the paginated result set.", - "in": "query", - "name": "page[number]", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Number of results to return per page.", - "in": "query", - "name": "page[size]", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Which field to use when ordering the results.", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/AuthorList" - }, - "type": "array" - }, - "included": { - "items": { - "$ref": "#/components/schemas/resource" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "List/authors/" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not found" - } - }, - "tags": [ - "authors" - ] -}""" - -snapshots[ - "test_path_with_id_parameter 1" -] = """{ - "description": "", - "operationId": "retrieve/authors/{id}/", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - }, - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "$ref": "#/components/parameters/sort" - }, - { - "description": "Which field to use when ordering the results.", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/AuthorDetail" - }, - "included": { - "items": { - "$ref": "#/components/schemas/resource" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "retrieve/authors/{id}/" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not found" - } - }, - "tags": [ - "authors" - ] -}""" - -snapshots[ - "test_post_request 1" -] = """{ - "description": "", - "operationId": "create/authors/", - "parameters": [], - "requestBody": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "name": { - "maxLength": 50, - "type": "string" - } - }, - "required": [ - "name", - "email" - ], - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "properties": { - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationships": { - "properties": { - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "first_entry": { - "$ref": "#/components/schemas/reltoone" - }, - "type": { - "$ref": "#/components/schemas/reltoone" - } - }, - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type" - ], - "type": "object" - } - }, - "required": [ - "data" - ] - } - } - } - }, - "responses": { - "201": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Author" - }, - "included": { - "items": { - "$ref": "#/components/schemas/resource" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-201). Assigned `id` and/or any other changes are in this response." - }, - "202": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/datum" - } - } - }, - "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" - }, - "204": { - "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) with the supplied `id`. No other changes from what was POSTed." - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "403": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-creating-responses-404)" - }, - "409": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" - } - }, - "tags": [ - "authors" - ] -}""" - -snapshots[ - "test_patch_request 1" -] = """{ - "description": "", - "operationId": "update/authors/{id}", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "name": { - "maxLength": 50, - "type": "string" - } - }, - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "properties": { - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationships": { - "properties": { - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "first_entry": { - "$ref": "#/components/schemas/reltoone" - }, - "type": { - "$ref": "#/components/schemas/reltoone" - } - }, - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - } - }, - "required": [ - "data" - ] - } - } - } - }, - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Author" - }, - "included": { - "items": { - "$ref": "#/components/schemas/resource" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "update/authors/{id}" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "403": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-updating-responses-404)" - }, - "409": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" - } - }, - "tags": [ - "authors" - ] -}""" - -snapshots[ - "test_delete_request 1" -] = """{ - "description": "", - "operationId": "destroy/authors/{id}", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/onlymeta" - } - } - }, - "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)" - }, - "202": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/datum" - } - } - }, - "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" - }, - "204": { - "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" - } - }, - "tags": [ - "authors" - ] -}""" diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py index 6b322d72..2267ec6e 100644 --- a/example/tests/test_errors.py +++ b/example/tests/test_errors.py @@ -79,7 +79,7 @@ def test_first_level_attribute_error(client, some_blog, snapshot): }, } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_first_level_custom_attribute_error(client, some_blog, snapshot): @@ -94,7 +94,7 @@ def test_first_level_custom_attribute_error(client, some_blog, snapshot): } } with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_second_level_array_error(client, some_blog, snapshot): @@ -110,7 +110,7 @@ def test_second_level_array_error(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_second_level_dict_error(client, some_blog, snapshot): @@ -126,7 +126,7 @@ def test_second_level_dict_error(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_third_level_array_error(client, some_blog, snapshot): @@ -142,7 +142,7 @@ def test_third_level_array_error(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_third_level_custom_array_error(client, some_blog, snapshot): @@ -160,7 +160,7 @@ def test_third_level_custom_array_error(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_third_level_dict_error(client, some_blog, snapshot): @@ -176,7 +176,7 @@ def test_third_level_dict_error(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_many_third_level_dict_errors(client, some_blog, snapshot): @@ -192,7 +192,7 @@ def test_many_third_level_dict_errors(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_relationship_errors_has_correct_pointers(client, some_blog, snapshot): @@ -210,4 +210,4 @@ def test_relationship_errors_has_correct_pointers(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index d32a9367..5b881f8b 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -33,7 +33,7 @@ def test_path_without_parameters(snapshot): inspector.view = view operation = inspector.get_operation(path, method) - snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + assert snapshot == json.dumps(operation, indent=2, sort_keys=True) def test_path_with_id_parameter(snapshot): @@ -47,7 +47,7 @@ def test_path_with_id_parameter(snapshot): inspector.view = view operation = inspector.get_operation(path, method) - snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + assert snapshot == json.dumps(operation, indent=2, sort_keys=True) def test_post_request(snapshot): @@ -61,7 +61,7 @@ def test_post_request(snapshot): inspector.view = view operation = inspector.get_operation(path, method) - snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + assert snapshot == json.dumps(operation, indent=2, sort_keys=True) def test_patch_request(snapshot): @@ -75,7 +75,7 @@ def test_patch_request(snapshot): inspector.view = view operation = inspector.get_operation(path, method) - snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + assert snapshot == json.dumps(operation, indent=2, sort_keys=True) def test_delete_request(snapshot): @@ -89,7 +89,7 @@ def test_delete_request(snapshot): inspector.view = view operation = inspector.get_operation(path, method) - snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + assert snapshot == json.dumps(operation, indent=2, sort_keys=True) @override_settings( diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 5ac6e9c9..4d83cd28 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -5,4 +5,4 @@ pytest==6.2.5 pytest-cov==3.0.0 pytest-django==4.4.0 pytest-factoryboy==2.1.0 -snapshottest==0.6.0 +syrupy==1.4.7 From d0251630bd6ef3edaf03e47e5c1e6b36d32ee82a Mon Sep 17 00:00:00 2001 From: sha016 <92833633+sha016@users.noreply.github.com> Date: Thu, 11 Nov 2021 08:15:05 -0600 Subject: [PATCH 336/488] Format fields in OpenAPI Schema (#1003) --- AUTHORS | 1 + CHANGELOG.md | 1 + example/tests/__snapshots__/test_openapi.ambr | 4 ++-- rest_framework_json_api/schemas/openapi.py | 7 ++++--- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index 84e52286..35d959c6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -39,6 +39,7 @@ Safa AlFulaij santiavenda Sergey Kolomenkin Stas S. +Steven A. Swaraj Baral Tim Selman Tom Glowka diff --git a/CHANGELOG.md b/CHANGELOG.md index db78a996..b631eda7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ any parts of the framework not mentioned in the documentation should generally b * Avoid error when `parser_context` is `None` while parsing. * Raise comprehensible error when reserved field names `meta` and `results` are used. * Use `relationships` in the error object `pointer` when the field is actually a relationship. +* Added missing inflection to the generated OpenAPI schema. ### Changed diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 28044c74..48aa21fa 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -133,7 +133,7 @@ "entries": { "$ref": "#/components/schemas/reltomany" }, - "first_entry": { + "firstEntry": { "$ref": "#/components/schemas/reltoone" }, "type": { @@ -541,7 +541,7 @@ "entries": { "$ref": "#/components/schemas/reltomany" }, - "first_entry": { + "firstEntry": { "$ref": "#/components/schemas/reltoone" }, "type": { diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 2dff2e10..c3d553b7 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -7,6 +7,7 @@ from rest_framework.schemas.utils import is_list_view from rest_framework_json_api import serializers, views +from rest_framework_json_api.utils import format_field_name class SchemaGenerator(drf_openapi.SchemaGenerator): @@ -655,12 +656,12 @@ def map_serializer(self, serializer): if isinstance(field, serializers.HiddenField): continue if isinstance(field, serializers.RelatedField): - relationships[field.field_name] = { + relationships[format_field_name(field.field_name)] = { "$ref": "#/components/schemas/reltoone" } continue if isinstance(field, serializers.ManyRelatedField): - relationships[field.field_name] = { + relationships[format_field_name(field.field_name)] = { "$ref": "#/components/schemas/reltomany" } continue @@ -682,7 +683,7 @@ def map_serializer(self, serializer): schema["description"] = str(field.help_text) self.map_field_validators(field, schema) - attributes[field.field_name] = schema + attributes[format_field_name(field.field_name)] = schema result = { "type": "object", From 83d84d02b7670a05d701bcb14bf57904bce30b94 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 18 Nov 2021 23:36:42 +0400 Subject: [PATCH 337/488] Use one GitHub runner per Python version (#1011) * Moving from tox-gh-actions to tox-py (following DRF) * Move configuration what tests are allowed to failed into tox configuration instead of GitHub settings * Simplification of configuration as tox has the lead and no additional configuration is needed. Co-authored-by: Alan Crosswell --- .github/workflows/tests.yml | 13 ++++--------- tox.ini | 21 +++------------------ 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a38766c7..ebe31bb1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,17 +5,12 @@ jobs: test: name: Run test runs-on: ubuntu-latest - continue-on-error: ${{ matrix.django-rest-framework == 'master' }} strategy: fail-fast: false matrix: python-version: ["3.6", "3.7", "3.8", "3.9"] - django: ["2.2", "3.0", "3.1", "3.2"] - django-rest-framework: ["3.12", "master"] env: PYTHON: ${{ matrix.python-version }} - DJANGO: ${{ matrix.django }} - DJANGO_REST_FRAMEWORK: ${{ matrix.django-rest-framework }} steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -25,13 +20,13 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install tox tox-gh-actions - - name: Test with tox - run: tox + pip install tox tox-py + - name: Run tox targets for ${{ matrix.python-version }} + run: tox --py current - name: Upload coverage report uses: codecov/codecov-action@v2 with: - env_vars: PYTHON,DJANGO,DJANGO_REST_FRAMEWORK + env_vars: PYTHON check: name: Run check runs-on: ubuntu-latest diff --git a/tox.ini b/tox.ini index f4160e5a..075cffc5 100644 --- a/tox.ini +++ b/tox.ini @@ -3,24 +3,6 @@ envlist = py{36,37,38,39}-django{22,30,31,32}-drf{312,master}, lint,docs -[gh-actions] -python = - 3.6: py36 - 3.7: py37 - 3.8: py38 - 3.9: py39 - -[gh-actions:env] -DJANGO = - 2.2: django22 - 3.0: django30 - 3.1: django31 - 3.2: django32 - -DJANGO_REST_FRAMEWORK = - 3.12: drf312 - master: drfmaster - [testenv] deps = django22: Django>=2.2,<2.3 @@ -61,3 +43,6 @@ deps = -rrequirements/requirements-documentation.txt commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html + +[testenv:py{36,37,38,39}-django{22,30,31,32}-drfmaster] +ignore_outcome = true From ffedc6bb755f9b81af7ee423ba64021535fef96c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 22 Nov 2021 21:51:03 +0400 Subject: [PATCH 338/488] Added support for Django 4.0 (#1010) * Added support for Django 4.0 * Bump to Django 4.0 first release candidate --- CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- example/settings/dev.py | 1 + example/urls_test.py | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 5 +++++ setup.cfg | 4 ++-- setup.py | 2 +- tests/test_relations.py | 2 +- tox.ini | 2 ++ 11 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b631eda7..18ca7b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Added + +* Added support for Django 4.0. + ### Fixed * Adjusted error messages to correctly use capital "JSON:API" abbreviation as used in the specification. diff --git a/README.rst b/README.rst index c3e661e3..b3f4ad35 100644 --- a/README.rst +++ b/README.rst @@ -89,7 +89,7 @@ Requirements ------------ 1. Python (3.6, 3.7, 3.8, 3.9) -2. Django (2.2, 3.0, 3.1, 3.2) +2. Django (2.2, 3.0, 3.1, 3.2, 4.0) 3. Django REST framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 51780af2..0d0d0caf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.6, 3.7, 3.8, 3.9) -2. Django (2.2, 3.0, 3.1, 3.2) +2. Django (2.2, 3.0, 3.1, 3.2, 4.0) 3. Django REST framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/example/settings/dev.py b/example/settings/dev.py index 12bed35c..c02180eb 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -5,6 +5,7 @@ MEDIA_ROOT = os.path.normcase(os.path.dirname(os.path.abspath(__file__))) MEDIA_URL = "/media/" +USE_TZ = False DATABASE_ENGINE = "sqlite3" diff --git a/example/urls_test.py b/example/urls_test.py index 5ee06a23..92802a81 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -1,4 +1,4 @@ -from django.conf.urls import re_path +from django.urls import re_path from rest_framework import routers from .api.resources.identity import GenericIdentity, Identity diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 1db26c07..0fcfcbbf 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ django-filter==21.1 -django-polymorphic==3.0.0 +django-polymorphic==3.1.0 pyyaml==6.0 uritemplate==4.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 4d83cd28..415e4124 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -6,3 +6,8 @@ pytest-cov==3.0.0 pytest-django==4.4.0 pytest-factoryboy==2.1.0 syrupy==1.4.7 +# TODO remove pytz dep again once DRF higher than 3.12.4 is released +# Django 4.0 removed dependency on pytz and made it optional but +# DRF requires it and will define it as dependency in future versions +# only adding this to testing though as DJA does not directly use pytz +pytz==2021.3 diff --git a/setup.cfg b/setup.cfg index 31e61331..2947a883 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,8 +58,8 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - # Django Debug Toolbar currently (2021-04-07) specifies default_app_config which is deprecated in Django 3.2: - ignore:'debug_toolbar' defines default_app_config = 'debug_toolbar.apps.DebugToolbarConfig'. Django now detects this configuration automatically. You can remove default_app_config.:PendingDeprecationWarning + # TODO remove again once DRF higher than 3.12.4 has been released + ignore:'rest_framework' defines default_app_config # TODO remove in next major version of DJA 5.0.0 # this deprecation warning filter needs to be added as AuthorSerializer is used in # too many tests which introduced the type field name in tests diff --git a/setup.py b/setup.py index 08712129..24611c13 100755 --- a/setup.py +++ b/setup.py @@ -97,7 +97,7 @@ def get_package_data(package): install_requires=[ "inflection>=0.3.0", "djangorestframework>=3.12,<3.13", - "django>=2.2,<3.3", + "django>=2.2,<4.1", ], extras_require={ "django-polymorphic": ["django-polymorphic>=2.0"], diff --git a/tests/test_relations.py b/tests/test_relations.py index 630dd9c8..74721cfa 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -1,5 +1,5 @@ import pytest -from django.conf.urls import re_path +from django.urls import re_path from rest_framework import status from rest_framework.fields import SkipField from rest_framework.routers import SimpleRouter diff --git a/tox.ini b/tox.ini index 075cffc5..b8b1a6ea 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] envlist = py{36,37,38,39}-django{22,30,31,32}-drf{312,master}, + py{38,39}-django40-drf{312,master}, lint,docs [testenv] @@ -9,6 +10,7 @@ deps = django30: Django>=3.0,<3.1 django31: Django>=3.1,<3.2 django32: Django>=3.2,<3.3 + django40: Django>=4.0rc1,<5.0 drf312: djangorestframework>=3.12,<3.13 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt From 3db6bce73e017a883e3f9139f5acaa466b634c71 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 29 Nov 2021 20:44:06 +0400 Subject: [PATCH 339/488] Added support for Python 3.10 (#1013) --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 1 + README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 1 + tox.ini | 1 + 6 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ebe31bb1..3d085c5c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.6", "3.7", "3.8", "3.9"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] env: PYTHON: ${{ matrix.python-version }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ca7b03..10559ddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Django 4.0. +* Added support for Python 3.10. ### Fixed diff --git a/README.rst b/README.rst index b3f4ad35..9f5c4c4b 100644 --- a/README.rst +++ b/README.rst @@ -88,7 +88,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.6, 3.7, 3.8, 3.9) +1. Python (3.6, 3.7, 3.8, 3.9, 3.10) 2. Django (2.2, 3.0, 3.1, 3.2, 4.0) 3. Django REST framework (3.12) diff --git a/docs/getting-started.md b/docs/getting-started.md index 0d0d0caf..41bffc39 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.6, 3.7, 3.8, 3.9) +1. Python (3.6, 3.7, 3.8, 3.9, 3.10) 2. Django (2.2, 3.0, 3.1, 3.2, 4.0) 3. Django REST framework (3.12) diff --git a/setup.py b/setup.py index 24611c13..482d7b22 100755 --- a/setup.py +++ b/setup.py @@ -90,6 +90,7 @@ def get_package_data(package): "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/tox.ini b/tox.ini index b8b1a6ea..a5d4869f 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = py{36,37,38,39}-django{22,30,31,32}-drf{312,master}, py{38,39}-django40-drf{312,master}, + py310-django{32,40}-drf{312,master}, lint,docs [testenv] From a4262ddaae31beb7a1b9106df55115e373bc8d24 Mon Sep 17 00:00:00 2001 From: Jonas Kiefer <17874616+jokiefer@users.noreply.github.com> Date: Sun, 5 Dec 2021 17:24:11 +0100 Subject: [PATCH 340/488] Added missing error message when no resource name is configured on serializer (#1020) --- AUTHORS | 1 + CHANGELOG.md | 1 + rest_framework_json_api/utils.py | 5 ++++- tests/test_utils.py | 16 ++++++++++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 35d959c6..fa91e9b2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ Jamie Bliss Jason Housley Jeppe Fihl-Pearson Jerel Unruh +Jonas Kiefer Jonas Metzener Jonathan Senecal Joseba Mendivil diff --git a/CHANGELOG.md b/CHANGELOG.md index 10559ddb..1fa3d92e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ any parts of the framework not mentioned in the documentation should generally b * Raise comprehensible error when reserved field names `meta` and `results` are used. * Use `relationships` in the error object `pointer` when the field is actually a relationship. * Added missing inflection to the generated OpenAPI schema. +* Added missing error message when `resource_name` is not properly configured. ### Changed diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 65cbc645..6189093f 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -320,7 +320,10 @@ def get_resource_type_from_serializer(serializer): return meta.resource_name elif hasattr(meta, "model"): return get_resource_type_from_model(meta.model) - raise AttributeError() + raise AttributeError( + f"can not detect 'resource_name' on serializer '{serializer.__class__.__name__}'" + f" in module '{serializer.__class__.__module__}'" + ) def get_included_resources(request, serializer=None): diff --git a/tests/test_utils.py b/tests/test_utils.py index 43c12dcf..d8810c0b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ import pytest from django.db import models from rest_framework import status +from rest_framework.fields import Field from rest_framework.generics import GenericAPIView from rest_framework.response import Response from rest_framework.views import APIView @@ -15,6 +16,7 @@ get_included_serializers, get_related_resource_type, get_resource_name, + get_resource_type_from_serializer, undo_format_field_name, undo_format_field_names, undo_format_link_segment, @@ -377,3 +379,17 @@ class Meta: } assert included_serializers == expected_included_serializers + + +def test_get_resource_type_from_serializer_without_resource_name_raises_error(): + class SerializerWithoutResourceName(serializers.Serializer): + something = Field() + + serializer = SerializerWithoutResourceName() + + with pytest.raises(AttributeError) as excinfo: + get_resource_type_from_serializer(serializer=serializer) + assert str(excinfo.value) == ( + "can not detect 'resource_name' on serializer " + "'SerializerWithoutResourceName' in module 'tests.test_utils'" + ) From b62ca08ea41596bb585ef7d977adbe1be704a09b Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 6 Dec 2021 10:22:23 -0500 Subject: [PATCH 341/488] Scheduled biweekly dependency update for week 49 (#1021) * Update black from 21.9b0 to 21.12b0 * Update isort from 5.9.3 to 5.10.1 * Update sphinx from 4.2.0 to 4.3.1 * Update twine from 3.4.2 to 3.7.0 * Update factory-boy from 3.2.0 to 3.2.1 * Update faker from 9.3.1 to 9.9.0 * Update pytest-django from 4.4.0 to 4.5.1 * Update syrupy from 1.4.7 to 1.5.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 55628500..99c23c93 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==21.9b0 +black==21.12b0 flake8==4.0.1 flake8-isort==4.1.1 -isort==5.9.3 +isort==5.10.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index d24d7101..e381bed3 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.2.0 +Sphinx==4.3.1 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 1a95714a..4cc9f1eb 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.4.2 +twine==3.7.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 415e4124..1221211e 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,11 +1,11 @@ django-debug-toolbar==3.2.2 -factory-boy==3.2.0 -Faker==9.3.1 +factory-boy==3.2.1 +Faker==9.9.0 pytest==6.2.5 pytest-cov==3.0.0 -pytest-django==4.4.0 +pytest-django==4.5.1 pytest-factoryboy==2.1.0 -syrupy==1.4.7 +syrupy==1.5.0 # TODO remove pytz dep again once DRF higher than 3.12.4 is released # Django 4.0 removed dependency on pytz and made it optional but # DRF requires it and will define it as dependency in future versions From 8e368a2a838f9e673a05a66bd72230b6ae7ef2ab Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 7 Dec 2021 20:58:19 +0400 Subject: [PATCH 342/488] Bumped to released Django version 4.0.0 (#1022) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index a5d4869f..ffba965a 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ deps = django30: Django>=3.0,<3.1 django31: Django>=3.1,<3.2 django32: Django>=3.2,<3.3 - django40: Django>=4.0rc1,<5.0 + django40: Django>=4.0,<5.0 drf312: djangorestframework>=3.12,<3.13 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt From 5403d508065b52ad84e35bf093c4cd37f05a24fb Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 7 Dec 2021 21:12:17 +0400 Subject: [PATCH 343/488] Running pyupgrade with Python 3+ support (#1023) Removes some old Python 2 literals Co-authored-by: Alan Crosswell --- example/tests/integration/test_includes.py | 22 ++++++--------- .../tests/integration/test_polymorphism.py | 28 +++++++++---------- example/tests/test_filters.py | 2 +- 3 files changed, 22 insertions(+), 30 deletions(-) diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index 223645d2..f8fe8604 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -21,7 +21,7 @@ def test_included_data_on_list(multiple_entries, client): comment_count = len( [resource for resource in included if resource["type"] == "comments"] ) - expected_comment_count = sum([entry.comments.count() for entry in multiple_entries]) + expected_comment_count = sum(entry.comments.count() for entry in multiple_entries) assert comment_count == expected_comment_count, "List comment count is incorrect" @@ -135,17 +135,15 @@ def test_deep_included_data_on_list(multiple_entries, client): comment_count = len( [resource for resource in included if resource["type"] == "comments"] ) - expected_comment_count = sum([entry.comments.count() for entry in multiple_entries]) + expected_comment_count = sum(entry.comments.count() for entry in multiple_entries) assert comment_count == expected_comment_count, "List comment count is incorrect" author_count = len( [resource for resource in included if resource["type"] == "authors"] ) expected_author_count = sum( - [ - entry.comments.filter(author__isnull=False).count() - for entry in multiple_entries - ] + entry.comments.filter(author__isnull=False).count() + for entry in multiple_entries ) assert author_count == expected_author_count, "List author count is incorrect" @@ -153,10 +151,8 @@ def test_deep_included_data_on_list(multiple_entries, client): [resource for resource in included if resource["type"] == "authorBios"] ) expected_author_bio_count = sum( - [ - entry.comments.filter(author__bio__isnull=False).count() - for entry in multiple_entries - ] + entry.comments.filter(author__bio__isnull=False).count() + for entry in multiple_entries ) assert ( author_bio_count == expected_author_bio_count @@ -166,10 +162,8 @@ def test_deep_included_data_on_list(multiple_entries, client): [resource for resource in included if resource["type"] == "writers"] ) expected_writer_count = sum( - [ - entry.comments.filter(author__isnull=False).count() - for entry in multiple_entries - ] + entry.comments.filter(author__isnull=False).count() + for entry in multiple_entries ) assert writer_count == expected_writer_count, "List writer count is incorrect" diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index bd41f203..6b1ef52f 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -24,12 +24,10 @@ def test_polymorphism_on_detail_relations(single_company, client): content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" ) - assert set( - [ - rel["type"] - for rel in content["data"]["relationships"]["futureProjects"]["data"] - ] - ) == set(["researchProjects", "artProjects"]) + assert { + rel["type"] + for rel in content["data"]["relationships"]["futureProjects"]["data"] + } == {"researchProjects", "artProjects"} def test_polymorphism_on_included_relations(single_company, client): @@ -47,15 +45,15 @@ def test_polymorphism_on_included_relations(single_company, client): == "artProjects" ) assert content["data"]["relationships"]["currentResearchProject"]["data"] is None - assert set( - [ - rel["type"] - for rel in content["data"]["relationships"]["futureProjects"]["data"] - ] - ) == set(["researchProjects", "artProjects"]) - assert set([x.get("type") for x in content.get("included")]) == set( - ["artProjects", "artProjects", "researchProjects"] - ), "Detail included types are incorrect" + assert { + rel["type"] + for rel in content["data"]["relationships"]["futureProjects"]["data"] + } == {"researchProjects", "artProjects"} + assert {x.get("type") for x in content.get("included")} == { + "artProjects", + "artProjects", + "researchProjects", + }, "Detail included types are incorrect" # Ensure that the child fields are present. assert content.get("included")[0].get("attributes").get("artist") is not None assert content.get("included")[1].get("attributes").get("artist") is not None diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 10d8886a..5bb12121 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -600,7 +600,7 @@ def test_search_multiple_keywords(self): expected_ids = set.intersection(*union) expected_len = len(expected_ids) self.assertEqual(len(dja_response["data"]), expected_len) - returned_ids = set([k["id"] for k in dja_response["data"]]) + returned_ids = {k["id"] for k in dja_response["data"]} self.assertEqual(returned_ids, expected_ids) def test_param_invalid(self): From 28e553a32d446e962c4e6c1f0cbb2a325c426fb8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 9 Dec 2021 22:50:24 +0400 Subject: [PATCH 344/488] Use assert when checking for reserved field names (#1025) This check is for development purposes to verify that the source code is well configured but not needed during production. Therefore a assert is more appropriate. --- rest_framework_json_api/serializers.py | 11 +++++------ tests/test_serializers.py | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 0b4a8dfd..1ac504ab 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -165,12 +165,11 @@ def get_fields(self): found_reserved_field_names = self._reserved_field_names.intersection( fields.keys() ) - if found_reserved_field_names: - raise AttributeError( - f"Serializer class {self.__class__.__module__}.{self.__class__.__qualname__} " - f"uses following reserved field name(s) which is not allowed: " - f"{', '.join(sorted(found_reserved_field_names))}" - ) + assert not found_reserved_field_names, ( + f"Serializer class {self.__class__.__module__}.{self.__class__.__qualname__} " + f"uses following reserved field name(s) which is not allowed: " + f"{', '.join(sorted(found_reserved_field_names))}" + ) if "type" in fields: # see https://jsonapi.org/format/#document-resource-object-fields diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 70bb140f..19ee23d6 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -37,7 +37,7 @@ class Meta: def test_reserved_field_names(): - with pytest.raises(AttributeError) as e: + with pytest.raises(AssertionError) as e: class ReservedFieldNamesSerializer(serializers.Serializer): meta = serializers.CharField() From aa06e04ca90003f07d47e85ae467803cd3b2a2a7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 9 Dec 2021 23:55:15 +0400 Subject: [PATCH 345/488] Release 4.3.0 (#1024) --- CHANGELOG.md | 2 +- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa3d92e..0ba7f5e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [4.3.0] - 2021-12-10 ### Added diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 6add109f..b1408d0b 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "4.2.1" +__version__ = "4.3.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From a9ac1566b665c514e7458f43f1064a2cf6cb18df Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 14 Dec 2021 19:01:13 +0400 Subject: [PATCH 346/488] Removed support for Django 3.0 and 3.1 (#1026) --- CHANGELOG.md | 9 +++++++++ README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 6 ++---- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba7f5e2..72a2f0d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Removed + +* Removed support for Django 3.0. +* Removed support for Django 3.1. + ## [4.3.0] - 2021-12-10 +This is the last release supporting Django 3.0 and Django 3.1. + ### Added * Added support for Django 4.0. diff --git a/README.rst b/README.rst index 9f5c4c4b..18317bd7 100644 --- a/README.rst +++ b/README.rst @@ -89,7 +89,7 @@ Requirements ------------ 1. Python (3.6, 3.7, 3.8, 3.9, 3.10) -2. Django (2.2, 3.0, 3.1, 3.2, 4.0) +2. Django (2.2, 3.2, 4.0) 3. Django REST framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 41bffc39..c473cd49 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.6, 3.7, 3.8, 3.9, 3.10) -2. Django (2.2, 3.0, 3.1, 3.2, 4.0) +2. Django (2.2, 3.2, 4.0) 3. Django REST framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/tox.ini b/tox.ini index ffba965a..e4baddf4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{36,37,38,39}-django{22,30,31,32}-drf{312,master}, + py{36,37,38,39}-django{22,32}-drf{312,master}, py{38,39}-django40-drf{312,master}, py310-django{32,40}-drf{312,master}, lint,docs @@ -8,8 +8,6 @@ envlist = [testenv] deps = django22: Django>=2.2,<2.3 - django30: Django>=3.0,<3.1 - django31: Django>=3.1,<3.2 django32: Django>=3.2,<3.3 django40: Django>=4.0,<5.0 drf312: djangorestframework>=3.12,<3.13 @@ -47,5 +45,5 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{36,37,38,39}-django{22,30,31,32}-drfmaster] +[testenv:py{36,37,38,39,310}-django{22,32}-drfmaster] ignore_outcome = true From b37cec05a0686cba02d1febfba5ba8cebe203492 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 14 Dec 2021 19:06:54 +0400 Subject: [PATCH 347/488] Updated urls to use path and re_path as is default in Django 2.2. (#1027) django.urls.conf.url is only there for Python 3.5 support which we already deleted anyway. Co-authored-by: Alan Crosswell --- example/urls.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/example/urls.py b/example/urls.py index 867f1bd7..84ca1e9f 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,6 +1,5 @@ from django.conf import settings -from django.conf.urls import include, url -from django.urls import path +from django.urls import include, path, re_path from django.views.generic import TemplateView from rest_framework import routers from rest_framework.schemas import get_schema_view @@ -36,53 +35,53 @@ router.register(r"lab-results", LabResultViewSet) urlpatterns = [ - url(r"^", include(router.urls)), - url( + path("", include(router.urls)), + re_path( r"^entries/(?P[^/.]+)/suggested/$", EntryViewSet.as_view({"get": "list"}), name="entry-suggested", ), - url( + re_path( r"entries/(?P[^/.]+)/blog$", BlogViewSet.as_view({"get": "retrieve"}), name="entry-blog", ), - url( + re_path( r"entries/(?P[^/.]+)/comments$", CommentViewSet.as_view({"get": "list"}), name="entry-comments", ), - url( + re_path( r"entries/(?P[^/.]+)/authors$", AuthorViewSet.as_view({"get": "list"}), name="entry-authors", ), - url( + re_path( r"entries/(?P[^/.]+)/featured$", EntryViewSet.as_view({"get": "retrieve"}), name="entry-featured", ), - url( + re_path( r"^authors/(?P[^/.]+)/(?P\w+)/$", AuthorViewSet.as_view({"get": "retrieve_related"}), name="author-related", ), - url( + re_path( r"^entries/(?P[^/.]+)/relationships/(?P\w+)$", EntryRelationshipView.as_view(), name="entry-relationships", ), - url( + re_path( r"^blogs/(?P[^/.]+)/relationships/(?P\w+)$", BlogRelationshipView.as_view(), name="blog-relationships", ), - url( + re_path( r"^comments/(?P[^/.]+)/relationships/(?P\w+)$", CommentRelationshipView.as_view(), name="comment-relationships", ), - url( + re_path( r"^authors/(?P[^/.]+)/relationships/(?P\w+)$", AuthorRelationshipView.as_view(), name="author-relationships", @@ -111,5 +110,5 @@ import debug_toolbar urlpatterns = [ - url(r"^__debug__/", include(debug_toolbar.urls)), + path("__debug__/", include(debug_toolbar.urls)), ] + urlpatterns From 0c179d748f06d70b1c3699d5406dd279935d0c15 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 14 Dec 2021 20:18:48 +0400 Subject: [PATCH 348/488] Adjusted to only use f-strings for slight performance improvement (#1028) --- CHANGELOG.md | 4 ++ .../tests/integration/test_polymorphism.py | 12 ++-- example/tests/test_serializers.py | 4 +- example/tests/test_views.py | 56 +++++++++---------- .../unit/test_default_drf_serializers.py | 16 +++--- .../django_filters/backends.py | 8 +-- rest_framework_json_api/filters.py | 6 +- rest_framework_json_api/relations.py | 4 +- rest_framework_json_api/serializers.py | 2 +- rest_framework_json_api/utils.py | 8 +-- 10 files changed, 57 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a2f0d8..c2fee4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Changed + +* Adjusted to only use f-strings for slight performance improvement. + ### Removed * Removed support for Django 3.0. diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 6b1ef52f..69a97220 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -64,8 +64,8 @@ def test_polymorphism_on_polymorphic_model_detail_patch(single_art_project, clie url = reverse("project-detail", kwargs={"pk": single_art_project.pk}) response = client.get(url) content = response.json() - test_topic = "test-{}".format(random.randint(0, 999999)) - test_artist = "test-{}".format(random.randint(0, 999999)) + test_topic = f"test-{random.randint(0, 999999)}" + test_artist = f"test-{random.randint(0, 999999)}" content["data"]["attributes"]["topic"] = test_topic content["data"]["attributes"]["artist"] = test_artist response = client.patch(url, data=content) @@ -92,8 +92,8 @@ def test_patch_on_polymorphic_model_without_including_required_field( def test_polymorphism_on_polymorphic_model_list_post(client): - test_topic = "New test topic {}".format(random.randint(0, 999999)) - test_artist = "test-{}".format(random.randint(0, 999999)) + test_topic = f"New test topic {random.randint(0, 999999)}" + test_artist = f"test-{random.randint(0, 999999)}" test_project_type = ProjectTypeFactory() url = reverse("project-list") data = { @@ -152,8 +152,8 @@ def test_polymorphic_model_without_any_instance(client): def test_invalid_type_on_polymorphic_model(client): - test_topic = "New test topic {}".format(random.randint(0, 999999)) - test_artist = "test-{}".format(random.randint(0, 999999)) + test_topic = f"New test topic {random.randint(0, 999999)}" + test_artist = f"test-{random.randint(0, 999999)}" url = reverse("project-list") data = { "data": { diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index 8f14fed8..37f50b53 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -40,9 +40,9 @@ def setUp(self): rating=3, ) for i in range(1, 6): - name = "some_author{}".format(i) + name = f"some_author{i}" self.entry.authors.add( - Author.objects.create(name=name, email="{}@example.org".format(name)) + Author.objects.create(name=name, email=f"{name}@example.org") ) def test_forward_relationship_not_loaded_when_not_included(self): diff --git a/example/tests/test_views.py b/example/tests/test_views.py index d976ed56..560f7547 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -76,15 +76,13 @@ def test_get_entry_relationship_blog(self): def test_get_entry_relationship_invalid_field(self): response = self.client.get( - "/entries/{}/relationships/invalid_field".format(self.first_entry.id) + f"/entries/{self.first_entry.id}/relationships/invalid_field" ) assert response.status_code == 404 def test_get_blog_relationship_entry_set(self): - response = self.client.get( - "/blogs/{}/relationships/entry_set".format(self.blog.id) - ) + response = self.client.get(f"/blogs/{self.blog.id}/relationships/entry_set") expected_data = [ {"type": format_resource_type("Entry"), "id": str(self.first_entry.id)}, {"type": format_resource_type("Entry"), "id": str(self.second_entry.id)}, @@ -94,9 +92,7 @@ def test_get_blog_relationship_entry_set(self): @override_settings(JSON_API_FORMAT_RELATED_LINKS="dasherize") def test_get_blog_relationship_entry_set_with_formatted_link(self): - response = self.client.get( - "/blogs/{}/relationships/entry-set".format(self.blog.id) - ) + response = self.client.get(f"/blogs/{self.blog.id}/relationships/entry-set") expected_data = [ {"type": format_resource_type("Entry"), "id": str(self.first_entry.id)}, {"type": format_resource_type("Entry"), "id": str(self.second_entry.id)}, @@ -105,17 +101,17 @@ def test_get_blog_relationship_entry_set_with_formatted_link(self): assert response.data == expected_data def test_put_entry_relationship_blog_returns_405(self): - url = "/entries/{}/relationships/blog".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/blog" response = self.client.put(url, data={}) assert response.status_code == 405 def test_patch_invalid_entry_relationship_blog_returns_400(self): - url = "/entries/{}/relationships/blog".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/blog" response = self.client.patch(url, data={"data": {"invalid": ""}}) assert response.status_code == 400 def test_relationship_view_errors_format(self): - url = "/entries/{}/relationships/blog".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/blog" response = self.client.patch(url, data={"data": {"invalid": ""}}) assert response.status_code == 400 @@ -125,14 +121,14 @@ def test_relationship_view_errors_format(self): assert "errors" in result def test_get_empty_to_one_relationship(self): - url = "/comments/{}/relationships/author".format(self.first_entry.id) + url = f"/comments/{self.first_entry.id}/relationships/author" response = self.client.get(url) expected_data = None assert response.data == expected_data def test_get_to_many_relationship_self_link(self): - url = "/authors/{}/relationships/comments".format(self.author.id) + url = f"/authors/{self.author.id}/relationships/comments" response = self.client.get(url) expected_data = { @@ -147,7 +143,7 @@ def test_get_to_many_relationship_self_link(self): assert json.loads(response.content.decode("utf-8")) == expected_data def test_patch_to_one_relationship(self): - url = "/entries/{}/relationships/blog".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/blog" request_data = { "data": { "type": format_resource_type("Blog"), @@ -162,7 +158,7 @@ def test_patch_to_one_relationship(self): assert response.data == request_data["data"] def test_patch_one_to_many_relationship(self): - url = "/blogs/{}/relationships/entry_set".format(self.first_entry.id) + url = f"/blogs/{self.first_entry.id}/relationships/entry_set" request_data = { "data": [ {"type": format_resource_type("Entry"), "id": str(self.first_entry.id)}, @@ -184,7 +180,7 @@ def test_patch_one_to_many_relationship(self): assert response.data == request_data["data"] def test_patch_one_to_many_relaitonship_with_none(self): - url = "/blogs/{}/relationships/entry_set".format(self.first_entry.id) + url = f"/blogs/{self.first_entry.id}/relationships/entry_set" request_data = {"data": None} response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() @@ -194,7 +190,7 @@ def test_patch_one_to_many_relaitonship_with_none(self): assert response.data == [] def test_patch_many_to_many_relationship(self): - url = "/entries/{}/relationships/authors".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/authors" request_data = { "data": [ {"type": format_resource_type("Author"), "id": str(self.author.id)}, @@ -216,7 +212,7 @@ def test_patch_many_to_many_relationship(self): assert response.data == request_data["data"] def test_post_to_one_relationship_should_fail(self): - url = "/entries/{}/relationships/blog".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/blog" request_data = { "data": { "type": format_resource_type("Blog"), @@ -227,7 +223,7 @@ def test_post_to_one_relationship_should_fail(self): assert response.status_code == 405, response.content.decode() def test_post_to_many_relationship_with_no_change(self): - url = "/entries/{}/relationships/comments".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/comments" request_data = { "data": [ { @@ -241,7 +237,7 @@ def test_post_to_many_relationship_with_no_change(self): assert len(response.rendered_content) == 0, response.rendered_content.decode() def test_post_to_many_relationship_with_change(self): - url = "/entries/{}/relationships/comments".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/comments" request_data = { "data": [ { @@ -256,7 +252,7 @@ def test_post_to_many_relationship_with_change(self): assert request_data["data"][0] in response.data def test_delete_to_one_relationship_should_fail(self): - url = "/entries/{}/relationships/blog".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/blog" request_data = { "data": { "type": format_resource_type("Blog"), @@ -267,7 +263,7 @@ def test_delete_to_one_relationship_should_fail(self): assert response.status_code == 405, response.content.decode() def test_delete_relationship_overriding_with_none(self): - url = "/comments/{}".format(self.second_comment.id) + url = f"/comments/{self.second_comment.id}" request_data = { "data": { "type": "comments", @@ -280,7 +276,7 @@ def test_delete_relationship_overriding_with_none(self): assert response.data["author"] is None def test_delete_to_many_relationship_with_no_change(self): - url = "/entries/{}/relationships/comments".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/comments" request_data = { "data": [ { @@ -294,7 +290,7 @@ def test_delete_to_many_relationship_with_no_change(self): assert len(response.rendered_content) == 0, response.rendered_content.decode() def test_delete_one_to_many_relationship_with_not_null_constraint(self): - url = "/entries/{}/relationships/comments".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/comments" request_data = { "data": [ { @@ -307,7 +303,7 @@ def test_delete_one_to_many_relationship_with_not_null_constraint(self): assert response.status_code == 409, response.content.decode() def test_delete_to_many_relationship_with_change(self): - url = "/authors/{}/relationships/comments".format(self.author.id) + url = f"/authors/{self.author.id}/relationships/comments" request_data = { "data": [ { @@ -323,7 +319,7 @@ def test_new_comment_data_patch_to_many_relationship(self): entry = EntryFactory(blog=self.blog, authors=(self.author,)) comment = CommentFactory(entry=entry) - url = "/authors/{}/relationships/comments".format(self.author.id) + url = f"/authors/{self.author.id}/relationships/comments" request_data = { "data": [ {"type": format_resource_type("Comment"), "id": str(comment.id)}, @@ -597,7 +593,7 @@ def setUp(self): self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") def test_no_content_response(self): - url = "/blogs/{}".format(self.blog.pk) + url = f"/blogs/{self.blog.pk}" response = self.client.delete(url) assert response.status_code == 204, response.rendered_content.decode() assert len(response.rendered_content) == 0, response.rendered_content.decode() @@ -618,8 +614,8 @@ def test_get_object_gives_correct_blog(self): expected = { "data": { "attributes": {"name": self.blog.name}, - "id": "{}".format(self.blog.id), - "links": {"self": "http://testserver/blogs/{}".format(self.blog.id)}, + "id": f"{self.blog.id}", + "links": {"self": f"http://testserver/blogs/{self.blog.id}"}, "meta": {"copyright": datetime.now().year}, "relationships": {"tags": {"data": [], "meta": {"count": 0}}}, "type": "blogs", @@ -656,13 +652,13 @@ def test_get_object_gives_correct_entry(self): "modDate": self.second_entry.mod_date, "pubDate": self.second_entry.pub_date, }, - "id": "{}".format(self.second_entry.id), + "id": f"{self.second_entry.id}", "meta": {"bodyFormat": "text"}, "relationships": { "authors": {"data": [], "meta": {"count": 0}}, "blog": { "data": { - "id": "{}".format(self.second_entry.blog_id), + "id": f"{self.second_entry.blog_id}", "type": "blogs", } }, diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index e8c78df8..31449856 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -93,8 +93,8 @@ def test_blog_create(client): expected = { "data": { "attributes": {"name": blog.name, "tags": []}, - "id": "{}".format(blog.id), - "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "id": f"{blog.id}", + "links": {"self": f"http://testserver/blogs/{blog.id}"}, "meta": {"copyright": datetime.now().year}, "type": "blogs", }, @@ -113,8 +113,8 @@ def test_get_object_gives_correct_blog(client, blog, entry): expected = { "data": { "attributes": {"name": blog.name, "tags": []}, - "id": "{}".format(blog.id), - "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "id": f"{blog.id}", + "links": {"self": f"http://testserver/blogs/{blog.id}"}, "meta": {"copyright": datetime.now().year}, "type": "blogs", }, @@ -134,8 +134,8 @@ def test_get_object_patches_correct_blog(client, blog, entry): request_data = { "data": { "attributes": {"name": new_name}, - "id": "{}".format(blog.id), - "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "id": f"{blog.id}", + "links": {"self": f"http://testserver/blogs/{blog.id}"}, "meta": {"copyright": datetime.now().year}, "relationships": {"tags": {"data": []}}, "type": "blogs", @@ -150,8 +150,8 @@ def test_get_object_patches_correct_blog(client, blog, entry): expected = { "data": { "attributes": {"name": new_name, "tags": []}, - "id": "{}".format(blog.id), - "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "id": f"{blog.id}", + "links": {"self": f"http://testserver/blogs/{blog.id}"}, "meta": {"copyright": datetime.now().year}, "type": "blogs", }, diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 61518e1c..5302bf09 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -78,7 +78,7 @@ def _validate_filter(self, keys, filterset_class): """ for k in keys: if (not filterset_class) or (k not in filterset_class.base_filters): - raise ValidationError("invalid filter[{}]".format(k)) + raise ValidationError(f"invalid filter[{k}]") def get_filterset(self, request, queryset, view): """ @@ -111,12 +111,10 @@ def get_filterset_kwargs(self, request, queryset, view): or m.groupdict()["ldelim"] != "[" or m.groupdict()["rdelim"] != "]" ): - raise ValidationError("invalid query parameter: {}".format(qp)) + raise ValidationError(f"invalid query parameter: {qp}") if m and qp != self.search_param: if not all(val): - raise ValidationError( - "missing value for query parameter {}".format(qp) - ) + raise ValidationError(f"missing value for query parameter {qp}") # convert JSON:API relationship path to Django ORM's __ notation key = m.groupdict()["assoc"].replace(".", "__") key = undo_format_field_name(key) diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index ba3794cb..33ce240f 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -93,14 +93,12 @@ def validate_query_params(self, request): for qp in request.query_params.keys(): m = self.query_regex.match(qp) if not m: - raise ValidationError("invalid query parameter: {}".format(qp)) + raise ValidationError(f"invalid query parameter: {qp}") if ( not m.group("type") == "filter" and len(request.query_params.getlist(qp)) > 1 ): - raise ValidationError( - "repeated query parameter not allowed: {}".format(qp) - ) + raise ValidationError(f"repeated query parameter not allowed: {qp}") def filter_queryset(self, request, queryset, view): """ diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index f6e91cfe..6cca15c8 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -324,7 +324,7 @@ class PolymorphicResourceRelatedField(ResourceRelatedField): "Incorrect relation type. Expected one of [{relation_type}], " "received {received_type}." ), - } + }, ) def __init__(self, polymorphic_serializer, *args, **kwargs): @@ -370,7 +370,7 @@ def __init__(self, method_name=None, **kwargs): super().__init__(**kwargs) def bind(self, field_name, parent): - default_method_name = "get_{field_name}".format(field_name=field_name) + default_method_name = f"get_{field_name}" if self.method_name is None: self.method_name = default_method_name super().bind(field_name, parent) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 1ac504ab..cfc6cf3f 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -442,7 +442,7 @@ def get_polymorphic_serializer_for_type(cls, obj_type): return cls._poly_type_serializer_map[obj_type] except KeyError: raise NotImplementedError( - "No polymorphic serializer has been found for type {}".format(obj_type) + f"No polymorphic serializer has been found for type {obj_type}" ) @classmethod diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 6189093f..b15f4c6c 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -418,7 +418,7 @@ def format_drf_errors(response, context, exc): # pointer can be determined only if there's a serializer. if has_serializer: rel = "relationships" if field in relationship_fields else "attributes" - pointer = "/data/{}/{}".format(rel, field) + pointer = f"/data/{rel}/{field}" if isinstance(exc, Http404) and isinstance(error, str): # 404 errors don't have a pointer errors.extend(format_error_object(error, None, response)) @@ -462,13 +462,11 @@ def format_error_object(message, pointer, response): errors.append(message) else: for k, v in message.items(): - errors.extend( - format_error_object(v, pointer + "/{}".format(k), response) - ) + errors.extend(format_error_object(v, pointer + f"/{k}", response)) elif isinstance(message, list): for num, error in enumerate(message): if isinstance(error, (list, dict)): - new_pointer = pointer + "/{}".format(num) + new_pointer = pointer + f"/{num}" else: new_pointer = pointer if error: From fb8f6c465b9aecf2a40b5dba58b89a52397ad2a1 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 14 Dec 2021 23:21:23 +0400 Subject: [PATCH 349/488] Added support for Django REST framework 3.13 (#1029) --- CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- requirements/requirements-testing.txt | 5 ----- setup.cfg | 2 -- setup.py | 2 +- tox.ini | 8 ++++---- 7 files changed, 11 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2fee4a2..654a0363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Added + +* Added support for Django REST framework 3.13. + ### Changed * Adjusted to only use f-strings for slight performance improvement. diff --git a/README.rst b/README.rst index 18317bd7..0142feee 100644 --- a/README.rst +++ b/README.rst @@ -90,7 +90,7 @@ Requirements 1. Python (3.6, 3.7, 3.8, 3.9, 3.10) 2. Django (2.2, 3.2, 4.0) -3. Django REST framework (3.12) +3. Django REST framework (3.12, 3.13) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index c473cd49..bd02261c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,7 +53,7 @@ like the following: 1. Python (3.6, 3.7, 3.8, 3.9, 3.10) 2. Django (2.2, 3.2, 4.0) -3. Django REST framework (3.12) +3. Django REST framework (3.12, 3.13) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 1221211e..552eff1f 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -6,8 +6,3 @@ pytest-cov==3.0.0 pytest-django==4.5.1 pytest-factoryboy==2.1.0 syrupy==1.5.0 -# TODO remove pytz dep again once DRF higher than 3.12.4 is released -# Django 4.0 removed dependency on pytz and made it optional but -# DRF requires it and will define it as dependency in future versions -# only adding this to testing though as DJA does not directly use pytz -pytz==2021.3 diff --git a/setup.cfg b/setup.cfg index 2947a883..afe7faab 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,8 +58,6 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - # TODO remove again once DRF higher than 3.12.4 has been released - ignore:'rest_framework' defines default_app_config # TODO remove in next major version of DJA 5.0.0 # this deprecation warning filter needs to be added as AuthorSerializer is used in # too many tests which introduced the type field name in tests diff --git a/setup.py b/setup.py index 482d7b22..eba979e0 100755 --- a/setup.py +++ b/setup.py @@ -97,7 +97,7 @@ def get_package_data(package): ], install_requires=[ "inflection>=0.3.0", - "djangorestframework>=3.12,<3.13", + "djangorestframework>=3.12,<3.14", "django>=2.2,<4.1", ], extras_require={ diff --git a/tox.ini b/tox.ini index e4baddf4..a493b63f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,7 @@ [tox] envlist = - py{36,37,38,39}-django{22,32}-drf{312,master}, - py{38,39}-django40-drf{312,master}, - py310-django{32,40}-drf{312,master}, + py{36,37,38,39,310}-django{22,32}-drf{312,313,master}, + py{38,39,310}-django40-drf{313,master}, lint,docs [testenv] @@ -11,6 +10,7 @@ deps = django32: Django>=3.2,<3.3 django40: Django>=4.0,<5.0 drf312: djangorestframework>=3.12,<3.13 + drf313: djangorestframework>=3.13,<3.14 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt @@ -45,5 +45,5 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{36,37,38,39,310}-django{22,32}-drfmaster] +[testenv:py{36,37,38,39,310}-django{22,32,40}-drfmaster] ignore_outcome = true From b45a9c3985ea2ffe78714b207352671d755ae82f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 15 Dec 2021 18:06:44 +0400 Subject: [PATCH 350/488] Set minimum required version of inflection to 0.5.0 (#1030) --- CHANGELOG.md | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 654a0363..07ff2079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Changed * Adjusted to only use f-strings for slight performance improvement. +* Set minimum required version of inflection to 0.5.0. ### Removed diff --git a/setup.py b/setup.py index eba979e0..97ca29a6 100755 --- a/setup.py +++ b/setup.py @@ -96,7 +96,7 @@ def get_package_data(package): "Topic :: Software Development :: Libraries :: Python Modules", ], install_requires=[ - "inflection>=0.3.0", + "inflection>=0.5.0", "djangorestframework>=3.12,<3.14", "django>=2.2,<4.1", ], From c46d495e38bf46c09277ce34230b966a4cd0ec1d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 15 Dec 2021 22:22:26 +0400 Subject: [PATCH 351/488] Update minimum required version of optional dependencies (#1031) --- CHANGELOG.md | 5 ++++- README.rst | 2 ++ docs/getting-started.md | 2 ++ setup.py | 6 +++--- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ff2079..b660b2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,10 @@ any parts of the framework not mentioned in the documentation should generally b ### Changed * Adjusted to only use f-strings for slight performance improvement. -* Set minimum required version of inflection to 0.5.0. +* Set minimum required version of inflection to 0.5. +* Set minimum required version of Django Filter to 2.4. +* Set minimum required version of Polymorphic Models for Django to 3.0. +* Set minimum required version of PyYAML to 5.4. ### Removed diff --git a/README.rst b/README.rst index 0142feee..32fe36f4 100644 --- a/README.rst +++ b/README.rst @@ -96,6 +96,8 @@ We **highly** recommend and only officially support the latest patch release of Generally Python and Django series are supported till the official end of life. For Django REST framework the last two series are supported. +For optional dependencies such as Django Filter only the latest release is officially supported even though lower versions should work as well. + ------------ Installation ------------ diff --git a/docs/getting-started.md b/docs/getting-started.md index bd02261c..fa49fa4b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -59,6 +59,8 @@ We **highly** recommend and only officially support the latest patch release of Generally Python and Django series are supported till the official end of life. For Django REST framework the last two series are supported. +For optional dependencies such as Django Filter only the latest release is officially supported even though lower versions should work as well. + ## Installation Install using `pip`... diff --git a/setup.py b/setup.py index 97ca29a6..05383a12 100755 --- a/setup.py +++ b/setup.py @@ -101,9 +101,9 @@ def get_package_data(package): "django>=2.2,<4.1", ], extras_require={ - "django-polymorphic": ["django-polymorphic>=2.0"], - "django-filter": ["django-filter>=2.0"], - "openapi": ["pyyaml>=5.3", "uritemplate>=3.0.1"], + "django-polymorphic": ["django-polymorphic>=3.0"], + "django-filter": ["django-filter>=2.4"], + "openapi": ["pyyaml>=5.4", "uritemplate>=3.0.1"], }, setup_requires=wheel, python_requires=">=3.6", From 6bda8f1d04b0aaddf4498f3ab38ad16f5946b373 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 15 Dec 2021 22:37:03 +0400 Subject: [PATCH 352/488] Rename primary branch from master to main (#1032) --- .github/workflows/codeql-analysis.yml | 4 ++-- docs/usage.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 89e9f974..9b429fa7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [ master ] + branches: [ main ] pull_request: # The branches below must be a subset of the branches above - branches: [ master ] + branches: [ main ] schedule: - cron: '33 5 * * 6' diff --git a/docs/usage.md b/docs/usage.md index d0bd07f4..939e4e6a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1054,7 +1054,7 @@ class MySchemaGenerator(JSONAPISchemaGenerator): }, 'license': { 'name': 'BSD 2 clause', - 'url': 'https://github.com/django-json-api/django-rest-framework-json-api/blob/master/LICENSE', + 'url': 'https://github.com/django-json-api/django-rest-framework-json-api/blob/main/LICENSE', } } schema['servers'] = [ From cb495a57b0a26451292846cfe43f8dd527f5d621 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 29 Dec 2021 16:58:28 +0400 Subject: [PATCH 353/488] Removed support for Python 3.6 (#1039) --- .github/workflows/tests.yml | 6 +++--- CHANGELOG.md | 3 ++- README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 5 ++--- tox.ini | 10 +++++----- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3d085c5c..2a77e701 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10"] env: PYTHON: ${{ matrix.python-version }} steps: @@ -36,10 +36,10 @@ jobs: tox-env: ["black", "lint", "docs"] steps: - uses: actions/checkout@v2 - - name: Set up Python 3.6 + - name: Set up Python 3.7 uses: actions/setup-python@v2 with: - python-version: 3.6 + python-version: 3.7 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/CHANGELOG.md b/CHANGELOG.md index b660b2b5..bcbce7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,10 +26,11 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Django 3.0. * Removed support for Django 3.1. +* Removed support for Python 3.6. ## [4.3.0] - 2021-12-10 -This is the last release supporting Django 3.0 and Django 3.1. +This is the last release supporting Django 3.0, Django 3.1 and Python 3.6. ### Added diff --git a/README.rst b/README.rst index 32fe36f4..64984627 100644 --- a/README.rst +++ b/README.rst @@ -88,7 +88,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.6, 3.7, 3.8, 3.9, 3.10) +1. Python (3.7, 3.8, 3.9, 3.10) 2. Django (2.2, 3.2, 4.0) 3. Django REST framework (3.12, 3.13) diff --git a/docs/getting-started.md b/docs/getting-started.md index fa49fa4b..c838cb22 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.6, 3.7, 3.8, 3.9, 3.10) +1. Python (3.7, 3.8, 3.9, 3.10) 2. Django (2.2, 3.2, 4.0) 3. Django REST framework (3.12, 3.13) diff --git a/setup.py b/setup.py index 05383a12..b0f912f9 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ def read(*paths): """ Build a file path from paths and return the contents. """ - with open(os.path.join(*paths), "r") as f: + with open(os.path.join(*paths)) as f: return f.read() @@ -86,7 +86,6 @@ def get_package_data(package): "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -106,6 +105,6 @@ def get_package_data(package): "openapi": ["pyyaml>=5.4", "uritemplate>=3.0.1"], }, setup_requires=wheel, - python_requires=">=3.6", + python_requires=">=3.7", zip_safe=False, ) diff --git a/tox.ini b/tox.ini index a493b63f..048d91d3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{36,37,38,39,310}-django{22,32}-drf{312,313,master}, + py{37,38,39,310}-django{22,32}-drf{312,313,master}, py{38,39,310}-django40-drf{313,master}, lint,docs @@ -23,13 +23,13 @@ commands = pytest --cov --no-cov-on-fail --cov-report xml {posargs} [testenv:black] -basepython = python3.6 +basepython = python3.7 deps = -rrequirements/requirements-codestyle.txt commands = black --check . [testenv:lint] -basepython = python3.6 +basepython = python3.7 deps = -rrequirements/requirements-codestyle.txt -rrequirements/requirements-testing.txt @@ -37,7 +37,7 @@ deps = commands = flake8 [testenv:docs] -basepython = python3.6 +basepython = python3.7 deps = -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt @@ -45,5 +45,5 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{36,37,38,39,310}-django{22,32,40}-drfmaster] +[testenv:py{37,38,39,310}-django{22,32,40}-drfmaster] ignore_outcome = true From 8cd79ae8188da8414418ba38d8825201fccf55e8 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 30 Dec 2021 08:26:54 -0500 Subject: [PATCH 354/488] Scheduled biweekly dependency update for week 51 (#1035) * Update sphinx from 4.3.1 to 4.3.2 * Update twine from 3.7.0 to 3.7.1 * Update django-debug-toolbar from 3.2.2 to 3.2.4 * Update faker from 9.9.0 to 10.0.0 * Update pytest-django from 4.5.1 to 4.5.2 Co-authored-by: Alan Crosswell --- requirements/requirements-documentation.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index e381bed3..3c5c5377 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.3.1 +Sphinx==4.3.2 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 4cc9f1eb..f6cd12a1 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.7.0 +twine==3.7.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 552eff1f..c101a337 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ -django-debug-toolbar==3.2.2 +django-debug-toolbar==3.2.4 factory-boy==3.2.1 -Faker==9.9.0 +Faker==10.0.0 pytest==6.2.5 pytest-cov==3.0.0 -pytest-django==4.5.1 +pytest-django==4.5.2 pytest-factoryboy==2.1.0 syrupy==1.5.0 From aa33959b05edad8441f4cdf549bc30f2b3ba16cf Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 30 Dec 2021 17:43:12 +0400 Subject: [PATCH 355/488] Removed all deprecations (#1040) Co-authored-by: Alan Crosswell --- CHANGELOG.md | 3 ++ example/factories.py | 2 +- example/fixtures/blogentry.json | 4 +- ...rename_type_author_author_type_and_more.py | 31 ++++++++++++ example/models.py | 2 +- example/serializers.py | 9 ++-- example/tests/__snapshots__/test_openapi.ambr | 12 ++--- example/tests/test_format_keys.py | 2 +- example/tests/test_model_viewsets.py | 26 ---------- example/tests/test_views.py | 8 +-- rest_framework_json_api/parsers.py | 8 +-- rest_framework_json_api/serializers.py | 15 +----- rest_framework_json_api/utils.py | 35 ++----------- setup.cfg | 4 -- tests/test_parsers.py | 3 +- tests/test_serializers.py | 9 ---- tests/test_utils.py | 49 +------------------ 17 files changed, 64 insertions(+), 158 deletions(-) create mode 100644 example/migrations/0011_rename_type_author_author_type_and_more.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bcbce7ce..43919ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Django 3.0. * Removed support for Django 3.1. * Removed support for Python 3.6. +* Removed obsolete method `utils.get_included_serializers`. +* Removed optional `format_type` argument of `utils.format_link_segment`. +* Removed `format_type`s default argument of `utils.format_value`. `format_type` is now required. ## [4.3.0] - 2021-12-10 diff --git a/example/factories.py b/example/factories.py index 96b85cad..4ca1e0b1 100644 --- a/example/factories.py +++ b/example/factories.py @@ -42,7 +42,7 @@ class Meta: email = factory.LazyAttribute(lambda x: faker.email()) bio = factory.RelatedFactory("example.factories.AuthorBioFactory", "author") - type = factory.SubFactory(AuthorTypeFactory) + author_type = factory.SubFactory(AuthorTypeFactory) class AuthorBioFactory(factory.django.DjangoModelFactory): diff --git a/example/fixtures/blogentry.json b/example/fixtures/blogentry.json index 464573e4..ceb4a9fe 100644 --- a/example/fixtures/blogentry.json +++ b/example/fixtures/blogentry.json @@ -77,7 +77,7 @@ "modified_at": "2016-05-02T10:09:48.277", "name": "Alice", "email": "alice@example.com", - "type": null + "author_type": null } }, { @@ -88,7 +88,7 @@ "modified_at": "2016-05-02T10:09:57.133", "name": "Bob", "email": "bob@example.com", - "type": null + "author_type": null } }, { diff --git a/example/migrations/0011_rename_type_author_author_type_and_more.py b/example/migrations/0011_rename_type_author_author_type_and_more.py new file mode 100644 index 00000000..191e901c --- /dev/null +++ b/example/migrations/0011_rename_type_author_author_type_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.0 on 2021-12-29 13:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("example", "0010_auto_20210714_0809"), + ] + + operations = [ + migrations.RenameField( + model_name="author", + old_name="type", + new_name="author_type", + ), + migrations.AlterField( + model_name="project", + name="polymorphic_ctype", + field=models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="polymorphic_%(app_label)s.%(class)s_set+", + to="contenttypes.contenttype", + ), + ), + ] diff --git a/example/models.py b/example/models.py index 63d93e33..c3850076 100644 --- a/example/models.py +++ b/example/models.py @@ -54,7 +54,7 @@ class Meta: class Author(BaseModel): name = models.CharField(max_length=50) email = models.EmailField() - type = models.ForeignKey(AuthorType, null=True, on_delete=models.CASCADE) + author_type = models.ForeignKey(AuthorType, null=True, on_delete=models.CASCADE) def __str__(self): return self.name diff --git a/example/serializers.py b/example/serializers.py index 0e1022cc..b9bf71d6 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -252,10 +252,13 @@ class AuthorSerializer(serializers.ModelSerializer): help_text="help for defaults", ) initials = serializers.SerializerMethodField() - included_serializers = {"bio": AuthorBioSerializer, "type": AuthorTypeSerializer} + included_serializers = { + "bio": AuthorBioSerializer, + "author_type": AuthorTypeSerializer, + } related_serializers = { "bio": "example.serializers.AuthorBioSerializer", - "type": "example.serializers.AuthorTypeSerializer", + "author_type": "example.serializers.AuthorTypeSerializer", "comments": "example.serializers.CommentSerializer", "entries": "example.serializers.EntrySerializer", "first_entry": "example.serializers.EntrySerializer", @@ -270,7 +273,7 @@ class Meta: "entries", "comments", "first_entry", - "type", + "author_type", "secrets", "defaults", "initials", diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 48aa21fa..cb03ca73 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -124,6 +124,9 @@ }, "relationships": { "properties": { + "authorType": { + "$ref": "#/components/schemas/reltoone" + }, "bio": { "$ref": "#/components/schemas/reltoone" }, @@ -135,9 +138,6 @@ }, "firstEntry": { "$ref": "#/components/schemas/reltoone" - }, - "type": { - "$ref": "#/components/schemas/reltoone" } }, "type": "object" @@ -532,6 +532,9 @@ }, "relationships": { "properties": { + "authorType": { + "$ref": "#/components/schemas/reltoone" + }, "bio": { "$ref": "#/components/schemas/reltoone" }, @@ -543,9 +546,6 @@ }, "firstEntry": { "$ref": "#/components/schemas/reltoone" - }, - "type": { - "$ref": "#/components/schemas/reltoone" } }, "type": "object" diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index 8d99eec7..b91cf595 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -59,7 +59,7 @@ def test_options_format_field_names(db, client): "bio", "entries", "firstEntry", - "type", + "authorType", "comments", "secrets", "defaults", diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index c58b5131..21a641f8 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -1,4 +1,3 @@ -import pytest from django.contrib.auth import get_user_model from django.test import override_settings from django.urls import reverse @@ -216,28 +215,3 @@ def test_404_error_pointer(self): response = self.client.get(not_found_url) assert 404 == response.status_code assert errors == response.json() - - -@pytest.mark.django_db -def test_patch_allow_field_type(author, author_type_factory, client): - """ - Verify that type field may be updated. - """ - # TODO remove in next major version 5.0.0 see serializers.ReservedFieldNamesMixin - with pytest.deprecated_call(): - author_type = author_type_factory() - url = reverse("author-detail", args=[author.id]) - - data = { - "data": { - "id": author.id, - "type": "authors", - "relationships": { - "data": {"id": author_type.id, "type": "author-type"} - }, - } - } - - response = client.patch(url, data=data) - - assert response.status_code == 200 diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 560f7547..a3aa1444 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -423,7 +423,7 @@ def test_get_related_serializer_class_many(self): self.assertEqual(got, EntrySerializer) def test_get_serializer_comes_from_included_serializers(self): - kwargs = {"pk": self.author.id, "related_field": "type"} + kwargs = {"pk": self.author.id, "related_field": "author_type"} view = self._get_view(kwargs) related_serializers = view.get_serializer_class().related_serializers delattr(view.get_serializer_class(), "related_serializers") @@ -470,14 +470,14 @@ def test_retrieve_related_single_reverse_lookup(self): def test_retrieve_related_single(self): url = reverse( "author-related", - kwargs={"pk": self.author.type.pk, "related_field": "type"}, + kwargs={"pk": self.author.author_type.pk, "related_field": "author_type"}, ) resp = self.client.get(url) expected = { "data": { "type": "authorTypes", - "id": str(self.author.type.id), - "attributes": {"name": str(self.author.type.name)}, + "id": str(self.author.author_type.id), + "attributes": {"name": str(self.author.author_type.name)}, } } self.assertEqual(resp.status_code, 200) diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 61824749..f77a6501 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -4,7 +4,7 @@ from rest_framework import parsers from rest_framework.exceptions import ParseError -from rest_framework_json_api import exceptions, renderers, serializers +from rest_framework_json_api import exceptions, renderers from rest_framework_json_api.utils import get_resource_name, undo_format_field_names @@ -160,12 +160,8 @@ def parse(self, stream, media_type=None, parser_context=None): ) # Construct the return data - serializer_class = getattr(view, "serializer_class", None) parsed_data = {"id": data.get("id")} if "id" in data else {} - # TODO remove in next major version 5.0.0 see serializers.ReservedFieldNamesMixin - if serializer_class is not None: - if issubclass(serializer_class, serializers.PolymorphicModelSerializer): - parsed_data["type"] = data.get("type") + parsed_data["type"] = data.get("type") parsed_data.update(self.parse_attributes(data)) parsed_data.update(self.parse_relationships(data)) parsed_data.update(self.parse_metadata(result)) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index cfc6cf3f..bfdfec71 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,4 +1,3 @@ -import warnings from collections import OrderedDict from collections.abc import Mapping @@ -157,7 +156,7 @@ def validate_path(serializer_class, field_path, path): class ReservedFieldNamesMixin: """Ensures that reserved field names are not used and an error raised instead.""" - _reserved_field_names = {"meta", "results"} + _reserved_field_names = {"meta", "results", "type"} def get_fields(self): fields = super().get_fields() @@ -171,18 +170,6 @@ def get_fields(self): f"{', '.join(sorted(found_reserved_field_names))}" ) - if "type" in fields: - # see https://jsonapi.org/format/#document-resource-object-fields - warnings.warn( - DeprecationWarning( - f"Field name 'type' found in serializer class " - f"{self.__class__.__module__}.{self.__class__.__qualname__} " - f"which is not allowed according to the JSON:API spec and " - f"won't be supported anymore in the next major DJA release. " - f"Rename 'type' field to something else. " - ) - ) - return fields diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index b15f4c6c..aba9de5a 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -1,6 +1,5 @@ import inspect import operator -import warnings from collections import OrderedDict import inflection @@ -147,23 +146,14 @@ def undo_format_field_name(field_name): return field_name -def format_link_segment(value, format_type=None): +def format_link_segment(value): """ Takes a string value and returns it with formatted keys as set in `format_type` or `JSON_API_FORMAT_RELATED_LINKS`. :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' """ - if format_type is None: - format_type = json_api_settings.FORMAT_RELATED_LINKS - else: - warnings.warn( - DeprecationWarning( - "Using `format_type` argument is deprecated." - "Use `format_value` instead." - ) - ) - + format_type = json_api_settings.FORMAT_RELATED_LINKS return format_value(value, format_type) @@ -179,15 +169,7 @@ def undo_format_link_segment(value): return value -def format_value(value, format_type=None): - if format_type is None: - warnings.warn( - DeprecationWarning( - "Using `format_value` without passing on `format_type` argument is deprecated." - "Use `format_field_name` instead." - ) - ) - format_type = json_api_settings.FORMAT_FIELD_NAMES +def format_value(value, format_type): if format_type == "dasherize": # inflection can't dasherize camelCase value = inflection.underscore(value) @@ -342,17 +324,6 @@ def get_default_included_resources_from_serializer(serializer): return list(getattr(meta, "included_resources", [])) -def get_included_serializers(serializer): - warnings.warn( - DeprecationWarning( - "Using of `get_included_serializers(serializer)` function is deprecated." - "Use `serializer.included_serializers` instead." - ) - ) - - return getattr(serializer, "included_serializers", dict()) - - def get_relation_instance(resource_instance, source, serializer): try: relation_instance = operator.attrgetter(source)(resource_instance) diff --git a/setup.cfg b/setup.cfg index afe7faab..527ddd6b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,10 +58,6 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - # TODO remove in next major version of DJA 5.0.0 - # this deprecation warning filter needs to be added as AuthorSerializer is used in - # too many tests which introduced the type field name in tests - ignore:Field name 'type' testpaths = example tests diff --git a/tests/test_parsers.py b/tests/test_parsers.py index f1207757..45ac5232 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -63,6 +63,7 @@ def test_parse_formats_field_names( result = parse(data, parser_context) assert result == { "id": "123", + "type": "BasicModel", "test_attribute": "test-value", "test_relationship": {"id": "123", "type": "TestRelationship"}, } @@ -85,7 +86,7 @@ def test_parse_with_default_arguments(self, parse): }, } result = parse(data, None) - assert result == {} + assert result == {"type": "BasicModel"} def test_parse_preserves_json_value_field_names( self, settings, parse, parser_context diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 19ee23d6..e1b14ed8 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -50,12 +50,3 @@ class ReservedFieldNamesSerializer(serializers.Serializer): "ReservedFieldNamesSerializer uses following reserved field name(s) which is " "not allowed: meta, results" ) - - -def test_serializer_fields_deprecated_field_name_type(): - with pytest.deprecated_call(): - - class TypeFieldNameSerializer(serializers.Serializer): - type = serializers.CharField() - - TypeFieldNameSerializer().fields diff --git a/tests/test_utils.py b/tests/test_utils.py index d8810c0b..efb1325d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,4 @@ import pytest -from django.db import models from rest_framework import status from rest_framework.fields import Field from rest_framework.generics import GenericAPIView @@ -13,7 +12,6 @@ format_link_segment, format_resource_type, format_value, - get_included_serializers, get_related_resource_type, get_resource_name, get_resource_type_from_serializer, @@ -23,13 +21,12 @@ ) from tests.models import ( BasicModel, - DJAModel, ForeignKeySource, ForeignKeyTarget, ManyToManySource, ManyToManyTarget, ) -from tests.serializers import BasicModelSerializer, ManyToManyTargetSerializer +from tests.serializers import BasicModelSerializer def test_get_resource_name_no_view(): @@ -256,11 +253,6 @@ def test_format_link_segment(settings, format_type, output): assert format_link_segment("first_Name") == output -def test_format_link_segment_deprecates_format_type_argument(): - with pytest.deprecated_call(): - assert "first-name" == format_link_segment("first_name", "dasherize") - - @pytest.mark.parametrize( "format_links,output", [ @@ -289,11 +281,6 @@ def test_format_value(settings, format_type, output): assert format_value("first_name", format_type) == output -def test_format_value_deprecates_default_format_type_argument(): - with pytest.deprecated_call(): - assert "first_name" == format_value("first_name") - - @pytest.mark.parametrize( "resource_type,pluralize,output", [ @@ -347,40 +334,6 @@ class PlainRelatedResourceTypeSerializer(serializers.Serializer): assert get_related_resource_type(field) == output -def test_get_included_serializers(): - class DeprecatedIncludedSerializersModel(DJAModel): - self = models.ForeignKey("self", on_delete=models.CASCADE) - target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) - other_target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) - - class Meta: - app_label = "tests" - - class DeprecatedIncludedSerializersSerializer(serializers.ModelSerializer): - included_serializers = { - "self": "self", - "target": ManyToManyTargetSerializer, - "other_target": "tests.serializers.ManyToManyTargetSerializer", - } - - class Meta: - model = DeprecatedIncludedSerializersModel - fields = ("self", "other_target", "target") - - with pytest.deprecated_call(): - included_serializers = get_included_serializers( - DeprecatedIncludedSerializersSerializer - ) - - expected_included_serializers = { - "self": DeprecatedIncludedSerializersSerializer, - "target": ManyToManyTargetSerializer, - "other_target": ManyToManyTargetSerializer, - } - - assert included_serializers == expected_included_serializers - - def test_get_resource_type_from_serializer_without_resource_name_raises_error(): class SerializerWithoutResourceName(serializers.Serializer): something = Field() From e9ca8d9348dc71eda0a48e5c71d83a6526ca2711 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 3 Jan 2022 21:58:41 +0400 Subject: [PATCH 356/488] Release 5.0.0 (#1042) --- CHANGELOG.md | 5 ++++- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43919ce2..e6b257d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [5.0.0] - 2022-01-03 + +This release is not backwards compatible. For easy migration best upgrade first to version +4.3.0 and resolve all deprecation warnings before updating to 5.0.0 ### Added diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index b1408d0b..e8cac7c0 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "4.3.0" +__version__ = "5.0.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From a1fd85d8a88d1aa42e5cbb6b3f9c0dc620214f5a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 21 Feb 2022 11:17:55 -0500 Subject: [PATCH 357/488] Scheduled biweekly dependency update for week 08 (#1054) * Update black from 21.12b0 to 22.1.0 * Update sphinx from 4.3.2 to 4.4.0 * Update twine from 3.7.1 to 3.8.0 * Update faker from 10.0.0 to 13.0.0 * Update pytest from 6.2.5 to 7.0.1 * Update syrupy from 1.5.0 to 1.7.4 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 99c23c93..c5664ddb 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==21.12b0 +black==22.1.0 flake8==4.0.1 flake8-isort==4.1.1 isort==5.10.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 3c5c5377..9c9e2db0 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.3.2 +Sphinx==4.4.0 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index f6cd12a1..f9d2c8fd 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.7.1 +twine==3.8.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index c101a337..934e5ad6 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2.4 factory-boy==3.2.1 -Faker==10.0.0 -pytest==6.2.5 +Faker==13.0.0 +pytest==7.0.1 pytest-cov==3.0.0 pytest-django==4.5.2 pytest-factoryboy==2.1.0 -syrupy==1.5.0 +syrupy==1.7.4 From 646d2a14df4303d720fe1d0e5d9d38d1c2c23412 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 21 Feb 2022 22:42:56 +0400 Subject: [PATCH 358/488] Added requirements.txt for ReadTheDocs (#1055) Flake8 and Spinx cannot be installed together anymore as of conflict with importlib-metadata package. As RTD only supports one requirements.txt file we need a separate one where only the dependencies for creation of documentation are installed. --- docs/requirements.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/requirements.txt diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..4ffe63ef --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,6 @@ +# RTD only supports one requirements.txt file, therefore this has been created. +# This file needs to keep in sync with tox testenv docs dependencies. + +-r ../requirements/requirements-optionals.txt +-r ../requirements/requirements-testing.txt +-r ../requirements/requirements-documentation.txt From 55087bc6a4e7c75ffe0db3c312b7151108b03dd4 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 12 Mar 2022 22:20:27 +0400 Subject: [PATCH 359/488] Convert ReadOnlyModelViewSet to pytest style (#1058) --- example/tests/test_views.py | 81 ------------------------------------- tests/test_views.py | 41 ++++++++++++++++++- 2 files changed, 40 insertions(+), 82 deletions(-) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index a3aa1444..592a72bc 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -3,15 +3,11 @@ from django.test import RequestFactory, override_settings from django.utils import timezone -from rest_framework import status -from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.request import Request -from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.test import APIRequestFactory, APITestCase, force_authenticate -from rest_framework_json_api import serializers, views from rest_framework_json_api.utils import format_resource_type from example.factories import AuthorFactory, CommentFactory, EntryFactory @@ -713,80 +709,3 @@ def test_get_object_gives_correct_entry(self): } got = resp.json() self.assertEqual(got, expected) - - -class BasicAuthorSerializer(serializers.ModelSerializer): - class Meta: - model = Author - fields = ("name",) - - -class ReadOnlyViewSetWithCustomActions(views.ReadOnlyModelViewSet): - queryset = Author.objects.all() - serializer_class = BasicAuthorSerializer - - @action(detail=False, methods=["get", "post", "patch", "delete"]) - def group_action(self, request): - return Response(status=status.HTTP_204_NO_CONTENT) - - @action(detail=True, methods=["get", "post", "patch", "delete"]) - def item_action(self, request, pk): - return Response(status=status.HTTP_204_NO_CONTENT) - - -class TestReadonlyModelViewSet(TestBase): - """ - Test if ReadOnlyModelViewSet allows to have custom actions with POST, PATCH, DELETE methods - """ - - factory = RequestFactory() - viewset_class = ReadOnlyViewSetWithCustomActions - media_type = "application/vnd.api+json" - - def test_group_action_allows_get(self): - view = self.viewset_class.as_view({"get": "group_action"}) - request = self.factory.get("/") - response = view(request) - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_group_action_allows_post(self): - view = self.viewset_class.as_view({"post": "group_action"}) - request = self.factory.post("/", "{}", content_type=self.media_type) - response = view(request) - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_group_action_allows_patch(self): - view = self.viewset_class.as_view({"patch": "group_action"}) - request = self.factory.patch("/", "{}", content_type=self.media_type) - response = view(request) - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_group_action_allows_delete(self): - view = self.viewset_class.as_view({"delete": "group_action"}) - request = self.factory.delete("/", "{}", content_type=self.media_type) - response = view(request) - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_item_action_allows_get(self): - view = self.viewset_class.as_view({"get": "item_action"}) - request = self.factory.get("/") - response = view(request, pk="1") - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_item_action_allows_post(self): - view = self.viewset_class.as_view({"post": "item_action"}) - request = self.factory.post("/", "{}", content_type=self.media_type) - response = view(request, pk="1") - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_item_action_allows_patch(self): - view = self.viewset_class.as_view({"patch": "item_action"}) - request = self.factory.patch("/", "{}", content_type=self.media_type) - response = view(request, pk="1") - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_item_action_allows_delete(self): - view = self.viewset_class.as_view({"delete": "item_action"}) - request = self.factory.delete("/", "{}", content_type=self.media_type) - response = view(request, pk="1") - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) diff --git a/tests/test_views.py b/tests/test_views.py index f8967982..c4466f72 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,6 +1,7 @@ import pytest from django.urls import path, reverse from rest_framework import status +from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.views import APIView @@ -9,8 +10,9 @@ from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.renderers import JSONRenderer from rest_framework_json_api.utils import format_link_segment -from rest_framework_json_api.views import ModelViewSet +from rest_framework_json_api.views import ModelViewSet, ReadOnlyModelViewSet from tests.models import BasicModel +from tests.serializers import BasicModelSerializer class TestModelViewSet: @@ -55,6 +57,43 @@ class RelatedFieldNameView(ModelViewSet): assert view.get_related_field_name() == related_model_field_name +class TestReadonlyModelViewSet: + @pytest.mark.parametrize( + "method", + ["get", "post", "patch", "delete"], + ) + @pytest.mark.parametrize( + "custom_action,action_kwargs", + [("list_action", {}), ("detail_action", {"pk": 1})], + ) + def test_custom_action_allows_all_methods( + self, rf, method, custom_action, action_kwargs + ): + """ + Test that write methods are allowed on custom list actions. + + Even though a read only view only allows reading, custom actions + should be allowed to define other methods which are allowed. + """ + + class ReadOnlyModelViewSetWithCustomActions(ReadOnlyModelViewSet): + serializer_class = BasicModelSerializer + queryset = BasicModel.objects.all() + + @action(detail=False, methods=["get", "post", "patch", "delete"]) + def list_action(self, request): + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=["get", "post", "patch", "delete"]) + def detail_action(self, request, pk): + return Response(status=status.HTTP_204_NO_CONTENT) + + view = ReadOnlyModelViewSetWithCustomActions.as_view({method: custom_action}) + request = getattr(rf, method)("/", data={}) + response = view(request, **action_kwargs) + assert response.status_code == status.HTTP_204_NO_CONTENT + + class TestAPIView: @pytest.mark.urls(__name__) def test_patch(self, client): From 73f7ed63c07694f74fb7e76efaf98a77917b8e4a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 28 Mar 2022 15:07:50 +0400 Subject: [PATCH 360/488] Added TestModelViewSet action tests in pytest style (#1060) --- example/tests/test_views.py | 131 ------------------------------------ tests/models.py | 3 + tests/test_views.py | 84 +++++++++++++++++++++-- tests/views.py | 4 +- 4 files changed, 84 insertions(+), 138 deletions(-) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 592a72bc..f6947fef 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -1,5 +1,4 @@ import json -from datetime import datetime from django.test import RequestFactory, override_settings from django.utils import timezone @@ -579,133 +578,3 @@ def _get_create_response(self, data, view): user = self.create_user("user", "pass") force_authenticate(request, user) return view(request) - - -class TestModelViewSet(TestBase): - def setUp(self): - self.author = Author.objects.create( - name="Super powerful superhero", email="i.am@lost.com" - ) - self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") - - def test_no_content_response(self): - url = f"/blogs/{self.blog.pk}" - response = self.client.delete(url) - assert response.status_code == 204, response.rendered_content.decode() - assert len(response.rendered_content) == 0, response.rendered_content.decode() - - -class TestBlogViewSet(APITestCase): - def setUp(self): - self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") - self.entry = Entry.objects.create( - blog=self.blog, - headline="headline one", - body_text="body_text two", - ) - - def test_get_object_gives_correct_blog(self): - url = reverse("entry-blog", kwargs={"entry_pk": self.entry.id}) - resp = self.client.get(url) - expected = { - "data": { - "attributes": {"name": self.blog.name}, - "id": f"{self.blog.id}", - "links": {"self": f"http://testserver/blogs/{self.blog.id}"}, - "meta": {"copyright": datetime.now().year}, - "relationships": {"tags": {"data": [], "meta": {"count": 0}}}, - "type": "blogs", - }, - "meta": {"apiDocs": "/docs/api/blogs"}, - } - got = resp.json() - self.assertEqual(got, expected) - - -class TestEntryViewSet(APITestCase): - def setUp(self): - self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") - self.first_entry = Entry.objects.create( - blog=self.blog, - headline="headline two", - body_text="body_text two", - ) - self.second_entry = Entry.objects.create( - blog=self.blog, - headline="headline two", - body_text="body_text two", - ) - self.maxDiff = None - - def test_get_object_gives_correct_entry(self): - url = reverse("entry-featured", kwargs={"entry_pk": self.first_entry.id}) - resp = self.client.get(url) - expected = { - "data": { - "attributes": { - "bodyText": self.second_entry.body_text, - "headline": self.second_entry.headline, - "modDate": self.second_entry.mod_date, - "pubDate": self.second_entry.pub_date, - }, - "id": f"{self.second_entry.id}", - "meta": {"bodyFormat": "text"}, - "relationships": { - "authors": {"data": [], "meta": {"count": 0}}, - "blog": { - "data": { - "id": f"{self.second_entry.blog_id}", - "type": "blogs", - } - }, - "blogHyperlinked": { - "links": { - "related": "http://testserver/entries/{}" - "/blog".format(self.second_entry.id), - "self": "http://testserver/entries/{}" - "/relationships/blog_hyperlinked".format( - self.second_entry.id - ), - } - }, - "comments": {"data": [], "meta": {"count": 0}}, - "commentsHyperlinked": { - "links": { - "related": "http://testserver/entries/{}" - "/comments".format(self.second_entry.id), - "self": "http://testserver/entries/{}/relationships" - "/comments_hyperlinked".format(self.second_entry.id), - } - }, - "featuredHyperlinked": { - "links": { - "related": "http://testserver/entries/{}" - "/featured".format(self.second_entry.id), - "self": "http://testserver/entries/{}/relationships" - "/featured_hyperlinked".format(self.second_entry.id), - } - }, - "suggested": { - "data": [{"id": "1", "type": "entries"}], - "links": { - "related": "http://testserver/entries/{}" - "/suggested/".format(self.second_entry.id), - "self": "http://testserver/entries/{}" - "/relationships/suggested".format(self.second_entry.id), - }, - }, - "suggestedHyperlinked": { - "links": { - "related": "http://testserver/entries/{}" - "/suggested/".format(self.second_entry.id), - "self": "http://testserver/entries/{}/relationships" - "/suggested_hyperlinked".format(self.second_entry.id), - } - }, - "tags": {"data": [], "meta": {"count": 0}}, - }, - "type": "posts", - } - } - got = resp.json() - self.assertEqual(got, expected) diff --git a/tests/models.py b/tests/models.py index 3c3e6146..6ad9ad6a 100644 --- a/tests/models.py +++ b/tests/models.py @@ -14,6 +14,9 @@ class Meta: class BasicModel(DJAModel): text = models.CharField(max_length=100) + class Meta: + ordering = ("id",) + # Models for relations tests # ManyToMany diff --git a/tests/test_views.py b/tests/test_views.py index c4466f72..42680d6a 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -3,6 +3,7 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.routers import SimpleRouter from rest_framework.views import APIView from rest_framework_json_api import serializers @@ -13,6 +14,7 @@ from rest_framework_json_api.views import ModelViewSet, ReadOnlyModelViewSet from tests.models import BasicModel from tests.serializers import BasicModelSerializer +from tests.views import BasicModelViewSet class TestModelViewSet: @@ -56,6 +58,70 @@ class RelatedFieldNameView(ModelViewSet): assert view.get_related_field_name() == related_model_field_name + @pytest.mark.urls(__name__) + def test_list(self, client, model): + url = reverse("basic-model-list") + response = client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": [ + { + "type": "BasicModel", + "id": str(model.pk), + "attributes": {"text": "Model"}, + } + ], + "links": { + "first": "http://testserver/basic_models/?page%5Bnumber%5D=1", + "last": "http://testserver/basic_models/?page%5Bnumber%5D=1", + "next": None, + "prev": None, + }, + "meta": {"pagination": {"count": 1, "page": 1, "pages": 1}}, + } + + @pytest.mark.urls(__name__) + def test_retrieve(self, client, model): + url = reverse("basic-model-detail", kwargs={"pk": model.pk}) + response = client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "BasicModel", + "id": str(model.pk), + "attributes": {"text": "Model"}, + } + } + + @pytest.mark.urls(__name__) + def test_patch(self, client, model): + data = { + "data": { + "id": str(model.pk), + "type": "BasicModel", + "attributes": {"text": "changed"}, + } + } + + url = reverse("basic-model-detail", kwargs={"pk": model.pk}) + response = client.patch(url, data=data) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "BasicModel", + "id": str(model.pk), + "attributes": {"text": "changed"}, + } + } + + @pytest.mark.urls(__name__) + def test_delete(self, client, model): + url = reverse("basic-model-detail", kwargs={"pk": model.pk}) + response = client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert BasicModel.objects.count() == 0 + assert len(response.rendered_content) == 0 + class TestReadonlyModelViewSet: @pytest.mark.parametrize( @@ -108,11 +174,17 @@ def test_patch(self, client): url = reverse("custom") response = client.patch(url, data=data) - result = response.json() + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "custom", + "id": "123", + "attributes": {"body": "hello"}, + } + } + - assert result["data"]["id"] == str(123) - assert result["data"]["type"] == "custom" - assert result["data"]["attributes"]["body"] == "hello" +# Routing setup class CustomModel: @@ -140,6 +212,10 @@ def patch(self, request, *args, **kwargs): return Response(status=status.HTTP_200_OK, data=serializer.data) +router = SimpleRouter() +router.register(r"basic_models", BasicModelViewSet, basename="basic-model") + urlpatterns = [ path("custom", CustomAPIView.as_view(), name="custom"), ] +urlpatterns += router.urls diff --git a/tests/views.py b/tests/views.py index e7046a52..42d8a0b0 100644 --- a/tests/views.py +++ b/tests/views.py @@ -5,6 +5,4 @@ class BasicModelViewSet(ModelViewSet): serializer_class = BasicModelSerializer - - class Meta: - model = BasicModel + queryset = BasicModel.objects.all() From 0e93808a748185434f4caa728dabb4f3e9c2cbd7 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 22 Apr 2022 11:37:07 -0700 Subject: [PATCH 361/488] Scheduled biweekly dependency update for week 16 (#1062) * Update black from 22.1.0 to 22.3.0 * Update sphinx from 4.4.0 to 4.5.0 * Update twine from 3.8.0 to 4.0.0 * Update faker from 13.0.0 to 13.3.4 * Update pytest from 7.0.1 to 7.1.1 * Update syrupy from 1.7.4 to 2.0.0 * Updated snapshots to newest amber format Co-authored-by: Oliver Sauder --- example/tests/__snapshots__/test_errors.ambr | 170 +++++++++--------- example/tests/__snapshots__/test_openapi.ambr | 30 ++-- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 6 +- 6 files changed, 106 insertions(+), 106 deletions(-) diff --git a/example/tests/__snapshots__/test_errors.ambr b/example/tests/__snapshots__/test_errors.ambr index d025fd4e..1ef85ab8 100644 --- a/example/tests/__snapshots__/test_errors.ambr +++ b/example/tests/__snapshots__/test_errors.ambr @@ -1,133 +1,133 @@ # name: test_first_level_attribute_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/headline', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_first_level_custom_attribute_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'detail': 'Too short', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/body-text', - }, + }), 'title': 'Too Short title', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_many_third_level_dict_errors - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comments/0/attachment/data', - }, + }), 'status': '400', - }, - { + }), + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comments/0/body', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_relationship_errors_has_correct_pointers - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'incorrect_type', 'detail': 'Incorrect type. Expected resource identifier object, received str.', - 'source': { + 'source': dict({ 'pointer': '/data/relationships/author', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_second_level_array_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comments/0/body', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_second_level_dict_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comment/body', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_third_level_array_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comments/0/attachments/0/data', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_third_level_custom_array_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'invalid', 'detail': 'Too short data', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comments/0/attachments/0/data', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_third_level_dict_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comments/0/attachment/data', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index cb03ca73..fb8c2abe 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -1,5 +1,5 @@ # name: test_delete_request - ' + ''' { "description": "", "operationId": "destroy/authors/{id}", @@ -63,10 +63,10 @@ "authors" ] } - ' ---- + ''' +# --- # name: test_patch_request - ' + ''' { "description": "", "operationId": "update/authors/{id}", @@ -245,10 +245,10 @@ "authors" ] } - ' ---- + ''' +# --- # name: test_path_with_id_parameter - ' + ''' { "description": "", "operationId": "retrieve/authors/{id}/", @@ -355,10 +355,10 @@ "authors" ] } - ' ---- + ''' +# --- # name: test_path_without_parameters - ' + ''' { "description": "", "operationId": "List/authors/", @@ -477,10 +477,10 @@ "authors" ] } - ' ---- + ''' +# --- # name: test_post_request - ' + ''' { "description": "", "operationId": "create/authors/", @@ -665,5 +665,5 @@ "authors" ] } - ' ---- + ''' +# --- diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index c5664ddb..e0660b38 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==22.1.0 +black==22.3.0 flake8==4.0.1 flake8-isort==4.1.1 isort==5.10.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 9c9e2db0..8b4ae902 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.4.0 +Sphinx==4.5.0 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index f9d2c8fd..4d384665 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.8.0 +twine==4.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 934e5ad6..81186b70 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2.4 factory-boy==3.2.1 -Faker==13.0.0 -pytest==7.0.1 +Faker==13.3.4 +pytest==7.1.1 pytest-cov==3.0.0 pytest-django==4.5.2 pytest-factoryboy==2.1.0 -syrupy==1.7.4 +syrupy==2.0.0 From e49af3fb52a39d507e1d013ab44f93d94dca1438 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 17 May 2022 23:48:29 -0700 Subject: [PATCH 362/488] Scheduled biweekly dependency update for week 20 (#1064) * Update django-debug-toolbar from 3.2.4 to 3.4.0 * Update faker from 13.3.4 to 13.11.1 * Update pytest from 7.1.1 to 7.1.2 * Update pytest-factoryboy from 2.1.0 to 2.3.0 * Update syrupy from 2.0.0 to 2.3.0 * Removed for DJA not directly related debug toolbar debug toolbar caused issues in newer version preventing from updating deps therefore removing it. Co-authored-by: Oliver Sauder --- example/settings/dev.py | 3 --- example/urls.py | 8 -------- requirements/requirements-testing.txt | 9 ++++----- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/example/settings/dev.py b/example/settings/dev.py index c02180eb..80482ffa 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -26,7 +26,6 @@ "rest_framework", "polymorphic", "example", - "debug_toolbar", "django_filters", "tests", ] @@ -62,8 +61,6 @@ PASSWORD_HASHERS = ("django.contrib.auth.hashers.UnsaltedMD5PasswordHasher",) -MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware",) - INTERNAL_IPS = ("127.0.0.1",) JSON_API_FORMAT_FIELD_NAMES = "camelize" diff --git a/example/urls.py b/example/urls.py index 84ca1e9f..3d1cf2fa 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.urls import include, path, re_path from django.views.generic import TemplateView from rest_framework import routers @@ -105,10 +104,3 @@ name="swagger-ui", ), ] - -if settings.DEBUG: - import debug_toolbar - - urlpatterns = [ - path("__debug__/", include(debug_toolbar.urls)), - ] + urlpatterns diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 81186b70..c0fdf712 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,7 @@ -django-debug-toolbar==3.2.4 factory-boy==3.2.1 -Faker==13.3.4 -pytest==7.1.1 +Faker==13.11.1 +pytest==7.1.2 pytest-cov==3.0.0 pytest-django==4.5.2 -pytest-factoryboy==2.1.0 -syrupy==2.0.0 +pytest-factoryboy==2.3.0 +syrupy==2.3.0 From f9eb27750fc813b4e4fa1a46dc377e232ef82975 Mon Sep 17 00:00:00 2001 From: leo-naeka Date: Wed, 29 Jun 2022 12:07:27 +0200 Subject: [PATCH 363/488] Fixed invalid relationship pointer in error object (#1070) Co-authored-by: Oliver Sauder --- CHANGELOG.md | 6 +++ example/tests/__snapshots__/test_errors.ambr | 56 +++++++++++++++++++- example/tests/test_errors.py | 56 ++++++++++++++++++-- rest_framework_json_api/utils.py | 4 +- 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b257d4..a1b72009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Fixed + +* Fixed invalid relationship pointer in error objects when field naming formatting is used. + ## [5.0.0] - 2022-01-03 This release is not backwards compatible. For easy migration best upgrade first to version diff --git a/example/tests/__snapshots__/test_errors.ambr b/example/tests/__snapshots__/test_errors.ambr index 1ef85ab8..fe872b46 100644 --- a/example/tests/__snapshots__/test_errors.ambr +++ b/example/tests/__snapshots__/test_errors.ambr @@ -47,14 +47,66 @@ ]), }) # --- -# name: test_relationship_errors_has_correct_pointers +# name: test_relationship_errors_has_correct_pointers_with_camelize dict({ 'errors': list([ dict({ 'code': 'incorrect_type', 'detail': 'Incorrect type. Expected resource identifier object, received str.', 'source': dict({ - 'pointer': '/data/relationships/author', + 'pointer': '/data/relationships/authors', + }), + 'status': '400', + }), + dict({ + 'code': 'incorrect_type', + 'detail': 'Incorrect type. Expected resource identifier object, received str.', + 'source': dict({ + 'pointer': '/data/relationships/mainAuthor', + }), + 'status': '400', + }), + ]), + }) +# --- +# name: test_relationship_errors_has_correct_pointers_with_dasherize + dict({ + 'errors': list([ + dict({ + 'code': 'incorrect_type', + 'detail': 'Incorrect type. Expected resource identifier object, received str.', + 'source': dict({ + 'pointer': '/data/relationships/authors', + }), + 'status': '400', + }), + dict({ + 'code': 'incorrect_type', + 'detail': 'Incorrect type. Expected resource identifier object, received str.', + 'source': dict({ + 'pointer': '/data/relationships/main-author', + }), + 'status': '400', + }), + ]), + }) +# --- +# name: test_relationship_errors_has_correct_pointers_with_no_formatting + dict({ + 'errors': list([ + dict({ + 'code': 'incorrect_type', + 'detail': 'Incorrect type. Expected resource identifier object, received str.', + 'source': dict({ + 'pointer': '/data/relationships/authors', + }), + 'status': '400', + }), + dict({ + 'code': 'incorrect_type', + 'detail': 'Incorrect type. Expected resource identifier object, received str.', + 'source': dict({ + 'pointer': '/data/relationships/main_author', }), 'status': '400', }), diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py index 2267ec6e..72e742c7 100644 --- a/example/tests/test_errors.py +++ b/example/tests/test_errors.py @@ -30,9 +30,12 @@ class EntrySerializer(serializers.Serializer): comment = CommentSerializer(required=False) headline = serializers.CharField(allow_null=True, required=True) body_text = serializers.CharField() - author = serializers.ResourceRelatedField( + main_author = serializers.ResourceRelatedField( queryset=Author.objects.all(), required=False ) + authors = serializers.ResourceRelatedField( + queryset=Author.objects.all(), required=False, many=True + ) def validate(self, attrs): body_text = attrs["body_text"] @@ -195,7 +198,31 @@ def test_many_third_level_dict_errors(client, some_blog, snapshot): assert snapshot == perform_error_test(client, data) -def test_relationship_errors_has_correct_pointers(client, some_blog, snapshot): +def test_relationship_errors_has_correct_pointers_with_camelize( + client, some_blog, snapshot +): + data = { + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + }, + "relationships": { + "mainAuthor": {"data": {"id": "INVALID_ID", "type": "authors"}}, + "authors": {"data": [{"id": "INVALID_ID", "type": "authors"}]}, + }, + } + } + + assert snapshot == perform_error_test(client, data) + + +@override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize") +def test_relationship_errors_has_correct_pointers_with_dasherize( + client, some_blog, snapshot +): data = { "data": { "type": "entries", @@ -205,7 +232,30 @@ def test_relationship_errors_has_correct_pointers(client, some_blog, snapshot): "headline": "headline", }, "relationships": { - "author": {"data": {"id": "INVALID_ID", "type": "authors"}} + "main-author": {"data": {"id": "INVALID_ID", "type": "authors"}}, + "authors": {"data": [{"id": "INVALID_ID", "type": "authors"}]}, + }, + } + } + + assert snapshot == perform_error_test(client, data) + + +@override_settings(JSON_API_FORMAT_FIELD_NAMES=None) +def test_relationship_errors_has_correct_pointers_with_no_formatting( + client, some_blog, snapshot +): + data = { + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "body_text": "body_text", + "headline": "headline", + }, + "relationships": { + "main_author": {"data": {"id": "INVALID_ID", "type": "authors"}}, + "authors": {"data": [{"id": "INVALID_ID", "type": "authors"}]}, }, } } diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index aba9de5a..8d2dfa73 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -380,7 +380,9 @@ def format_drf_errors(response, context, exc): serializer = context["view"].get_serializer() fields = get_serializer_fields(serializer) or dict() relationship_fields = [ - name for name, field in fields.items() if is_relationship_field(field) + format_field_name(name) + for name, field in fields.items() + if is_relationship_field(field) ] for field, error in response.data.items(): From bbeb13ada97cee3e86d3943015d317e84bf2b6c4 Mon Sep 17 00:00:00 2001 From: Ashley Loewen Date: Wed, 13 Jul 2022 16:59:47 -0400 Subject: [PATCH 364/488] Support many related field (#1065) Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 1 + rest_framework_json_api/utils.py | 18 ++++++------- tests/models.py | 11 ++++++++ tests/test_utils.py | 43 ++++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/AUTHORS b/AUTHORS index fa91e9b2..419144b5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -2,6 +2,7 @@ Adam Wróbel Adam Ziolkowski Alan Crosswell Anton Shutik +Ashley Loewen Asif Saif Uddin Beni Keller Boris Pleshakov diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b72009..bb0ec748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Fixed invalid relationship pointer in error objects when field naming formatting is used. +* Properly resolved related resource type when nested source field is defined. ## [5.0.0] - 2022-01-03 diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 8d2dfa73..8317c10f 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -211,15 +211,15 @@ def get_related_resource_type(relation): relation_model = relation.model elif hasattr(relation, "get_queryset") and relation.get_queryset() is not None: relation_model = relation.get_queryset().model - elif ( - getattr(relation, "many", False) - and hasattr(relation.child, "Meta") - and hasattr(relation.child.Meta, "model") - ): - # For ManyToMany relationships, get the model from the child - # serializer of the list serializer - relation_model = relation.child.Meta.model - else: + elif hasattr(relation, "child_relation"): + # For ManyRelatedField relationships, get the model from the child relationship + try: + return get_related_resource_type(relation.child_relation) + except AttributeError: + # Some read only relationships fail to get it directly, fall through to + # get via the parent + pass + if not relation_model: parent_serializer = relation.parent parent_model = None if isinstance(parent_serializer, PolymorphicModelSerializer): diff --git a/tests/models.py b/tests/models.py index 6ad9ad6a..63718fc6 100644 --- a/tests/models.py +++ b/tests/models.py @@ -39,3 +39,14 @@ class ForeignKeySource(DJAModel): target = models.ForeignKey( ForeignKeyTarget, related_name="sources", on_delete=models.CASCADE ) + + +class NestedRelatedSource(DJAModel): + m2m_source = models.ManyToManyField(ManyToManySource, related_name="nested_source") + fk_source = models.ForeignKey( + ForeignKeySource, related_name="nested_source", on_delete=models.CASCADE + ) + m2m_target = models.ManyToManyField(ManyToManySource, related_name="nested_source") + fk_target = models.ForeignKey( + ForeignKeySource, related_name="nested_source", on_delete=models.CASCADE + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index efb1325d..9b47e9ff 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -25,6 +25,7 @@ ForeignKeyTarget, ManyToManySource, ManyToManyTarget, + NestedRelatedSource, ) from tests.serializers import BasicModelSerializer @@ -313,6 +314,48 @@ class Meta: assert get_related_resource_type(field) == output +@pytest.mark.parametrize( + "field,output,related_field_kwargs", + [ + ( + "m2m_source.targets", + "ManyToManyTarget", + {"many": True, "queryset": ManyToManyTarget.objects.all()}, + ), + ( + "m2m_target.sources.", + "ManyToManySource", + {"many": True, "queryset": ManyToManySource.objects.all()}, + ), + ( + "fk_source.target", + "ForeignKeyTarget", + {"many": True, "queryset": ForeignKeyTarget.objects.all()}, + ), + ( + "fk_target.source", + "ForeignKeySource", + {"many": True, "queryset": ForeignKeySource.objects.all()}, + ), + ], +) +def test_get_related_resource_type_from_nested_source( + db, field, output, related_field_kwargs +): + class RelatedResourceTypeSerializer(serializers.ModelSerializer): + relation = serializers.ResourceRelatedField( + source=field, **related_field_kwargs + ) + + class Meta: + model = NestedRelatedSource + fields = ("relation",) + + serializer = RelatedResourceTypeSerializer() + field = serializer.fields["relation"] + assert get_related_resource_type(field) == output + + @pytest.mark.parametrize( "related_field_kwargs,output", [ From d7f2cc326509178effd11c643f094a8267055661 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 14 Jul 2022 21:44:52 +0200 Subject: [PATCH 365/488] Removed support for Django 2.2 (#1072) Co-authored-by: Alan Crosswell --- CHANGELOG.md | 6 ++++++ README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 2 +- tox.ini | 5 ++--- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb0ec748..e871249e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,17 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed invalid relationship pointer in error objects when field naming formatting is used. * Properly resolved related resource type when nested source field is defined. +### Removed + +* Removed support for Django 2.2. + ## [5.0.0] - 2022-01-03 This release is not backwards compatible. For easy migration best upgrade first to version 4.3.0 and resolve all deprecation warnings before updating to 5.0.0 +This is the last release supporting Django 2.2. + ### Added * Added support for Django REST framework 3.13. diff --git a/README.rst b/README.rst index 64984627..d7309745 100644 --- a/README.rst +++ b/README.rst @@ -89,7 +89,7 @@ Requirements ------------ 1. Python (3.7, 3.8, 3.9, 3.10) -2. Django (2.2, 3.2, 4.0) +2. Django (3.2, 4.0) 3. Django REST framework (3.12, 3.13) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index c838cb22..1a9b5cc2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.7, 3.8, 3.9, 3.10) -2. Django (2.2, 3.2, 4.0) +2. Django (3.2, 4.0) 3. Django REST framework (3.12, 3.13) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/setup.py b/setup.py index b0f912f9..b8b68b52 100755 --- a/setup.py +++ b/setup.py @@ -97,7 +97,7 @@ def get_package_data(package): install_requires=[ "inflection>=0.5.0", "djangorestframework>=3.12,<3.14", - "django>=2.2,<4.1", + "django>=3.2,<4.1", ], extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], diff --git a/tox.ini b/tox.ini index 048d91d3..cee02210 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,11 @@ [tox] envlist = - py{37,38,39,310}-django{22,32}-drf{312,313,master}, + py{37,38,39,310}-django32-drf{312,313,master}, py{38,39,310}-django40-drf{313,master}, lint,docs [testenv] deps = - django22: Django>=2.2,<2.3 django32: Django>=3.2,<3.3 django40: Django>=4.0,<5.0 drf312: djangorestframework>=3.12,<3.13 @@ -45,5 +44,5 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{37,38,39,310}-django{22,32,40}-drfmaster] +[testenv:py{37,38,39,310}-django{32,40}-drfmaster] ignore_outcome = true From db5cf1c7b1a2a404a8fea41122293a8b7967c9a1 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 18 Jul 2022 13:48:14 -0500 Subject: [PATCH 366/488] Scheduled biweekly dependency update for week 29 (#1073) * Update black from 22.3.0 to 22.6.0 * Update sphinx from 4.5.0 to 5.0.2 * Update django-filter from 21.1 to 22.1 * Update twine from 4.0.0 to 4.0.1 * Update faker from 13.11.1 to 13.15.0 * Update pytest-factoryboy from 2.3.0 to 2.5.0 * Update syrupy from 2.3.0 to 2.3.1 * Set language code to en in docs Co-authored-by: Oliver Sauder --- docs/conf.py | 2 +- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 9038cd2b..ea67682f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -78,7 +78,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index e0660b38..d9cd493b 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==22.3.0 +black==22.6.0 flake8==4.0.1 flake8-isort==4.1.1 isort==5.10.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 8b4ae902..77309503 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.5.0 +Sphinx==5.0.2 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 0fcfcbbf..9d274a6d 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==21.1 +django-filter==22.1 django-polymorphic==3.1.0 pyyaml==6.0 uritemplate==4.1.1 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 4d384665..d76bde9d 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==4.0.0 +twine==4.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index c0fdf712..2a24638a 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.2.1 -Faker==13.11.1 +Faker==13.15.0 pytest==7.1.2 pytest-cov==3.0.0 pytest-django==4.5.2 -pytest-factoryboy==2.3.0 -syrupy==2.3.0 +pytest-factoryboy==2.5.0 +syrupy==2.3.1 From 46551a9aada19c503e363b20b47765009038d996 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 4 Aug 2022 14:53:57 +0200 Subject: [PATCH 367/488] Added Django support for version 4.1. (#1077) --- CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- example/tests/unit/test_default_drf_serializers.py | 12 ++++++------ example/tests/unit/test_renderers.py | 12 ++++++------ setup.cfg | 2 ++ setup.py | 2 +- tox.ini | 7 ++++--- 8 files changed, 25 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e871249e..91faffde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed invalid relationship pointer in error objects when field naming formatting is used. * Properly resolved related resource type when nested source field is defined. +### Added + +* Added support for Django 4.1. + ### Removed * Removed support for Django 2.2. diff --git a/README.rst b/README.rst index d7309745..c82ebb2d 100644 --- a/README.rst +++ b/README.rst @@ -89,7 +89,7 @@ Requirements ------------ 1. Python (3.7, 3.8, 3.9, 3.10) -2. Django (3.2, 4.0) +2. Django (3.2, 4.0, 4.1) 3. Django REST framework (3.12, 3.13) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 1a9b5cc2..e03b415b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.7, 3.8, 3.9, 3.10) -2. Django (3.2, 4.0) +2. Django (3.2, 4.0, 4.1) 3. Django REST framework (3.12, 3.13) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index 31449856..d74edac7 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -44,26 +44,26 @@ class DummyTestViewSet(viewsets.ModelViewSet): serializer_class = DummyTestSerializer -def render_dummy_test_serialized_view(view_class): - serializer = DummyTestSerializer(instance=Entry()) +def render_dummy_test_serialized_view(view_class, entry): + serializer = DummyTestSerializer(instance=entry) renderer = JSONRenderer() return renderer.render(serializer.data, renderer_context={"view": view_class()}) # tests -def test_simple_reverse_relation_included_renderer(): +def test_simple_reverse_relation_included_renderer(db, entry): """ Test renderer when a single reverse fk relation is passed. """ - rendered = render_dummy_test_serialized_view(DummyTestViewSet) + rendered = render_dummy_test_serialized_view(DummyTestViewSet, entry) assert rendered -def test_render_format_field_names(settings): +def test_render_format_field_names(db, settings, entry): """Test that json field is kept untouched.""" settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" - rendered = render_dummy_test_serialized_view(DummyTestViewSet) + rendered = render_dummy_test_serialized_view(DummyTestViewSet, entry) result = json.loads(rendered.decode()) assert result["data"]["attributes"]["json-field"] == {"JsonKey": "JsonValue"} diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index 47d37b5b..00eaf28b 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -88,25 +88,25 @@ def render_dummy_test_serialized_view(view_class, instance): return renderer.render(serializer.data, renderer_context={"view": view_class()}) -def test_simple_reverse_relation_included_renderer(): +def test_simple_reverse_relation_included_renderer(db, entry): """ Test renderer when a single reverse fk relation is passed. """ - rendered = render_dummy_test_serialized_view(DummyTestViewSet, Entry()) + rendered = render_dummy_test_serialized_view(DummyTestViewSet, entry) assert rendered -def test_simple_reverse_relation_included_read_only_viewset(): - rendered = render_dummy_test_serialized_view(ReadOnlyDummyTestViewSet, Entry()) +def test_simple_reverse_relation_included_read_only_viewset(db, entry): + rendered = render_dummy_test_serialized_view(ReadOnlyDummyTestViewSet, entry) assert rendered -def test_render_format_field_names(settings): +def test_render_format_field_names(db, entry, settings): """Test that json field is kept untouched.""" settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" - rendered = render_dummy_test_serialized_view(DummyTestViewSet, Entry()) + rendered = render_dummy_test_serialized_view(DummyTestViewSet, entry) result = json.loads(rendered.decode()) assert result["data"]["attributes"]["json-field"] == {"JsonKey": "JsonValue"} diff --git a/setup.cfg b/setup.cfg index 527ddd6b..2eb90b9b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,6 +58,8 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning + # Remove when DRF is not depending on it anymore + ignore:The django.utils.timezone.utc alias is deprecated. testpaths = example tests diff --git a/setup.py b/setup.py index b8b68b52..6ac4d9f3 100755 --- a/setup.py +++ b/setup.py @@ -97,7 +97,7 @@ def get_package_data(package): install_requires=[ "inflection>=0.5.0", "djangorestframework>=3.12,<3.14", - "django>=3.2,<4.1", + "django>=3.2,<4.2", ], extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], diff --git a/tox.ini b/tox.ini index cee02210..4d52ce48 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,14 @@ [tox] envlist = py{37,38,39,310}-django32-drf{312,313,master}, - py{38,39,310}-django40-drf{313,master}, + py{38,39,310}-django{40,41}-drf{313,master}, lint,docs [testenv] deps = django32: Django>=3.2,<3.3 - django40: Django>=4.0,<5.0 + django40: Django>=4.0,<4.1 + django41: Django>=4.1,<4.2 drf312: djangorestframework>=3.12,<3.13 drf313: djangorestframework>=3.13,<3.14 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip @@ -44,5 +45,5 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{37,38,39,310}-django{32,40}-drfmaster] +[testenv:py{37,38,39,310}-django{32,40,41}-drfmaster] ignore_outcome = true From c695c31469ed11a2aed945a49e969eab29545606 Mon Sep 17 00:00:00 2001 From: Arkadiy Korotaev <131850+koriaf@users.noreply.github.com> Date: Fri, 5 Aug 2022 17:21:57 +0200 Subject: [PATCH 368/488] Avoid overwriting of pointer when formatting error object (#1074) This enables to use custom error object with custom source pointers. --- CHANGELOG.md | 1 + docs/usage.md | 17 ++++++++++++- rest_framework_json_api/utils.py | 9 +++---- tests/test_utils.py | 43 ++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91faffde..91551dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed invalid relationship pointer in error objects when field naming formatting is used. * Properly resolved related resource type when nested source field is defined. +* Prevented overwriting of pointer in custom error object ### Added diff --git a/docs/usage.md b/docs/usage.md index 939e4e6a..952fe80c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -237,7 +237,7 @@ class MyViewset(ModelViewSet): ``` -### Exception handling +### Error objects / Exception handling For the `exception_handler` class, if the optional `JSON_API_UNIFORM_EXCEPTIONS` is set to True, all exceptions will respond with the JSON:API [error format](https://jsonapi.org/format/#error-objects). @@ -245,6 +245,21 @@ all exceptions will respond with the JSON:API [error format](https://jsonapi.org When `JSON_API_UNIFORM_EXCEPTIONS` is False (the default), non-JSON:API views will respond with the normal DRF error format. +In case you need a custom error object you can simply raise an `rest_framework.serializers.ValidationError` like the following: + +```python +raise serializers.ValidationError( + { + "id": "your-id", + "detail": "your detail message", + "source": { + "pointer": "/data/attributes/your-pointer", + } + + } +) +``` + ### Performance Testing If you are trying to see if your viewsets are configured properly to optimize performance, diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 8317c10f..2e86e762 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -417,21 +417,20 @@ def format_error_object(message, pointer, response): if isinstance(message, dict): # as there is no required field in error object we check that all fields are string - # except links and source which might be a dict + # except links, source or meta which might be a dict is_custom_error = all( [ isinstance(value, str) for key, value in message.items() - if key not in ["links", "source"] + if key not in ["links", "source", "meta"] ] ) if is_custom_error: if "source" not in message: message["source"] = {} - message["source"] = { - "pointer": pointer, - } + if "pointer" not in message["source"]: + message["source"]["pointer"] = pointer errors.append(message) else: for k, v in message.items(): diff --git a/tests/test_utils.py b/tests/test_utils.py index 9b47e9ff..038e8ce9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,6 +7,7 @@ from rest_framework_json_api import serializers from rest_framework_json_api.utils import ( + format_error_object, format_field_name, format_field_names, format_link_segment, @@ -389,3 +390,45 @@ class SerializerWithoutResourceName(serializers.Serializer): "can not detect 'resource_name' on serializer " "'SerializerWithoutResourceName' in module 'tests.test_utils'" ) + + +@pytest.mark.parametrize( + "message,pointer,response,result", + [ + # Test that pointer does not get overridden in custom error + ( + { + "status": "400", + "source": { + "pointer": "/data/custom-pointer", + }, + "meta": {"key": "value"}, + }, + "/data/default-pointer", + Response(status.HTTP_400_BAD_REQUEST), + [ + { + "status": "400", + "source": {"pointer": "/data/custom-pointer"}, + "meta": {"key": "value"}, + } + ], + ), + # Test that pointer gets added to custom error + ( + { + "detail": "custom message", + }, + "/data/default-pointer", + Response(status.HTTP_400_BAD_REQUEST), + [ + { + "detail": "custom message", + "source": {"pointer": "/data/default-pointer"}, + } + ], + ), + ], +) +def test_format_error_object(message, pointer, response, result): + assert result == format_error_object(message, pointer, response) From 38cf7f01339afd35e225125e34a6ba0e0690dfd0 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 15 Aug 2022 15:25:47 -0500 Subject: [PATCH 369/488] Scheduled biweekly dependency update for week 33 (#1081) * Update flake8 from 4.0.1 to 5.0.4 * Update flake8-isort from 4.1.1 to 4.2.0 * Update sphinx from 5.0.2 to 5.1.1 * Update faker from 13.15.0 to 14.0.0 * Update syrupy from 2.3.1 to 3.0.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index d9cd493b..9215fdc6 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ black==22.6.0 -flake8==4.0.1 -flake8-isort==4.1.1 +flake8==5.0.4 +flake8-isort==4.2.0 isort==5.10.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 77309503..cadfb380 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==5.0.2 +Sphinx==5.1.1 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 2a24638a..ef200e11 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.2.1 -Faker==13.15.0 +Faker==14.0.0 pytest==7.1.2 pytest-cov==3.0.0 pytest-django==4.5.2 pytest-factoryboy==2.5.0 -syrupy==2.3.1 +syrupy==3.0.0 From c8511a07b2cd456cd5701f3d49391d9d0183c36d Mon Sep 17 00:00:00 2001 From: Alexander Seidmann <40206670+aseidma@users.noreply.github.com> Date: Sat, 20 Aug 2022 15:09:56 +0200 Subject: [PATCH 370/488] Added a separate parse_data method to JSONParser (#1083) Co-authored-by: Alex Seidmann Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 1 + rest_framework_json_api/parsers.py | 19 +++++++++++++------ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/AUTHORS b/AUTHORS index 419144b5..15a5393f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,6 +1,7 @@ Adam Wróbel Adam Ziolkowski Alan Crosswell +Alex Seidmann Anton Shutik Ashley Loewen Asif Saif Uddin diff --git a/CHANGELOG.md b/CHANGELOG.md index 91551dca..41990d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Django 4.1. +* Expanded JSONParser API with `parse_data` method ### Removed diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index f77a6501..2aef1aa1 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -70,14 +70,11 @@ def parse_metadata(result): else: return {} - def parse(self, stream, media_type=None, parser_context=None): + def parse_data(self, result, parser_context): """ - Parses the incoming bytestream as JSON and returns the resulting data + Formats the output of calling JSONParser to match the JSON:API specification + and returns the result. """ - result = super().parse( - stream, media_type=media_type, parser_context=parser_context - ) - if not isinstance(result, dict) or "data" not in result: raise ParseError("Received document does not contain primary data") @@ -166,3 +163,13 @@ def parse(self, stream, media_type=None, parser_context=None): parsed_data.update(self.parse_relationships(data)) parsed_data.update(self.parse_metadata(result)) return parsed_data + + def parse(self, stream, media_type=None, parser_context=None): + """ + Parses the incoming bytestream as JSON and returns the resulting data + """ + result = super().parse( + stream, media_type=media_type, parser_context=parser_context + ) + + return self.parse_data(result, parser_context) From c016b8bc5a71256c015cdbb91413ccf70dc8e3fd Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 21 Aug 2022 03:09:03 -0400 Subject: [PATCH 371/488] Document how to override DRF generateschema generator_class. (#1084) * Document how to override DRF generateschema generator_class. * changelog --- CHANGELOG.md | 4 ++++ docs/usage.md | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41990d56..73a9a738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Django 4.1. * Expanded JSONParser API with `parse_data` method +### Changed + +* Improved documentation of how to override DRF's generateschema `--generator_class` to generate a proper DJA OAS schema. + ### Removed * Removed support for Django 2.2. diff --git a/docs/usage.md b/docs/usage.md index 952fe80c..40bb0f27 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1085,11 +1085,10 @@ class MySchemaGenerator(JSONAPISchemaGenerator): ### Generate a Static Schema on Command Line See [DRF documentation for generateschema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-static-schema-with-the-generateschema-management-command) -To generate an OAS schema document, use something like: +To generate a static OAS schema document, using the `generateschema` management command, you **must override DRF's default** `generator_class` with the DJA-specific version: ```text -$ django-admin generateschema --settings=example.settings \ - --generator_class myapp.views.MySchemaGenerator >myschema.yaml +$ ./manage.py generateschema --generator_class rest_framework_json_api.schemas.openapi.SchemaGenerator ``` You can then use any number of OAS tools such as From 897305c76806b1185ff06e3636f10978754d4398 Mon Sep 17 00:00:00 2001 From: Jonas Kiefer <17874616+jokiefer@users.noreply.github.com> Date: Tue, 6 Sep 2022 22:59:57 +0200 Subject: [PATCH 372/488] Adhered to field naming format setting when generating schema (#1048) Co-authored-by: Oliver Sauder --- CHANGELOG.md | 1 + example/migrations/0012_author_full_name.py | 19 ++++++++ example/models.py | 1 + example/serializers.py | 1 + example/tests/__snapshots__/test_openapi.ambr | 45 +++++++++++++++++++ example/tests/test_format_keys.py | 1 + example/views.py | 1 + .../django_filters/backends.py | 5 ++- rest_framework_json_api/schemas/openapi.py | 2 +- 9 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 example/migrations/0012_author_full_name.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a9a738..46dc3419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed invalid relationship pointer in error objects when field naming formatting is used. * Properly resolved related resource type when nested source field is defined. * Prevented overwriting of pointer in custom error object +* Adhered to field naming format setting when generating schema parameters and required fields. ### Added diff --git a/example/migrations/0012_author_full_name.py b/example/migrations/0012_author_full_name.py new file mode 100644 index 00000000..1f67558e --- /dev/null +++ b/example/migrations/0012_author_full_name.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1 on 2022-09-06 15:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("example", "0011_rename_type_author_author_type_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="author", + name="full_name", + field=models.CharField(default="", max_length=50), + preserve_default=False, + ), + ] diff --git a/example/models.py b/example/models.py index c3850076..8fc86c22 100644 --- a/example/models.py +++ b/example/models.py @@ -53,6 +53,7 @@ class Meta: class Author(BaseModel): name = models.CharField(max_length=50) + full_name = models.CharField(max_length=50) email = models.EmailField() author_type = models.ForeignKey(AuthorType, null=True, on_delete=models.CASCADE) diff --git a/example/serializers.py b/example/serializers.py index b9bf71d6..75a1de11 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -268,6 +268,7 @@ class Meta: model = Author fields = ( "name", + "full_name", "email", "bio", "entries", diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index fb8c2abe..a0231471 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -104,6 +104,10 @@ "maxLength": 254, "type": "string" }, + "fullName": { + "maxLength": 50, + "type": "string" + }, "name": { "maxLength": 50, "type": "string" @@ -280,6 +284,24 @@ "type": "string" } }, + { + "description": "author_type", + "in": "query", + "name": "filter[authorType]", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "name", + "in": "query", + "name": "filter[name]", + "required": false, + "schema": { + "type": "string" + } + }, { "description": "A search term.", "in": "query", @@ -399,6 +421,24 @@ "type": "string" } }, + { + "description": "author_type", + "in": "query", + "name": "filter[authorType]", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "name", + "in": "query", + "name": "filter[name]", + "required": false, + "schema": { + "type": "string" + } + }, { "description": "A search term.", "in": "query", @@ -508,6 +548,10 @@ "maxLength": 254, "type": "string" }, + "fullName": { + "maxLength": 50, + "type": "string" + }, "name": { "maxLength": 50, "type": "string" @@ -515,6 +559,7 @@ }, "required": [ "name", + "fullName", "email" ], "type": "object" diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index b91cf595..d0e36d81 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -55,6 +55,7 @@ def test_options_format_field_names(db, client): data = response.json()["data"] expected_keys = { "name", + "fullName", "email", "bio", "entries", diff --git a/example/views.py b/example/views.py index 27ec6c2e..edb49ba8 100644 --- a/example/views.py +++ b/example/views.py @@ -222,6 +222,7 @@ class NoFiltersetEntryViewSet(EntryViewSet): class AuthorViewSet(ModelViewSet): queryset = Author.objects.all() + filterset_fields = ("author_type", "name") def get_serializer_class(self): serializer_classes = { diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 5302bf09..365831c7 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -4,7 +4,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings -from rest_framework_json_api.utils import undo_format_field_name +from rest_framework_json_api.utils import format_field_name, undo_format_field_name class DjangoFilterBackend(DjangoFilterBackend): @@ -139,5 +139,6 @@ def get_schema_operation_parameters(self, view): result = super().get_schema_operation_parameters(view) for res in result: if "name" in res: - res["name"] = "filter[{}]".format(res["name"]).replace("__", ".") + name = format_field_name(res["name"].replace("__", ".")) + res["name"] = "filter[{}]".format(name) return result diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index c3d553b7..1aa690fa 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -667,7 +667,7 @@ def map_serializer(self, serializer): continue if field.required: - required.append(field.field_name) + required.append(format_field_name(field.field_name)) schema = self.map_field(field) if field.read_only: From cbf52a3ee98c708eee06c50278cf73de5ab4f3e6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 7 Sep 2022 00:45:46 +0200 Subject: [PATCH 373/488] Avoided using same related name twice in tests models (#1086) --- tests/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/models.py b/tests/models.py index 63718fc6..a483f2d0 100644 --- a/tests/models.py +++ b/tests/models.py @@ -46,7 +46,7 @@ class NestedRelatedSource(DJAModel): fk_source = models.ForeignKey( ForeignKeySource, related_name="nested_source", on_delete=models.CASCADE ) - m2m_target = models.ManyToManyField(ManyToManySource, related_name="nested_source") + m2m_target = models.ManyToManyField(ManyToManySource, related_name="nested_target") fk_target = models.ForeignKey( - ForeignKeySource, related_name="nested_source", on_delete=models.CASCADE + ForeignKeySource, related_name="nested_target", on_delete=models.CASCADE ) From 3a832bd5770a5f7b6766b0d2098476dbe857e5f6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 23 Sep 2022 00:06:13 +0200 Subject: [PATCH 374/488] Added support for DRF 3.14 and dropping support for DRF 3.12. (#1090) --- CHANGELOG.md | 4 +++- README.rst | 2 +- docs/getting-started.md | 2 +- rest_framework_json_api/compat.py | 15 +++++++++++++++ rest_framework_json_api/metadata.py | 3 ++- rest_framework_json_api/schemas/openapi.py | 5 +++-- setup.py | 2 +- tox.ini | 7 ++++--- 8 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 rest_framework_json_api/compat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 46dc3419..a295aee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Django 4.1. +* Added support for Django REST framework 3.14. * Expanded JSONParser API with `parse_data` method ### Changed @@ -29,13 +30,14 @@ any parts of the framework not mentioned in the documentation should generally b ### Removed * Removed support for Django 2.2. +* Removed support for Django REST framework 3.12. ## [5.0.0] - 2022-01-03 This release is not backwards compatible. For easy migration best upgrade first to version 4.3.0 and resolve all deprecation warnings before updating to 5.0.0 -This is the last release supporting Django 2.2. +This is the last release supporting Django 2.2 and Django REST framework 3.12. ### Added diff --git a/README.rst b/README.rst index c82ebb2d..8812bc5d 100644 --- a/README.rst +++ b/README.rst @@ -90,7 +90,7 @@ Requirements 1. Python (3.7, 3.8, 3.9, 3.10) 2. Django (3.2, 4.0, 4.1) -3. Django REST framework (3.12, 3.13) +3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index e03b415b..ab0e961d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,7 +53,7 @@ like the following: 1. Python (3.7, 3.8, 3.9, 3.10) 2. Django (3.2, 4.0, 4.1) -3. Django REST framework (3.12, 3.13) +3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/rest_framework_json_api/compat.py b/rest_framework_json_api/compat.py new file mode 100644 index 00000000..96648eb0 --- /dev/null +++ b/rest_framework_json_api/compat.py @@ -0,0 +1,15 @@ +# Django REST framework 3.14 removed NullBooleanField +# can be removed once support for DRF 3.13 is dropped. +try: + from rest_framework.serializers import NullBooleanField +except ImportError: # pragma: no cover + NullBooleanField = object() + + +# Django REST framework 3.14 deprecates usage of `_get_reference`. +# can be removed once support for DRF 3.13 is dropped. +def get_reference(schema, serializer): + try: + return schema.get_reference(serializer) + except AttributeError: # pragma: no cover + return schema._get_reference(serializer) diff --git a/rest_framework_json_api/metadata.py b/rest_framework_json_api/metadata.py index 6b88e578..1906de71 100644 --- a/rest_framework_json_api/metadata.py +++ b/rest_framework_json_api/metadata.py @@ -7,6 +7,7 @@ from rest_framework.settings import api_settings from rest_framework.utils.field_mapping import ClassLookupDict +from rest_framework_json_api.compat import NullBooleanField from rest_framework_json_api.utils import format_field_name, get_related_resource_type @@ -23,7 +24,7 @@ class JSONAPIMetadata(SimpleMetadata): serializers.Field: "GenericField", serializers.RelatedField: "Relationship", serializers.BooleanField: "Boolean", - serializers.NullBooleanField: "Boolean", + NullBooleanField: "Boolean", serializers.CharField: "String", serializers.URLField: "URL", serializers.EmailField: "Email", diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 1aa690fa..59ed3a3e 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -7,6 +7,7 @@ from rest_framework.schemas.utils import is_list_view from rest_framework_json_api import serializers, views +from rest_framework_json_api.compat import get_reference from rest_framework_json_api.utils import format_field_name @@ -515,10 +516,10 @@ def _get_toplevel_200_response(self, operation, collection=True): if collection: data = { "type": "array", - "items": self._get_reference(self.view.get_serializer()), + "items": get_reference(self, self.view.get_serializer()), } else: - data = self._get_reference(self.view.get_serializer()) + data = get_reference(self, self.view.get_serializer()) return { "description": operation["operationId"], diff --git a/setup.py b/setup.py index 6ac4d9f3..87e296f1 100755 --- a/setup.py +++ b/setup.py @@ -96,7 +96,7 @@ def get_package_data(package): ], install_requires=[ "inflection>=0.5.0", - "djangorestframework>=3.12,<3.14", + "djangorestframework>=3.13,<3.15", "django>=3.2,<4.2", ], extras_require={ diff --git a/tox.ini b/tox.ini index 4d52ce48..3d1219b5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,8 @@ [tox] envlist = - py{37,38,39,310}-django32-drf{312,313,master}, - py{38,39,310}-django{40,41}-drf{313,master}, + py{37,38,39,310}-django32-drf{313,314,master}, + py{38,39,310}-django40-drf{313,314,master}, + py{38,39,310}-django41-drf{314,master}, lint,docs [testenv] @@ -9,8 +10,8 @@ deps = django32: Django>=3.2,<3.3 django40: Django>=4.0,<4.1 django41: Django>=4.1,<4.2 - drf312: djangorestframework>=3.12,<3.13 drf313: djangorestframework>=3.13,<3.14 + drf314: djangorestframework>=3.14,<3.15 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt From bc2cfd521937eac3bf4ec345f2dce2fc0ae6caec Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 23 Sep 2022 15:00:03 -0500 Subject: [PATCH 375/488] Scheduled biweekly dependency update for week 38 (#1089) * Update black from 22.6.0 to 22.8.0 * Update faker from 14.0.0 to 14.2.0 * Update pytest from 7.1.2 to 7.1.3 Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 9215fdc6..819acc6f 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==22.6.0 +black==22.8.0 flake8==5.0.4 flake8-isort==4.2.0 isort==5.10.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index ef200e11..949a2c39 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ factory-boy==3.2.1 -Faker==14.0.0 -pytest==7.1.2 +Faker==14.2.0 +pytest==7.1.3 pytest-cov==3.0.0 pytest-django==4.5.2 pytest-factoryboy==2.5.0 From 81f4fe292c861dd78edde4e13f3b0e54b863b4c9 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 24 Sep 2022 14:43:40 +0200 Subject: [PATCH 376/488] Release 6.0.0 (#1091) --- CHANGELOG.md | 2 ++ rest_framework_json_api/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a295aee1..713f60e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +## [6.0.0] - 2022-09-24 + ### Fixed * Fixed invalid relationship pointer in error objects when field naming formatting is used. diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index e8cac7c0..dd0ae050 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "5.0.0" +__version__ = "6.0.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From 236c7376bdde14b52ba493a1dbe7ad3356d2f465 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 25 Oct 2022 22:46:22 +0400 Subject: [PATCH 377/488] Added support for Python 3.11 (#1096) --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- example/tests/test_model_viewsets.py | 10 +++++++--- setup.py | 1 + tox.ini | 2 +- 7 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2a77e701..9b277e3f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] env: PYTHON: ${{ matrix.python-version }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 713f60e3..ef228d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Added + +* Added support for Python 3.11. + ## [6.0.0] - 2022-09-24 ### Fixed diff --git a/README.rst b/README.rst index 8812bc5d..0e11f8b1 100644 --- a/README.rst +++ b/README.rst @@ -88,7 +88,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.7, 3.8, 3.9, 3.10) +1. Python (3.7, 3.8, 3.9, 3.10, 3.11) 2. Django (3.2, 4.0, 4.1) 3. Django REST framework (3.13, 3.14) diff --git a/docs/getting-started.md b/docs/getting-started.md index ab0e961d..b73af5ea 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.7, 3.8, 3.9, 3.10) +1. Python (3.7, 3.8, 3.9, 3.10, 3.11) 2. Django (3.2, 4.0, 4.1) 3. Django REST framework (3.13, 3.14) diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index 21a641f8..ce6c7ba5 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -208,10 +208,14 @@ def test_key_in_post(self): def test_404_error_pointer(self): self.client.login(username="miles", password="pw") not_found_url = reverse("user-detail", kwargs={"pk": 12345}) - errors = { - "errors": [{"detail": "Not found.", "status": "404", "code": "not_found"}] - } response = self.client.get(not_found_url) assert 404 == response.status_code + result = response.json() + + # exact detail message differs between Python versions + # but only relevant is that there is a message + assert result["errors"][0].pop("detail") is not None + + errors = {"errors": [{"status": "404", "code": "not_found"}]} assert errors == response.json() diff --git a/setup.py b/setup.py index 87e296f1..3c60bba2 100755 --- a/setup.py +++ b/setup.py @@ -90,6 +90,7 @@ def get_package_data(package): "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/tox.ini b/tox.ini index 3d1219b5..563b08ef 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ envlist = py{37,38,39,310}-django32-drf{313,314,master}, py{38,39,310}-django40-drf{313,314,master}, - py{38,39,310}-django41-drf{314,master}, + py{38,39,310,311}-django41-drf{314,master}, lint,docs [testenv] From 5e6872bb26c1c3a57040cc0c67f8459f5b59abc1 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 25 Oct 2022 23:07:58 +0400 Subject: [PATCH 378/488] Added 3rdParty packages policy (#1097) --- docs/usage.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/usage.md b/docs/usage.md index 40bb0f27..aa5f7767 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1128,3 +1128,16 @@ urlpatterns = [ ] ``` +## Third Party Packages + +### About Third Party Packages + +Following the example of [Django REST framework](https://www.django-rest-framework.org/community/third-party-packages/) we also support, encourage and strongly favor the creation of Third Party Packages to encapsulate new behavior rather than adding additional functionality directly to Django REST framework JSON:API especially when it involves adding new dependencies. + +We aim to make creating third party packages as easy as possible, whilst keeping a simple and well maintained core API. By promoting third party packages we ensure that the responsibility for a package remains with its author. If a package proves suitably popular it can always be considered for inclusion into the DJA core. + +### Existing Third Party Packages + +To submit new content, [open an issue](https://github.com/django-json-api/django-rest-framework-json-api/issues/new/choose) or [create a pull request](https://github.com/django-json-api/django-rest-framework-json-api/compare). + +* [drf-yasg-json-api](https://github.com/glowka/drf-yasg-json-api) - Automated generation of Swagger/OpenAPI 2.0 from Django REST framework JSON:API endpoints. From 4e182922882b72cdd1b50626a39e1a27cb36384b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 26 Oct 2022 17:09:58 +0400 Subject: [PATCH 379/488] Added support to overwrite serializer methods in schema class (#1098) --- CHANGELOG.md | 4 +++ rest_framework_json_api/schemas/openapi.py | 34 ++++++++++++++-------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef228d1d..5919fae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Python 3.11. +### Changed + +* Added support to overwrite serializer methods in customized schema class + ## [6.0.0] - 2022-09-24 ### Fixed diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 59ed3a3e..99fc2462 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -427,9 +427,9 @@ def get_operation(self, path, method): # get request and response code schemas if method == "GET": if is_list_view(path, method, self.view): - self._add_get_collection_response(operation) + self._add_get_collection_response(operation, path) else: - self._add_get_item_response(operation) + self._add_get_item_response(operation, path) elif method == "POST": self._add_post_item_response(operation, path) elif method == "PATCH": @@ -487,25 +487,29 @@ def _get_sort_parameters(self, path, method): """ return [{"$ref": "#/components/parameters/sort"}] - def _add_get_collection_response(self, operation): + def _add_get_collection_response(self, operation, path): """ Add GET 200 response for a collection to operation """ operation["responses"] = { - "200": self._get_toplevel_200_response(operation, collection=True) + "200": self._get_toplevel_200_response( + operation, path, "GET", collection=True + ) } self._add_get_4xx_responses(operation) - def _add_get_item_response(self, operation): + def _add_get_item_response(self, operation, path): """ add GET 200 response for an item to operation """ operation["responses"] = { - "200": self._get_toplevel_200_response(operation, collection=False) + "200": self._get_toplevel_200_response( + operation, path, "GET", collection=False + ) } self._add_get_4xx_responses(operation) - def _get_toplevel_200_response(self, operation, collection=True): + def _get_toplevel_200_response(self, operation, path, method, collection=True): """ return top-level JSON:API GET 200 response @@ -516,10 +520,12 @@ def _get_toplevel_200_response(self, operation, collection=True): if collection: data = { "type": "array", - "items": get_reference(self, self.view.get_serializer()), + "items": get_reference( + self, self.get_response_serializer(path, method) + ), } else: - data = get_reference(self, self.view.get_serializer()) + data = get_reference(self, self.get_response_serializer(path, method)) return { "description": operation["operationId"], @@ -555,7 +561,9 @@ def _add_post_item_response(self, operation, path): """ operation["requestBody"] = self.get_request_body(path, "POST") operation["responses"] = { - "201": self._get_toplevel_200_response(operation, collection=False) + "201": self._get_toplevel_200_response( + operation, path, "POST", collection=False + ) } operation["responses"]["201"]["description"] = ( "[Created](https://jsonapi.org/format/#crud-creating-responses-201). " @@ -574,7 +582,9 @@ def _add_patch_item_response(self, operation, path): """ operation["requestBody"] = self.get_request_body(path, "PATCH") operation["responses"] = { - "200": self._get_toplevel_200_response(operation, collection=False) + "200": self._get_toplevel_200_response( + operation, path, "PATCH", collection=False + ) } self._add_patch_4xx_responses(operation) @@ -591,7 +601,7 @@ def get_request_body(self, path, method): """ A request body is required by JSON:API for POST, PATCH, and DELETE methods. """ - serializer = self.get_serializer(path, method) + serializer = self.get_request_serializer(path, method) if not isinstance(serializer, (serializers.BaseSerializer,)): return {} is_relationship = isinstance(self.view, views.RelationshipView) From eab94bd862399c77694c96da03421b8bc477ffb7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 27 Oct 2022 17:06:15 +0400 Subject: [PATCH 380/488] Clarified how to set resource object identifier type (#1099) `resource_name` is not used in JSON:API spec. Using resource object identifier type in the documentation makes it easier for newcomers to find this setting. Also clarified usage of `resource_name = False`, a question which occasionally has come up during discussions. --- docs/usage.md | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index aa5f7767..5da8846a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -278,18 +278,15 @@ class MyModelSerializer(serializers.ModelSerializer): # ... ``` -### Setting the resource_name - -You may manually set the `resource_name` property on views, serializers, or -models to specify the `type` key in the json output. In the case of setting the -`resource_name` property for models you must include the property inside a -`JSONAPIMeta` class on the model. It is automatically set for you as the plural -of the view or model name except on resources that do not subclass -`rest_framework.viewsets.ModelViewSet`: +### Setting resource identifier object type +You may manually set resource identifier object type by using `resource_name` property on views, serializers, or +models. In case of setting the `resource_name` property for models you must include the property inside a +`JSONAPIMeta` class on the model. It is usually automatically set for you as the plural of the view or model name except +on resources that do not subclass `rest_framework.viewsets.ModelViewSet`: Example - `resource_name` on View: -``` python +```python class Me(generics.GenericAPIView): """ Current user's identity endpoint. @@ -301,12 +298,9 @@ class Me(generics.GenericAPIView): allowed_methods = ['GET'] permission_classes = (permissions.IsAuthenticated, ) ``` -If you set the `resource_name` property on the object to `False` the data -will be returned without modification. - Example - `resource_name` on Model: -``` python +```python class Me(models.Model): """ A simple model @@ -324,11 +318,25 @@ on the view should be used sparingly as serializers and models are shared betwee multiple endpoints. Setting the `resource_name` on views may result in a different `type` being set depending on which endpoint the resource is fetched from. +### Build JSON:API view output manually + +If in a view you want to build the output manually, you can set `resource_name` to `False`. + +Example: +```python +class User(ModelViewSet): + resource_name = False + queryset = User.objects.all() + serializer_class = UserSerializer + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + data = [{"id": 1, "type": "users", "attributes": {"fullName": "Test User"}}]) +``` ### Inflecting object and relation keys -This package includes the ability (off by default) to automatically convert [JSON:API field names](https://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the python/rest_framework's preferred underscore to -a format of your choice. To hook this up include the following setting in your +This package includes the ability (off by default) to automatically convert [JSON:API field names](https://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the Django REST framework's preferred underscore to a format of your choice. To hook this up include the following setting in your project settings: ``` python From 0b374e186b2b70886ca7fcb8ada55ad19906709a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 28 Oct 2022 00:18:16 -0500 Subject: [PATCH 381/488] Scheduled biweekly dependency update for week 42 (#1095) * Update black from 22.8.0 to 22.10.0 * Update flake8-isort from 4.2.0 to 5.0.0 * Update sphinx from 5.1.1 to 5.3.0 * Update faker from 14.2.0 to 15.1.1 * Update pytest-cov from 3.0.0 to 4.0.0 * Update syrupy from 3.0.0 to 3.0.2 Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 819acc6f..2b00c11f 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==22.8.0 +black==22.10.0 flake8==5.0.4 -flake8-isort==4.2.0 +flake8-isort==5.0.0 isort==5.10.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index cadfb380..9801cc37 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==5.1.1 +Sphinx==5.3.0 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 949a2c39..24bbcc65 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.2.1 -Faker==14.2.0 +Faker==15.1.1 pytest==7.1.3 -pytest-cov==3.0.0 +pytest-cov==4.0.0 pytest-django==4.5.2 pytest-factoryboy==2.5.0 -syrupy==3.0.0 +syrupy==3.0.2 From 55bd31204e83e2c07d63d442fd706a3287412cab Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 8 Nov 2022 06:25:47 -0500 Subject: [PATCH 382/488] Scheduled biweekly dependency update for week 45 (#1100) * Update sphinx_rtd_theme from 1.0.0 to 1.1.1 * Update faker from 15.1.1 to 15.2.0 * Update pytest from 7.1.3 to 7.2.0 * Update syrupy from 3.0.2 to 3.0.4 * Use correct setup name of pytest Co-authored-by: Oliver Sauder --- example/tests/unit/test_pagination.py | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/example/tests/unit/test_pagination.py b/example/tests/unit/test_pagination.py index 45042939..83570e7c 100644 --- a/example/tests/unit/test_pagination.py +++ b/example/tests/unit/test_pagination.py @@ -14,7 +14,7 @@ class TestLimitOffset: Unit tests for `pagination.JsonApiLimitOffsetPagination`. """ - def setup(self): + def setup_method(self): class ExamplePagination(pagination.JsonApiLimitOffsetPagination): default_limit = 10 max_limit = 15 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 9801cc37..95e25adc 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 Sphinx==5.3.0 -sphinx_rtd_theme==1.0.0 +sphinx_rtd_theme==1.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 24bbcc65..921fc035 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.2.1 -Faker==15.1.1 -pytest==7.1.3 +Faker==15.2.0 +pytest==7.2.0 pytest-cov==4.0.0 pytest-django==4.5.2 pytest-factoryboy==2.5.0 -syrupy==3.0.2 +syrupy==3.0.4 From 9ced3be23c5b5c7772d11959148e350300e382d0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 9 Nov 2022 00:43:39 +0400 Subject: [PATCH 383/488] Introduce flake8 bugbear plugin (#1101) * Introduce flake8 bugbear * Enable optional bugbear warnings --- example/settings/dev.py | 2 +- .../test_non_paginated_responses.py | 12 +++--- example/tests/integration/test_pagination.py | 6 +-- .../tests/integration/test_polymorphism.py | 6 +-- example/tests/test_filters.py | 10 ++--- example/tests/test_views.py | 4 +- example/views.py | 4 +- requirements/requirements-codestyle.txt | 1 + .../django_filters/backends.py | 4 +- rest_framework_json_api/exceptions.py | 5 ++- rest_framework_json_api/metadata.py | 2 +- rest_framework_json_api/parsers.py | 4 +- rest_framework_json_api/relations.py | 2 +- rest_framework_json_api/schemas/openapi.py | 43 +++++++++++-------- rest_framework_json_api/serializers.py | 21 ++++----- rest_framework_json_api/utils.py | 16 +++---- rest_framework_json_api/views.py | 14 +++--- setup.cfg | 7 ++- 18 files changed, 88 insertions(+), 75 deletions(-) diff --git a/example/settings/dev.py b/example/settings/dev.py index 80482ffa..8e13ec15 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -68,7 +68,7 @@ REST_FRAMEWORK = { "PAGE_SIZE": 5, "EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler", - "DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.JsonApiPageNumberPagination", + "DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.JsonApiPageNumberPagination", # noqa: B950 "DEFAULT_PARSER_CLASSES": ( "rest_framework_json_api.parsers.JSONParser", "rest_framework.parsers.FormParser", diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 5a2e59c8..3483b7f4 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -29,7 +29,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "blogHyperlinked": { "links": { "related": "http://testserver/entries/1/blog", - "self": "http://testserver/entries/1/relationships/blog_hyperlinked", + "self": "http://testserver/entries/1/relationships/blog_hyperlinked", # noqa: B950 } }, "authors": { @@ -43,7 +43,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "commentsHyperlinked": { "links": { "related": "http://testserver/entries/1/comments", - "self": "http://testserver/entries/1/relationships/comments_hyperlinked", + "self": "http://testserver/entries/1/relationships/comments_hyperlinked", # noqa: B950 } }, "suggested": { @@ -63,7 +63,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "featuredHyperlinked": { "links": { "related": "http://testserver/entries/1/featured", - "self": "http://testserver/entries/1/relationships/featured_hyperlinked", + "self": "http://testserver/entries/1/relationships/featured_hyperlinked", # noqa: B950 } }, "tags": {"data": [], "meta": {"count": 0}}, @@ -84,7 +84,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "blogHyperlinked": { "links": { "related": "http://testserver/entries/2/blog", - "self": "http://testserver/entries/2/relationships/blog_hyperlinked", + "self": "http://testserver/entries/2/relationships/blog_hyperlinked", # noqa: B950 } }, "authors": { @@ -98,7 +98,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "commentsHyperlinked": { "links": { "related": "http://testserver/entries/2/comments", - "self": "http://testserver/entries/2/relationships/comments_hyperlinked", + "self": "http://testserver/entries/2/relationships/comments_hyperlinked", # noqa: B950 } }, "suggested": { @@ -118,7 +118,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "featuredHyperlinked": { "links": { "related": "http://testserver/entries/2/featured", - "self": "http://testserver/entries/2/relationships/featured_hyperlinked", + "self": "http://testserver/entries/2/relationships/featured_hyperlinked", # noqa: B950 } }, "tags": {"data": [], "meta": {"count": 0}}, diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index aedf8c6c..c723fce1 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -29,7 +29,7 @@ def test_pagination_with_single_entry(single_entry, client): "blogHyperlinked": { "links": { "related": "http://testserver/entries/1/blog", - "self": "http://testserver/entries/1/relationships/blog_hyperlinked", + "self": "http://testserver/entries/1/relationships/blog_hyperlinked", # noqa: B950 } }, "authors": { @@ -43,7 +43,7 @@ def test_pagination_with_single_entry(single_entry, client): "commentsHyperlinked": { "links": { "related": "http://testserver/entries/1/comments", - "self": "http://testserver/entries/1/relationships/comments_hyperlinked", + "self": "http://testserver/entries/1/relationships/comments_hyperlinked", # noqa: B950 } }, "suggested": { @@ -63,7 +63,7 @@ def test_pagination_with_single_entry(single_entry, client): "featuredHyperlinked": { "links": { "related": "http://testserver/entries/1/featured", - "self": "http://testserver/entries/1/relationships/featured_hyperlinked", + "self": "http://testserver/entries/1/relationships/featured_hyperlinked", # noqa: B950 } }, "tags": { diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 69a97220..116243f1 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -33,7 +33,7 @@ def test_polymorphism_on_detail_relations(single_company, client): def test_polymorphism_on_included_relations(single_company, client): response = client.get( reverse("company-detail", kwargs={"pk": single_company.pk}) - + "?include=current_project,future_projects,current_art_project,current_research_project" + + "?include=current_project,future_projects,current_art_project,current_research_project" # noqa: B950 ) content = response.json() assert ( @@ -169,14 +169,14 @@ def test_invalid_type_on_polymorphic_model(client): try: assert ( content["errors"][0]["detail"] - == "The resource object's type (invalidProjects) is not the type that constitute the " + == "The resource object's type (invalidProjects) is not the type that constitute the " # noqa: B950 "collection represented by the endpoint (one of [researchProjects, artProjects])." ) except AssertionError: # Available type list order isn't enforced assert ( content["errors"][0]["detail"] - == "The resource object's type (invalidProjects) is not the type that constitute the " + == "The resource object's type (invalidProjects) is not the type that constitute the " # noqa: B950 "collection represented by the endpoint (one of [artProjects, researchProjects])." ) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 5bb12121..ab74dd0b 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -482,7 +482,7 @@ def test_search_keywords(self): "blog": {"data": {"type": "blogs", "id": "1"}}, "blogHyperlinked": { "links": { - "self": "http://testserver/entries/7/relationships/blog_hyperlinked", # noqa: E501 + "self": "http://testserver/entries/7/relationships/blog_hyperlinked", # noqa: B950 "related": "http://testserver/entries/7/blog", } }, @@ -490,7 +490,7 @@ def test_search_keywords(self): "comments": {"meta": {"count": 0}, "data": []}, "commentsHyperlinked": { "links": { - "self": "http://testserver/entries/7/relationships/comments_hyperlinked", # noqa: E501 + "self": "http://testserver/entries/7/relationships/comments_hyperlinked", # noqa: B950 "related": "http://testserver/entries/7/comments", } }, @@ -515,14 +515,14 @@ def test_search_keywords(self): }, "suggestedHyperlinked": { "links": { - "self": "http://testserver/entries/7/relationships/suggested_hyperlinked", # noqa: E501 + "self": "http://testserver/entries/7/relationships/suggested_hyperlinked", # noqa: B950 "related": "http://testserver/entries/7/suggested/", } }, "tags": {"data": [], "meta": {"count": 0}}, "featuredHyperlinked": { "links": { - "self": "http://testserver/entries/7/relationships/featured_hyperlinked", # noqa: E501 + "self": "http://testserver/entries/7/relationships/featured_hyperlinked", # noqa: B950 "related": "http://testserver/entries/7/featured", } }, @@ -550,7 +550,7 @@ def test_search_multiple_keywords(self): 1. For each keyword, search the 4 search_fields for a match and then get the result set which is the union of all results for the given keyword. 2. Intersect those results sets such that *all* keywords are represented. - See `example/fixtures/blogentry.json` for the test content that the searches are based on. + See `example/fixtures/blogentry.json` for what test content the searches are based on. The searches test for both direct entries and related blogs across multiple fields. """ for searches in ( diff --git a/example/tests/test_views.py b/example/tests/test_views.py index f6947fef..ad3fe124 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -562,8 +562,8 @@ def test_if_returns_error_on_bad_endpoint_name(self): expected = [ { "detail": ( - "The resource object's type (bad) is not the type that constitute the collection " - "represented by the endpoint (blogs)." + "The resource object's type (bad) is not the type that constitute the " + "collection represented by the endpoint (blogs)." ), "source": {"pointer": "/data"}, "status": "409", diff --git a/example/views.py b/example/views.py index edb49ba8..b0d92811 100644 --- a/example/views.py +++ b/example/views.py @@ -144,8 +144,8 @@ class NoPagination(JsonApiPageNumberPagination): class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination - # override the default filter backends in order to test QueryParameterValidationFilter without - # breaking older usage of non-standard query params like `page_size`. + # override the default filter backends in order to test QueryParameterValidationFilter + # without breaking older usage of non-standard query params like `page_size`. filter_backends = ( QueryParameterValidationFilter, OrderingFilter, diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 2b00c11f..f60aab51 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,5 @@ black==22.10.0 flake8==5.0.4 +flake8-bugbear==22.10.27 flake8-isort==5.0.0 isort==5.10.1 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 365831c7..83495004 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -44,7 +44,9 @@ class DjangoFilterBackend(DjangoFilterBackend): - A related resource path can be used: - ``?filter[inventory.item.partNum]=123456`` (where `inventory.item` is the relationship path) + ``?filter[inventory.item.partNum]=123456`` + + where `inventory.item` is the relationship path. If you are also using rest_framework.filters.SearchFilter you'll want to customize the name of the query parameter for searching to make sure it doesn't conflict diff --git a/rest_framework_json_api/exceptions.py b/rest_framework_json_api/exceptions.py index b13ccad5..1c7acbdb 100644 --- a/rest_framework_json_api/exceptions.py +++ b/rest_framework_json_api/exceptions.py @@ -18,8 +18,9 @@ def rendered_with_json_api(view): def exception_handler(exc, context): # Import this here to avoid potential edge-case circular imports, which # crashes with: - # "ImportError: Could not import 'rest_framework_json_api.parsers.JSONParser' for API setting - # 'DEFAULT_PARSER_CLASSES'. ImportError: cannot import name 'exceptions'.'" + # "ImportError: Could not import 'rest_framework_json_api.parsers.JSONParser' for + # API setting 'DEFAULT_PARSER_CLASSES'. + # ImportError: cannot import name 'exceptions'.'" # # Also see: https://github.com/django-json-api/django-rest-framework-json-api/issues/158 from rest_framework.views import exception_handler as drf_exception_handler diff --git a/rest_framework_json_api/metadata.py b/rest_framework_json_api/metadata.py index 1906de71..14b9f69b 100644 --- a/rest_framework_json_api/metadata.py +++ b/rest_framework_json_api/metadata.py @@ -113,7 +113,7 @@ def get_field_info(self, field): field_info["type"] = self.type_lookup[field] try: - serializer_model = getattr(serializer.Meta, "model") + serializer_model = serializer.Meta.model field_info["relationship_type"] = self.relation_type_lookup[ getattr(serializer_model, field.field_name) ] diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 2aef1aa1..e26f6028 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -85,8 +85,8 @@ def parse_data(self, result, parser_context): from rest_framework_json_api.views import RelationshipView if isinstance(view, RelationshipView): - # We skip parsing the object as JSON:API Resource Identifier Object and not a regular - # Resource Object + # We skip parsing the object as JSON:API Resource Identifier Object and not + # a regular Resource Object if isinstance(data, list): for resource_identifier_object in data: if not ( diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 6cca15c8..6eb69bdb 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -123,7 +123,7 @@ def get_links(self, obj=None, lookup_field="pk"): # AuthorViewSet.as_view({'get': 'retrieve_related'})) # 2. url(r'^authors/(?P[^/.]+)/bio/$', # AuthorBioViewSet.as_view({'get': 'retrieve'})) - # So, if related_link_url_kwarg == 'pk' it will add 'related_field' parameter to reverse() + # So, if related_link_url_kwarg == 'pk' it adds 'related_field' parameter to reverse() if self.related_link_url_kwarg == "pk": related_kwargs = self_kwargs else: diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 99fc2462..5ddc8143 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -115,11 +115,11 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): "uniqueItems": True, }, # A RelationshipView uses a ResourceIdentifierObjectSerializer (hence the name - # ResourceIdentifierObject returned by get_component_name()) which serializes type and - # id. These can be lists or individual items depending on whether the relationship is - # toMany or toOne so offer both options since we are not iterating over all the - # possible {related_field}'s but rather rendering one path schema which may represent - # toMany and toOne relationships. + # ResourceIdentifierObject returned by get_component_name()) which serializes type + # and id. These can be lists or individual items depending on whether the + # relationship is toMany or toOne so offer both options since we are not iterating + # over all the possible {related_field}'s but rather rendering one path schema + # which may represent toMany and toOne relationships. "ResourceIdentifierObject": { "oneOf": [ {"$ref": "#/components/schemas/relationshipToOne"}, @@ -181,10 +181,12 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): "properties": { "pointer": { "type": "string", - "description": "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " - "to the associated entity in the request document " - "[e.g. `/data` for a primary data object, or " - "`/data/attributes/title` for a specific attribute.", + "description": ( + "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " + "to the associated entity in the request document " + "[e.g. `/data` for a primary data object, or " + "`/data/attributes/title` for a specific attribute.", + ), }, "parameter": { "type": "string", @@ -273,7 +275,8 @@ def get_schema(self, request=None, public=False): _, view_endpoints = self._get_paths_and_endpoints(None if public else request) #: `expanded_endpoints` is like view_endpoints with one extra field tacked on: - #: - 'action' copy of current view.action (list/fetch) as this gets reset for each request. + #: - 'action' copy of current view.action (list/fetch) as this gets reset for + # each request. expanded_endpoints = [] for path, method, view in view_endpoints: if hasattr(view, "action") and view.action == "retrieve_related": @@ -371,14 +374,15 @@ def _expand_related(self, path, method, view, view_endpoints): def _find_related_view(self, view_endpoints, related_serializer, parent_view): """ - For a given related_serializer, try to find it's "parent" view instance in view_endpoints. + For a given related_serializer, try to find it's "parent" view instance. + :param view_endpoints: list of all view endpoints :param related_serializer: the related serializer for a given related field :param parent_view: the parent view (used to find toMany vs. toOne). TODO: not actually used. :return:view """ - for path, method, view in view_endpoints: + for _path, _method, view in view_endpoints: view_serializer = view.get_serializer() if isinstance(view_serializer, related_serializer): return view @@ -468,7 +472,7 @@ def _get_fields_parameters(self, path, method): # TODO: See if able to identify the specific types for fields[type]=... and return this: # name: fields # in: query - # description: '[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets)' + # description: '[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets)' # noqa: B950 # required: true # style: deepObject # schema: @@ -609,10 +613,11 @@ def get_request_body(self, path, method): # DRF uses a $ref to the component schema definition, but this # doesn't work for JSON:API due to the different required fields based on # the method, so make those changes and inline another copy of the schema. + # TODO: A future improvement could make this DRYer with multiple component schemas: - # A base schema for each viewset that has no required fields - # One subclassed from the base that requires some fields (`type` but not `id` for POST) - # Another subclassed from base with required type/id but no required attributes (PATCH) + # A base schema for each viewset that has no required fields + # One subclassed from the base that requires some fields (`type` but not `id` for POST) + # Another subclassed from base with required type/id but no required attributes (PATCH) if is_relationship: item_schema = {"$ref": "#/components/schemas/ResourceIdentifierObject"} @@ -653,7 +658,9 @@ def get_request_body(self, path, method): def map_serializer(self, serializer): """ Custom map_serializer that serializes the schema using the JSON:API spec. - Non-attributes like related and identity fields, are move to 'relationships' and 'links'. + + Non-attributes like related and identity fields, are moved to 'relationships' + and 'links'. """ # TODO: remove attributes, etc. for relationshipView?? required = [] @@ -830,7 +837,7 @@ def _add_delete_responses(self, operation): } self._add_async_response(operation) operation["responses"]["204"] = { - "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)", + "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)", # noqa: B950 } # the 4xx errors: self._add_generic_failure_responses(operation) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index bfdfec71..91f86464 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -98,7 +98,7 @@ def __init__(self, *args, **kwargs): # iterate over a *copy* of self.fields' underlying OrderedDict, because we may # modify the original during the iteration. # self.fields is a `rest_framework.utils.serializer_helpers.BindingDict` - for field_name, field in self.fields.fields.copy().items(): + for field_name, _field in self.fields.fields.copy().items(): if ( field_name == api_settings.URL_FIELD_NAME ): # leave self link there @@ -208,17 +208,13 @@ def __new__(cls, name, bases, attrs): serializer = super().__new__(cls, name, bases, attrs) if attrs.get("included_serializers", None): - setattr( - serializer, - "included_serializers", - LazySerializersDict(serializer, attrs["included_serializers"]), + serializer.included_serializers = LazySerializersDict( + serializer, attrs["included_serializers"] ) if attrs.get("related_serializers", None): - setattr( - serializer, - "related_serializers", - LazySerializersDict(serializer, attrs["related_serializers"]), + serializer.related_serializers = LazySerializersDict( + serializer, attrs["related_serializers"] ) return serializer @@ -334,10 +330,9 @@ def __new__(cls, name, bases, attrs): return new_class polymorphic_serializers = getattr(new_class, "polymorphic_serializers", None) - if not polymorphic_serializers: - raise NotImplementedError( - "A PolymorphicModelSerializer must define a `polymorphic_serializers` attribute." - ) + assert ( + polymorphic_serializers is not None + ), "A PolymorphicModelSerializer must define a `polymorphic_serializers` attribute." serializer_to_model = { serializer: serializer.Meta.model for serializer in polymorphic_serializers } diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 2e86e762..c9114128 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -48,7 +48,7 @@ def get_resource_name(context, expand_polymorphic_types=False): return "errors" try: - resource_name = getattr(view, "resource_name") + resource_name = view.resource_name except AttributeError: try: if "kwargs" in context and "related_field" in context["kwargs"]: @@ -80,10 +80,10 @@ def get_resource_name(context, expand_polymorphic_types=False): def get_serializer_fields(serializer): fields = None if hasattr(serializer, "child"): - fields = getattr(serializer.child, "fields") + fields = serializer.child.fields meta = getattr(serializer.child, "Meta", None) if hasattr(serializer, "fields"): - fields = getattr(serializer, "fields") + fields = serializer.fields meta = getattr(serializer, "Meta", None) if fields is not None: @@ -159,8 +159,8 @@ def format_link_segment(value): def undo_format_link_segment(value): """ - Takes a link segment and undos format link segment to underscore which is the Python convention - but only in case `JSON_API_FORMAT_RELATED_LINKS` is actually configured. + Takes a link segment and undos format link segment to underscore which is the Python + convention but only in case `JSON_API_FORMAT_RELATED_LINKS` is actually configured. """ if json_api_settings.FORMAT_RELATED_LINKS: @@ -331,7 +331,7 @@ def get_relation_instance(resource_instance, source, serializer): # if the field is not defined on the model then we check the serializer # and if no value is there we skip over the field completely serializer_method = getattr(serializer, source, None) - if serializer_method and hasattr(serializer_method, "__call__"): + if serializer_method and callable(serializer_method): relation_instance = serializer_method(resource_instance) else: return False, None @@ -356,8 +356,8 @@ class Hyperlink(str): https://github.com/tomchristie/django-rest-framework """ - def __new__(self, url, name): - ret = str.__new__(self, url) + def __new__(cls, url, name): + ret = str.__new__(cls, url) ret.name = name return ret diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index c53864a0..de7e8aa6 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -129,7 +129,10 @@ def get_queryset(self, *args, **kwargs): class RelatedMixin: """ - This mixin handles all related entities, whose Serializers are declared in "related_serializers" + Mixing handling related links. + + This mixin handles all related entities, whose Serializers are declared + in "related_serializers". """ def retrieve_related(self, request, *args, **kwargs): @@ -162,6 +165,10 @@ def get_related_serializer_class(self): if "related_field" in self.kwargs: field_name = self.get_related_field_name() + assert hasattr(parent_serializer_class, "included_serializers") or hasattr( + parent_serializer_class, "related_serializers" + ), 'Either "included_serializers" or "related_serializers" should be configured' + # Try get the class from related_serializers if hasattr(parent_serializer_class, "related_serializers"): _class = parent_serializer_class.related_serializers.get( @@ -177,11 +184,6 @@ def get_related_serializer_class(self): if _class is None: raise NotFound - else: - assert ( - False - ), 'Either "included_serializers" or "related_serializers" should be configured' - return _class return parent_serializer_class diff --git a/setup.cfg b/setup.cfg index 2eb90b9b..c9d88f15 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,8 +9,13 @@ max-line-length = 88 extend-ignore = # whitespace before ':' - disabled as not PEP8 compliant E203, - # line too long (managed by black) + # line too long, as using bugbear E501 +extend-select = + # Line too long. This is a pragmatic equivalent of pycodestyle's E501 + B950, + # Invalid first argument used for method. + B902 exclude = build/lib, .eggs From db8b0ff66e4bcfbb755535268662300609b0568d Mon Sep 17 00:00:00 2001 From: Michael K Date: Thu, 10 Nov 2022 05:52:30 +0000 Subject: [PATCH 384/488] Use syntax highlighting in readme (#1102) --- README.rst | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 0e11f8b1..acc50976 100644 --- a/README.rst +++ b/README.rst @@ -24,7 +24,9 @@ Overview * Format specification: https://jsonapi.org/format/ -By default, Django REST framework will produce a response like:: +By default, Django REST framework will produce a response like: + +.. code:: JSON { "count": 20, @@ -39,7 +41,9 @@ By default, Django REST framework will produce a response like:: However, for an ``identity`` model in JSON:API format the response should look -like the following:: +like the following: + +.. code:: JSON { "links": { @@ -104,7 +108,7 @@ Installation Install using ``pip``... -:: +.. code:: sh $ pip install djangorestframework-jsonapi $ # for optional package integrations @@ -115,7 +119,7 @@ Install using ``pip``... or from source... -:: +.. code:: sh $ git clone https://github.com/django-json-api/django-rest-framework-json-api.git $ cd django-rest-framework-json-api @@ -124,7 +128,7 @@ or from source... and add ``rest_framework_json_api`` to your ``INSTALLED_APPS`` setting below ``rest_framework``. -:: +.. code:: python INSTALLED_APPS = [ ... @@ -140,7 +144,7 @@ Running the example app It is recommended to create a virtualenv for testing. Assuming it is already installed and activated: -:: +.. code:: sh $ git clone https://github.com/django-json-api/django-rest-framework-json-api.git $ cd django-rest-framework-json-api @@ -172,7 +176,7 @@ One can either add ``rest_framework_json_api.parsers.JSONParser`` and ``rest_framework_json_api.renderers.JSONRenderer`` to each ``ViewSet`` class, or override ``settings.REST_FRAMEWORK`` -:: +.. code:: python REST_FRAMEWORK = { 'PAGE_SIZE': 10, From 531e6a2b919fa7775b1d509341d0f340c5afbf34 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 14 Nov 2022 20:38:11 +0400 Subject: [PATCH 385/488] Repaired example app (#1105) --- example/fixtures/drf_example.json | 6 ++++-- example/settings/dev.py | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/example/fixtures/drf_example.json b/example/fixtures/drf_example.json index 498c0d1c..944f502c 100644 --- a/example/fixtures/drf_example.json +++ b/example/fixtures/drf_example.json @@ -26,8 +26,9 @@ "created_at": "2016-05-02T10:09:48.277", "modified_at": "2016-05-02T10:09:48.277", "name": "Alice", + "full_name": "Alice Test", "email": "alice@example.com", - "type": null + "author_type": null } }, { @@ -37,8 +38,9 @@ "created_at": "2016-05-02T10:09:57.133", "modified_at": "2016-05-02T10:09:57.133", "name": "Bob", + "full_name": "Bob Test", "email": "bob@example.com", - "type": null + "author_type": null } }, { diff --git a/example/settings/dev.py b/example/settings/dev.py index 8e13ec15..c5405338 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -6,6 +6,7 @@ MEDIA_ROOT = os.path.normcase(os.path.dirname(os.path.abspath(__file__))) MEDIA_URL = "/media/" USE_TZ = False +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" DATABASE_ENGINE = "sqlite3" From adfffeedc95990269b614bc9121b1b54b8095be0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 16 Nov 2022 16:12:43 +0400 Subject: [PATCH 386/488] Adjusted security policy (#1107) --- SECURITY.md | 4 ++-- docs/CONTRIBUTING.md | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index e524f6a5..5cb25b29 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,6 @@ If you believe you've found something in Django REST framework JSON:API which has security implications, please **do not raise the issue in a public forum**. -Send a description of the issue via email to [rest-framework-jsonapi-security@googlegroups.com][security-mail]. The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. +Use the security advisory to [report a vulnerability](https://github.com/django-json-api/django-rest-framework-json-api/security/advisories/new) instead. -[security-mail]: mailto:rest-framework-jsonapi-security@googlegroups.com +The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 2aa87cfe..0715c364 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -69,4 +69,3 @@ In case a new maintainer joins our team we need to consider to what of following * [Github organization](https://github.com/django-json-api) * [Read the Docs project](https://django-rest-framework-json-api.readthedocs.io/) * [PyPi project](https://pypi.org/project/djangorestframework-jsonapi/) -* [Google Groups security mailing list](https://groups.google.com/g/rest-framework-jsonapi-security) From ee7b7de4fb0be1694749fa5359a2f403f5a1ad90 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 30 Nov 2022 21:57:40 +0400 Subject: [PATCH 387/488] Moved pagination and settings tests to tests module (#1110) Those were already in pytest style and only needed some simplifications. --- example/tests/unit/test_pagination.py | 92 ------------------- tests/test_pagination.py | 55 +++++++++++ .../tests/unit => tests}/test_settings.py | 0 3 files changed, 55 insertions(+), 92 deletions(-) delete mode 100644 example/tests/unit/test_pagination.py create mode 100644 tests/test_pagination.py rename {example/tests/unit => tests}/test_settings.py (100%) diff --git a/example/tests/unit/test_pagination.py b/example/tests/unit/test_pagination.py deleted file mode 100644 index 83570e7c..00000000 --- a/example/tests/unit/test_pagination.py +++ /dev/null @@ -1,92 +0,0 @@ -from collections import OrderedDict - -from rest_framework.request import Request -from rest_framework.test import APIRequestFactory -from rest_framework.utils.urls import replace_query_param - -from rest_framework_json_api import pagination - -factory = APIRequestFactory() - - -class TestLimitOffset: - """ - Unit tests for `pagination.JsonApiLimitOffsetPagination`. - """ - - def setup_method(self): - class ExamplePagination(pagination.JsonApiLimitOffsetPagination): - default_limit = 10 - max_limit = 15 - - self.pagination = ExamplePagination() - self.queryset = range(1, 101) - self.base_url = "http://testserver/" - - def paginate_queryset(self, request): - return list(self.pagination.paginate_queryset(self.queryset, request)) - - def get_paginated_content(self, queryset): - response = self.pagination.get_paginated_response(queryset) - return response.data - - def get_test_request(self, arguments): - return Request(factory.get("/", arguments)) - - def test_valid_offset_limit(self): - """ - Basic test, assumes offset and limit are given. - """ - offset = 10 - limit = 5 - count = len(self.queryset) - last_offset = (count // limit) * limit - next_offset = 15 - prev_offset = 5 - - request = self.get_test_request( - { - self.pagination.limit_query_param: limit, - self.pagination.offset_query_param: offset, - } - ) - base_url = replace_query_param( - self.base_url, self.pagination.limit_query_param, limit - ) - last_url = replace_query_param( - base_url, self.pagination.offset_query_param, last_offset - ) - first_url = base_url - next_url = replace_query_param( - base_url, self.pagination.offset_query_param, next_offset - ) - prev_url = replace_query_param( - base_url, self.pagination.offset_query_param, prev_offset - ) - queryset = self.paginate_queryset(request) - content = self.get_paginated_content(queryset) - next_offset = offset + limit - - expected_content = { - "results": list(range(offset + 1, next_offset + 1)), - "links": OrderedDict( - [ - ("first", first_url), - ("last", last_url), - ("next", next_url), - ("prev", prev_url), - ] - ), - "meta": { - "pagination": OrderedDict( - [ - ("count", count), - ("limit", limit), - ("offset", offset), - ] - ) - }, - } - - assert queryset == list(range(offset + 1, next_offset + 1)) - assert content == expected_content diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 00000000..c09ac71c --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,55 @@ +from collections import OrderedDict + +from rest_framework.request import Request + +from rest_framework_json_api.pagination import JsonApiLimitOffsetPagination + + +class TestLimitOffsetPagination: + def test_get_paginated_response(self, rf): + pagination = JsonApiLimitOffsetPagination() + queryset = range(1, 101) + offset = 10 + limit = 5 + count = len(queryset) + + request = Request( + rf.get( + "/", + { + pagination.limit_query_param: limit, + pagination.offset_query_param: offset, + }, + ) + ) + queryset = list(pagination.paginate_queryset(queryset, request)) + content = pagination.get_paginated_response(queryset).data + + expected_content = { + "results": list(range(11, 16)), + "links": OrderedDict( + [ + ("first", "http://testserver/?page%5Blimit%5D=5"), + ( + "last", + "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=100", + ), + ( + "next", + "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=15", + ), + ("prev", "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=5"), + ] + ), + "meta": { + "pagination": OrderedDict( + [ + ("count", count), + ("limit", limit), + ("offset", offset), + ] + ) + }, + } + + assert content == expected_content diff --git a/example/tests/unit/test_settings.py b/tests/test_settings.py similarity index 100% rename from example/tests/unit/test_settings.py rename to tests/test_settings.py From 3d4a926a566b61e4926267ad791f45a232336da8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 8 Dec 2022 16:26:41 +0400 Subject: [PATCH 388/488] Updated to CodeQL v2 (#1112) --- .github/workflows/codeql-analysis.yml | 45 ++++++++++++++++----------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9b429fa7..8faf1a2f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,55 +13,62 @@ name: "CodeQL" on: push: - branches: [ main ] + branches: [ "main" ] pull_request: # The branches below must be a subset of the branches above - branches: [ main ] + branches: [ "main" ] schedule: - - cron: '33 5 * * 6' + - cron: '39 4 * * 2' jobs: analyze: name: Analyze runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v2 # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - #- run: | - # make bootstrap - # make release + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" From caccd48914cad676cbcc9a8be1cec73ba576f15a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 12 Dec 2022 22:28:56 +0400 Subject: [PATCH 389/488] Unified tox configuration (#1113) * Unified tox configuration * Followed example of DRF using -f cmd line of tox v4 instead of tox-py * For linting jobs updated to Python 3.8 as will be required by future updates of Flake8 * Added missing black job * Ignored only testenvs which are actually defined --- .github/workflows/tests.yml | 8 ++++---- tox.ini | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9b277e3f..75573cb7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,9 +20,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install tox tox-py + pip install tox - name: Run tox targets for ${{ matrix.python-version }} - run: tox --py current + run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) - name: Upload coverage report uses: codecov/codecov-action@v2 with: @@ -36,10 +36,10 @@ jobs: tox-env: ["black", "lint", "docs"] steps: - uses: actions/checkout@v2 - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: 3.8 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/tox.ini b/tox.ini index 563b08ef..c9fd139b 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,9 @@ envlist = py{37,38,39,310}-django32-drf{313,314,master}, py{38,39,310}-django40-drf{313,314,master}, py{38,39,310,311}-django41-drf{314,master}, - lint,docs + black, + docs, + lint [testenv] deps = @@ -24,13 +26,13 @@ commands = pytest --cov --no-cov-on-fail --cov-report xml {posargs} [testenv:black] -basepython = python3.7 +basepython = python3.8 deps = -rrequirements/requirements-codestyle.txt commands = black --check . [testenv:lint] -basepython = python3.7 +basepython = python3.8 deps = -rrequirements/requirements-codestyle.txt -rrequirements/requirements-testing.txt @@ -38,7 +40,7 @@ deps = commands = flake8 [testenv:docs] -basepython = python3.7 +basepython = python3.8 deps = -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt @@ -46,5 +48,11 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{37,38,39,310}-django{32,40,41}-drfmaster] +[testenv:py{37,38,39,310}-django32-drfmaster] +ignore_outcome = true + +[testenv:py{38,39,310}-django40-drfmaster] +ignore_outcome = true + +[testenv:py{38,39,310,311}-django41-drfmaster] ignore_outcome = true From 5f48b708bbe72dc8aaf684ab45bb43e39777d70b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 12 Dec 2022 22:41:01 +0400 Subject: [PATCH 390/488] Removed obsolete tests (#1114) * Format keys/field names is extensively tested in test_utils * Testing factories simply tests functionality of another library but nothing DJA specific Co-authored-by: Alan Crosswell --- example/tests/test_format_keys.py | 69 ---------------------------- example/tests/unit/test_factories.py | 47 ------------------- 2 files changed, 116 deletions(-) delete mode 100644 example/tests/test_format_keys.py delete mode 100644 example/tests/unit/test_factories.py diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py deleted file mode 100644 index d0e36d81..00000000 --- a/example/tests/test_format_keys.py +++ /dev/null @@ -1,69 +0,0 @@ -from django.contrib.auth import get_user_model -from django.urls import reverse -from django.utils import encoding -from rest_framework import status - -from example.tests import TestBase - - -class FormatKeysSetTests(TestBase): - """ - Test that camelization and underscoring of key names works if they are activated. - """ - - list_url = reverse("user-list") - - def setUp(self): - super().setUp() - self.detail_url = reverse("user-detail", kwargs={"pk": self.miles.pk}) - - def test_camelization(self): - """ - Test that camelization works. - """ - response = self.client.get(self.list_url) - self.assertEqual(response.status_code, 200) - - user = get_user_model().objects.all()[0] - expected = { - "data": [ - { - "type": "users", - "id": encoding.force_str(user.pk), - "attributes": { - "firstName": user.first_name, - "lastName": user.last_name, - "email": user.email, - }, - } - ], - "links": { - "first": "http://testserver/identities?page%5Bnumber%5D=1", - "last": "http://testserver/identities?page%5Bnumber%5D=2", - "next": "http://testserver/identities?page%5Bnumber%5D=2", - "prev": None, - }, - "meta": {"pagination": {"page": 1, "pages": 2, "count": 2}}, - } - - assert expected == response.json() - - -def test_options_format_field_names(db, client): - response = client.options(reverse("author-list")) - assert response.status_code == status.HTTP_200_OK - data = response.json()["data"] - expected_keys = { - "name", - "fullName", - "email", - "bio", - "entries", - "firstEntry", - "authorType", - "comments", - "secrets", - "defaults", - "initials", - } - assert expected_keys == data["actions"]["POST"].keys() diff --git a/example/tests/unit/test_factories.py b/example/tests/unit/test_factories.py deleted file mode 100644 index ac9d7b2a..00000000 --- a/example/tests/unit/test_factories.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest - -from example.factories import BlogFactory -from example.models import Blog - -pytestmark = pytest.mark.django_db - - -def test_factory_instance(blog_factory): - - assert blog_factory == BlogFactory - - -def test_model_instance(blog): - - assert isinstance(blog, Blog) - - -def test_multiple_blog(blog_factory): - another_blog = blog_factory(name="Cool Blog") - new_blog = blog_factory(name="Awesome Blog") - - assert another_blog.name == "Cool Blog" - assert new_blog.name == "Awesome Blog" - - -def test_factories_with_relations(author_factory, entry_factory): - - author = author_factory(name="Joel Spolsky") - entry = entry_factory( - headline=( - "The Absolute Minimum Every Software Developer" - "Absolutely, Positively Must Know About Unicode " - "and Character Sets (No Excuses!)" - ), - blog__name="Joel on Software", - authors=(author,), - ) - - assert entry.blog.name == "Joel on Software" - assert entry.headline == ( - "The Absolute Minimum Every Software Developer" - "Absolutely, Positively Must Know About Unicode " - "and Character Sets (No Excuses!)" - ) - assert entry.authors.all().count() == 1 - assert entry.authors.all()[0].name == "Joel Spolsky" From 4dc1c9779c6d5c9dd2a54c46dfb0b6b2e619c4a6 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 19 Dec 2022 12:28:28 -0500 Subject: [PATCH 391/488] Scheduled biweekly dependency update for week 51 (#1115) * Update black from 22.10.0 to 22.12.0 * Update flake8 from 5.0.4 to 6.0.0 * Update flake8-bugbear from 22.10.27 to 22.12.6 * Update flake8-isort from 5.0.0 to 5.0.3 * Update isort from 5.10.1 to 5.11.3 * Update twine from 4.0.1 to 4.0.2 * Update faker from 15.2.0 to 15.3.4 * Update pytest-factoryboy from 2.5.0 to 2.5.1 * Update syrupy from 3.0.4 to 3.0.5 --- requirements/requirements-codestyle.txt | 10 +++++----- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index f60aab51..93eb7b6f 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==22.10.0 -flake8==5.0.4 -flake8-bugbear==22.10.27 -flake8-isort==5.0.0 -isort==5.10.1 +black==22.12.0 +flake8==6.0.0 +flake8-bugbear==22.12.6 +flake8-isort==5.0.3 +isort==5.11.3 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index d76bde9d..eb2532c4 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==4.0.1 +twine==4.0.2 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 921fc035..cf10eb2c 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.2.1 -Faker==15.2.0 +Faker==15.3.4 pytest==7.2.0 pytest-cov==4.0.0 pytest-django==4.5.2 -pytest-factoryboy==2.5.0 -syrupy==3.0.4 +pytest-factoryboy==2.5.1 +syrupy==3.0.5 From 72a062c89e797e1452557f0e75c3ac1c0ba9497b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 20 Dec 2022 00:19:25 +0400 Subject: [PATCH 392/488] Removed unnecessary test dependencies when building docs (#1116) Removed unnecessary test dependencies when building docs --- tox.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/tox.ini b/tox.ini index c9fd139b..236f5517 100644 --- a/tox.ini +++ b/tox.ini @@ -42,7 +42,6 @@ commands = flake8 [testenv:docs] basepython = python3.8 deps = - -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt -rrequirements/requirements-documentation.txt commands = From 085554e28c663d9a336bf7174c58d81925d9d650 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 20 Dec 2022 00:27:58 +0400 Subject: [PATCH 393/488] Set daily schedule for running test jobs (#1117) This way we will quickly notice when a point release in Python, DRF or Django makes DJA fail. Co-authored-by: Alan Crosswell --- .github/workflows/tests.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 75573cb7..a22301fe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,5 +1,11 @@ name: Tests -on: [push, pull_request] +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: '0 4 * * *' jobs: test: From a4b1b25bffd7a35b6609ff0b6f77c3a4b8339fd1 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 27 Jan 2023 14:05:29 -0500 Subject: [PATCH 394/488] Scheduled biweekly dependency update for week 03 (#1119) * Update flake8-bugbear from 22.12.6 to 23.1.14 * Update flake8-isort from 5.0.3 to 6.0.0 * Update isort from 5.11.3 to 5.11.4 * Update faker from 15.3.4 to 16.4.0 * Update pytest from 7.2.0 to 7.2.1 * Update syrupy from 3.0.5 to 3.0.6 * Addressed linting issue B028 by using !r format string --------- Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 6 +++--- requirements/requirements-testing.txt | 6 +++--- rest_framework_json_api/utils.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 93eb7b6f..b698dde6 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==22.12.0 flake8==6.0.0 -flake8-bugbear==22.12.6 -flake8-isort==5.0.3 -isort==5.11.3 +flake8-bugbear==23.1.14 +flake8-isort==6.0.0 +isort==5.11.4 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index cf10eb2c..d3b097fd 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.2.1 -Faker==15.3.4 -pytest==7.2.0 +Faker==16.4.0 +pytest==7.2.1 pytest-cov==4.0.0 pytest-django==4.5.2 pytest-factoryboy==2.5.1 -syrupy==3.0.5 +syrupy==3.0.6 diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index c9114128..130894aa 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -303,8 +303,8 @@ def get_resource_type_from_serializer(serializer): elif hasattr(meta, "model"): return get_resource_type_from_model(meta.model) raise AttributeError( - f"can not detect 'resource_name' on serializer '{serializer.__class__.__name__}'" - f" in module '{serializer.__class__.__module__}'" + f"can not detect 'resource_name' on serializer {serializer.__class__.__name__!r}" + f" in module {serializer.__class__.__module__!r}" ) From 735ce291d7385fea39b5a50c107449c2d8fdd3d9 Mon Sep 17 00:00:00 2001 From: kal Date: Wed, 1 Feb 2023 07:24:34 -0500 Subject: [PATCH 395/488] Avoided duplicated sort query parameter in schema generation (#1124) --- AUTHORS | 1 + CHANGELOG.md | 4 ++++ example/tests/__snapshots__/test_openapi.ambr | 10 ++-------- example/tests/unit/test_filter_schema_params.py | 3 ++- rest_framework_json_api/filters.py | 3 +++ rest_framework_json_api/schemas/openapi.py | 16 ---------------- 6 files changed, 12 insertions(+), 25 deletions(-) diff --git a/AUTHORS b/AUTHORS index 15a5393f..e27ba5f5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -21,6 +21,7 @@ Jonas Kiefer Jonas Metzener Jonathan Senecal Joseba Mendivil +Kal Kevin Partington Kieran Evans Léo S. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5919fae3..a509b262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ any parts of the framework not mentioned in the documentation should generally b * Added support to overwrite serializer methods in customized schema class +### Fixed + +* Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated schema definition + ## [6.0.0] - 2022-09-24 ### Fixed diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index a0231471..713387d3 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -273,10 +273,7 @@ "$ref": "#/components/parameters/fields" }, { - "$ref": "#/components/parameters/sort" - }, - { - "description": "Which field to use when ordering the results.", + "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", "in": "query", "name": "sort", "required": false, @@ -391,9 +388,6 @@ { "$ref": "#/components/parameters/fields" }, - { - "$ref": "#/components/parameters/sort" - }, { "description": "A page number within the paginated result set.", "in": "query", @@ -413,7 +407,7 @@ } }, { - "description": "Which field to use when ordering the results.", + "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", "in": "query", "name": "sort", "required": false, diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py index f50304ac..d7cb4fb8 100644 --- a/example/tests/unit/test_filter_schema_params.py +++ b/example/tests/unit/test_filter_schema_params.py @@ -72,7 +72,8 @@ def test_filters_get_schema_params(): "name": "sort", "required": False, "in": "query", - "description": "Which field to use when ordering the results.", + "description": "[list of fields to sort by]" + "(https://jsonapi.org/format/#fetching-sorting)", "schema": {"type": "string"}, } ], diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index 33ce240f..ead6f84b 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -22,6 +22,9 @@ class OrderingFilter(OrderingFilter): #: override :py:attr:`rest_framework.filters.OrderingFilter.ordering_param` #: with JSON:API-compliant query parameter name. ordering_param = "sort" + ordering_description = ( + "[list of fields to sort by]" "(https://jsonapi.org/format/#fetching-sorting)" + ) def remove_invalid_fields(self, queryset, fields, view, request): """ diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 5ddc8143..99ec8ff1 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -248,15 +248,6 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): }, "explode": True, }, - "sort": { - "name": "sort", - "in": "query", - "description": "[list of fields to sort by]" - "(https://jsonapi.org/format/#fetching-sorting)", - "required": False, - "style": "form", - "schema": {"type": "string"}, - }, }, } @@ -422,7 +413,6 @@ def get_operation(self, path, method): if method in ["GET", "HEAD"]: parameters += self._get_include_parameters(path, method) parameters += self._get_fields_parameters(path, method) - parameters += self._get_sort_parameters(path, method) parameters += self.get_pagination_parameters(path, method) parameters += self.get_filter_parameters(path, method) operation["parameters"] = parameters @@ -485,12 +475,6 @@ def _get_fields_parameters(self, path, method): # explode: true return [{"$ref": "#/components/parameters/fields"}] - def _get_sort_parameters(self, path, method): - """ - sort parameter: https://jsonapi.org/format/#fetching-sorting - """ - return [{"$ref": "#/components/parameters/sort"}] - def _add_get_collection_response(self, operation, path): """ Add GET 200 response for a collection to operation From ee2b8f2f0464463fb3e13a95a22e71962eee9ec4 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 21 Feb 2023 01:21:22 -0500 Subject: [PATCH 396/488] Scheduled biweekly dependency update for week 08 (#1129) * Update black from 22.12.0 to 23.1.0 * Update flake8-bugbear from 23.1.14 to 23.2.13 * Update isort from 5.11.4 to 5.12.0 * Update sphinx from 5.3.0 to 6.1.3 * Update sphinx_rtd_theme from 1.1.1 to 1.2.0 * Update faker from 16.4.0 to 17.0.0 * Reformatted with black 2023 style * Set warning stacklevel so it is clear This is a simply warning with a key where not stacktrace is needed. --------- Co-authored-by: Oliver Sauder --- example/migrations/0001_initial.py | 1 - example/migrations/0002_taggeditem.py | 1 - example/migrations/0003_polymorphics.py | 1 - example/migrations/0004_auto_20171011_0631.py | 1 - example/migrations/0005_auto_20180922_1508.py | 1 - example/migrations/0006_auto_20181228_0752.py | 1 - example/migrations/0007_artproject_description.py | 1 - example/migrations/0008_labresults.py | 1 - example/migrations/0009_labresults_author.py | 1 - example/migrations/0010_auto_20210714_0809.py | 1 - .../0011_rename_type_author_author_type_and_more.py | 1 - example/migrations/0012_author_full_name.py | 1 - example/serializers.py | 1 - example/tests/conftest.py | 2 -- example/tests/integration/test_meta.py | 2 -- example/tests/integration/test_model_resource_name.py | 2 -- example/tests/integration/test_non_paginated_responses.py | 1 - example/tests/integration/test_pagination.py | 1 - example/tests/unit/test_default_drf_serializers.py | 4 ---- requirements/requirements-codestyle.txt | 6 +++--- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-testing.txt | 2 +- rest_framework_json_api/renderers.py | 2 -- rest_framework_json_api/schemas/openapi.py | 5 ++--- rest_framework_json_api/utils.py | 1 - rest_framework_json_api/views.py | 2 -- 26 files changed, 8 insertions(+), 39 deletions(-) diff --git a/example/migrations/0001_initial.py b/example/migrations/0001_initial.py index 0161cd49..18805099 100644 --- a/example/migrations/0001_initial.py +++ b/example/migrations/0001_initial.py @@ -3,7 +3,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [] diff --git a/example/migrations/0002_taggeditem.py b/example/migrations/0002_taggeditem.py index 0abf49e0..62b1cf81 100644 --- a/example/migrations/0002_taggeditem.py +++ b/example/migrations/0002_taggeditem.py @@ -3,7 +3,6 @@ class Migration(migrations.Migration): - dependencies = [ ("contenttypes", "0002_remove_content_type_name"), ("example", "0001_initial"), diff --git a/example/migrations/0003_polymorphics.py b/example/migrations/0003_polymorphics.py index 1bae8491..70c62ddd 100644 --- a/example/migrations/0003_polymorphics.py +++ b/example/migrations/0003_polymorphics.py @@ -3,7 +3,6 @@ class Migration(migrations.Migration): - dependencies = [ ("contenttypes", "0002_remove_content_type_name"), ("example", "0002_taggeditem"), diff --git a/example/migrations/0004_auto_20171011_0631.py b/example/migrations/0004_auto_20171011_0631.py index fa597a29..51db8e3f 100644 --- a/example/migrations/0004_auto_20171011_0631.py +++ b/example/migrations/0004_auto_20171011_0631.py @@ -3,7 +3,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0003_polymorphics"), ] diff --git a/example/migrations/0005_auto_20180922_1508.py b/example/migrations/0005_auto_20180922_1508.py index 58b2808d..52c3a0ac 100644 --- a/example/migrations/0005_auto_20180922_1508.py +++ b/example/migrations/0005_auto_20180922_1508.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0004_auto_20171011_0631"), ] diff --git a/example/migrations/0006_auto_20181228_0752.py b/example/migrations/0006_auto_20181228_0752.py index 4126c797..3ef2678e 100644 --- a/example/migrations/0006_auto_20181228_0752.py +++ b/example/migrations/0006_auto_20181228_0752.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0005_auto_20180922_1508"), ] diff --git a/example/migrations/0007_artproject_description.py b/example/migrations/0007_artproject_description.py index 20f9d42e..4ed2d7a9 100644 --- a/example/migrations/0007_artproject_description.py +++ b/example/migrations/0007_artproject_description.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0006_auto_20181228_0752"), ] diff --git a/example/migrations/0008_labresults.py b/example/migrations/0008_labresults.py index e0a1d6ba..290c2cb8 100644 --- a/example/migrations/0008_labresults.py +++ b/example/migrations/0008_labresults.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0007_artproject_description"), ] diff --git a/example/migrations/0009_labresults_author.py b/example/migrations/0009_labresults_author.py index 6365d01c..6c805ab0 100644 --- a/example/migrations/0009_labresults_author.py +++ b/example/migrations/0009_labresults_author.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0008_labresults"), ] diff --git a/example/migrations/0010_auto_20210714_0809.py b/example/migrations/0010_auto_20210714_0809.py index de36ba20..cd96cccc 100644 --- a/example/migrations/0010_auto_20210714_0809.py +++ b/example/migrations/0010_auto_20210714_0809.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0009_labresults_author"), ] diff --git a/example/migrations/0011_rename_type_author_author_type_and_more.py b/example/migrations/0011_rename_type_author_author_type_and_more.py index 191e901c..cc2f5546 100644 --- a/example/migrations/0011_rename_type_author_author_type_and_more.py +++ b/example/migrations/0011_rename_type_author_author_type_and_more.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("contenttypes", "0002_remove_content_type_name"), ("example", "0010_auto_20210714_0809"), diff --git a/example/migrations/0012_author_full_name.py b/example/migrations/0012_author_full_name.py index 1f67558e..0486d041 100644 --- a/example/migrations/0012_author_full_name.py +++ b/example/migrations/0012_author_full_name.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0011_rename_type_author_author_type_and_more"), ] diff --git a/example/serializers.py b/example/serializers.py index 75a1de11..3d94e6cc 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -181,7 +181,6 @@ class JSONAPIMeta: class EntryDRFSerializers(drf_serilazers.ModelSerializer): - tags = TaggedItemDRFSerializer(many=True, read_only=True) url = drf_serilazers.HyperlinkedIdentityField( view_name="drf-entry-blog-detail", diff --git a/example/tests/conftest.py b/example/tests/conftest.py index df5bbdfc..22ab6bd1 100644 --- a/example/tests/conftest.py +++ b/example/tests/conftest.py @@ -31,7 +31,6 @@ @pytest.fixture def single_entry(blog, author, entry_factory, comment_factory, tagged_item_factory): - entry = entry_factory(blog=blog, authors=(author,)) comment_factory(entry=entry) tagged_item_factory(content_object=entry) @@ -40,7 +39,6 @@ def single_entry(blog, author, entry_factory, comment_factory, tagged_item_facto @pytest.fixture def multiple_entries(blog_factory, author_factory, entry_factory, comment_factory): - entries = [ entry_factory(blog=blog_factory(), authors=(author_factory(),)), entry_factory(blog=blog_factory(), authors=(author_factory(),)), diff --git a/example/tests/integration/test_meta.py b/example/tests/integration/test_meta.py index f54cda8e..c63a6d9b 100644 --- a/example/tests/integration/test_meta.py +++ b/example/tests/integration/test_meta.py @@ -7,7 +7,6 @@ def test_top_level_meta_for_list_view(blog, client): - expected = { "data": [ { @@ -37,7 +36,6 @@ def test_top_level_meta_for_list_view(blog, client): def test_top_level_meta_for_detail_view(blog, client): - expected = { "data": { "type": "blogs", diff --git a/example/tests/integration/test_model_resource_name.py b/example/tests/integration/test_model_resource_name.py index 2dacd2d5..b2fda333 100644 --- a/example/tests/integration/test_model_resource_name.py +++ b/example/tests/integration/test_model_resource_name.py @@ -50,7 +50,6 @@ def _check_relationship_and_included_comment_type_are_the_same(django_client, ur @pytest.mark.usefixtures("single_entry") class TestModelResourceName: - create_data = { "data": { "type": "resource_name_from_JSONAPIMeta", @@ -147,7 +146,6 @@ def teardown_method(self, method): @pytest.mark.usefixtures("single_entry") class TestResourceNameConsistency: - # Included rename tests def test_type_match_on_included_and_inline_base(self, client): _check_relationship_and_included_comment_type_are_the_same( diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 3483b7f4..60376a8b 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -11,7 +11,6 @@ new=lambda s: [], ) def test_multiple_entries_no_pagination(multiple_entries, client): - expected = { "data": [ { diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index c723fce1..4c60e96e 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -11,7 +11,6 @@ new=lambda s: [], ) def test_pagination_with_single_entry(single_entry, client): - expected = { "data": [ { diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index d74edac7..87231896 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -71,7 +71,6 @@ def test_render_format_field_names(db, settings, entry): @pytest.mark.django_db def test_blog_create(client): - url = reverse("drf-entry-blog-list") name = "Dummy Name" @@ -107,7 +106,6 @@ def test_blog_create(client): @pytest.mark.django_db def test_get_object_gives_correct_blog(client, blog, entry): - url = reverse("drf-entry-blog-detail", kwargs={"entry_pk": entry.id}) resp = client.get(url) expected = { @@ -126,7 +124,6 @@ def test_get_object_gives_correct_blog(client, blog, entry): @pytest.mark.django_db def test_get_object_patches_correct_blog(client, blog, entry): - url = reverse("drf-entry-blog-detail", kwargs={"entry_pk": entry.id}) new_name = blog.name + " update" assert not new_name == blog.name @@ -163,7 +160,6 @@ def test_get_object_patches_correct_blog(client, blog, entry): @pytest.mark.django_db def test_get_object_deletes_correct_blog(client, entry): - url = reverse("drf-entry-blog-detail", kwargs={"entry_pk": entry.id}) resp = client.delete(url) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index b698dde6..4092f80f 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==22.12.0 +black==23.1.0 flake8==6.0.0 -flake8-bugbear==23.1.14 +flake8-bugbear==23.2.13 flake8-isort==6.0.0 -isort==5.11.4 +isort==5.12.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 95e25adc..36e96657 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==5.3.0 -sphinx_rtd_theme==1.1.1 +Sphinx==6.1.3 +sphinx_rtd_theme==1.2.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index d3b097fd..69371227 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ factory-boy==3.2.1 -Faker==16.4.0 +Faker==17.0.0 pytest==7.2.1 pytest-cov==4.0.0 pytest-django==4.5.2 diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index db297870..43fef0a3 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -499,7 +499,6 @@ def render_errors(self, data, accepted_media_type=None, renderer_context=None): ) def render(self, data, accepted_media_type=None, renderer_context=None): - renderer_context = renderer_context or {} view = renderer_context.get("view", None) @@ -545,7 +544,6 @@ def render(self, data, accepted_media_type=None, renderer_context=None): included_resources = utils.get_included_resources(request, serializer) if serializer is not None: - # Extract root meta for any type of serializer json_api_meta.update(self.extract_root_meta(serializer, serializer_data)) diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 99ec8ff1..7cb7d9ca 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -298,9 +298,8 @@ def get_schema(self, request=None, public=False): if components_schemas[k] == components[k]: continue warnings.warn( - 'Schema component "{}" has been overriden with a different value.'.format( - k - ) + f'Schema component "{k}" has been overriden with a different value.', + stacklevel=1, ) components_schemas.update(components) diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 130894aa..91b3aba9 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -415,7 +415,6 @@ def format_drf_errors(response, context, exc): def format_error_object(message, pointer, response): errors = [] if isinstance(message, dict): - # as there is no required field in error object we check that all fields are string # except links, source or meta which might be a dict is_custom_error = all( diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index de7e8aa6..0b3df693 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -66,7 +66,6 @@ def get_queryset(self, *args, **kwargs): self.request, self.get_serializer_class() ) for included in included_resources + ["__all__"]: - select_related = self.get_select_related(included) if select_related is not None: qs = qs.select_related(*select_related) @@ -110,7 +109,6 @@ def get_queryset(self, *args, **kwargs): if level == levels[-1]: included_model = field else: - if issubclass(field_class, ReverseOneToOneDescriptor): model_field = field.related.field else: From acffc186207449fb378a64277258d55f528effde Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 21 Feb 2023 15:16:59 +0400 Subject: [PATCH 397/488] Adjusted to use same Python and Ubuntu version for RDT (#1130) --- .readthedocs.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..96c57f04 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,6 @@ +version: 2 + +build: + os: "ubuntu-22.04" + tools: + python: "3.8" From b8faed84b96126aab7c4e0241cb62c9ce4f7e13f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 21 Feb 2023 15:24:02 +0400 Subject: [PATCH 398/488] Adjusted some still old formatted strings to f-strings (#1131) --- CHANGELOG.md | 1 + docs/conf.py | 2 +- rest_framework_json_api/django_filters/backends.py | 2 +- rest_framework_json_api/renderers.py | 4 ++-- rest_framework_json_api/settings.py | 2 +- rest_framework_json_api/utils.py | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a509b262..ce154cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Changed * Added support to overwrite serializer methods in customized schema class +* Adjusted some still old formatted strings to f-strings. ### Fixed diff --git a/docs/conf.py b/docs/conf.py index ea67682f..fcc91d58 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,7 +61,7 @@ # General information about the project. project = "Django REST framework JSON:API" year = datetime.date.today().year -copyright = "{}, Django REST framework JSON:API contributors".format(year) +copyright = f"{year}, Django REST framework JSON:API contributors" author = "Django REST framework JSON:API contributors" # The version info for the project you're documenting, acts as replacement for diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 83495004..c0044839 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -142,5 +142,5 @@ def get_schema_operation_parameters(self, view): for res in result: if "name" in res: name = format_field_name(res["name"].replace("__", ".")) - res["name"] = "filter[{}]".format(name) + res["name"] = f"filter[{name}]" return result diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 43fef0a3..0540a39f 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -166,7 +166,7 @@ def extract_relationships(cls, fields, resource, resource_instance): (relations.PrimaryKeyRelatedField, relations.HyperlinkedRelatedField), ): resolved, relation = utils.get_relation_instance( - resource_instance, "%s_id" % source, field.parent + resource_instance, f"{source}_id", field.parent ) if not resolved: continue @@ -341,7 +341,7 @@ def extract_included( serializer_data = field.data new_included_resources = [ - key.replace("%s." % field_name, "", 1) + key.replace(f"{field_name}.", "", 1) for key in included_resources if field_name == key.split(".")[0] ] diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 0800e901..83228359 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -30,7 +30,7 @@ def __init__(self, user_settings=settings, defaults=DEFAULTS): def __getattr__(self, attr): if attr not in self.defaults: - raise AttributeError("Invalid JSON:API setting: '%s'" % attr) + raise AttributeError(f"Invalid JSON:API setting: '{attr}'") value = getattr( self.user_settings, JSON_API_SETTINGS_PREFIX + attr, self.defaults[attr] diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 91b3aba9..b7f2f9a0 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -269,7 +269,7 @@ def get_related_resource_type(relation): if hasattr(relation, "child_relation"): return get_related_resource_type(relation.child_relation) raise APIException( - _("Could not resolve resource type for relation %s" % relation) + _(f"Could not resolve resource type for relation {relation}") ) return get_resource_type_from_model(relation_model) From 8e1edcd1ac8ac92d864128fcde383563a2398318 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 21 Feb 2023 16:07:20 +0400 Subject: [PATCH 399/488] Added requirements.txt and spinx docs location to RTD conf (#1133) --- .readthedocs.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 96c57f04..d4503fd3 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -4,3 +4,10 @@ build: os: "ubuntu-22.04" tools: python: "3.8" + +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: docs/requirements.txt From 5c88b9339bd9ed640c2c8b80b0d3789653aa6620 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 21 Feb 2023 17:06:39 +0400 Subject: [PATCH 400/488] Made docs/requirements.txt obsolete and install project itself (#1134) --- .readthedocs.yaml | 5 ++++- docs/requirements.txt | 6 ------ tox.ini | 1 + 3 files changed, 5 insertions(+), 7 deletions(-) delete mode 100644 docs/requirements.txt diff --git a/.readthedocs.yaml b/.readthedocs.yaml index d4503fd3..d70a3ae5 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,4 +10,7 @@ sphinx: python: install: - - requirements: docs/requirements.txt + - requirements: requirements/requirements-optionals.txt + - requirements: requirements/requirements-documentation.txt + - method: pip + path: . diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 4ffe63ef..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -# RTD only supports one requirements.txt file, therefore this has been created. -# This file needs to keep in sync with tox testenv docs dependencies. - --r ../requirements/requirements-optionals.txt --r ../requirements/requirements-testing.txt --r ../requirements/requirements-documentation.txt diff --git a/tox.ini b/tox.ini index 236f5517..0879e756 100644 --- a/tox.ini +++ b/tox.ini @@ -40,6 +40,7 @@ deps = commands = flake8 [testenv:docs] +# keep in sync with .readthedocs.yml basepython = python3.8 deps = -rrequirements/requirements-optionals.txt From ad5a793b54ca2922223352a58830515d25fa4807 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 22 Feb 2023 20:20:32 +0400 Subject: [PATCH 401/488] Replaced OrderedDict with dict (#1132) * try using python dict instead of ordered dict in metadata * try using python dict instead of ordered dict in pagination field * try using python dict instead of ordered dict in relations * Adjusted to dict syntax * Removed OrderedDict from renderers.py * Removed OrderedDict in serializers, utils and views * Simplified creating of dicts in renderers.py * Added CHANGELOG entry --------- Co-authored-by: Asif Saif Uddin --- CHANGELOG.md | 1 + rest_framework_json_api/metadata.py | 16 ++---- rest_framework_json_api/pagination.py | 53 +++++++---------- rest_framework_json_api/relations.py | 15 ++--- rest_framework_json_api/renderers.py | 80 ++++++++++---------------- rest_framework_json_api/serializers.py | 5 +- rest_framework_json_api/utils.py | 7 +-- rest_framework_json_api/views.py | 7 +-- tests/test_pagination.py | 34 ++++------- 9 files changed, 83 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce154cbb..7a1c3444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ any parts of the framework not mentioned in the documentation should generally b * Added support to overwrite serializer methods in customized schema class * Adjusted some still old formatted strings to f-strings. +* Replaced `OrderedDict` with `dict` which is also ordered since Python 3.7. ### Fixed diff --git a/rest_framework_json_api/metadata.py b/rest_framework_json_api/metadata.py index 14b9f69b..c7f7b7b4 100644 --- a/rest_framework_json_api/metadata.py +++ b/rest_framework_json_api/metadata.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.db.models.fields import related from django.utils.encoding import force_str from rest_framework import serializers @@ -65,7 +63,7 @@ class JSONAPIMetadata(SimpleMetadata): ) def determine_metadata(self, request, view): - metadata = OrderedDict() + metadata = {} metadata["name"] = view.get_view_name() metadata["description"] = view.get_view_description() metadata["renders"] = [ @@ -92,19 +90,17 @@ def get_serializer_info(self, serializer): # Remove the URL field if present serializer.fields.pop(api_settings.URL_FIELD_NAME, None) - return OrderedDict( - [ - (format_field_name(field_name), self.get_field_info(field)) - for field_name, field in serializer.fields.items() - ] - ) + return { + format_field_name(field_name): self.get_field_info(field) + for field_name, field in serializer.fields.items() + } def get_field_info(self, field): """ Given an instance of a serializer field, return a dictionary of metadata about it. """ - field_info = OrderedDict() + field_info = {} serializer = field.parent if isinstance(field, serializers.ManyRelatedField): diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index 6cbd744c..0c0d3bb9 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -1,7 +1,6 @@ """ Pagination fields """ -from collections import OrderedDict from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination from rest_framework.utils.urls import remove_query_param, replace_query_param @@ -36,22 +35,18 @@ def get_paginated_response(self, data): { "results": data, "meta": { - "pagination": OrderedDict( - [ - ("page", self.page.number), - ("pages", self.page.paginator.num_pages), - ("count", self.page.paginator.count), - ] - ) + "pagination": { + "page": self.page.number, + "pages": self.page.paginator.num_pages, + "count": self.page.paginator.count, + } + }, + "links": { + "first": self.build_link(1), + "last": self.build_link(self.page.paginator.num_pages), + "next": self.build_link(next), + "prev": self.build_link(previous), }, - "links": OrderedDict( - [ - ("first", self.build_link(1)), - ("last", self.build_link(self.page.paginator.num_pages)), - ("next", self.build_link(next)), - ("prev", self.build_link(previous)), - ] - ), } ) @@ -97,21 +92,17 @@ def get_paginated_response(self, data): { "results": data, "meta": { - "pagination": OrderedDict( - [ - ("count", self.count), - ("limit", self.limit), - ("offset", self.offset), - ] - ) + "pagination": { + "count": self.count, + "limit": self.limit, + "offset": self.offset, + } + }, + "links": { + "first": self.get_first_link(), + "last": self.get_last_link(), + "next": self.get_next_link(), + "prev": self.get_previous_link(), }, - "links": OrderedDict( - [ - ("first", self.get_first_link()), - ("last", self.get_last_link()), - ("next", self.get_next_link()), - ("prev", self.get_previous_link()), - ] - ), } ) diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 6eb69bdb..bb360ebb 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -1,5 +1,4 @@ import json -from collections import OrderedDict import inflection from django.core.exceptions import ImproperlyConfigured @@ -104,7 +103,7 @@ def get_url(self, name, view_name, kwargs, request): def get_links(self, obj=None, lookup_field="pk"): request = self.context.get("request", None) view = self.context.get("view", None) - return_data = OrderedDict() + return_data = {} kwargs = { lookup_field: getattr(obj, lookup_field) @@ -257,7 +256,7 @@ def to_representation(self, value): if resource_type is None or not self._skip_polymorphic_optimization: resource_type = get_resource_type_from_instance(value) - return OrderedDict([("type", resource_type), ("id", str(pk))]) + return {"type": resource_type, "id": str(pk)} def get_resource_type_from_included_serializer(self): """ @@ -301,12 +300,10 @@ def get_choices(self, cutoff=None): if cutoff is not None: queryset = queryset[:cutoff] - return OrderedDict( - [ - (json.dumps(self.to_representation(item)), self.display_value(item)) - for item in queryset - ] - ) + return { + json.dumps(self.to_representation(item)): self.display_value(item) + for item in queryset + } class PolymorphicResourceRelatedField(ResourceRelatedField): diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 0540a39f..7263b96b 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -2,13 +2,13 @@ Renderers """ import copy -from collections import OrderedDict, defaultdict +from collections import defaultdict from collections.abc import Iterable import inflection from django.db.models import Manager from django.template import loader -from django.utils import encoding +from django.utils.encoding import force_str from rest_framework import relations, renderers from rest_framework.fields import SkipField, get_attribute from rest_framework.relations import PKOnlyObject @@ -56,7 +56,7 @@ def extract_attributes(cls, fields, resource): """ Builds the `attributes` object of the JSON:API resource object. """ - data = OrderedDict() + data = {} for field_name, field in iter(fields.items()): # ID is always provided in the root of JSON:API so remove it from attributes if field_name == "id": @@ -89,7 +89,7 @@ def extract_relationships(cls, fields, resource, resource_instance): # Avoid circular deps from rest_framework_json_api.relations import ResourceRelatedField - data = OrderedDict() + data = {} # Don't try to extract relationships from a non-existent resource if resource_instance is None: @@ -125,16 +125,10 @@ def extract_relationships(cls, fields, resource, resource_instance): relation_instance if relation_instance is not None else list() ) - for related_object in relation_queryset: - relation_data.append( - OrderedDict( - [ - ("type", relation_type), - ("id", encoding.force_str(related_object.pk)), - ] - ) - ) - + relation_data = [ + {"type": relation_type, "id": force_str(related_object.pk)} + for related_object in relation_queryset + ] data.update( { field_name: { @@ -171,18 +165,12 @@ def extract_relationships(cls, fields, resource, resource_instance): if not resolved: continue relation_id = relation if resource.get(field_name) else None - relation_data = { - "data": ( - OrderedDict( - [ - ("type", relation_type), - ("id", encoding.force_str(relation_id)), - ] - ) - if relation_id is not None - else None - ) - } + relation_data = {"data": None} + if relation_id is not None: + relation_data["data"] = { + "type": relation_type, + "id": force_str(relation_id), + } if isinstance( field, relations.HyperlinkedRelatedField @@ -233,12 +221,10 @@ def extract_relationships(cls, fields, resource, resource_instance): ) relation_data.append( - OrderedDict( - [ - ("type", nested_resource_instance_type), - ("id", encoding.force_str(nested_resource_instance.pk)), - ] - ) + { + "type": nested_resource_instance_type, + "id": force_str(nested_resource_instance.pk), + } ) data.update( { @@ -419,7 +405,7 @@ def extract_meta(cls, serializer, resource): else: meta = getattr(serializer, "Meta", None) meta_fields = getattr(meta, "meta_fields", []) - data = OrderedDict() + data = {} for field_name in meta_fields: data.update({field_name: resource.get(field_name)}) return data @@ -457,37 +443,33 @@ def build_json_resource_obj( # Determine type from the instance if the underlying model is polymorphic if force_type_resolution: resource_name = utils.get_resource_type_from_instance(resource_instance) - resource_data = [ - ("type", resource_name), - ( - "id", - encoding.force_str(resource_instance.pk) if resource_instance else None, - ), - ("attributes", cls.extract_attributes(fields, resource)), - ] + resource_id = force_str(resource_instance.pk) if resource_instance else None + resource_data = { + "type": resource_name, + "id": resource_id, + "attributes": cls.extract_attributes(fields, resource), + } relationships = cls.extract_relationships(fields, resource, resource_instance) if relationships: - resource_data.append(("relationships", relationships)) + resource_data["relationships"] = relationships # Add 'self' link if field is present and valid if api_settings.URL_FIELD_NAME in resource and isinstance( fields[api_settings.URL_FIELD_NAME], relations.RelatedField ): - resource_data.append( - ("links", {"self": resource[api_settings.URL_FIELD_NAME]}) - ) + resource_data["links"] = {"self": resource[api_settings.URL_FIELD_NAME]} meta = cls.extract_meta(serializer, resource) if meta: - resource_data.append(("meta", utils.format_field_names(meta))) + resource_data["meta"] = utils.format_field_names(meta) - return OrderedDict(resource_data) + return resource_data def render_relationship_view( self, data, accepted_media_type=None, renderer_context=None ): # Special case for RelationshipView view = renderer_context.get("view", None) - render_data = OrderedDict([("data", data)]) + render_data = {"data": data} links = view.get_links() if links: render_data.update({"links": links}), @@ -615,7 +597,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): ) # Make sure we render data in a specific order - render_data = OrderedDict() + render_data = {} if isinstance(data, dict) and data.get("links"): render_data["links"] = data.get("links") diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 91f86464..a288471e 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,4 +1,3 @@ -from collections import OrderedDict from collections.abc import Mapping import inflection @@ -95,7 +94,7 @@ def __init__(self, *args, **kwargs): pass else: fieldset = request.query_params.get(param_name).split(",") - # iterate over a *copy* of self.fields' underlying OrderedDict, because we may + # iterate over a *copy* of self.fields' underlying dict, because we may # modify the original during the iteration. # self.fields is a `rest_framework.utils.serializer_helpers.BindingDict` for field_name, _field in self.fields.fields.copy().items(): @@ -305,7 +304,7 @@ def get_field_names(self, declared_fields, info): """ meta_fields = getattr(self.Meta, "meta_fields", []) - declared = OrderedDict() + declared = {} for field_name in set(declared_fields.keys()): field = declared_fields[field_name] if field_name not in meta_fields: diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index b7f2f9a0..3d374eed 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -1,6 +1,5 @@ import inspect import operator -from collections import OrderedDict import inflection from django.conf import settings @@ -107,11 +106,7 @@ def format_field_names(obj, format_type=None): format_type = json_api_settings.FORMAT_FIELD_NAMES if isinstance(obj, dict): - formatted = OrderedDict() - for key, value in obj.items(): - key = format_value(key, format_type) - formatted[key] = value - return formatted + return {format_value(key, format_type): value for key, value in obj.items()} return obj diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 0b3df693..90bb5574 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -23,7 +23,6 @@ from rest_framework_json_api.serializers import ResourceIdentifierObjectSerializer from rest_framework_json_api.utils import ( Hyperlink, - OrderedDict, get_included_resources, get_resource_type_from_instance, undo_format_link_segment, @@ -275,7 +274,7 @@ def get_url(self, name, view_name, kwargs, request): return Hyperlink(url, name) def get_links(self): - return_data = OrderedDict() + return_data = {} self_link = self.get_url( "self", self.self_link_view_name, self.kwargs, self.request ) @@ -284,9 +283,9 @@ def get_links(self): "related", self.related_link_view_name, related_kwargs, self.request ) if self_link: - return_data.update({"self": self_link}) + return_data["self"] = self_link if related_link: - return_data.update({"related": related_link}) + return_data["related"] = related_link return return_data def get(self, request, *args, **kwargs): diff --git a/tests/test_pagination.py b/tests/test_pagination.py index c09ac71c..10f0ebbf 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from rest_framework.request import Request from rest_framework_json_api.pagination import JsonApiLimitOffsetPagination @@ -27,28 +25,18 @@ def test_get_paginated_response(self, rf): expected_content = { "results": list(range(11, 16)), - "links": OrderedDict( - [ - ("first", "http://testserver/?page%5Blimit%5D=5"), - ( - "last", - "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=100", - ), - ( - "next", - "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=15", - ), - ("prev", "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=5"), - ] - ), + "links": { + "first": "http://testserver/?page%5Blimit%5D=5", + "last": "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=100", + "next": "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=15", + "prev": "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=5", + }, "meta": { - "pagination": OrderedDict( - [ - ("count", count), - ("limit", limit), - ("offset", offset), - ] - ) + "pagination": { + "count": count, + "limit": limit, + "offset": offset, + } }, } From 2e931e0e10d169c68814586d58242f7b123c5d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Fri, 10 Mar 2023 08:23:42 +0200 Subject: [PATCH 402/488] Use /data as the pointer for non-field serializer errors (#1137) --- AUTHORS | 1 + CHANGELOG.md | 1 + example/api/serializers/identity.py | 7 ++++++ example/tests/test_generic_viewset.py | 32 ++++++++++++++++++++++++++ rest_framework_json_api/serializers.py | 7 +++++- rest_framework_json_api/utils.py | 9 ++++++-- 6 files changed, 54 insertions(+), 3 deletions(-) diff --git a/AUTHORS b/AUTHORS index e27ba5f5..797ab52f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -3,6 +3,7 @@ Adam Ziolkowski Alan Crosswell Alex Seidmann Anton Shutik +Arttu Perälä Ashley Loewen Asif Saif Uddin Beni Keller diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1c3444..3f9c175e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated schema definition +* Non-field serializer errors are given a source.pointer value of "/data". ## [6.0.0] - 2022-09-24 diff --git a/example/api/serializers/identity.py b/example/api/serializers/identity.py index 069259d2..5e2a42e3 100644 --- a/example/api/serializers/identity.py +++ b/example/api/serializers/identity.py @@ -23,6 +23,13 @@ def validate_last_name(self, data): ) return data + def validate(self, data): + if data["first_name"] == data["last_name"]: + raise serializers.ValidationError( + "First name cannot be the same as last name!" + ) + return data + class Meta: model = auth_models.User fields = ( diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index c8a28f24..40812d6e 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -129,3 +129,35 @@ def test_custom_validation_exceptions(self): ) assert expected == response.json() + + def test_nonfield_validation_exceptions(self): + """ + Non-field errors should be attributed to /data source.pointer. + """ + expected = { + "errors": [ + { + "status": "400", + "source": { + "pointer": "/data", + }, + "detail": "First name cannot be the same as last name!", + "code": "invalid", + }, + ] + } + response = self.client.post( + "/identities", + { + "data": { + "type": "users", + "attributes": { + "email": "miles@example.com", + "first_name": "Miles", + "last_name": "Miles", + }, + } + }, + ) + + assert expected == response.json() diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index a288471e..4b619ac4 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -155,7 +155,12 @@ def validate_path(serializer_class, field_path, path): class ReservedFieldNamesMixin: """Ensures that reserved field names are not used and an error raised instead.""" - _reserved_field_names = {"meta", "results", "type"} + _reserved_field_names = { + "meta", + "results", + "type", + api_settings.NON_FIELD_ERRORS_KEY, + } def get_fields(self): fields = super().get_fields() diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 3d374eed..dab8a3bb 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -13,6 +13,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions, relations from rest_framework.exceptions import APIException +from rest_framework.settings import api_settings from .settings import json_api_settings @@ -381,10 +382,14 @@ def format_drf_errors(response, context, exc): ] for field, error in response.data.items(): + non_field_error = field == api_settings.NON_FIELD_ERRORS_KEY field = format_field_name(field) pointer = None - # pointer can be determined only if there's a serializer. - if has_serializer: + if non_field_error: + # Serializer error does not refer to a specific field. + pointer = "/data" + elif has_serializer: + # pointer can be determined only if there's a serializer. rel = "relationships" if field in relationship_fields else "attributes" pointer = f"/data/{rel}/{field}" if isinstance(exc, Http404) and isinstance(error, str): From 3b9661ec29d893cb2858c94d7ff54a14ca4c9034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Mon, 13 Mar 2023 15:42:58 +0200 Subject: [PATCH 403/488] Revert SchemaGenerator description value from tuple to str (#1138) --- example/tests/__snapshots__/test_openapi.ambr | 545 ++++++++++++++++++ example/tests/test_openapi.py | 7 +- rest_framework_json_api/schemas/openapi.py | 2 +- 3 files changed, 548 insertions(+), 6 deletions(-) diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 713387d3..fd270b40 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -706,3 +706,548 @@ } ''' # --- +# name: test_schema_construction + ''' + { + "components": { + "parameters": { + "fields": { + "description": "[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets).\nUse fields[\\]=field1,field2,...,fieldN", + "explode": true, + "in": "query", + "name": "fields", + "required": false, + "schema": { + "type": "object" + }, + "style": "deepObject" + }, + "include": { + "description": "[list of included related resources](https://jsonapi.org/format/#fetching-includes)", + "in": "query", + "name": "include", + "required": false, + "schema": { + "type": "string" + }, + "style": "form" + } + }, + "schemas": { + "AuthorList": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "defaults": { + "default": "default", + "description": "help for defaults", + "maxLength": 20, + "minLength": 3, + "type": "string", + "writeOnly": true + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "fullName": { + "maxLength": 50, + "type": "string" + }, + "initials": { + "readOnly": true, + "type": "string" + }, + "name": { + "maxLength": 50, + "type": "string" + } + }, + "required": [ + "name", + "fullName", + "email" + ], + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "properties": { + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationships": { + "properties": { + "authorType": { + "$ref": "#/components/schemas/reltoone" + }, + "bio": { + "$ref": "#/components/schemas/reltoone" + }, + "comments": { + "$ref": "#/components/schemas/reltomany" + }, + "entries": { + "$ref": "#/components/schemas/reltomany" + }, + "firstEntry": { + "$ref": "#/components/schemas/reltoone" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + }, + "ResourceIdentifierObject": { + "oneOf": [ + { + "$ref": "#/components/schemas/relationshipToOne" + }, + { + "$ref": "#/components/schemas/relationshipToMany" + } + ] + }, + "datum": { + "description": "singular item", + "properties": { + "data": { + "$ref": "#/components/schemas/resource" + } + } + }, + "error": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "id": { + "type": "string" + }, + "links": { + "$ref": "#/components/schemas/links" + }, + "source": { + "properties": { + "meta": { + "$ref": "#/components/schemas/meta" + }, + "parameter": { + "description": "A string indicating which query parameter caused the error.", + "type": "string" + }, + "pointer": { + "description": "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) to the associated entity in the request document [e.g. `/data` for a primary data object, or `/data/attributes/title` for a specific attribute.", + "type": "string" + } + }, + "type": "object" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, + "errors": { + "items": { + "$ref": "#/components/schemas/error" + }, + "type": "array", + "uniqueItems": true + }, + "failure": { + "properties": { + "errors": { + "$ref": "#/components/schemas/errors" + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "$ref": "#/components/schemas/links" + }, + "meta": { + "$ref": "#/components/schemas/meta" + } + }, + "required": [ + "errors" + ], + "type": "object" + }, + "id": { + "description": "Each resource object\u2019s type and id pair MUST [identify](https://jsonapi.org/format/#document-resource-object-identification) a single, unique resource.", + "type": "string" + }, + "jsonapi": { + "additionalProperties": false, + "description": "The server's implementation", + "properties": { + "meta": { + "$ref": "#/components/schemas/meta" + }, + "version": { + "type": "string" + } + }, + "type": "object" + }, + "link": { + "oneOf": [ + { + "description": "a string containing the link's URL", + "format": "uri-reference", + "type": "string" + }, + { + "properties": { + "href": { + "description": "a string containing the link's URL", + "format": "uri-reference", + "type": "string" + }, + "meta": { + "$ref": "#/components/schemas/meta" + } + }, + "required": [ + "href" + ], + "type": "object" + } + ] + }, + "linkage": { + "description": "the 'type' and 'id'", + "properties": { + "id": { + "$ref": "#/components/schemas/id" + }, + "meta": { + "$ref": "#/components/schemas/meta" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + }, + "links": { + "additionalProperties": { + "$ref": "#/components/schemas/link" + }, + "type": "object" + }, + "meta": { + "additionalProperties": true, + "type": "object" + }, + "nulltype": { + "default": null, + "nullable": true, + "type": "object" + }, + "onlymeta": { + "additionalProperties": false, + "properties": { + "meta": { + "$ref": "#/components/schemas/meta" + } + } + }, + "pageref": { + "oneOf": [ + { + "format": "uri-reference", + "type": "string" + }, + { + "$ref": "#/components/schemas/nulltype" + } + ] + }, + "pagination": { + "properties": { + "first": { + "$ref": "#/components/schemas/pageref" + }, + "last": { + "$ref": "#/components/schemas/pageref" + }, + "next": { + "$ref": "#/components/schemas/pageref" + }, + "prev": { + "$ref": "#/components/schemas/pageref" + } + }, + "type": "object" + }, + "relationshipLinks": { + "additionalProperties": true, + "description": "optional references to other resource objects", + "properties": { + "related": { + "$ref": "#/components/schemas/link" + }, + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationshipToMany": { + "description": "An array of objects each containing the 'type' and 'id' for to-many relationships", + "items": { + "$ref": "#/components/schemas/linkage" + }, + "type": "array", + "uniqueItems": true + }, + "relationshipToOne": { + "anyOf": [ + { + "$ref": "#/components/schemas/nulltype" + }, + { + "$ref": "#/components/schemas/linkage" + } + ], + "description": "reference to other resource in a to-one relationship" + }, + "reltomany": { + "description": "a multiple 'to-many' relationship", + "properties": { + "data": { + "$ref": "#/components/schemas/relationshipToMany" + }, + "links": { + "$ref": "#/components/schemas/relationshipLinks" + }, + "meta": { + "$ref": "#/components/schemas/meta" + } + }, + "type": "object" + }, + "reltoone": { + "description": "a singular 'to-one' relationship", + "properties": { + "data": { + "$ref": "#/components/schemas/relationshipToOne" + }, + "links": { + "$ref": "#/components/schemas/relationshipLinks" + }, + "meta": { + "$ref": "#/components/schemas/meta" + } + }, + "type": "object" + }, + "resource": { + "additionalProperties": false, + "properties": { + "attributes": { + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "$ref": "#/components/schemas/links" + }, + "meta": { + "$ref": "#/components/schemas/meta" + }, + "relationships": { + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + }, + "type": { + "description": "The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships.", + "type": "string" + } + } + }, + "info": { + "title": "", + "version": "" + }, + "openapi": "3.0.2", + "paths": { + "/authors/": { + "get": { + "description": "", + "operationId": "List/authors/", + "parameters": [ + { + "$ref": "#/components/parameters/include" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "description": "A page number within the paginated result set.", + "in": "query", + "name": "page[number]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Number of results to return per page.", + "in": "query", + "name": "page[size]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "author_type", + "in": "query", + "name": "filter[authorType]", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "name", + "in": "query", + "name": "filter[name]", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search term.", + "in": "query", + "name": "filter[search]", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AuthorList" + }, + "type": "array" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "List/authors/" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not found" + } + }, + "tags": [ + "authors" + ] + } + } + } + } + ''' +# --- diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 5b881f8b..69cfbccc 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -97,7 +97,7 @@ def test_delete_request(snapshot): "DEFAULT_SCHEMA_CLASS": "rest_framework_json_api.schemas.openapi.AutoSchema" } ) -def test_schema_construction(): +def test_schema_construction(snapshot): """Construction of the top level dictionary.""" patterns = [ re_path("^authors/?$", views.AuthorViewSet.as_view({"get": "list"})), @@ -107,10 +107,7 @@ def test_schema_construction(): request = create_request("/") schema = generator.get_schema(request=request) - assert "openapi" in schema - assert "info" in schema - assert "paths" in schema - assert "components" in schema + assert snapshot == json.dumps(schema, indent=2, sort_keys=True) def test_schema_related_serializers(): diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 7cb7d9ca..e3c35a37 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -185,7 +185,7 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " "to the associated entity in the request document " "[e.g. `/data` for a primary data object, or " - "`/data/attributes/title` for a specific attribute.", + "`/data/attributes/title` for a specific attribute." ), }, "parameter": { From 1182281750e92777e37fd3701d35085943125b24 Mon Sep 17 00:00:00 2001 From: Jeppe Fihl-Pearson Date: Tue, 4 Apr 2023 15:47:23 +0100 Subject: [PATCH 404/488] Add support for Django 4.2 (#1142) * Add support for Django 4.2 * Don't use deprecated password hashing implementation The tests run pretty quickly regardless, so it should be okay to use the default password hasher. * Reinstate tests for Django 4.1 --- CHANGELOG.md | 1 + README.rst | 2 +- docs/getting-started.md | 2 +- example/settings/dev.py | 2 -- setup.py | 2 +- tox.ini | 5 +++++ 6 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9c175e..e7243f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Python 3.11. +* Added support for Django 4.2. ### Changed diff --git a/README.rst b/README.rst index acc50976..82c2b08f 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.7, 3.8, 3.9, 3.10, 3.11) -2. Django (3.2, 4.0, 4.1) +2. Django (3.2, 4.0, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index b73af5ea..07798fae 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.7, 3.8, 3.9, 3.10, 3.11) -2. Django (3.2, 4.0, 4.1) +2. Django (3.2, 4.0, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/example/settings/dev.py b/example/settings/dev.py index c5405338..7b40e61f 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -60,8 +60,6 @@ SECRET_KEY = "abc123" -PASSWORD_HASHERS = ("django.contrib.auth.hashers.UnsaltedMD5PasswordHasher",) - INTERNAL_IPS = ("127.0.0.1",) JSON_API_FORMAT_FIELD_NAMES = "camelize" diff --git a/setup.py b/setup.py index 3c60bba2..6ebee0d5 100755 --- a/setup.py +++ b/setup.py @@ -98,7 +98,7 @@ def get_package_data(package): install_requires=[ "inflection>=0.5.0", "djangorestframework>=3.13,<3.15", - "django>=3.2,<4.2", + "django>=3.2,<4.3", ], extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], diff --git a/tox.ini b/tox.ini index 0879e756..ede6f27f 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ envlist = py{37,38,39,310}-django32-drf{313,314,master}, py{38,39,310}-django40-drf{313,314,master}, py{38,39,310,311}-django41-drf{314,master}, + py{38,39,310,311}-django42-drf{314,master}, black, docs, lint @@ -12,6 +13,7 @@ deps = django32: Django>=3.2,<3.3 django40: Django>=4.0,<4.1 django41: Django>=4.1,<4.2 + django42: Django>=4.2,<4.3 drf313: djangorestframework>=3.13,<3.14 drf314: djangorestframework>=3.14,<3.15 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip @@ -56,3 +58,6 @@ ignore_outcome = true [testenv:py{38,39,310,311}-django41-drfmaster] ignore_outcome = true + +[testenv:py{38,39,310,311}-django42-drfmaster] +ignore_outcome = true From b6311415cf8c7150a37aaae61983f7cca099f632 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 21 Apr 2023 06:04:22 -0500 Subject: [PATCH 405/488] Scheduled biweekly dependency update for week 16 (#1143) * Update black from 23.1.0 to 23.3.0 * Update flake8-bugbear from 23.2.13 to 23.3.23 * Update django-filter from 22.1 to 23.1 * Update faker from 17.0.0 to 18.4.0 * Update pytest from 7.2.1 to 7.3.1 * Update syrupy from 3.0.6 to 4.0.1 * Revert to previous syrupy which supports 3.7 --------- Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 4092f80f..8eba9fae 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==23.1.0 +black==23.3.0 flake8==6.0.0 -flake8-bugbear==23.2.13 +flake8-bugbear==23.3.23 flake8-isort==6.0.0 isort==5.12.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 9d274a6d..b8a8b54d 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==22.1 +django-filter==23.1 django-polymorphic==3.1.0 pyyaml==6.0 uritemplate==4.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 69371227..7fac28a4 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ factory-boy==3.2.1 -Faker==17.0.0 -pytest==7.2.1 +Faker==18.4.0 +pytest==7.3.1 pytest-cov==4.0.0 pytest-django==4.5.2 pytest-factoryboy==2.5.1 From 7cab679b5db4e497d01e7555d50c398c6b376bbe Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 25 Apr 2023 20:35:06 +0400 Subject: [PATCH 406/488] Ignored pkg_resources deprecation warnings (#1146) This is to fix failing CI. Should actually be fixed in django-polymorphic where pkg_resources is used. --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index c9d88f15..d3ea5ebc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -65,6 +65,9 @@ filterwarnings = error::PendingDeprecationWarning # Remove when DRF is not depending on it anymore ignore:The django.utils.timezone.utc alias is deprecated. + # can be removed once fixed in django polymorphic + ignore:pkg_resources is deprecated as an API + ignore:Deprecated call to `pkg_resource testpaths = example tests From ac8ffe22f0bcc639e9f128ede15e59bf95e5e081 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 22 May 2023 22:12:00 +0400 Subject: [PATCH 407/488] Removed upper bounds of Django and DRF (#1152) We only set upper bounds in cases a release is actually breaking. --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 6ebee0d5..7d01a919 100755 --- a/setup.py +++ b/setup.py @@ -97,8 +97,8 @@ def get_package_data(package): ], install_requires=[ "inflection>=0.5.0", - "djangorestframework>=3.13,<3.15", - "django>=3.2,<4.3", + "djangorestframework>=3.13", + "django>=3.2", ], extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], From a7c1a00ad97acd7d3476a8da536a549a5c4d3127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Mon, 12 Jun 2023 17:02:00 +0300 Subject: [PATCH 408/488] Omit include parameter from OpenAPI schema when left unused (#1157) --- CHANGELOG.md | 2 ++ example/tests/test_openapi.py | 16 ++++++++++++++++ rest_framework_json_api/schemas/openapi.py | 10 +++++++--- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7243f3f..214f73b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ any parts of the framework not mentioned in the documentation should generally b * Added support to overwrite serializer methods in customized schema class * Adjusted some still old formatted strings to f-strings. * Replaced `OrderedDict` with `dict` which is also ordered since Python 3.7. +* Compound document "include" parameter is only included in the OpenAPI schema if serializer + implements `included_serializers`. ### Fixed diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 69cfbccc..a355540e 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -110,6 +110,22 @@ def test_schema_construction(snapshot): assert snapshot == json.dumps(schema, indent=2, sort_keys=True) +def test_schema_parameters_include(): + """Include paramater is only used when serializer defines included_serializers.""" + patterns = [ + re_path("^authors/?$", views.AuthorViewSet.as_view({"get": "list"})), + re_path("^project-types/?$", views.ProjectTypeViewset.as_view({"get": "list"})), + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request("/") + schema = generator.get_schema(request=request) + + include_ref = {"$ref": "#/components/parameters/include"} + assert include_ref in schema["paths"]["/authors/"]["get"]["parameters"] + assert include_ref not in schema["paths"]["/project-types/"]["get"]["parameters"] + + def test_schema_related_serializers(): """ Confirm that paths are generated for related fields. For example: diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index e3c35a37..f1f4f92e 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -406,11 +406,13 @@ def get_operation(self, path, method): operation["operationId"] = self.get_operation_id(path, method) operation["description"] = self.get_description(path, method) + serializer = self.get_response_serializer(path, method) + parameters = [] parameters += self.get_path_parameters(path, method) # pagination, filters only apply to GET/HEAD of collections and items if method in ["GET", "HEAD"]: - parameters += self._get_include_parameters(path, method) + parameters += self._get_include_parameters(path, method, serializer) parameters += self._get_fields_parameters(path, method) parameters += self.get_pagination_parameters(path, method) parameters += self.get_filter_parameters(path, method) @@ -448,11 +450,13 @@ def get_operation_id(self, path, method): action = self.method_mapping[method.lower()] return action + path - def _get_include_parameters(self, path, method): + def _get_include_parameters(self, path, method, serializer): """ includes parameter: https://jsonapi.org/format/#fetching-includes """ - return [{"$ref": "#/components/parameters/include"}] + if getattr(serializer, "included_serializers", {}): + return [{"$ref": "#/components/parameters/include"}] + return [] def _get_fields_parameters(self, path, method): """ From 66d2de49e5b3a876cc0847f9efa183fcb4181cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Tue, 13 Jun 2023 08:56:33 +0300 Subject: [PATCH 409/488] Omit id field from OpenAPI schema attributes (#1156) --- CHANGELOG.md | 1 + example/tests/test_openapi.py | 15 +++++++++++++++ rest_framework_json_api/schemas/openapi.py | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 214f73b5..50e8406c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ any parts of the framework not mentioned in the documentation should generally b * Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated schema definition * Non-field serializer errors are given a source.pointer value of "/data". +* Fixed "id" field being added to /data/attributes in the OpenAPI schema when it is not rendered there. ## [6.0.0] - 2022-09-24 diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index a355540e..a2fa6f45 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -110,6 +110,21 @@ def test_schema_construction(snapshot): assert snapshot == json.dumps(schema, indent=2, sort_keys=True) +def test_schema_id_field(): + """ID field is only included in the root, not the attributes.""" + patterns = [ + re_path("^companies/?$", views.CompanyViewset.as_view({"get": "list"})), + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request("/") + schema = generator.get_schema(request=request) + + company_properties = schema["components"]["schemas"]["Company"]["properties"] + assert company_properties["id"] == {"$ref": "#/components/schemas/id"} + assert "id" not in company_properties["attributes"]["properties"] + + def test_schema_parameters_include(): """Include paramater is only used when serializer defines included_serializers.""" patterns = [ diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index f1f4f92e..9ae8a355 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -670,6 +670,10 @@ def map_serializer(self, serializer): "$ref": "#/components/schemas/reltomany" } continue + if field.field_name == "id": + # ID is always provided in the root of JSON:API and removed from the + # attributes in JSONRenderer. + continue if field.required: required.append(format_field_name(field.field_name)) From 80eea77e5253d453ba1f6754beb3d5691eaad50a Mon Sep 17 00:00:00 2001 From: Jonathan Hiles Date: Wed, 14 Jun 2023 04:30:26 +1000 Subject: [PATCH 410/488] Allowed to overwrite resource id in serializer (#1127) Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 17 +++++++ docs/usage.md | 38 ++++++++++++++++ rest_framework_json_api/relations.py | 14 +++--- rest_framework_json_api/renderers.py | 3 +- rest_framework_json_api/utils.py | 13 ++++++ tests/test_relations.py | 25 ++++++++++- tests/test_utils.py | 17 +++++++ tests/test_views.py | 67 ++++++++++++++++++++++++++++ 9 files changed, 187 insertions(+), 8 deletions(-) diff --git a/AUTHORS b/AUTHORS index 797ab52f..fbd3fe6e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -20,6 +20,7 @@ Jeppe Fihl-Pearson Jerel Unruh Jonas Kiefer Jonas Metzener +Jonathan Hiles Jonathan Senecal Joseba Mendivil Kal diff --git a/CHANGELOG.md b/CHANGELOG.md index 50e8406c..972a3daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,23 @@ any parts of the framework not mentioned in the documentation should generally b * Replaced `OrderedDict` with `dict` which is also ordered since Python 3.7. * Compound document "include" parameter is only included in the OpenAPI schema if serializer implements `included_serializers`. +* Allowed overwriting of resource id by defining an `id` field on the serializer. + + Example: + ```python + class CustomIdSerializer(serializers.Serializer): + id = serializers.CharField(source='name') + body = serializers.CharField() + ``` + +* Allowed overwriting resource id on resource related fields by creating custom `ResourceRelatedField`. + + Example: + ```python + class CustomResourceRelatedField(relations.ResourceRelatedField): + def get_resource_id(self, value): + return value.name + ``` ### Fixed diff --git a/docs/usage.md b/docs/usage.md index 5da8846a..a7dadcce 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -278,6 +278,44 @@ class MyModelSerializer(serializers.ModelSerializer): # ... ``` +### Overwriting the resource object's id + +Per default the primary key property `pk` on the instance is used as the resource identifier. + +It is possible to overwrite the resource id by defining an `id` field on the serializer like: + +```python +class UserSerializer(serializers.ModelSerializer): + id = serializers.CharField(source='email') + name = serializers.CharField() + + class Meta: + model = User +``` + +This also works on generic serializers. + +In case you also use a model as a resource related field make sure to overwrite `get_resource_id` by creating a custom `ResourceRelatedField` class: + +```python +class UserResourceRelatedField(ResourceRelatedField): + def get_resource_id(self, value): + return value.email + +class GroupSerializer(serializers.ModelSerializer): + user = UserResourceRelatedField(queryset=User.objects) + name = serializers.CharField() + + class Meta: + model = Group +``` + +
+ Note: + When using different id than primary key, make sure that your view + manages it properly by overwriting `get_object`. +
+ ### Setting resource identifier object type You may manually set resource identifier object type by using `resource_name` property on views, serializers, or diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index bb360ebb..32547253 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -247,17 +247,21 @@ def to_internal_value(self, data): return super().to_internal_value(data["id"]) def to_representation(self, value): - if getattr(self, "pk_field", None) is not None: - pk = self.pk_field.to_representation(value.pk) - else: - pk = value.pk - + pk = self.get_resource_id(value) resource_type = self.get_resource_type_from_included_serializer() if resource_type is None or not self._skip_polymorphic_optimization: resource_type = get_resource_type_from_instance(value) return {"type": resource_type, "id": str(pk)} + def get_resource_id(self, value): + """ + Get resource id of related field. + + Per default pk of value is returned. + """ + return super().to_representation(value) + def get_resource_type_from_included_serializer(self): """ Check to see it this resource has a different resource_name when diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 7263b96b..f7660208 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -443,10 +443,9 @@ def build_json_resource_obj( # Determine type from the instance if the underlying model is polymorphic if force_type_resolution: resource_name = utils.get_resource_type_from_instance(resource_instance) - resource_id = force_str(resource_instance.pk) if resource_instance else None resource_data = { "type": resource_name, - "id": resource_id, + "id": utils.get_resource_id(resource_instance, resource), "attributes": cls.extract_attributes(fields, resource), } relationships = cls.extract_relationships(fields, resource, resource_instance) diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index dab8a3bb..2e57fbbd 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -304,6 +304,19 @@ def get_resource_type_from_serializer(serializer): ) +def get_resource_id(resource_instance, resource): + """Returns the resource identifier for a given instance (`id` takes priority over `pk`).""" + if resource and "id" in resource: + return resource["id"] and encoding.force_str(resource["id"]) or None + if resource_instance: + return ( + hasattr(resource_instance, "pk") + and encoding.force_str(resource_instance.pk) + or None + ) + return None + + def get_included_resources(request, serializer=None): """Build a list of included resources.""" include_resources_param = request.query_params.get("include") if request else None diff --git a/tests/test_relations.py b/tests/test_relations.py index 74721cfa..ad4bebcb 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -10,9 +10,10 @@ HyperlinkedRelatedField, SerializerMethodHyperlinkedRelatedField, ) +from rest_framework_json_api.serializers import ModelSerializer, ResourceRelatedField from rest_framework_json_api.utils import format_link_segment from rest_framework_json_api.views import RelationshipView -from tests.models import BasicModel +from tests.models import BasicModel, ForeignKeySource, ForeignKeyTarget from tests.serializers import ( ForeignKeySourceSerializer, ManyToManySourceReadOnlySerializer, @@ -46,6 +47,28 @@ def test_serialize( assert serializer.data["target"] == expected + def test_get_resource_id(self, foreign_key_target): + class CustomResourceRelatedField(ResourceRelatedField): + def get_resource_id(self, value): + return value.name + + class CustomPkFieldSerializer(ModelSerializer): + target = CustomResourceRelatedField( + queryset=ForeignKeyTarget.objects, pk_field="name" + ) + + class Meta: + model = ForeignKeySource + fields = ("target",) + + serializer = CustomPkFieldSerializer(instance={"target": foreign_key_target}) + expected = { + "type": "ForeignKeyTarget", + "id": "Target", + } + + assert serializer.data["target"] == expected + @pytest.mark.parametrize( "format_type,pluralize_type,resource_type", [ diff --git a/tests/test_utils.py b/tests/test_utils.py index 038e8ce9..f2a3d176 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -14,6 +14,7 @@ format_resource_type, format_value, get_related_resource_type, + get_resource_id, get_resource_name, get_resource_type_from_serializer, undo_format_field_name, @@ -392,6 +393,22 @@ class SerializerWithoutResourceName(serializers.Serializer): ) +@pytest.mark.parametrize( + "resource_instance, resource, expected", + [ + (None, None, None), + (object(), {}, None), + (BasicModel(id=5), None, "5"), + (BasicModel(id=9), {}, "9"), + (None, {"id": 11}, "11"), + (object(), {"pk": 11}, None), + (BasicModel(id=6), {"id": 11}, "11"), + ], +) +def test_get_resource_id(resource_instance, resource, expected): + assert get_resource_id(resource_instance, resource) == expected + + @pytest.mark.parametrize( "message,pointer,response,result", [ diff --git a/tests/test_views.py b/tests/test_views.py index 42680d6a..47fec02a 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -183,6 +183,50 @@ def test_patch(self, client): } } + @pytest.mark.urls(__name__) + def test_post_with_missing_id(self, client): + data = { + "data": { + "id": None, + "type": "custom", + "attributes": {"body": "hello"}, + } + } + + url = reverse("custom") + + response = client.post(url, data=data) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "custom", + "id": None, + "attributes": {"body": "hello"}, + } + } + + @pytest.mark.urls(__name__) + def test_patch_with_custom_id(self, client): + data = { + "data": { + "id": 2_193_102, + "type": "custom", + "attributes": {"body": "hello"}, + } + } + + url = reverse("custom-id") + + response = client.patch(url, data=data) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "custom", + "id": "2176ce", # get_id() -> hex + "attributes": {"body": "hello"}, + } + } + # Routing setup @@ -202,6 +246,14 @@ class CustomModelSerializer(serializers.Serializer): id = serializers.IntegerField() +class CustomIdModelSerializer(serializers.Serializer): + id = serializers.SerializerMethodField() + body = serializers.CharField() + + def get_id(self, obj): + return hex(obj.id)[2:] + + class CustomAPIView(APIView): parser_classes = [JSONParser] renderer_classes = [JSONRenderer] @@ -211,11 +263,26 @@ def patch(self, request, *args, **kwargs): serializer = CustomModelSerializer(CustomModel(request.data)) return Response(status=status.HTTP_200_OK, data=serializer.data) + def post(self, request, *args, **kwargs): + serializer = CustomModelSerializer(request.data) + return Response(status=status.HTTP_200_OK, data=serializer.data) + + +class CustomIdAPIView(APIView): + parser_classes = [JSONParser] + renderer_classes = [JSONRenderer] + resource_name = "custom" + + def patch(self, request, *args, **kwargs): + serializer = CustomIdModelSerializer(CustomModel(request.data)) + return Response(status=status.HTTP_200_OK, data=serializer.data) + router = SimpleRouter() router.register(r"basic_models", BasicModelViewSet, basename="basic-model") urlpatterns = [ path("custom", CustomAPIView.as_view(), name="custom"), + path("custom-id", CustomIdAPIView.as_view(), name="custom-id"), ] urlpatterns += router.urls From 57df2f1d663425bf84fba3de951355624569b26e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 20 Jun 2023 15:08:17 +0400 Subject: [PATCH 411/488] Added missing group type to custom query parameter regex (#1158) --- docs/usage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index a7dadcce..a50a97f7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -118,7 +118,7 @@ If you want to change the list of valid query parameters, override the `.query_r ```python # compiled regex that matches the allowed https://jsonapi.org/format/#query-parameters # `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s -query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') +query_regex = re.compile(r"^(sort|include)$|^(?Pfilter|fields|page)(\[[\w\.\-]+\])?$") ``` For example: ```python @@ -126,7 +126,7 @@ import re from rest_framework_json_api.filters import QueryParameterValidationFilter class MyQPValidator(QueryParameterValidationFilter): - query_regex = re.compile(r'^(sort|include|page|page_size)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') + query_regex = re.compile(r"^(sort|include|page|page_size)$|^(?Pfilter|fields|page)(\[[\w\.\-]+\])?$") ``` If you don't care if non-JSON:API query parameters are allowed (and potentially silently ignored), From 71508efa3fee8ff75c9cfcd092844801aeb407e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Wed, 28 Jun 2023 08:27:20 +0300 Subject: [PATCH 412/488] Fix many serializer method fields having the incorrect relation schema (#1161) --- CHANGELOG.md | 2 ++ example/tests/test_openapi.py | 22 ++++++++++++++++++++++ rest_framework_json_api/schemas/openapi.py | 15 +++++++++++---- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 972a3daf..d494fc2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ any parts of the framework not mentioned in the documentation should generally b * Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated schema definition * Non-field serializer errors are given a source.pointer value of "/data". * Fixed "id" field being added to /data/attributes in the OpenAPI schema when it is not rendered there. +* Fixed `SerializerMethodResourceRelatedField(many=True)` fields being given + a "reltoone" schema reference instead of "reltomany". ## [6.0.0] - 2022-09-24 diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index a2fa6f45..5710da2a 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -141,6 +141,28 @@ def test_schema_parameters_include(): assert include_ref not in schema["paths"]["/project-types/"]["get"]["parameters"] +def test_schema_serializer_method_resource_related_field(): + """SerializerMethodResourceRelatedField fieds have the correct relation ref.""" + patterns = [ + re_path("^entries/?$", views.EntryViewSet.as_view({"get": "list"})), + ] + generator = SchemaGenerator(patterns=patterns) + + request = Request(RequestFactory().get("/", {"include": "featured"})) + schema = generator.get_schema(request=request) + + entry_schema = schema["components"]["schemas"]["Entry"] + entry_relationships = entry_schema["properties"]["relationships"]["properties"] + + rel_to_many_ref = {"$ref": "#/components/schemas/reltomany"} + assert entry_relationships["suggested"] == rel_to_many_ref + assert entry_relationships["suggestedHyperlinked"] == rel_to_many_ref + + rel_to_one_ref = {"$ref": "#/components/schemas/reltoone"} + assert entry_relationships["featured"] == rel_to_one_ref + assert entry_relationships["featuredHyperlinked"] == rel_to_one_ref + + def test_schema_related_serializers(): """ Confirm that paths are generated for related fields. For example: diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 9ae8a355..4a0eeb83 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -8,6 +8,7 @@ from rest_framework_json_api import serializers, views from rest_framework_json_api.compat import get_reference +from rest_framework_json_api.relations import ManySerializerMethodResourceRelatedField from rest_framework_json_api.utils import format_field_name @@ -660,14 +661,20 @@ def map_serializer(self, serializer): continue if isinstance(field, serializers.HiddenField): continue - if isinstance(field, serializers.RelatedField): + if isinstance( + field, + ( + serializers.ManyRelatedField, + ManySerializerMethodResourceRelatedField, + ), + ): relationships[format_field_name(field.field_name)] = { - "$ref": "#/components/schemas/reltoone" + "$ref": "#/components/schemas/reltomany" } continue - if isinstance(field, serializers.ManyRelatedField): + if isinstance(field, serializers.RelatedField): relationships[format_field_name(field.field_name)] = { - "$ref": "#/components/schemas/reltomany" + "$ref": "#/components/schemas/reltoone" } continue if field.field_name == "id": From 311385e4b4f38bfc76203be8b7b3584ddc8cd080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Wed, 28 Jun 2023 21:19:12 +0300 Subject: [PATCH 413/488] Include meta section for SerializerMethodResourceRelatedField(many=True) (#1162) Fixes #572 --- CHANGELOG.md | 2 ++ example/tests/integration/test_non_paginated_responses.py | 2 ++ example/tests/integration/test_pagination.py | 1 + example/tests/test_filters.py | 1 + rest_framework_json_api/renderers.py | 6 ++++++ 5 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d494fc2a..a9dc65db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ any parts of the framework not mentioned in the documentation should generally b return value.name ``` +* `SerializerMethodResourceRelatedField(many=True)` relationship data now includes a meta section. + ### Fixed * Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated schema definition diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 60376a8b..92d26de3 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -51,6 +51,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "related": "http://testserver/entries/1/suggested/", "self": "http://testserver/entries/1/relationships/suggested", }, + "meta": {"count": 1}, }, "suggestedHyperlinked": { "links": { @@ -106,6 +107,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "related": "http://testserver/entries/2/suggested/", "self": "http://testserver/entries/2/relationships/suggested", }, + "meta": {"count": 1}, }, "suggestedHyperlinked": { "links": { diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 4c60e96e..1a4bd056 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -51,6 +51,7 @@ def test_pagination_with_single_entry(single_entry, client): "related": "http://testserver/entries/1/suggested/", "self": "http://testserver/entries/1/relationships/suggested", }, + "meta": {"count": 0}, }, "suggestedHyperlinked": { "links": { diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index ab74dd0b..87f9d059 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -512,6 +512,7 @@ def test_search_keywords(self): {"type": "entries", "id": "11"}, {"type": "entries", "id": "12"}, ], + "meta": {"count": 11}, }, "suggestedHyperlinked": { "links": { diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index f7660208..9a0d9c0c 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -19,6 +19,7 @@ from rest_framework_json_api import utils from rest_framework_json_api.relations import ( HyperlinkedMixin, + ManySerializerMethodResourceRelatedField, ResourceRelatedField, SkipDataMixin, ) @@ -152,6 +153,11 @@ def extract_relationships(cls, fields, resource, resource_instance): if not isinstance(field, SkipDataMixin): relation_data.update({"data": resource.get(field_name)}) + if isinstance(field, ManySerializerMethodResourceRelatedField): + relation_data.update( + {"meta": {"count": len(resource.get(field_name))}} + ) + data.update({field_name: relation_data}) continue From 590dbb478b34deb642ad32ed77fff7190c65bcce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Sat, 1 Jul 2023 08:52:23 +0300 Subject: [PATCH 414/488] Specify required relationship fields in OpenAPI schema (#1163) --- CHANGELOG.md | 1 + example/tests/__snapshots__/test_openapi.ambr | 10 ++++++++++ rest_framework_json_api/schemas/openapi.py | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9dc65db..a5f86d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ any parts of the framework not mentioned in the documentation should generally b ``` * `SerializerMethodResourceRelatedField(many=True)` relationship data now includes a meta section. +* Required relationship fields are now marked as required in the OpenAPI schema. ### Fixed diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index fd270b40..93b8f7fb 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -587,6 +587,11 @@ "$ref": "#/components/schemas/reltoone" } }, + "required": [ + "bio", + "entries", + "comments" + ], "type": "object" }, "type": { @@ -801,6 +806,11 @@ "$ref": "#/components/schemas/reltoone" } }, + "required": [ + "bio", + "entries", + "comments" + ], "type": "object" }, "type": { diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 4a0eeb83..a0d2be0d 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -631,6 +631,15 @@ def get_request_body(self, path, method): ): # noqa E501 if "readOnly" in schema: del item_schema["properties"]["attributes"]["properties"][name] + + if "properties" in item_schema and "relationships" in item_schema["properties"]: + # No required relationships for PATCH + if ( + method in ["PATCH", "PUT"] + and "required" in item_schema["properties"]["relationships"] + ): + del item_schema["properties"]["relationships"]["required"] + return { "content": { ct: { @@ -653,6 +662,7 @@ def map_serializer(self, serializer): # TODO: remove attributes, etc. for relationshipView?? required = [] attributes = {} + relationships_required = [] relationships = {} for field in serializer.fields.values(): @@ -668,11 +678,15 @@ def map_serializer(self, serializer): ManySerializerMethodResourceRelatedField, ), ): + if field.required: + relationships_required.append(format_field_name(field.field_name)) relationships[format_field_name(field.field_name)] = { "$ref": "#/components/schemas/reltomany" } continue if isinstance(field, serializers.RelatedField): + if field.required: + relationships_required.append(format_field_name(field.field_name)) relationships[format_field_name(field.field_name)] = { "$ref": "#/components/schemas/reltoone" } @@ -727,6 +741,10 @@ def map_serializer(self, serializer): "type": "object", "properties": relationships, } + if relationships_required: + result["properties"]["relationships"][ + "required" + ] = relationships_required return result def _add_async_response(self, operation): From 39a9c92274e795bc3318ca64a1aa7c7cdc71a5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Mon, 3 Jul 2023 17:07:29 +0300 Subject: [PATCH 415/488] Add HTTP 400 Bad Request as a possible generic error response (#1165) --- CHANGELOG.md | 1 + example/tests/__snapshots__/test_openapi.ambr | 60 +++++++++++++++++++ rest_framework_json_api/schemas/openapi.py | 1 + 3 files changed, 62 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5f86d1d..05ebecd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Python 3.11. * Added support for Django 4.2. +* Added `400 Bad Request` as a possible error response in the OpenAPI schema. ### Changed diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 93b8f7fb..28cb78f0 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -38,6 +38,16 @@ "204": { "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)" }, + "400": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "bad request" + }, "401": { "content": { "application/vnd.api+json": { @@ -204,6 +214,16 @@ }, "description": "update/authors/{id}" }, + "400": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "bad request" + }, "401": { "content": { "application/vnd.api+json": { @@ -349,6 +369,16 @@ }, "description": "retrieve/authors/{id}/" }, + "400": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "bad request" + }, "401": { "content": { "application/vnd.api+json": { @@ -486,6 +516,16 @@ }, "description": "List/authors/" }, + "400": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "bad request" + }, "401": { "content": { "application/vnd.api+json": { @@ -664,6 +704,16 @@ "204": { "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) with the supplied `id`. No other changes from what was POSTed." }, + "400": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "bad request" + }, "401": { "content": { "application/vnd.api+json": { @@ -1231,6 +1281,16 @@ }, "description": "List/authors/" }, + "400": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "bad request" + }, "401": { "content": { "application/vnd.api+json": { diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index a0d2be0d..9c1dd620 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -779,6 +779,7 @@ def _add_generic_failure_responses(self, operation): Add generic failure response(s) to operation """ for code, reason in [ + ("400", "bad request"), ("401", "not authorized"), ]: operation["responses"][code] = self._failure_response(reason) From 126cd9fa6fa56a62fb4514089073060cc20c5dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Wed, 5 Jul 2023 22:12:26 +0300 Subject: [PATCH 416/488] Add additionalProperties to includes' attributes and relationships (#1164) Create new schema for objects in the included array This allows documenting the "attributes" and "relationships" objects to possibly have additional properties. --- CHANGELOG.md | 2 + example/tests/__snapshots__/test_openapi.ambr | 40 ++++++++++++++++--- rest_framework_json_api/schemas/openapi.py | 23 ++++++++++- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ebecd8..82c32eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,8 @@ any parts of the framework not mentioned in the documentation should generally b * `SerializerMethodResourceRelatedField(many=True)` relationship data now includes a meta section. * Required relationship fields are now marked as required in the OpenAPI schema. +* Objects in the included array are documented in the OpenAPI schema to possibly have additional + properties in their "attributes" and "relationships" objects. ### Fixed diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 28cb78f0..18aec07f 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -185,7 +185,7 @@ }, "included": { "items": { - "$ref": "#/components/schemas/resource" + "$ref": "#/components/schemas/include" }, "type": "array", "uniqueItems": true @@ -340,7 +340,7 @@ }, "included": { "items": { - "$ref": "#/components/schemas/resource" + "$ref": "#/components/schemas/include" }, "type": "array", "uniqueItems": true @@ -487,7 +487,7 @@ }, "included": { "items": { - "$ref": "#/components/schemas/resource" + "$ref": "#/components/schemas/include" }, "type": "array", "uniqueItems": true @@ -662,7 +662,7 @@ }, "included": { "items": { - "$ref": "#/components/schemas/resource" + "$ref": "#/components/schemas/include" }, "type": "array", "uniqueItems": true @@ -962,6 +962,36 @@ "description": "Each resource object\u2019s type and id pair MUST [identify](https://jsonapi.org/format/#document-resource-object-identification) a single, unique resource.", "type": "string" }, + "include": { + "additionalProperties": false, + "properties": { + "attributes": { + "additionalProperties": true, + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "$ref": "#/components/schemas/links" + }, + "meta": { + "$ref": "#/components/schemas/meta" + }, + "relationships": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + }, "jsonapi": { "additionalProperties": false, "description": "The server's implementation", @@ -1252,7 +1282,7 @@ }, "included": { "items": { - "$ref": "#/components/schemas/resource" + "$ref": "#/components/schemas/include" }, "type": "array", "uniqueItems": true diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 9c1dd620..cc8ae233 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -49,6 +49,27 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): "meta": {"$ref": "#/components/schemas/meta"}, }, }, + "include": { + "type": "object", + "required": ["type", "id"], + "additionalProperties": False, + "properties": { + "type": {"$ref": "#/components/schemas/type"}, + "id": {"$ref": "#/components/schemas/id"}, + "attributes": { + "type": "object", + "additionalProperties": True, + # ... + }, + "relationships": { + "type": "object", + "additionalProperties": True, + # ... + }, + "links": {"$ref": "#/components/schemas/links"}, + "meta": {"$ref": "#/components/schemas/meta"}, + }, + }, "link": { "oneOf": [ { @@ -531,7 +552,7 @@ def _get_toplevel_200_response(self, operation, path, method, collection=True): "included": { "type": "array", "uniqueItems": True, - "items": {"$ref": "#/components/schemas/resource"}, + "items": {"$ref": "#/components/schemas/include"}, }, "links": { "description": "Link members related to primary data", From b7dd5851b9380e48598870c277f525c8e5d685f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Thu, 13 Jul 2023 18:37:53 +0300 Subject: [PATCH 417/488] Exclude callable field defaults from the OpenAPI schema (#1167) * Exclude callable field defaults from the OpenAPI schema * Organize callable default test in line with project standards --- CHANGELOG.md | 1 + rest_framework_json_api/schemas/openapi.py | 2 +- tests/schemas/test_openapi.py | 11 +++++++++++ tests/serializers.py | 19 +++++++++++++------ 4 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 tests/schemas/test_openapi.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c32eec..efbe93d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed "id" field being added to /data/attributes in the OpenAPI schema when it is not rendered there. * Fixed `SerializerMethodResourceRelatedField(many=True)` fields being given a "reltoone" schema reference instead of "reltomany". +* Callable field default values are excluded from the OpenAPI schema, as they don't resolve to YAML data types. ## [6.0.0] - 2022-09-24 diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index cc8ae233..52f08da6 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -727,7 +727,7 @@ def map_serializer(self, serializer): schema["writeOnly"] = True if field.allow_null: schema["nullable"] = True - if field.default and field.default != empty: + if field.default and field.default != empty and not callable(field.default): schema["default"] = field.default if field.help_text: # Ensure django gettext_lazy is rendered correctly diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py new file mode 100644 index 00000000..427f18fc --- /dev/null +++ b/tests/schemas/test_openapi.py @@ -0,0 +1,11 @@ +from rest_framework_json_api.schemas.openapi import AutoSchema +from tests.serializers import CallableDefaultSerializer + + +class TestAutoSchema: + def test_schema_callable_default(self): + inspector = AutoSchema() + result = inspector.map_serializer(CallableDefaultSerializer()) + assert result["properties"]["attributes"]["properties"]["field"] == { + "type": "string", + } diff --git a/tests/serializers.py b/tests/serializers.py index 8894a211..e0f7c03b 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -1,5 +1,5 @@ +from rest_framework_json_api import serializers from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.serializers import ModelSerializer from tests.models import ( BasicModel, ForeignKeySource, @@ -9,13 +9,13 @@ ) -class BasicModelSerializer(ModelSerializer): +class BasicModelSerializer(serializers.ModelSerializer): class Meta: fields = ("text",) model = BasicModel -class ForeignKeySourceSerializer(ModelSerializer): +class ForeignKeySourceSerializer(serializers.ModelSerializer): target = ResourceRelatedField(queryset=ForeignKeyTarget.objects) class Meta: @@ -23,7 +23,7 @@ class Meta: fields = ("target",) -class ManyToManySourceSerializer(ModelSerializer): +class ManyToManySourceSerializer(serializers.ModelSerializer): targets = ResourceRelatedField(many=True, queryset=ManyToManyTarget.objects) class Meta: @@ -31,14 +31,21 @@ class Meta: fields = ("targets",) -class ManyToManyTargetSerializer(ModelSerializer): +class ManyToManyTargetSerializer(serializers.ModelSerializer): class Meta: model = ManyToManyTarget -class ManyToManySourceReadOnlySerializer(ModelSerializer): +class ManyToManySourceReadOnlySerializer(serializers.ModelSerializer): targets = ResourceRelatedField(many=True, read_only=True) class Meta: model = ManyToManySource fields = ("targets",) + + +class CallableDefaultSerializer(serializers.Serializer): + field = serializers.CharField(default=serializers.CreateOnlyDefault("default")) + + class Meta: + fields = ("field",) From cd5f17970123400a2c5b98cfbb11f940d2cf09a9 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 25 Aug 2023 15:39:19 +0400 Subject: [PATCH 418/488] Release 6.1.0 (#1173) --- CHANGELOG.md | 4 ++-- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efbe93d2..243f13b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [6.1.0] - 2023-08-25 ### Added @@ -48,7 +48,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed -* Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated schema definition +* Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated OpenAPI schema definition * Non-field serializer errors are given a source.pointer value of "/data". * Fixed "id" field being added to /data/attributes in the OpenAPI schema when it is not rendered there. * Fixed `SerializerMethodResourceRelatedField(many=True)` fields being given diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index dd0ae050..96e6db9d 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "6.0.0" +__version__ = "6.1.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From ed5a999ace76d967f96731ccc799c42cdc72f426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Tue, 19 Sep 2023 08:10:35 +0300 Subject: [PATCH 419/488] Fix schema generation for nested serializers (#1177) * Fix Serializer schema generation when used as a ListField child * Fix Serializer schema generation when used in another serializer --- CHANGELOG.md | 6 +++ example/factories.py | 22 ++++++++++ example/migrations/0013_questionnaire.py | 28 +++++++++++++ .../migrations/0014_questionnaire_metadata.py | 18 +++++++++ example/models.py | 6 +++ example/serializers.py | 20 ++++++++++ example/tests/conftest.py | 2 + example/tests/test_openapi.py | 40 +++++++++++++++++++ example/tests/test_serializers.py | 33 +++++++++++++++ example/urls.py | 2 + example/urls_test.py | 2 + example/views.py | 7 ++++ rest_framework_json_api/schemas/openapi.py | 8 ++++ 13 files changed, 194 insertions(+) create mode 100644 example/migrations/0013_questionnaire.py create mode 100644 example/migrations/0014_questionnaire_metadata.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 243f13b3..7a3b4a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Fixed + +* Fixed OpenAPI schema generation for `Serializer` when used inside another `Serializer` or as a child of `ListField`. + ## [6.1.0] - 2023-08-25 ### Added diff --git a/example/factories.py b/example/factories.py index 4ca1e0b1..37340df4 100644 --- a/example/factories.py +++ b/example/factories.py @@ -12,6 +12,7 @@ Company, Entry, ProjectType, + Questionnaire, ResearchProject, TaggedItem, ) @@ -140,3 +141,24 @@ def future_projects(self, create, extracted, **kwargs): if extracted: for project in extracted: self.future_projects.add(project) + + +class QuestionnaireFactory(factory.django.DjangoModelFactory): + class Meta: + model = Questionnaire + + name = factory.LazyAttribute(lambda x: faker.text()) + questions = [ + { + "text": "What is your name?", + "required": True, + }, + { + "text": "What is your quest?", + "required": False, + }, + { + "text": "What is the air-speed velocity of an unladen swallow?", + }, + ] + metadata = {"author": "Bridgekeeper"} diff --git a/example/migrations/0013_questionnaire.py b/example/migrations/0013_questionnaire.py new file mode 100644 index 00000000..0a3b7cd2 --- /dev/null +++ b/example/migrations/0013_questionnaire.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.5 on 2023-09-07 02:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("example", "0012_author_full_name"), + ] + + operations = [ + migrations.CreateModel( + name="Questionnaire", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("questions", models.JSONField()), + ], + ), + ] diff --git a/example/migrations/0014_questionnaire_metadata.py b/example/migrations/0014_questionnaire_metadata.py new file mode 100644 index 00000000..e320ebb8 --- /dev/null +++ b/example/migrations/0014_questionnaire_metadata.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2023-09-12 07:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("example", "0013_questionnaire"), + ] + + operations = [ + migrations.AddField( + model_name="questionnaire", + name="metadata", + field=models.JSONField(default={}), + preserve_default=False, + ), + ] diff --git a/example/models.py b/example/models.py index 8fc86c22..35a15a8e 100644 --- a/example/models.py +++ b/example/models.py @@ -180,3 +180,9 @@ class Company(models.Model): def __str__(self): return self.name + + +class Questionnaire(models.Model): + name = models.CharField(max_length=100) + questions = models.JSONField() + metadata = models.JSONField() diff --git a/example/serializers.py b/example/serializers.py index 3d94e6cc..94fe8556 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -18,6 +18,7 @@ LabResults, Project, ProjectType, + Questionnaire, ResearchProject, TaggedItem, ) @@ -421,3 +422,22 @@ class CompanySerializer(serializers.ModelSerializer): class Meta: model = Company fields = "__all__" + + +class QuestionSerializer(serializers.Serializer): + text = serializers.CharField() + required = serializers.BooleanField(default=False) + + +class QuestionnaireMetadataSerializer(serializers.Serializer): + author = serializers.CharField() + producer = serializers.CharField(default=None) + + +class QuestionnaireSerializer(serializers.ModelSerializer): + questions = serializers.ListField(child=QuestionSerializer()) + metadata = QuestionnaireMetadataSerializer() + + class Meta: + model = Questionnaire + fields = ("name", "questions", "metadata") diff --git a/example/tests/conftest.py b/example/tests/conftest.py index 22ab6bd1..6e4b05ba 100644 --- a/example/tests/conftest.py +++ b/example/tests/conftest.py @@ -12,6 +12,7 @@ CommentFactory, CompanyFactory, EntryFactory, + QuestionnaireFactory, ResearchProjectFactory, TaggedItemFactory, ) @@ -27,6 +28,7 @@ register(ArtProjectFactory) register(ResearchProjectFactory) register(CompanyFactory) +register(QuestionnaireFactory) @pytest.fixture diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 5710da2a..2333dd6a 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -125,6 +125,46 @@ def test_schema_id_field(): assert "id" not in company_properties["attributes"]["properties"] +def test_schema_subserializers(): + """Schema for child Serializers reflects the actual response structure.""" + patterns = [ + re_path( + "^questionnaires/?$", views.QuestionnaireViewset.as_view({"get": "list"}) + ), + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request("/") + schema = generator.get_schema(request=request) + + assert { + "type": "object", + "properties": { + "metadata": { + "type": "object", + "properties": { + "author": {"type": "string"}, + "producer": {"type": "string"}, + }, + "required": ["author"], + }, + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "required": {"type": "boolean", "default": False}, + }, + "required": ["text"], + }, + }, + "name": {"type": "string", "maxLength": 100}, + }, + "required": ["name", "questions", "metadata"], + } == schema["components"]["schemas"]["Questionnaire"]["properties"]["attributes"] + + def test_schema_parameters_include(): """Include paramater is only used when serializer defines included_serializers.""" patterns = [ diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index 37f50b53..9ad487e5 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -224,6 +224,39 @@ def test_model_serializer_with_implicit_fields(self, comment, client): assert response.status_code == 200 assert expected == response.json() + def test_model_serializer_with_subserializers(self, questionnaire, client): + expected = { + "data": { + "type": "questionnaires", + "id": str(questionnaire.pk), + "attributes": { + "name": questionnaire.name, + "questions": [ + { + "text": "What is your name?", + "required": True, + }, + { + "text": "What is your quest?", + "required": False, + }, + { + "text": "What is the air-speed velocity of an unladen swallow?", + "required": False, + }, + ], + "metadata": {"author": "Bridgekeeper", "producer": None}, + }, + }, + } + + response = client.get( + reverse("questionnaire-detail", kwargs={"pk": questionnaire.pk}) + ) + + assert response.status_code == 200 + assert expected == response.json() + class TestPolymorphicModelSerializer(TestCase): def setUp(self): diff --git a/example/urls.py b/example/urls.py index 3d1cf2fa..413d058d 100644 --- a/example/urls.py +++ b/example/urls.py @@ -19,6 +19,7 @@ NonPaginatedEntryViewSet, ProjectTypeViewset, ProjectViewset, + QuestionnaireViewset, ) router = routers.DefaultRouter(trailing_slash=False) @@ -32,6 +33,7 @@ router.register(r"projects", ProjectViewset) router.register(r"project-types", ProjectTypeViewset) router.register(r"lab-results", LabResultViewSet) +router.register(r"questionnaires", QuestionnaireViewset) urlpatterns = [ path("", include(router.urls)), diff --git a/example/urls_test.py b/example/urls_test.py index 92802a81..bb8fbecf 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -20,6 +20,7 @@ NonPaginatedEntryViewSet, ProjectTypeViewset, ProjectViewset, + QuestionnaireViewset, ) router = routers.DefaultRouter(trailing_slash=False) @@ -38,6 +39,7 @@ router.register(r"projects", ProjectViewset) router.register(r"project-types", ProjectTypeViewset) router.register(r"lab-results", LabResultViewSet) +router.register(r"questionnaires", QuestionnaireViewset) # for the old tests router.register(r"identities", Identity) diff --git a/example/views.py b/example/views.py index b0d92811..9c949684 100644 --- a/example/views.py +++ b/example/views.py @@ -29,6 +29,7 @@ LabResults, Project, ProjectType, + Questionnaire, ) from example.serializers import ( AuthorDetailSerializer, @@ -43,6 +44,7 @@ LabResultsSerializer, ProjectSerializer, ProjectTypeSerializer, + QuestionnaireSerializer, ) HTTP_422_UNPROCESSABLE_ENTITY = 422 @@ -292,3 +294,8 @@ class LabResultViewSet(ReadOnlyModelViewSet): "__all__": [], "author": ["author__bio", "author__entries"], } + + +class QuestionnaireViewset(ModelViewSet): + queryset = Questionnaire.objects.all() + serializer_class = QuestionnaireSerializer diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 52f08da6..c0d9ca3a 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -681,6 +681,14 @@ def map_serializer(self, serializer): and 'links'. """ # TODO: remove attributes, etc. for relationshipView?? + if isinstance( + serializer.parent, (serializers.ListField, serializers.BaseSerializer) + ): + # Return plain non-JSON:API serializer schema for serializers nested inside + # a Serializer or a ListField, as those don't use the full JSON:API + # serializer schemas. + return super().map_serializer(serializer) + required = [] attributes = {} relationships_required = [] From b75255c3e4d658f79f26c6b8ed1d08c97ca9273c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 19 Sep 2023 17:03:53 +0400 Subject: [PATCH 420/488] Removed support for Python 3.7 (#1178) --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 6 ++++++ README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 1 - tox.ini | 4 ++-- 6 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a22301fe..f90480ac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11"] env: PYTHON: ${{ matrix.python-version }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3b4a57..36d3ba82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,14 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed OpenAPI schema generation for `Serializer` when used inside another `Serializer` or as a child of `ListField`. +### Removed + +* Removed support for Python 3.7. + ## [6.1.0] - 2023-08-25 +This is the last release supporting Python 3.7. + ### Added * Added support for Python 3.11. diff --git a/README.rst b/README.rst index 82c2b08f..6708dab5 100644 --- a/README.rst +++ b/README.rst @@ -92,7 +92,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.7, 3.8, 3.9, 3.10, 3.11) +1. Python (3.8, 3.9, 3.10, 3.11) 2. Django (3.2, 4.0, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) diff --git a/docs/getting-started.md b/docs/getting-started.md index 07798fae..e65a0ac3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.7, 3.8, 3.9, 3.10, 3.11) +1. Python (3.8, 3.9, 3.10, 3.11) 2. Django (3.2, 4.0, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) diff --git a/setup.py b/setup.py index 7d01a919..67d98385 100755 --- a/setup.py +++ b/setup.py @@ -86,7 +86,6 @@ def get_package_data(package): "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/tox.ini b/tox.ini index ede6f27f..9fb72d1c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{37,38,39,310}-django32-drf{313,314,master}, + py{38,39,310}-django32-drf{313,314,master}, py{38,39,310}-django40-drf{313,314,master}, py{38,39,310,311}-django41-drf{314,master}, py{38,39,310,311}-django42-drf{314,master}, @@ -50,7 +50,7 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{37,38,39,310}-django32-drfmaster] +[testenv:py{38,39,310}-django32-drfmaster] ignore_outcome = true [testenv:py{38,39,310}-django40-drfmaster] From 14c1ddcc0e6153aabcfdce8eb2520fb3a54c28a5 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 21 Sep 2023 08:22:24 -0500 Subject: [PATCH 421/488] Scheduled biweekly dependency update for week 38 (#1176) * Update black from 23.3.0 to 23.9.1 * Update flake8 from 6.0.0 to 6.1.0 * Update flake8-bugbear from 23.3.23 to 23.9.16 * Update flake8-isort from 6.0.0 to 6.1.0 * Update sphinx from 6.1.3 to 7.2.6 * Update sphinx_rtd_theme from 1.2.0 to 1.3.0 * Update django-filter from 23.1 to 23.3 * Update pyyaml from 6.0 to 6.0.1 * Update factory-boy from 3.2.1 to 3.3.0 * Update faker from 18.4.0 to 19.6.1 * Update pytest from 7.3.1 to 7.4.2 * Update pytest-cov from 4.0.0 to 4.1.0 * Update syrupy from 3.0.6 to 4.5.0 * Adopt to updated versions * Use Python 3.9 for build * Removed duplicated entry in set * Increased tox base python to 3.9 --------- Co-authored-by: Oliver Sauder --- .github/workflows/tests.yml | 4 ++-- .readthedocs.yaml | 2 +- example/factories.py | 4 ++++ example/tests/__snapshots__/test_errors.ambr | 1 + example/tests/__snapshots__/test_openapi.ambr | 1 + example/tests/integration/test_polymorphism.py | 1 - requirements/requirements-codestyle.txt | 8 ++++---- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-optionals.txt | 4 ++-- requirements/requirements-testing.txt | 10 +++++----- setup.cfg | 3 +++ tox.ini | 6 +++--- 12 files changed, 28 insertions(+), 20 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f90480ac..b167fde5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,10 +42,10 @@ jobs: tox-env: ["black", "lint", "docs"] steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.readthedocs.yaml b/.readthedocs.yaml index d70a3ae5..997afe3f 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,7 +3,7 @@ version: 2 build: os: "ubuntu-22.04" tools: - python: "3.8" + python: "3.9" sphinx: configuration: docs/conf.py diff --git a/example/factories.py b/example/factories.py index 37340df4..87698fa2 100644 --- a/example/factories.py +++ b/example/factories.py @@ -37,6 +37,7 @@ class Meta: class AuthorFactory(factory.django.DjangoModelFactory): class Meta: + skip_postgeneration_save = True model = Author name = factory.LazyAttribute(lambda x: faker.name()) @@ -49,6 +50,7 @@ class Meta: class AuthorBioFactory(factory.django.DjangoModelFactory): class Meta: model = AuthorBio + skip_postgeneration_save = True author = factory.SubFactory(AuthorFactory) body = factory.LazyAttribute(lambda x: faker.text()) @@ -69,6 +71,7 @@ class Meta: class EntryFactory(factory.django.DjangoModelFactory): class Meta: model = Entry + skip_postgeneration_save = True headline = factory.LazyAttribute(lambda x: faker.sentence(nb_words=4)) body_text = factory.LazyAttribute(lambda x: faker.text()) @@ -130,6 +133,7 @@ class Meta: class CompanyFactory(factory.django.DjangoModelFactory): class Meta: model = Company + skip_postgeneration_save = True name = factory.LazyAttribute(lambda x: faker.company()) current_project = factory.SubFactory(ArtProjectFactory) diff --git a/example/tests/__snapshots__/test_errors.ambr b/example/tests/__snapshots__/test_errors.ambr index fe872b46..5dca2771 100644 --- a/example/tests/__snapshots__/test_errors.ambr +++ b/example/tests/__snapshots__/test_errors.ambr @@ -1,3 +1,4 @@ +# serializer version: 1 # name: test_first_level_attribute_error dict({ 'errors': list([ diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 18aec07f..1de8057b 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -1,3 +1,4 @@ +# serializer version: 1 # name: test_delete_request ''' { diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 116243f1..836a9046 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -50,7 +50,6 @@ def test_polymorphism_on_included_relations(single_company, client): for rel in content["data"]["relationships"]["futureProjects"]["data"] } == {"researchProjects", "artProjects"} assert {x.get("type") for x in content.get("included")} == { - "artProjects", "artProjects", "researchProjects", }, "Detail included types are incorrect" diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 8eba9fae..cc8adaed 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==23.3.0 -flake8==6.0.0 -flake8-bugbear==23.3.23 -flake8-isort==6.0.0 +black==23.9.1 +flake8==6.1.0 +flake8-bugbear==23.9.16 +flake8-isort==6.1.0 isort==5.12.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 36e96657..8dc46ee5 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==6.1.3 -sphinx_rtd_theme==1.2.0 +Sphinx==7.2.6 +sphinx_rtd_theme==1.3.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index b8a8b54d..fc16b02c 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==23.1 +django-filter==23.3 django-polymorphic==3.1.0 -pyyaml==6.0 +pyyaml==6.0.1 uritemplate==4.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 7fac28a4..64cb57b7 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ -factory-boy==3.2.1 -Faker==18.4.0 -pytest==7.3.1 -pytest-cov==4.0.0 +factory-boy==3.3.0 +Faker==19.6.1 +pytest==7.4.2 +pytest-cov==4.1.0 pytest-django==4.5.2 pytest-factoryboy==2.5.1 -syrupy==3.0.6 +syrupy==4.5.0 diff --git a/setup.cfg b/setup.cfg index d3ea5ebc..f55ed558 100644 --- a/setup.cfg +++ b/setup.cfg @@ -68,6 +68,9 @@ filterwarnings = # can be removed once fixed in django polymorphic ignore:pkg_resources is deprecated as an API ignore:Deprecated call to `pkg_resource + # Django filter schema generation. Can be removed once we remove + # schema support + ignore:Built-in schema generation is deprecated. testpaths = example tests diff --git a/tox.ini b/tox.ini index 9fb72d1c..8d6df244 100644 --- a/tox.ini +++ b/tox.ini @@ -28,13 +28,13 @@ commands = pytest --cov --no-cov-on-fail --cov-report xml {posargs} [testenv:black] -basepython = python3.8 +basepython = python3.9 deps = -rrequirements/requirements-codestyle.txt commands = black --check . [testenv:lint] -basepython = python3.8 +basepython = python3.9 deps = -rrequirements/requirements-codestyle.txt -rrequirements/requirements-testing.txt @@ -43,7 +43,7 @@ commands = flake8 [testenv:docs] # keep in sync with .readthedocs.yml -basepython = python3.8 +basepython = python3.9 deps = -rrequirements/requirements-optionals.txt -rrequirements/requirements-documentation.txt From 1e37d00aa6a7515f3baf45aa660caf448c4585d9 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 22 Sep 2023 14:32:41 +0400 Subject: [PATCH 422/488] Increased minimum Python version (#1179) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 67d98385..c7b3d974 100755 --- a/setup.py +++ b/setup.py @@ -105,6 +105,6 @@ def get_package_data(package): "openapi": ["pyyaml>=5.4", "uritemplate>=3.0.1"], }, setup_requires=wheel, - python_requires=">=3.7", + python_requires=">=3.8", zip_safe=False, ) From ec67f0cb9120830d07c8b47c4f41935fac802c34 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 24 Sep 2023 22:38:47 +0400 Subject: [PATCH 423/488] Removed support for Django 4.0 (#1180) --- CHANGELOG.md | 3 ++- README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 5 ----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d3ba82..83b2e62d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,11 @@ any parts of the framework not mentioned in the documentation should generally b ### Removed * Removed support for Python 3.7. +* Removed support for Django 4.0. ## [6.1.0] - 2023-08-25 -This is the last release supporting Python 3.7. +This is the last release supporting Python 3.7 and Django 4.0. ### Added diff --git a/README.rst b/README.rst index 6708dab5..f156f3ab 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.8, 3.9, 3.10, 3.11) -2. Django (3.2, 4.0, 4.1, 4.2) +2. Django (3.2, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index e65a0ac3..f1ab7d4d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.8, 3.9, 3.10, 3.11) -2. Django (3.2, 4.0, 4.1, 4.2) +2. Django (3.2, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/tox.ini b/tox.ini index 8d6df244..a4749185 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,6 @@ [tox] envlist = py{38,39,310}-django32-drf{313,314,master}, - py{38,39,310}-django40-drf{313,314,master}, py{38,39,310,311}-django41-drf{314,master}, py{38,39,310,311}-django42-drf{314,master}, black, @@ -11,7 +10,6 @@ envlist = [testenv] deps = django32: Django>=3.2,<3.3 - django40: Django>=4.0,<4.1 django41: Django>=4.1,<4.2 django42: Django>=4.2,<4.3 drf313: djangorestframework>=3.13,<3.14 @@ -53,9 +51,6 @@ commands = [testenv:py{38,39,310}-django32-drfmaster] ignore_outcome = true -[testenv:py{38,39,310}-django40-drfmaster] -ignore_outcome = true - [testenv:py{38,39,310,311}-django41-drfmaster] ignore_outcome = true From a45c475a772a3e9d3cf32694ee30dc2880ee2d6c Mon Sep 17 00:00:00 2001 From: "Panagiotis H.M. Issaris" Date: Wed, 11 Oct 2023 13:56:42 +0200 Subject: [PATCH 424/488] Add various links on the PyPI project page (#1183) * Add various links on the PyPI project page Add links to the documentation, change log, source code and issue tracker to the PyPI project page. * Split strings as the linter expects max line lengths of 88 * Reformatted project url links --------- Co-authored-by: Oliver Sauder --- setup.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/setup.py b/setup.py index c7b3d974..eeb88b97 100755 --- a/setup.py +++ b/setup.py @@ -94,6 +94,15 @@ def get_package_data(package): "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", ], + project_urls={ + "Documentation": "https://django-rest-framework-json-api.readthedocs.org/", + "Changelog": ( + "https://github.com/django-json-api/django-rest-framework-json-api/" + "blob/main/CHANGELOG.md" + ), + "Source": "https://github.com/django-json-api/django-rest-framework-json-api", + "Tracker": "https://github.com/django-json-api/django-rest-framework-json-api/issues", + }, install_requires=[ "inflection>=0.5.0", "djangorestframework>=3.13", From ffc3ca34611f8e24f5cad76b1ef5abd68f2f3eb1 Mon Sep 17 00:00:00 2001 From: Antoine Auger Date: Thu, 12 Oct 2023 08:09:32 +0200 Subject: [PATCH 425/488] Ensure that ModelSerializer fields are in same order than in DRF (#1182) * fix(ModelSerializer): preserve field order like DRF * test(ModelSerializer): add test_get_field_names * docs: update changelog and authors files --------- Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 1 + rest_framework_json_api/serializers.py | 10 ++++---- tests/test_serializers.py | 34 ++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index fbd3fe6e..7f4a8237 100644 --- a/AUTHORS +++ b/AUTHORS @@ -2,6 +2,7 @@ Adam Wróbel Adam Ziolkowski Alan Crosswell Alex Seidmann +Antoine Auger Anton Shutik Arttu Perälä Ashley Loewen diff --git a/CHANGELOG.md b/CHANGELOG.md index 83b2e62d..3e69577b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Fixed OpenAPI schema generation for `Serializer` when used inside another `Serializer` or as a child of `ListField`. +* `ModelSerializer` fields are now returned in the same order than DRF ### Removed diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 4b619ac4..c680f60a 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -309,11 +309,11 @@ def get_field_names(self, declared_fields, info): """ meta_fields = getattr(self.Meta, "meta_fields", []) - declared = {} - for field_name in set(declared_fields.keys()): - field = declared_fields[field_name] - if field_name not in meta_fields: - declared[field_name] = field + declared = { + field_name: field + for field_name, field in declared_fields.items() + if field_name not in meta_fields + } fields = super().get_field_names(declared, info) return list(fields) + list(getattr(self.Meta, "meta_fields", list())) diff --git a/tests/test_serializers.py b/tests/test_serializers.py index e1b14ed8..9d4200a3 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,5 +1,6 @@ import pytest from django.db import models +from rest_framework.utils import model_meta from rest_framework_json_api import serializers from tests.models import DJAModel, ManyToManyTarget @@ -50,3 +51,36 @@ class ReservedFieldNamesSerializer(serializers.Serializer): "ReservedFieldNamesSerializer uses following reserved field name(s) which is " "not allowed: meta, results" ) + + +def test_get_field_names(): + class MyTestModel(DJAModel): + verified = models.BooleanField(default=False) + uuid = models.UUIDField() + + class AnotherSerializer(serializers.Serializer): + ref_id = serializers.CharField() + reference_string = serializers.CharField() + + class MyTestModelSerializer(AnotherSerializer, serializers.ModelSerializer): + an_extra_field = serializers.CharField() + + class Meta: + model = MyTestModel + fields = "__all__" + extra_kwargs = { + "verified": {"read_only": True}, + } + + # Same logic than in DRF get_fields() method + declared_fields = MyTestModelSerializer._declared_fields + info = model_meta.get_field_info(MyTestModel) + + assert MyTestModelSerializer().get_field_names(declared_fields, info) == [ + "id", + "ref_id", + "reference_string", + "an_extra_field", + "verified", + "uuid", + ] From 22a8a7ee00d7df162de99c57afb700cb62101f7a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 19 Oct 2023 09:11:08 -0500 Subject: [PATCH 426/488] Scheduled biweekly dependency update for week 42 (#1184) * Update faker from 19.6.1 to 19.10.0 * Update pytest-factoryboy from 2.5.1 to 2.6.0 --- requirements/requirements-testing.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 64cb57b7..fad665ae 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.0 -Faker==19.6.1 +Faker==19.10.0 pytest==7.4.2 pytest-cov==4.1.0 pytest-django==4.5.2 -pytest-factoryboy==2.5.1 +pytest-factoryboy==2.6.0 syrupy==4.5.0 From 2752093345c42b2a15f786417781cb06004042b1 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 6 Nov 2023 11:23:01 -0500 Subject: [PATCH 427/488] Scheduled biweekly dependency update for week 45 (#1186) * Update black from 23.9.1 to 23.10.1 * Update flake8-isort from 6.1.0 to 6.1.1 * Update faker from 19.10.0 to 19.13.0 * Update pytest from 7.4.2 to 7.4.3 * Update pytest-django from 4.5.2 to 4.6.0 * Update syrupy from 4.5.0 to 4.6.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-testing.txt | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index cc8adaed..6d848f66 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==23.9.1 +black==23.10.1 flake8==6.1.0 flake8-bugbear==23.9.16 -flake8-isort==6.1.0 +flake8-isort==6.1.1 isort==5.12.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index fad665ae..fab46118 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.0 -Faker==19.10.0 -pytest==7.4.2 +Faker==19.13.0 +pytest==7.4.3 pytest-cov==4.1.0 -pytest-django==4.5.2 +pytest-django==4.6.0 pytest-factoryboy==2.6.0 -syrupy==4.5.0 +syrupy==4.6.0 From 4d27bbd69ca056538077f194377e9bc58b994105 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 20 Nov 2023 11:23:51 -0500 Subject: [PATCH 428/488] Scheduled biweekly dependency update for week 47 (#1187) * Update black from 23.10.1 to 23.11.0 * Update faker from 19.13.0 to 20.0.3 * Update pytest-django from 4.6.0 to 4.7.0 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 6d848f66..9191b07a 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==23.10.1 +black==23.11.0 flake8==6.1.0 flake8-bugbear==23.9.16 flake8-isort==6.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index fab46118..56865900 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.0 -Faker==19.13.0 +Faker==20.0.3 pytest==7.4.3 pytest-cov==4.1.0 -pytest-django==4.6.0 +pytest-django==4.7.0 pytest-factoryboy==2.6.0 syrupy==4.6.0 From 027e44cc2219c9517cb6ae8ccf91e6eaf6ef01c8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 20 Nov 2023 21:38:03 +0400 Subject: [PATCH 429/488] Added Python 3.12 support (#1185) --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- requirements/requirements-optionals.txt | 5 ++++- setup.cfg | 6 +++--- setup.py | 1 + tox.ini | 4 ++-- 8 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b167fde5..17c6a9a5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] env: PYTHON: ${{ matrix.python-version }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e69577b..c4c9eca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Added + +* Added support for Python 3.12 + ### Fixed * Fixed OpenAPI schema generation for `Serializer` when used inside another `Serializer` or as a child of `ListField`. diff --git a/README.rst b/README.rst index f156f3ab..59ba17da 100644 --- a/README.rst +++ b/README.rst @@ -92,7 +92,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.8, 3.9, 3.10, 3.11) +1. Python (3.8, 3.9, 3.10, 3.11, 3.12) 2. Django (3.2, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) diff --git a/docs/getting-started.md b/docs/getting-started.md index f1ab7d4d..e1ca4abd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.8, 3.9, 3.10, 3.11) +1. Python (3.8, 3.9, 3.10, 3.11, 3.12) 2. Django (3.2, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index fc16b02c..f01f61de 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,7 @@ django-filter==23.3 -django-polymorphic==3.1.0 +# once next version has been released (>3.1.0) this +# should be set to pinned version again +# see https://github.com/django-polymorphic/django-polymorphic/pull/541 +django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master pyyaml==6.0.1 uritemplate==4.1.1 diff --git a/setup.cfg b/setup.cfg index f55ed558..a02f67e6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -65,12 +65,12 @@ filterwarnings = error::PendingDeprecationWarning # Remove when DRF is not depending on it anymore ignore:The django.utils.timezone.utc alias is deprecated. - # can be removed once fixed in django polymorphic - ignore:pkg_resources is deprecated as an API - ignore:Deprecated call to `pkg_resource # Django filter schema generation. Can be removed once we remove # schema support ignore:Built-in schema generation is deprecated. + # can be removed once django filter has released a new version including + # https://github.com/carltongibson/django-filter/pull/1623 + ignore:'pkgutil.find_loader' is deprecated and slated for removal testpaths = example tests diff --git a/setup.py b/setup.py index eeb88b97..359324fb 100755 --- a/setup.py +++ b/setup.py @@ -90,6 +90,7 @@ def get_package_data(package): "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/tox.ini b/tox.ini index a4749185..3943b462 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ envlist = py{38,39,310}-django32-drf{313,314,master}, py{38,39,310,311}-django41-drf{314,master}, - py{38,39,310,311}-django42-drf{314,master}, + py{38,39,310,311,312}-django42-drf{314,master}, black, docs, lint @@ -54,5 +54,5 @@ ignore_outcome = true [testenv:py{38,39,310,311}-django41-drfmaster] ignore_outcome = true -[testenv:py{38,39,310,311}-django42-drfmaster] +[testenv:py{38,39,310,311,312}-django42-drfmaster] ignore_outcome = true From 6aaaac7b28da6b644d2dc3c39b05d2f75ee5db18 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Dec 2023 06:52:05 -0800 Subject: [PATCH 430/488] Scheduled biweekly dependency update for week 49 (#1189) * Update flake8-bugbear from 23.9.16 to 23.12.2 * Update sphinx_rtd_theme from 1.3.0 to 2.0.0 * Update django-filter from 23.3 to 23.4 * Pin django-polymorphic to latest version 3.1.0 * Update faker from 20.0.3 to 20.1.0 * Fix django-polymorhpic version for now * Fixing B018 useless dict --------- Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-optionals.txt | 4 ++-- requirements/requirements-testing.txt | 2 +- rest_framework_json_api/renderers.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 9191b07a..3ffdd922 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==23.11.0 flake8==6.1.0 -flake8-bugbear==23.9.16 +flake8-bugbear==23.12.2 flake8-isort==6.1.1 isort==5.12.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 8dc46ee5..49668994 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 Sphinx==7.2.6 -sphinx_rtd_theme==1.3.0 +sphinx_rtd_theme==2.0.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index f01f61de..9fd6b604 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,7 +1,7 @@ -django-filter==23.3 +django-filter==23.4 # once next version has been released (>3.1.0) this # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 -django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master +django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore pyyaml==6.0.1 uritemplate==4.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 56865900..eeb2dffc 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ factory-boy==3.3.0 -Faker==20.0.3 +Faker==20.1.0 pytest==7.4.3 pytest-cov==4.1.0 pytest-django==4.7.0 diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 9a0d9c0c..b065af5b 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -477,7 +477,7 @@ def render_relationship_view( render_data = {"data": data} links = view.get_links() if links: - render_data.update({"links": links}), + render_data["links"] = links return super().render(render_data, accepted_media_type, renderer_context) def render_errors(self, data, accepted_media_type=None, renderer_context=None): From c566235ece195a31a1fe2f114bdadcd1b8992450 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 18 Dec 2023 11:46:35 -0800 Subject: [PATCH 431/488] Scheduled biweekly dependency update for week 51 (#1192) * Update black from 23.11.0 to 23.12.0 * Update isort from 5.12.0 to 5.13.2 * Update django-filter from 23.4 to 23.5 * Update faker from 20.1.0 to 21.0.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 3ffdd922..4d684cc1 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==23.11.0 +black==23.12.0 flake8==6.1.0 flake8-bugbear==23.12.2 flake8-isort==6.1.1 -isort==5.12.0 +isort==5.13.2 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 9fd6b604..0e4a92da 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==23.4 +django-filter==23.5 # once next version has been released (>3.1.0) this # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index eeb2dffc..42cb2ba0 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ factory-boy==3.3.0 -Faker==20.1.0 +Faker==21.0.0 pytest==7.4.3 pytest-cov==4.1.0 pytest-django==4.7.0 From 4d4dc37f918d1b31052bee57136f620da6f1738e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 27 Dec 2023 15:52:43 +0400 Subject: [PATCH 432/488] Added support for Django 5.0 (#1195) --- CHANGELOG.md | 1 + README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 5 +++++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c9eca0..9664124c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Python 3.12 +* Added support for Django 5.0 ### Fixed diff --git a/README.rst b/README.rst index 59ba17da..6292b387 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (3.2, 4.1, 4.2) +2. Django (3.2, 4.1, 4.2, 5.0) 3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index e1ca4abd..10a1ec41 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (3.2, 4.1, 4.2) +2. Django (3.2, 4.1, 4.2, 5.0) 3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/tox.ini b/tox.ini index 3943b462..f7f83820 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ envlist = py{38,39,310}-django32-drf{313,314,master}, py{38,39,310,311}-django41-drf{314,master}, py{38,39,310,311,312}-django42-drf{314,master}, + py{310,311,312}-django50-drf{314,master}, black, docs, lint @@ -12,6 +13,7 @@ deps = django32: Django>=3.2,<3.3 django41: Django>=4.1,<4.2 django42: Django>=4.2,<4.3 + django50: Django>=5.0,<5.1 drf313: djangorestframework>=3.13,<3.14 drf314: djangorestframework>=3.14,<3.15 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip @@ -56,3 +58,6 @@ ignore_outcome = true [testenv:py{38,39,310,311,312}-django42-drfmaster] ignore_outcome = true + +[testenv:py{310,311,312}-django50-drfmaster] +ignore_outcome = true From 36c92a373a5c48cf318a31c81bf8873af30a9a26 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 28 Dec 2023 20:58:18 +0400 Subject: [PATCH 433/488] Avoided that an empty attributes dict is rendered (#1196) --- CHANGELOG.md | 2 ++ example/tests/unit/test_renderers.py | 4 +++- rest_framework_json_api/renderers.py | 4 +++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9664124c..f64e70ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed OpenAPI schema generation for `Serializer` when used inside another `Serializer` or as a child of `ListField`. * `ModelSerializer` fields are now returned in the same order than DRF +* Avoided that an empty attributes dict is rendered in case serializer does not + provide any attribute fields. ### Removed diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index 00eaf28b..f34ce8d6 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -126,7 +126,7 @@ class WriteonlyTestSerializer(serializers.ModelSerializer): class Meta: model = Entry - fields = ("comments", "rating") + fields = ("headline", "comments", "rating") class WriteOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): queryset = Entry.objects.all() @@ -136,6 +136,7 @@ class WriteOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): result = json.loads(rendered.decode()) assert "rating" not in result["data"]["attributes"] + assert "headline" in result["data"]["attributes"] assert "relationships" not in result["data"] @@ -153,6 +154,7 @@ class EmptyRelationshipViewSet(views.ReadOnlyModelViewSet): rendered = render_dummy_test_serialized_view(EmptyRelationshipViewSet, Author()) result = json.loads(rendered.decode()) + assert "attributes" not in result["data"] assert "relationships" in result["data"] assert "bio" in result["data"]["relationships"] assert result["data"]["relationships"]["bio"] == {"data": None} diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index b065af5b..0664f73a 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -452,8 +452,10 @@ def build_json_resource_obj( resource_data = { "type": resource_name, "id": utils.get_resource_id(resource_instance, resource), - "attributes": cls.extract_attributes(fields, resource), } + attributes = cls.extract_attributes(fields, resource) + if attributes: + resource_data["attributes"] = attributes relationships = cls.extract_relationships(fields, resource, resource_instance) if relationships: resource_data["relationships"] = relationships From b9a73d39e1efe5cc000e55a5929db9d0929b36ff Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 15 Jan 2024 08:13:08 -0800 Subject: [PATCH 434/488] Scheduled biweekly dependency update for week 02 (#1198) * Update black from 23.12.0 to 23.12.1 * Update flake8 from 6.1.0 to 7.0.0 * Update faker from 21.0.0 to 22.2.0 * Update pytest from 7.4.3 to 7.4.4 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-testing.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 4d684cc1..656f8aab 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==23.12.0 -flake8==6.1.0 +black==23.12.1 +flake8==7.0.0 flake8-bugbear==23.12.2 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 42cb2ba0..b4595383 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ factory-boy==3.3.0 -Faker==21.0.0 -pytest==7.4.3 +Faker==22.2.0 +pytest==7.4.4 pytest-cov==4.1.0 pytest-django==4.7.0 pytest-factoryboy==2.6.0 From ca45a2b448a580d5370ed3afbba9e8e221cfa1e9 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 5 Mar 2024 02:15:57 -0800 Subject: [PATCH 435/488] Scheduled biweekly dependency update for week 09 (#1203) * Update black from 23.12.1 to 24.2.0 * Update flake8-bugbear from 23.12.2 to 24.2.6 * Update twine from 4.0.2 to 5.0.0 * Update faker from 22.2.0 to 23.3.0 * Update pytest from 7.4.4 to 8.1.0 * Update pytest-django from 4.7.0 to 4.8.0 * Update syrupy from 4.6.0 to 4.6.1 * Rerun black * Updated pytest-factoryboy to work with newest pytest --------- Co-authored-by: Oliver Sauder --- example/tests/test_sideload_resources.py | 1 + requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 10 +++++----- rest_framework_json_api/parsers.py | 1 + rest_framework_json_api/relations.py | 6 +++--- rest_framework_json_api/renderers.py | 1 + 7 files changed, 14 insertions(+), 11 deletions(-) diff --git a/example/tests/test_sideload_resources.py b/example/tests/test_sideload_resources.py index 5ca96afe..51bfb841 100644 --- a/example/tests/test_sideload_resources.py +++ b/example/tests/test_sideload_resources.py @@ -1,6 +1,7 @@ """ Test sideloading resources """ + import json from django.urls import reverse diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 656f8aab..df3e0608 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==23.12.1 +black==24.2.0 flake8==7.0.0 -flake8-bugbear==23.12.2 +flake8-bugbear==24.2.6 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index eb2532c4..489eeb83 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==4.0.2 +twine==5.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index b4595383..b15dd948 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.0 -Faker==22.2.0 -pytest==7.4.4 +Faker==23.3.0 +pytest==8.1.0 pytest-cov==4.1.0 -pytest-django==4.7.0 -pytest-factoryboy==2.6.0 -syrupy==4.6.0 +pytest-django==4.8.0 +pytest-factoryboy==2.6.1 +syrupy==4.6.1 diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index e26f6028..a0a2aeb2 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -1,6 +1,7 @@ """ Parsers """ + from rest_framework import parsers from rest_framework.exceptions import ParseError diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 32547253..0742c1ec 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -106,9 +106,9 @@ def get_links(self, obj=None, lookup_field="pk"): return_data = {} kwargs = { - lookup_field: getattr(obj, lookup_field) - if obj - else view.kwargs[lookup_field] + lookup_field: ( + getattr(obj, lookup_field) if obj else view.kwargs[lookup_field] + ) } field_name = self.field_name if self.field_name else self.parent.field_name diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 0664f73a..639f0b11 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -1,6 +1,7 @@ """ Renderers """ + import copy from collections import defaultdict from collections.abc import Iterable From ee86180f704b6e34855c10a38979a61c7c5d02be Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 18 Mar 2024 21:38:36 +0400 Subject: [PATCH 436/488] Use sphinx theme locally and on rtd as extension (#1207) --- docs/conf.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index fcc91d58..80a85e4b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -40,7 +40,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.autodoc", "recommonmark"] +extensions = ["sphinx.ext.autodoc", "recommonmark", "sphinx_rtd_theme"] autodoc_member_order = "bysource" autodoc_inherit_docstrings = False @@ -122,15 +122,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = "default" - -on_rtd = os.environ.get("READTHEDOCS", None) == "True" - -if not on_rtd: # only import and set the theme if we're building docs locally - import sphinx_rtd_theme - - html_theme = "sphinx_rtd_theme" - html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the From 70685c96fed442e4c9554608af78236de5a32ebf Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 18 Mar 2024 22:28:36 +0400 Subject: [PATCH 437/488] Added support for Django REST framework 3.15 (#1209) * Added support for Django REST framework 3.15 As per our policy this drops support for 3.13 as we only support two versions of DRF. * Added clarification in changelog about removed compat definitions --- CHANGELOG.md | 5 ++++- README.rst | 2 +- docs/getting-started.md | 2 +- rest_framework_json_api/compat.py | 15 --------------- rest_framework_json_api/metadata.py | 2 -- rest_framework_json_api/schemas/openapi.py | 7 ++----- setup.cfg | 2 -- setup.py | 2 +- tox.ini | 10 +++++----- 9 files changed, 14 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f64e70ba..b832850f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Python 3.12 * Added support for Django 5.0 +* Added support for Django REST framework 3.15 ### Fixed @@ -26,10 +27,12 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Python 3.7. * Removed support for Django 4.0. +* Removed support for Django REST framework 3.13. +* Removed obsolete compat `NullBooleanField` and `get_reference` definitions. ## [6.1.0] - 2023-08-25 -This is the last release supporting Python 3.7 and Django 4.0. +This is the last release supporting Python 3.7, Django 4.0 and Django REST framework 3.13. ### Added diff --git a/README.rst b/README.rst index 6292b387..e283f7bf 100644 --- a/README.rst +++ b/README.rst @@ -94,7 +94,7 @@ Requirements 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) 2. Django (3.2, 4.1, 4.2, 5.0) -3. Django REST framework (3.13, 3.14) +3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 10a1ec41..aeccd46f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,7 +53,7 @@ like the following: 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) 2. Django (3.2, 4.1, 4.2, 5.0) -3. Django REST framework (3.13, 3.14) +3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/rest_framework_json_api/compat.py b/rest_framework_json_api/compat.py index 96648eb0..e69de29b 100644 --- a/rest_framework_json_api/compat.py +++ b/rest_framework_json_api/compat.py @@ -1,15 +0,0 @@ -# Django REST framework 3.14 removed NullBooleanField -# can be removed once support for DRF 3.13 is dropped. -try: - from rest_framework.serializers import NullBooleanField -except ImportError: # pragma: no cover - NullBooleanField = object() - - -# Django REST framework 3.14 deprecates usage of `_get_reference`. -# can be removed once support for DRF 3.13 is dropped. -def get_reference(schema, serializer): - try: - return schema.get_reference(serializer) - except AttributeError: # pragma: no cover - return schema._get_reference(serializer) diff --git a/rest_framework_json_api/metadata.py b/rest_framework_json_api/metadata.py index c7f7b7b4..e761e919 100644 --- a/rest_framework_json_api/metadata.py +++ b/rest_framework_json_api/metadata.py @@ -5,7 +5,6 @@ from rest_framework.settings import api_settings from rest_framework.utils.field_mapping import ClassLookupDict -from rest_framework_json_api.compat import NullBooleanField from rest_framework_json_api.utils import format_field_name, get_related_resource_type @@ -22,7 +21,6 @@ class JSONAPIMetadata(SimpleMetadata): serializers.Field: "GenericField", serializers.RelatedField: "Relationship", serializers.BooleanField: "Boolean", - NullBooleanField: "Boolean", serializers.CharField: "String", serializers.URLField: "URL", serializers.EmailField: "Email", diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index c0d9ca3a..650876e6 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -7,7 +7,6 @@ from rest_framework.schemas.utils import is_list_view from rest_framework_json_api import serializers, views -from rest_framework_json_api.compat import get_reference from rest_framework_json_api.relations import ManySerializerMethodResourceRelatedField from rest_framework_json_api.utils import format_field_name @@ -533,12 +532,10 @@ def _get_toplevel_200_response(self, operation, path, method, collection=True): if collection: data = { "type": "array", - "items": get_reference( - self, self.get_response_serializer(path, method) - ), + "items": self.get_reference(self.get_response_serializer(path, method)), } else: - data = get_reference(self, self.get_response_serializer(path, method)) + data = self.get_reference(self.get_response_serializer(path, method)) return { "description": operation["operationId"], diff --git a/setup.cfg b/setup.cfg index a02f67e6..8f0317c8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,8 +63,6 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - # Remove when DRF is not depending on it anymore - ignore:The django.utils.timezone.utc alias is deprecated. # Django filter schema generation. Can be removed once we remove # schema support ignore:Built-in schema generation is deprecated. diff --git a/setup.py b/setup.py index 359324fb..4ff629e4 100755 --- a/setup.py +++ b/setup.py @@ -106,7 +106,7 @@ def get_package_data(package): }, install_requires=[ "inflection>=0.5.0", - "djangorestframework>=3.13", + "djangorestframework>=3.14", "django>=3.2", ], extras_require={ diff --git a/tox.ini b/tox.ini index f7f83820..e2d842db 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ [tox] envlist = - py{38,39,310}-django32-drf{313,314,master}, - py{38,39,310,311}-django41-drf{314,master}, - py{38,39,310,311,312}-django42-drf{314,master}, - py{310,311,312}-django50-drf{314,master}, + py{38,39,310}-django32-drf{314,315,master}, + py{38,39,310,311}-django41-drf{314,315,master}, + py{38,39,310,311,312}-django42-drf{314,315,master}, + py{310,311,312}-django50-drf{314,315,master}, black, docs, lint @@ -14,8 +14,8 @@ deps = django41: Django>=4.1,<4.2 django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 - drf313: djangorestframework>=3.13,<3.14 drf314: djangorestframework>=3.14,<3.15 + drf315: djangorestframework>=3.15,<3.16 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt From 9da7b9ffc3ad62074b18492734b90a78f4d0d542 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 19 Mar 2024 15:24:44 +0400 Subject: [PATCH 438/488] Removed support for Django 4.1 (#1210) --- CHANGELOG.md | 3 ++- README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 5 ----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b832850f..ee8a8f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,12 +27,13 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Python 3.7. * Removed support for Django 4.0. +* Removed support for Django 4.1. * Removed support for Django REST framework 3.13. * Removed obsolete compat `NullBooleanField` and `get_reference` definitions. ## [6.1.0] - 2023-08-25 -This is the last release supporting Python 3.7, Django 4.0 and Django REST framework 3.13. +This is the last release supporting Python 3.7, Django 4.0, Django 4.1 and Django REST framework 3.13. ### Added diff --git a/README.rst b/README.rst index e283f7bf..afe00f85 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (3.2, 4.1, 4.2, 5.0) +2. Django (3.2, 4.2, 5.0) 3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index aeccd46f..5aec6ffe 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (3.2, 4.1, 4.2, 5.0) +2. Django (3.2, 4.2, 5.0) 3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/tox.ini b/tox.ini index e2d842db..354e328c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,6 @@ [tox] envlist = py{38,39,310}-django32-drf{314,315,master}, - py{38,39,310,311}-django41-drf{314,315,master}, py{38,39,310,311,312}-django42-drf{314,315,master}, py{310,311,312}-django50-drf{314,315,master}, black, @@ -11,7 +10,6 @@ envlist = [testenv] deps = django32: Django>=3.2,<3.3 - django41: Django>=4.1,<4.2 django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 drf314: djangorestframework>=3.14,<3.15 @@ -53,9 +51,6 @@ commands = [testenv:py{38,39,310}-django32-drfmaster] ignore_outcome = true -[testenv:py{38,39,310,311}-django41-drfmaster] -ignore_outcome = true - [testenv:py{38,39,310,311,312}-django42-drfmaster] ignore_outcome = true From 2cbae19d40fdd440f160e148f1527fde813ba502 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Mar 2024 23:44:07 +0400 Subject: [PATCH 439/488] Bump black from 24.2.0 to 24.3.0 in /requirements (#1211) Bumps [black](https://github.com/psf/black) from 24.2.0 to 24.3.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/24.2.0...24.3.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/requirements-codestyle.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index df3e0608..491d81d6 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==24.2.0 +black==24.3.0 flake8==7.0.0 flake8-bugbear==24.2.6 flake8-isort==6.1.1 From f34fb421f1076ff3b2410f099e493add8b607efa Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 17 Apr 2024 22:47:23 +0200 Subject: [PATCH 440/488] Migrated some included tests to pytest style (#1218) Migrated included tests to pytest style --- example/tests/integration/test_includes.py | 65 ---------- tests/conftest.py | 35 +++++ tests/models.py | 6 +- tests/serializers.py | 36 ++++-- tests/test_utils.py | 2 +- tests/test_views.py | 143 ++++++++++++++++++++- tests/views.py | 33 ++++- 7 files changed, 237 insertions(+), 83 deletions(-) diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index f8fe8604..d32ff7e3 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -4,71 +4,6 @@ pytestmark = pytest.mark.django_db -def test_included_data_on_list(multiple_entries, client): - response = client.get( - reverse("entry-list"), data={"include": "comments", "page[size]": 5} - ) - included = response.json().get("included") - - assert len(response.json()["data"]) == len( - multiple_entries - ), "Incorrect entry count" - assert [x.get("type") for x in included] == [ - "comments", - "comments", - ], "List included types are incorrect" - - comment_count = len( - [resource for resource in included if resource["type"] == "comments"] - ) - expected_comment_count = sum(entry.comments.count() for entry in multiple_entries) - assert comment_count == expected_comment_count, "List comment count is incorrect" - - -def test_included_data_on_list_with_one_to_one_relations(multiple_entries, client): - response = client.get( - reverse("entry-list"), data={"include": "authors.bio.metadata", "page[size]": 5} - ) - included = response.json().get("included") - - assert len(response.json()["data"]) == len( - multiple_entries - ), "Incorrect entry count" - expected_include_types = [ - "authorBioMetadata", - "authorBioMetadata", - "authorBios", - "authorBios", - "authors", - "authors", - ] - include_types = [x.get("type") for x in included] - assert include_types == expected_include_types, "List included types are incorrect" - - -def test_default_included_data_on_detail(single_entry, client): - return test_included_data_on_detail( - single_entry=single_entry, client=client, query="" - ) - - -def test_included_data_on_detail(single_entry, client, query="?include=comments"): - response = client.get( - reverse("entry-detail", kwargs={"pk": single_entry.pk}) + query - ) - included = response.json().get("included") - - assert [x.get("type") for x in included] == [ - "comments" - ], "Detail included types are incorrect" - - comment_count = len( - [resource for resource in included if resource["type"] == "comments"] - ) - expected_comment_count = single_entry.comments.count() - assert comment_count == expected_comment_count, "Detail comment count is incorrect" - - def test_dynamic_related_data_is_included(single_entry, entry_factory, client): entry_factory() response = client.get( diff --git a/tests/conftest.py b/tests/conftest.py index ebdf5348..682d8342 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,9 +3,11 @@ from tests.models import ( BasicModel, + ForeignKeySource, ForeignKeyTarget, ManyToManySource, ManyToManyTarget, + NestedRelatedSource, ) @@ -39,6 +41,11 @@ def foreign_key_target(db): return ForeignKeyTarget.objects.create(name="Target") +@pytest.fixture +def foreign_key_source(db, foreign_key_target): + return ForeignKeySource.objects.create(name="Source", target=foreign_key_target) + + @pytest.fixture def many_to_many_source(db, many_to_many_targets): source = ManyToManySource.objects.create(name="Source") @@ -54,6 +61,34 @@ def many_to_many_targets(db): ] +@pytest.fixture +def many_to_many_sources(db, many_to_many_targets): + source1 = ManyToManySource.objects.create(name="Source1") + source2 = ManyToManySource.objects.create(name="Source2") + + source1.targets.add(*many_to_many_targets) + source2.targets.add(*many_to_many_targets) + + return [source1, source2] + + +@pytest.fixture +def nested_related_source( + db, + foreign_key_source, + foreign_key_target, + many_to_many_targets, + many_to_many_sources, +): + source = NestedRelatedSource.objects.create( + fk_source=foreign_key_source, fk_target=foreign_key_target + ) + source.m2m_targets.add(*many_to_many_targets) + source.m2m_sources.add(*many_to_many_sources) + + return source + + @pytest.fixture def client(): return APIClient() diff --git a/tests/models.py b/tests/models.py index a483f2d0..812ee5bf 100644 --- a/tests/models.py +++ b/tests/models.py @@ -42,11 +42,11 @@ class ForeignKeySource(DJAModel): class NestedRelatedSource(DJAModel): - m2m_source = models.ManyToManyField(ManyToManySource, related_name="nested_source") + m2m_sources = models.ManyToManyField(ManyToManySource, related_name="nested_source") fk_source = models.ForeignKey( ForeignKeySource, related_name="nested_source", on_delete=models.CASCADE ) - m2m_target = models.ManyToManyField(ManyToManySource, related_name="nested_target") + m2m_targets = models.ManyToManyField(ManyToManyTarget, related_name="nested_target") fk_target = models.ForeignKey( - ForeignKeySource, related_name="nested_target", on_delete=models.CASCADE + ForeignKeyTarget, related_name="nested_target", on_delete=models.CASCADE ) diff --git a/tests/serializers.py b/tests/serializers.py index e0f7c03b..4159039d 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -1,11 +1,11 @@ from rest_framework_json_api import serializers -from rest_framework_json_api.relations import ResourceRelatedField from tests.models import ( BasicModel, ForeignKeySource, ForeignKeyTarget, ManyToManySource, ManyToManyTarget, + NestedRelatedSource, ) @@ -15,33 +15,51 @@ class Meta: model = BasicModel +class ForeignKeyTargetSerializer(serializers.ModelSerializer): + class Meta: + fields = ("name",) + model = ForeignKeyTarget + + class ForeignKeySourceSerializer(serializers.ModelSerializer): - target = ResourceRelatedField(queryset=ForeignKeyTarget.objects) + included_serializers = {"target": ForeignKeyTargetSerializer} class Meta: model = ForeignKeySource fields = ("target",) +class ManyToManyTargetSerializer(serializers.ModelSerializer): + class Meta: + fields = ("name",) + model = ManyToManyTarget + + class ManyToManySourceSerializer(serializers.ModelSerializer): - targets = ResourceRelatedField(many=True, queryset=ManyToManyTarget.objects) + included_serializers = {"targets": "tests.serializers.ManyToManyTargetSerializer"} class Meta: model = ManyToManySource fields = ("targets",) -class ManyToManyTargetSerializer(serializers.ModelSerializer): +class ManyToManySourceReadOnlySerializer(serializers.ModelSerializer): class Meta: - model = ManyToManyTarget + model = ManyToManySource + fields = ("targets",) -class ManyToManySourceReadOnlySerializer(serializers.ModelSerializer): - targets = ResourceRelatedField(many=True, read_only=True) +class NestedRelatedSourceSerializer(serializers.ModelSerializer): + included_serializers = { + "m2m_sources": ManyToManySourceSerializer, + "fk_source": ForeignKeySourceSerializer, + "m2m_targets": ManyToManyTargetSerializer, + "fk_target": ForeignKeyTargetSerializer, + } class Meta: - model = ManyToManySource - fields = ("targets",) + model = NestedRelatedSource + fields = ("m2m_sources", "fk_source", "m2m_targets", "fk_target") class CallableDefaultSerializer(serializers.Serializer): diff --git a/tests/test_utils.py b/tests/test_utils.py index f2a3d176..4e103ae2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -325,7 +325,7 @@ class Meta: {"many": True, "queryset": ManyToManyTarget.objects.all()}, ), ( - "m2m_target.sources.", + "m2m_target.sources", "ManyToManySource", {"many": True, "queryset": ManyToManySource.objects.all()}, ), diff --git a/tests/test_views.py b/tests/test_views.py index 47fec02a..de5d1b7a 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -12,9 +12,14 @@ from rest_framework_json_api.renderers import JSONRenderer from rest_framework_json_api.utils import format_link_segment from rest_framework_json_api.views import ModelViewSet, ReadOnlyModelViewSet -from tests.models import BasicModel -from tests.serializers import BasicModelSerializer -from tests.views import BasicModelViewSet +from tests.models import BasicModel, ForeignKeySource +from tests.serializers import BasicModelSerializer, ForeignKeyTargetSerializer +from tests.views import ( + BasicModelViewSet, + ForeignKeySourceViewSet, + ManyToManySourceViewSet, + NestedRelatedSourceViewSet, +) class TestModelViewSet: @@ -80,6 +85,90 @@ def test_list(self, client, model): "meta": {"pagination": {"count": 1, "page": 1, "pages": 1}}, } + @pytest.mark.urls(__name__) + def test_list_with_include_foreign_key(self, client, foreign_key_source): + url = reverse("foreign-key-source-list") + response = client.get(url, data={"include": "target"}) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert "included" in result + assert [ + { + "type": "ForeignKeyTarget", + "id": str(foreign_key_source.target.pk), + "attributes": {"name": foreign_key_source.target.name}, + } + ] == result["included"] + + @pytest.mark.urls(__name__) + def test_list_with_include_many_to_many_field( + self, client, many_to_many_source, many_to_many_targets + ): + url = reverse("many-to-many-source-list") + response = client.get(url, data={"include": "targets"}) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert "included" in result + assert [ + { + "type": "ManyToManyTarget", + "id": str(target.pk), + "attributes": {"name": target.name}, + } + for target in many_to_many_targets + ] == result["included"] + + @pytest.mark.urls(__name__) + def test_list_with_include_nested_related_field( + self, client, nested_related_source, many_to_many_sources, many_to_many_targets + ): + url = reverse("nested-related-source-list") + response = client.get(url, data={"include": "m2m_sources,m2m_sources.targets"}) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert "included" in result + + assert [ + { + "type": "ManyToManySource", + "id": str(source.pk), + "relationships": { + "targets": { + "data": [ + {"id": str(target.pk), "type": "ManyToManyTarget"} + for target in source.targets.all() + ], + "meta": {"count": source.targets.count()}, + } + }, + } + for source in many_to_many_sources + ] + [ + { + "type": "ManyToManyTarget", + "id": str(target.pk), + "attributes": {"name": target.name}, + } + for target in many_to_many_targets + ] == result[ + "included" + ] + + @pytest.mark.urls(__name__) + def test_list_with_default_included_resources(self, client, foreign_key_source): + url = reverse("default-included-resources-list") + response = client.get(url) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert "included" in result + assert [ + { + "type": "ForeignKeyTarget", + "id": str(foreign_key_source.target.pk), + "attributes": {"name": foreign_key_source.target.name}, + } + ] == result["included"] + @pytest.mark.urls(__name__) def test_retrieve(self, client, model): url = reverse("basic-model-detail", kwargs={"pk": model.pk}) @@ -93,6 +182,21 @@ def test_retrieve(self, client, model): } } + @pytest.mark.urls(__name__) + def test_retrieve_with_include_foreign_key(self, client, foreign_key_source): + url = reverse("foreign-key-source-detail", kwargs={"pk": foreign_key_source.pk}) + response = client.get(url, data={"include": "target"}) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert "included" in result + assert [ + { + "type": "ForeignKeyTarget", + "id": str(foreign_key_source.target.pk), + "attributes": {"name": foreign_key_source.target.name}, + } + ] == result["included"] + @pytest.mark.urls(__name__) def test_patch(self, client, model): data = { @@ -231,6 +335,23 @@ def test_patch_with_custom_id(self, client): # Routing setup +class DefaultIncludedResourcesSerializer(serializers.ModelSerializer): + included_serializers = {"target": ForeignKeyTargetSerializer} + + class Meta: + model = ForeignKeySource + fields = ("target",) + + class JSONAPIMeta: + included_resources = ["target"] + + +class DefaultIncludedResourcesViewSet(ModelViewSet): + serializer_class = DefaultIncludedResourcesSerializer + queryset = ForeignKeySource.objects.all() + ordering = ["id"] + + class CustomModel: def __init__(self, response_dict): for k, v in response_dict.items(): @@ -280,6 +401,22 @@ def patch(self, request, *args, **kwargs): router = SimpleRouter() router.register(r"basic_models", BasicModelViewSet, basename="basic-model") +router.register( + r"foreign_key_sources", ForeignKeySourceViewSet, basename="foreign-key-source" +) +router.register( + r"many_to_many_sources", ManyToManySourceViewSet, basename="many-to-many-source" +) +router.register( + r"nested_related_sources", + NestedRelatedSourceViewSet, + basename="nested-related-source", +) +router.register( + r"default_included_resources", + DefaultIncludedResourcesViewSet, + basename="default-included-resources", +) urlpatterns = [ path("custom", CustomAPIView.as_view(), name="custom"), diff --git a/tests/views.py b/tests/views.py index 42d8a0b0..72a7ea59 100644 --- a/tests/views.py +++ b/tests/views.py @@ -1,8 +1,37 @@ from rest_framework_json_api.views import ModelViewSet -from tests.models import BasicModel -from tests.serializers import BasicModelSerializer +from tests.models import ( + BasicModel, + ForeignKeySource, + ManyToManySource, + NestedRelatedSource, +) +from tests.serializers import ( + BasicModelSerializer, + ForeignKeySourceSerializer, + ManyToManySourceSerializer, + NestedRelatedSourceSerializer, +) class BasicModelViewSet(ModelViewSet): serializer_class = BasicModelSerializer queryset = BasicModel.objects.all() + ordering = ["text"] + + +class ForeignKeySourceViewSet(ModelViewSet): + serializer_class = ForeignKeySourceSerializer + queryset = ForeignKeySource.objects.all() + ordering = ["name"] + + +class ManyToManySourceViewSet(ModelViewSet): + serializer_class = ManyToManySourceSerializer + queryset = ManyToManySource.objects.all() + ordering = ["name"] + + +class NestedRelatedSourceViewSet(ModelViewSet): + serializer_class = NestedRelatedSourceSerializer + queryset = NestedRelatedSource.objects.all() + ordering = ["id"] From fc0d0b5a44aaa977dc8f7947edc82be9104a23e6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 17 Apr 2024 23:21:41 +0200 Subject: [PATCH 441/488] Drop support for Django 3.2 (#1219) Run django-upgrade --target-version 4.2 --- CHANGELOG.md | 3 ++- README.rst | 2 +- docs/getting-started.md | 2 +- example/urls_test.py | 6 +++--- setup.py | 2 +- tox.ini | 5 ----- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8a8f07..964f6b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Removed * Removed support for Python 3.7. +* Removed support for Django 3.2. * Removed support for Django 4.0. * Removed support for Django 4.1. * Removed support for Django REST framework 3.13. @@ -33,7 +34,7 @@ any parts of the framework not mentioned in the documentation should generally b ## [6.1.0] - 2023-08-25 -This is the last release supporting Python 3.7, Django 4.0, Django 4.1 and Django REST framework 3.13. +This is the last release supporting Python 3.7, Django 3.2, Django 4.0, Django 4.1 and Django REST framework 3.13. ### Added diff --git a/README.rst b/README.rst index afe00f85..fd340af6 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (3.2, 4.2, 5.0) +2. Django (4.2, 5.0) 3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 5aec6ffe..fd70c79a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (3.2, 4.2, 5.0) +2. Django (4.2, 5.0) 3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/example/urls_test.py b/example/urls_test.py index bb8fbecf..26d4db21 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -1,4 +1,4 @@ -from django.urls import re_path +from django.urls import path, re_path from rest_framework import routers from .api.resources.identity import GenericIdentity, Identity @@ -46,8 +46,8 @@ urlpatterns = [ # old tests - re_path( - r"identities/default/(?P\d+)$", + path( + "identities/default/", GenericIdentity.as_view(), name="user-default", ), diff --git a/setup.py b/setup.py index 4ff629e4..95d5ca0b 100755 --- a/setup.py +++ b/setup.py @@ -107,7 +107,7 @@ def get_package_data(package): install_requires=[ "inflection>=0.5.0", "djangorestframework>=3.14", - "django>=3.2", + "django>=4.2", ], extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], diff --git a/tox.ini b/tox.ini index 354e328c..68646fb4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] envlist = - py{38,39,310}-django32-drf{314,315,master}, py{38,39,310,311,312}-django42-drf{314,315,master}, py{310,311,312}-django50-drf{314,315,master}, black, @@ -9,7 +8,6 @@ envlist = [testenv] deps = - django32: Django>=3.2,<3.3 django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 drf314: djangorestframework>=3.14,<3.15 @@ -48,9 +46,6 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{38,39,310}-django32-drfmaster] -ignore_outcome = true - [testenv:py{38,39,310,311,312}-django42-drfmaster] ignore_outcome = true From 2568417f1940834c058ccf8319cf3e4bc0913bee Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 18 Apr 2024 09:29:43 -0700 Subject: [PATCH 442/488] Scheduled biweekly dependency update for week 15 (#1217) * Update black from 24.3.0 to 24.4.0 * Update django-filter from 23.5 to 24.2 * Update faker from 23.3.0 to 24.9.0 * Update pytest from 8.1.0 to 8.1.1 * Update pytest-cov from 4.1.0 to 5.0.0 * Update pytest-factoryboy from 2.6.1 to 2.7.0 --------- Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 491d81d6..dc65b0ef 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==24.3.0 +black==24.4.0 flake8==7.0.0 flake8-bugbear==24.2.6 flake8-isort==6.1.1 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 0e4a92da..efc82a58 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==23.5 +django-filter==24.2 # once next version has been released (>3.1.0) this # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index b15dd948..d2c31244 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.0 -Faker==23.3.0 -pytest==8.1.0 -pytest-cov==4.1.0 +Faker==24.9.0 +pytest==8.1.1 +pytest-cov==5.0.0 pytest-django==4.8.0 -pytest-factoryboy==2.6.1 +pytest-factoryboy==2.7.0 syrupy==4.6.1 From 6ec7ad49352e822707459d1a1bb79c9333f3d107 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 18 Apr 2024 23:07:02 +0200 Subject: [PATCH 443/488] Avoided shadowing of exception when rendering errors (#1220) --- CHANGELOG.md | 1 + rest_framework_json_api/utils.py | 13 +++++++------ tests/test_views.py | 11 +++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 964f6b16..0b05600b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ any parts of the framework not mentioned in the documentation should generally b * `ModelSerializer` fields are now returned in the same order than DRF * Avoided that an empty attributes dict is rendered in case serializer does not provide any attribute fields. +* Avoided shadowing of exception when rendering errors (regression since 4.3.0). ### Removed diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 2e57fbbd..e12080ac 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -381,11 +381,7 @@ def format_drf_errors(response, context, exc): errors.extend(format_error_object(message, "/data", response)) # handle all errors thrown from serializers else: - # Avoid circular deps - from rest_framework import generics - - has_serializer = isinstance(context["view"], generics.GenericAPIView) - if has_serializer: + try: serializer = context["view"].get_serializer() fields = get_serializer_fields(serializer) or dict() relationship_fields = [ @@ -393,6 +389,11 @@ def format_drf_errors(response, context, exc): for name, field in fields.items() if is_relationship_field(field) ] + except Exception: + # ignore potential errors when retrieving serializer + # as it might shadow error which is currently being + # formatted + serializer = None for field, error in response.data.items(): non_field_error = field == api_settings.NON_FIELD_ERRORS_KEY @@ -401,7 +402,7 @@ def format_drf_errors(response, context, exc): if non_field_error: # Serializer error does not refer to a specific field. pointer = "/data" - elif has_serializer: + elif serializer: # pointer can be determined only if there's a serializer. rel = "relationships" if field in relationship_fields else "attributes" pointer = f"/data/{rel}/{field}" diff --git a/tests/test_views.py b/tests/test_views.py index de5d1b7a..acba7e66 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -154,6 +154,17 @@ def test_list_with_include_nested_related_field( "included" ] + @pytest.mark.urls(__name__) + def test_list_with_invalid_include(self, client, foreign_key_source): + url = reverse("foreign-key-source-list") + response = client.get(url, data={"include": "invalid"}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + result = response.json() + assert ( + result["errors"][0]["detail"] + == "This endpoint does not support the include parameter for path invalid" + ) + @pytest.mark.urls(__name__) def test_list_with_default_included_resources(self, client, foreign_key_source): url = reverse("default-included-resources-list") From 0eabc3909c4a76ea37b7518404a625918cabb671 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 30 Apr 2024 21:31:11 +0200 Subject: [PATCH 444/488] Updated Codecov GitHub action (#1222) --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 17c6a9a5..2f966adc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,8 +30,9 @@ jobs: - name: Run tox targets for ${{ matrix.python-version }} run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) - name: Upload coverage report - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v4 with: + token: ${{ secrets.CODECOV_TOKEN }} env_vars: PYTHON check: name: Run check From 6c609f86502e98f37ff67ca849c8bd79e88b5615 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 1 May 2024 15:07:45 +0200 Subject: [PATCH 445/488] Ensured that sparse fields only applies when rendering not when parsing (#1221) * Added missing name field to ForeignKeySourceSerializer * Only extract attributes provided by serialized data * Added changelog --- CHANGELOG.md | 2 + .../test_non_paginated_responses.py | 4 +- example/tests/integration/test_pagination.py | 2 +- .../integration/test_sparse_fieldsets.py | 1 + example/tests/test_filters.py | 2 +- .../tests/unit/test_renderer_class_methods.py | 4 +- example/views.py | 5 +- rest_framework_json_api/renderers.py | 122 ++++++++++-------- rest_framework_json_api/serializers.py | 45 +++---- tests/serializers.py | 5 +- tests/test_relations.py | 13 +- tests/test_views.py | 37 ++++++ 12 files changed, 156 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b05600b..186c58c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ any parts of the framework not mentioned in the documentation should generally b * Avoided that an empty attributes dict is rendered in case serializer does not provide any attribute fields. * Avoided shadowing of exception when rendering errors (regression since 4.3.0). +* Ensured that sparse fields only applies when rendering, not when parsing. +* Adjusted that sparse fields properly removes meta fields when not defined. ### Removed diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 92d26de3..6434b7a7 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -14,7 +14,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): expected = { "data": [ { - "type": "posts", + "type": "entries", "id": "1", "attributes": { "headline": multiple_entries[0].headline, @@ -70,7 +70,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): }, }, { - "type": "posts", + "type": "entries", "id": "2", "attributes": { "headline": multiple_entries[1].headline, diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 1a4bd056..0f5ac17e 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -14,7 +14,7 @@ def test_pagination_with_single_entry(single_entry, client): expected = { "data": [ { - "type": "posts", + "type": "entries", "id": "1", "attributes": { "headline": single_entry.headline, diff --git a/example/tests/integration/test_sparse_fieldsets.py b/example/tests/integration/test_sparse_fieldsets.py index 605d218d..cf9cee20 100644 --- a/example/tests/integration/test_sparse_fieldsets.py +++ b/example/tests/integration/test_sparse_fieldsets.py @@ -15,6 +15,7 @@ def test_sparse_fieldset_valid_fields(client, entry): entry = data[0] assert entry["attributes"].keys() == {"headline"} assert entry["relationships"].keys() == {"blog"} + assert "meta" not in entry @pytest.mark.parametrize( diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 87f9d059..8e45ded1 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -470,7 +470,7 @@ def test_search_keywords(self): expected_result = { "data": [ { - "type": "posts", + "type": "entries", "id": "7", "attributes": { "headline": "ANTH3868X", diff --git a/example/tests/unit/test_renderer_class_methods.py b/example/tests/unit/test_renderer_class_methods.py index 6e7c9ea1..838f6819 100644 --- a/example/tests/unit/test_renderer_class_methods.py +++ b/example/tests/unit/test_renderer_class_methods.py @@ -102,8 +102,8 @@ def test_extract_attributes(): assert sorted(JSONRenderer.extract_attributes(fields, resource)) == sorted( expected ), "Regular fields should be extracted" - assert sorted(JSONRenderer.extract_attributes(fields, {})) == sorted( - {"username": ""} + assert ( + JSONRenderer.extract_attributes(fields, {}) == {} ), "Should not extract read_only fields on empty serializer" diff --git a/example/views.py b/example/views.py index 9c949684..da171698 100644 --- a/example/views.py +++ b/example/views.py @@ -112,7 +112,10 @@ class BlogCustomViewSet(JsonApiViewSet): class EntryViewSet(ModelViewSet): queryset = Entry.objects.all() - resource_name = "posts" + # TODO it should not be supported to overwrite resource name + # of endpoints with serializers as includes and sparse fields + # cannot be aware of it + # resource_name = "posts" def get_serializer_class(self): return EntrySerializer diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 639f0b11..8c632f6a 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -17,13 +17,26 @@ from rest_framework.settings import api_settings import rest_framework_json_api -from rest_framework_json_api import utils from rest_framework_json_api.relations import ( HyperlinkedMixin, ManySerializerMethodResourceRelatedField, ResourceRelatedField, SkipDataMixin, ) +from rest_framework_json_api.utils import ( + format_errors, + format_field_name, + format_field_names, + get_included_resources, + get_related_resource_type, + get_relation_instance, + get_resource_id, + get_resource_name, + get_resource_type_from_instance, + get_resource_type_from_serializer, + get_serializer_fields, + is_relationship_field, +) class JSONRenderer(renderers.JSONRenderer): @@ -57,31 +70,20 @@ class JSONRenderer(renderers.JSONRenderer): def extract_attributes(cls, fields, resource): """ Builds the `attributes` object of the JSON:API resource object. - """ - data = {} - for field_name, field in iter(fields.items()): - # ID is always provided in the root of JSON:API so remove it from attributes - if field_name == "id": - continue - # don't output a key for write only fields - if fields[field_name].write_only: - continue - # Skip fields with relations - if utils.is_relationship_field(field): - continue - # Skip read_only attribute fields when `resource` is an empty - # serializer. Prevents the "Raw Data" form of the browsable API - # from rendering `"foo": null` for read only fields - try: - resource[field_name] - except KeyError: - if fields[field_name].read_only: - continue + Ensures that ID which is always provided in a JSON:API resource object + and relationships are not returned. + """ - data.update({field_name: resource.get(field_name)}) + invalid_fields = {"id", api_settings.URL_FIELD_NAME} - return utils.format_field_names(data) + return { + format_field_name(field_name): value + for field_name, value in resource.items() + if field_name in fields + and field_name not in invalid_fields + and not is_relationship_field(fields[field_name]) + } @classmethod def extract_relationships(cls, fields, resource, resource_instance): @@ -107,14 +109,14 @@ def extract_relationships(cls, fields, resource, resource_instance): continue # Skip fields without relations - if not utils.is_relationship_field(field): + if not is_relationship_field(field): continue source = field.source - relation_type = utils.get_related_resource_type(field) + relation_type = get_related_resource_type(field) if isinstance(field, relations.HyperlinkedIdentityField): - resolved, relation_instance = utils.get_relation_instance( + resolved, relation_instance = get_relation_instance( resource_instance, source, field.parent ) if not resolved: @@ -166,7 +168,7 @@ def extract_relationships(cls, fields, resource, resource_instance): field, (relations.PrimaryKeyRelatedField, relations.HyperlinkedRelatedField), ): - resolved, relation = utils.get_relation_instance( + resolved, relation = get_relation_instance( resource_instance, f"{source}_id", field.parent ) if not resolved: @@ -189,7 +191,7 @@ def extract_relationships(cls, fields, resource, resource_instance): continue if isinstance(field, relations.ManyRelatedField): - resolved, relation_instance = utils.get_relation_instance( + resolved, relation_instance = get_relation_instance( resource_instance, source, field.parent ) if not resolved: @@ -222,9 +224,7 @@ def extract_relationships(cls, fields, resource, resource_instance): for nested_resource_instance in relation_instance: nested_resource_instance_type = ( relation_type - or utils.get_resource_type_from_instance( - nested_resource_instance - ) + or get_resource_type_from_instance(nested_resource_instance) ) relation_data.append( @@ -243,7 +243,7 @@ def extract_relationships(cls, fields, resource, resource_instance): ) continue - return utils.format_field_names(data) + return format_field_names(data) @classmethod def extract_relation_instance(cls, field, resource_instance): @@ -289,7 +289,7 @@ def extract_included( continue # Skip fields without relations - if not utils.is_relationship_field(field): + if not is_relationship_field(field): continue try: @@ -341,7 +341,7 @@ def extract_included( if isinstance(field, ListSerializer): serializer = field.child - relation_type = utils.get_resource_type_from_serializer(serializer) + relation_type = get_resource_type_from_serializer(serializer) relation_queryset = list(relation_instance) if serializer_data: @@ -350,11 +350,9 @@ def extract_included( nested_resource_instance = relation_queryset[position] resource_type = ( relation_type - or utils.get_resource_type_from_instance( - nested_resource_instance - ) + or get_resource_type_from_instance(nested_resource_instance) ) - serializer_fields = utils.get_serializer_fields( + serializer_fields = get_serializer_fields( serializer.__class__( nested_resource_instance, context=serializer.context ) @@ -378,10 +376,10 @@ def extract_included( ) if isinstance(field, Serializer): - relation_type = utils.get_resource_type_from_serializer(field) + relation_type = get_resource_type_from_serializer(field) # Get the serializer fields - serializer_fields = utils.get_serializer_fields(field) + serializer_fields = get_serializer_fields(field) if serializer_data: new_item = cls.build_json_resource_obj( serializer_fields, @@ -414,7 +412,8 @@ def extract_meta(cls, serializer, resource): meta_fields = getattr(meta, "meta_fields", []) data = {} for field_name in meta_fields: - data.update({field_name: resource.get(field_name)}) + if field_name in resource: + data.update({field_name: resource[field_name]}) return data @classmethod @@ -434,6 +433,24 @@ def extract_root_meta(cls, serializer, resource): data.update(json_api_meta) return data + @classmethod + def _filter_sparse_fields(cls, serializer, fields, resource_name): + request = serializer.context.get("request") + if request: + sparse_fieldset_query_param = f"fields[{resource_name}]" + sparse_fieldset_value = request.query_params.get( + sparse_fieldset_query_param + ) + if sparse_fieldset_value: + sparse_fields = sparse_fieldset_value.split(",") + return { + field_name: field + for field_name, field, in fields.items() + if field_name in sparse_fields + } + + return fields + @classmethod def build_json_resource_obj( cls, @@ -449,11 +466,15 @@ def build_json_resource_obj( """ # Determine type from the instance if the underlying model is polymorphic if force_type_resolution: - resource_name = utils.get_resource_type_from_instance(resource_instance) + resource_name = get_resource_type_from_instance(resource_instance) resource_data = { "type": resource_name, - "id": utils.get_resource_id(resource_instance, resource), + "id": get_resource_id(resource_instance, resource), } + + # TODO remove this filter by rewriting extract_relationships + # so it uses the serialized data as a basis + fields = cls._filter_sparse_fields(serializer, fields, resource_name) attributes = cls.extract_attributes(fields, resource) if attributes: resource_data["attributes"] = attributes @@ -468,7 +489,7 @@ def build_json_resource_obj( meta = cls.extract_meta(serializer, resource) if meta: - resource_data["meta"] = utils.format_field_names(meta) + resource_data["meta"] = format_field_names(meta) return resource_data @@ -485,7 +506,7 @@ def render_relationship_view( def render_errors(self, data, accepted_media_type=None, renderer_context=None): return super().render( - utils.format_errors(data), accepted_media_type, renderer_context + format_errors(data), accepted_media_type, renderer_context ) def render(self, data, accepted_media_type=None, renderer_context=None): @@ -495,7 +516,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): request = renderer_context.get("request", None) # Get the resource name. - resource_name = utils.get_resource_name(renderer_context) + resource_name = get_resource_name(renderer_context) # If this is an error response, skip the rest. if resource_name == "errors": @@ -531,7 +552,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): serializer = getattr(serializer_data, "serializer", None) - included_resources = utils.get_included_resources(request, serializer) + included_resources = get_included_resources(request, serializer) if serializer is not None: # Extract root meta for any type of serializer @@ -558,7 +579,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): else: resource_serializer_class = serializer.child - fields = utils.get_serializer_fields(resource_serializer_class) + fields = get_serializer_fields(resource_serializer_class) force_type_resolution = getattr( resource_serializer_class, "_poly_force_type_resolution", False ) @@ -581,7 +602,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): included_cache, ) else: - fields = utils.get_serializer_fields(serializer) + fields = get_serializer_fields(serializer) force_type_resolution = getattr( serializer, "_poly_force_type_resolution", False ) @@ -640,7 +661,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): ) if json_api_meta: - render_data["meta"] = utils.format_field_names(json_api_meta) + render_data["meta"] = format_field_names(json_api_meta) return super().render(render_data, accepted_media_type, renderer_context) @@ -690,7 +711,6 @@ def get_includes_form(self, view): serializer_class = view.get_serializer_class() except AttributeError: return - if not hasattr(serializer_class, "included_serializers"): return diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index c680f60a..66650caf 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -75,35 +75,32 @@ class SparseFieldsetsMixin: Specification: https://jsonapi.org/format/#fetching-sparse-fieldsets """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - context = kwargs.get("context") - request = context.get("request") if context else None + @property + def _readable_fields(self): + request = self.context.get("request") if self.context else None + readable_fields = super()._readable_fields if request: - sparse_fieldset_query_param = "fields[{}]".format( - get_resource_type_from_serializer(self) - ) try: - param_name = next( - key - for key in request.query_params - if sparse_fieldset_query_param == key + resource_type = get_resource_type_from_serializer(self) + sparse_fieldset_query_param = f"fields[{resource_type}]" + + sparse_fieldset_value = request.query_params.get( + sparse_fieldset_query_param ) - except StopIteration: + if sparse_fieldset_value: + sparse_fields = sparse_fieldset_value.split(",") + return ( + field + for field in readable_fields + if field.field_name in sparse_fields + or field.field_name == api_settings.URL_FIELD_NAME + ) + except AttributeError: + # no type on serializer, must be used only as only nested pass - else: - fieldset = request.query_params.get(param_name).split(",") - # iterate over a *copy* of self.fields' underlying dict, because we may - # modify the original during the iteration. - # self.fields is a `rest_framework.utils.serializer_helpers.BindingDict` - for field_name, _field in self.fields.fields.copy().items(): - if ( - field_name == api_settings.URL_FIELD_NAME - ): # leave self link there - continue - if field_name not in fieldset: - self.fields.pop(field_name) + + return readable_fields class IncludedResourcesValidationMixin: diff --git a/tests/serializers.py b/tests/serializers.py index 4159039d..ddf28f98 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -26,7 +26,10 @@ class ForeignKeySourceSerializer(serializers.ModelSerializer): class Meta: model = ForeignKeySource - fields = ("target",) + fields = ( + "name", + "target", + ) class ManyToManyTargetSerializer(serializers.ModelSerializer): diff --git a/tests/test_relations.py b/tests/test_relations.py index ad4bebcb..5f8883be1 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -39,7 +39,9 @@ def test_serialize( settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type - serializer = ForeignKeySourceSerializer(instance={"target": foreign_key_target}) + serializer = ForeignKeySourceSerializer( + instance={"target": foreign_key_target, "name": "Test"} + ) expected = { "type": resource_type, "id": str(foreign_key_target.pk), @@ -85,7 +87,10 @@ def test_deserialize( settings.JSON_API_PLURALIZE_TYPES = pluralize_type serializer = ForeignKeySourceSerializer( - data={"target": {"type": resource_type, "id": str(foreign_key_target.pk)}} + data={ + "target": {"type": resource_type, "id": str(foreign_key_target.pk)}, + "name": "Test", + } ) assert serializer.is_valid() @@ -191,7 +196,9 @@ def test_deserialize_many_to_many_relation( ], ) def test_invalid_resource_id_object(self, resource_identifier, error): - serializer = ForeignKeySourceSerializer(data={"target": resource_identifier}) + serializer = ForeignKeySourceSerializer( + data={"target": resource_identifier, "name": "Test"} + ) assert not serializer.is_valid() assert serializer.errors == {"target": [error]} diff --git a/tests/test_views.py b/tests/test_views.py index acba7e66..468c2cbd 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -237,6 +237,43 @@ def test_delete(self, client, model): assert BasicModel.objects.count() == 0 assert len(response.rendered_content) == 0 + @pytest.mark.urls(__name__) + def test_create_with_sparse_fields(self, client, foreign_key_target): + url = reverse("foreign-key-source-list") + data = { + "data": { + "id": None, + "type": "ForeignKeySource", + "attributes": {"name": "Test"}, + "relationships": { + "target": { + "data": { + "id": str(foreign_key_target.pk), + "type": "ForeignKeyTarget", + } + } + }, + } + } + response = client.post(f"{url}?fields[ForeignKeySource]=target", data=data) + assert response.status_code == status.HTTP_201_CREATED + foreign_key_source = ForeignKeySource.objects.first() + assert foreign_key_source.name == "Test" + assert response.json() == { + "data": { + "id": str(foreign_key_source.pk), + "type": "ForeignKeySource", + "relationships": { + "target": { + "data": { + "id": str(foreign_key_target.pk), + "type": "ForeignKeyTarget", + } + } + }, + } + } + class TestReadonlyModelViewSet: @pytest.mark.parametrize( From ce516c9bddeab436efc22eadbe729be68903f6fd Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 2 May 2024 21:04:02 +0200 Subject: [PATCH 446/488] Removed obsolete warning filter (#1223) --- setup.cfg | 3 --- 1 file changed, 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index 8f0317c8..4230dcbb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -66,9 +66,6 @@ filterwarnings = # Django filter schema generation. Can be removed once we remove # schema support ignore:Built-in schema generation is deprecated. - # can be removed once django filter has released a new version including - # https://github.com/carltongibson/django-filter/pull/1623 - ignore:'pkgutil.find_loader' is deprecated and slated for removal testpaths = example tests From 366769e55e821eba11928125c903ad5043f9ff43 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 2 May 2024 21:33:30 +0200 Subject: [PATCH 447/488] Release 7.0.0 (#1224) --- CHANGELOG.md | 2 +- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 186c58c7..e1d08de6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [7.0.0] - 2024-05-02 ### Added diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 96e6db9d..6d2c35b4 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "6.1.0" +__version__ = "7.0.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From 9d9e47b6915037c220b77746cc7d1b5d8f0f36b1 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 7 May 2024 14:10:31 -0700 Subject: [PATCH 448/488] Scheduled biweekly dependency update for week 18 (#1225) * Update black from 24.4.0 to 24.4.2 * Update flake8-bugbear from 24.2.6 to 24.4.26 * Update sphinx from 7.2.6 to 7.3.7 * Update faker from 24.9.0 to 25.0.1 * Update pytest from 8.1.1 to 8.2.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index dc65b0ef..4c270607 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==24.4.0 +black==24.4.2 flake8==7.0.0 -flake8-bugbear==24.2.6 +flake8-bugbear==24.4.26 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 49668994..13a66219 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==7.2.6 +Sphinx==7.3.7 sphinx_rtd_theme==2.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index d2c31244..1dfa20d4 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ factory-boy==3.3.0 -Faker==24.9.0 -pytest==8.1.1 +Faker==25.0.1 +pytest==8.2.0 pytest-cov==5.0.0 pytest-django==4.8.0 pytest-factoryboy==2.7.0 From 21493c1abda91af80de13202407485ef1a21dc50 Mon Sep 17 00:00:00 2001 From: js-nh Date: Wed, 29 May 2024 10:08:18 +0200 Subject: [PATCH 449/488] Added HTTP 429 Too Many Requests as a possible generic error response (#1226) * Add HTTP 429 Too Many Requests as a possible generic error response * Update CHANGELOG.md --------- Co-authored-by: Oliver Sauder --- CHANGELOG.md | 6 ++ example/tests/__snapshots__/test_openapi.ambr | 60 +++++++++++++++++++ rest_framework_json_api/schemas/openapi.py | 1 + 3 files changed, 67 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d08de6..a61d5faf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Added + +* Added `429 Too Many Requests` as a possible error response in the OpenAPI schema. + ## [7.0.0] - 2024-05-02 ### Added diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 1de8057b..f72c6ff8 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -68,6 +68,16 @@ } }, "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" + }, + "429": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "too many requests" } }, "tags": [ @@ -264,6 +274,16 @@ } }, "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" + }, + "429": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "too many requests" } }, "tags": [ @@ -399,6 +419,16 @@ } }, "description": "not found" + }, + "429": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "too many requests" } }, "tags": [ @@ -546,6 +576,16 @@ } }, "description": "not found" + }, + "429": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "too many requests" } }, "tags": [ @@ -754,6 +794,16 @@ } }, "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" + }, + "429": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "too many requests" } }, "tags": [ @@ -1341,6 +1391,16 @@ } }, "description": "not found" + }, + "429": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "too many requests" } }, "tags": [ diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 650876e6..b44ce7a4 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -807,6 +807,7 @@ def _add_generic_failure_responses(self, operation): for code, reason in [ ("400", "bad request"), ("401", "not authorized"), + ("429", "too many requests"), ]: operation["responses"][code] = self._failure_response(reason) From dbe939a5cd23144f46dc1b191f290b4aa59dd8a4 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 4 Jun 2024 23:49:35 +0200 Subject: [PATCH 450/488] Ensured that URL and id field are kept when using sparse fields (#1231) Ensured that URL and id field are not filtered out when using sparse fields URL field is considered a field in DRF but is not in JSON:API spec therefore we may not exclude it. ID on the other hand is a required field and may not be filtered. --- CHANGELOG.md | 4 ++ rest_framework_json_api/renderers.py | 5 +- rest_framework_json_api/serializers.py | 7 ++- tests/serializers.py | 12 +++++ tests/test_views.py | 64 +++++++++++++++++++++++--- tests/views.py | 15 ++++++ 6 files changed, 98 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a61d5faf..47a6dec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ any parts of the framework not mentioned in the documentation should generally b * Added `429 Too Many Requests` as a possible error response in the OpenAPI schema. +### Fixed + +* Ensured that URL and id field are kept when using sparse fields (regression since 7.0.0) + ## [7.0.0] - 2024-05-02 ### Added diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 8c632f6a..5980b95d 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -446,7 +446,10 @@ def _filter_sparse_fields(cls, serializer, fields, resource_name): return { field_name: field for field_name, field, in fields.items() - if field_name in sparse_fields + if field.field_name in sparse_fields + # URL field is not considered a field in JSON:API spec + # but a link so need to keep it + or field.field_name == api_settings.URL_FIELD_NAME } return fields diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 66650caf..3ba9de86 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -94,10 +94,15 @@ def _readable_fields(self): field for field in readable_fields if field.field_name in sparse_fields + # URL field is not considered a field in JSON:API spec + # but a link so need to keep it or field.field_name == api_settings.URL_FIELD_NAME + # ID is a required field which might have been overwritten + # so need to keep it + or field.field_name == "id" ) except AttributeError: - # no type on serializer, must be used only as only nested + # no type on serializer, may only be used nested pass return readable_fields diff --git a/tests/serializers.py b/tests/serializers.py index ddf28f98..c312b83a 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -1,3 +1,5 @@ +from rest_framework.settings import api_settings + from rest_framework_json_api import serializers from tests.models import ( BasicModel, @@ -32,6 +34,16 @@ class Meta: ) +class ForeignKeySourcetHyperlinkedSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = ForeignKeySource + fields = ( + "name", + "target", + api_settings.URL_FIELD_NAME, + ) + + class ManyToManyTargetSerializer(serializers.ModelSerializer): class Meta: fields = ("name",) diff --git a/tests/test_views.py b/tests/test_views.py index 468c2cbd..45f8aaca 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -16,7 +16,9 @@ from tests.serializers import BasicModelSerializer, ForeignKeyTargetSerializer from tests.views import ( BasicModelViewSet, + ForeignKeySourcetHyperlinkedViewSet, ForeignKeySourceViewSet, + ForeignKeyTargetViewSet, ManyToManySourceViewSet, NestedRelatedSourceViewSet, ) @@ -87,7 +89,7 @@ def test_list(self, client, model): @pytest.mark.urls(__name__) def test_list_with_include_foreign_key(self, client, foreign_key_source): - url = reverse("foreign-key-source-list") + url = reverse("foreignkeysource-list") response = client.get(url, data={"include": "target"}) assert response.status_code == status.HTTP_200_OK result = response.json() @@ -156,7 +158,7 @@ def test_list_with_include_nested_related_field( @pytest.mark.urls(__name__) def test_list_with_invalid_include(self, client, foreign_key_source): - url = reverse("foreign-key-source-list") + url = reverse("foreignkeysource-list") response = client.get(url, data={"include": "invalid"}) assert response.status_code == status.HTTP_400_BAD_REQUEST result = response.json() @@ -195,7 +197,7 @@ def test_retrieve(self, client, model): @pytest.mark.urls(__name__) def test_retrieve_with_include_foreign_key(self, client, foreign_key_source): - url = reverse("foreign-key-source-detail", kwargs={"pk": foreign_key_source.pk}) + url = reverse("foreignkeysource-detail", kwargs={"pk": foreign_key_source.pk}) response = client.get(url, data={"include": "target"}) assert response.status_code == status.HTTP_200_OK result = response.json() @@ -208,6 +210,20 @@ def test_retrieve_with_include_foreign_key(self, client, foreign_key_source): } ] == result["included"] + @pytest.mark.urls(__name__) + def test_retrieve_hyperlinked_with_sparse_fields(self, client, foreign_key_source): + url = reverse( + "foreignkeysourcehyperlinked-detail", kwargs={"pk": foreign_key_source.pk} + ) + response = client.get(url, data={"fields[ForeignKeySource]": "name"}) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data["attributes"] == {"name": foreign_key_source.name} + assert "relationships" not in data + assert data["links"] == { + "self": f"http://testserver/foreign_key_sources/{foreign_key_source.pk}/" + } + @pytest.mark.urls(__name__) def test_patch(self, client, model): data = { @@ -239,7 +255,7 @@ def test_delete(self, client, model): @pytest.mark.urls(__name__) def test_create_with_sparse_fields(self, client, foreign_key_target): - url = reverse("foreign-key-source-list") + url = reverse("foreignkeysource-list") data = { "data": { "id": None, @@ -379,6 +395,28 @@ def test_patch_with_custom_id(self, client): } } + @pytest.mark.urls(__name__) + def test_patch_with_custom_id_with_sparse_fields(self, client): + data = { + "data": { + "id": 2_193_102, + "type": "custom", + "attributes": {"body": "hello"}, + } + } + + url = reverse("custom-id") + + response = client.patch(f"{url}?fields[custom]=body", data=data) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "custom", + "id": "2176ce", # get_id() -> hex + "attributes": {"body": "hello"}, + } + } + # Routing setup @@ -415,13 +453,16 @@ class CustomModelSerializer(serializers.Serializer): id = serializers.IntegerField() -class CustomIdModelSerializer(serializers.Serializer): +class CustomIdSerializer(serializers.Serializer): id = serializers.SerializerMethodField() body = serializers.CharField() def get_id(self, obj): return hex(obj.id)[2:] + class Meta: + resource_name = "custom" + class CustomAPIView(APIView): parser_classes = [JSONParser] @@ -443,14 +484,23 @@ class CustomIdAPIView(APIView): resource_name = "custom" def patch(self, request, *args, **kwargs): - serializer = CustomIdModelSerializer(CustomModel(request.data)) + serializer = CustomIdSerializer( + CustomModel(request.data), context={"request": self.request} + ) return Response(status=status.HTTP_200_OK, data=serializer.data) +# TODO remove basename and use default (lowercase of model) +# this makes using HyperlinkedIdentityField easier and reduces +# configuration in general router = SimpleRouter() router.register(r"basic_models", BasicModelViewSet, basename="basic-model") +router.register(r"foreign_key_sources", ForeignKeySourceViewSet) +router.register(r"foreign_key_targets", ForeignKeyTargetViewSet) router.register( - r"foreign_key_sources", ForeignKeySourceViewSet, basename="foreign-key-source" + r"foreign_key_sources_hyperlinked", + ForeignKeySourcetHyperlinkedViewSet, + "foreignkeysourcehyperlinked", ) router.register( r"many_to_many_sources", ManyToManySourceViewSet, basename="many-to-many-source" diff --git a/tests/views.py b/tests/views.py index 72a7ea59..dba769a6 100644 --- a/tests/views.py +++ b/tests/views.py @@ -2,12 +2,15 @@ from tests.models import ( BasicModel, ForeignKeySource, + ForeignKeyTarget, ManyToManySource, NestedRelatedSource, ) from tests.serializers import ( BasicModelSerializer, ForeignKeySourceSerializer, + ForeignKeySourcetHyperlinkedSerializer, + ForeignKeyTargetSerializer, ManyToManySourceSerializer, NestedRelatedSourceSerializer, ) @@ -25,6 +28,18 @@ class ForeignKeySourceViewSet(ModelViewSet): ordering = ["name"] +class ForeignKeySourcetHyperlinkedViewSet(ModelViewSet): + serializer_class = ForeignKeySourcetHyperlinkedSerializer + queryset = ForeignKeySource.objects.all() + ordering = ["name"] + + +class ForeignKeyTargetViewSet(ModelViewSet): + serializer_class = ForeignKeyTargetSerializer + queryset = ForeignKeyTarget.objects.all() + ordering = ["name"] + + class ManyToManySourceViewSet(ModelViewSet): serializer_class = ManyToManySourceSerializer queryset = ManyToManySource.objects.all() From 3f1ea673d4469f1094d9b1c26616babf32be465d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 6 Jun 2024 15:02:58 +0200 Subject: [PATCH 451/488] Release 7.0.1 (#1232) New release as for regression #1228 --- CHANGELOG.md | 2 +- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a6dec7..12ca5017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [7.0.1] - 2024-06-06 ### Added diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 6d2c35b4..9a8c48a3 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "7.0.0" +__version__ = "7.0.1" __author__ = "" __license__ = "BSD" __copyright__ = "" From 53469f355dcf5c1dd1bf064ed64abc21325f11b0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 24 Jun 2024 21:13:34 +0200 Subject: [PATCH 452/488] Re-enabled overwriting of URL field (#1237) Enabled overwriting of URL field URL_FIELD_NAME is usually used as self-link in links. However it should be allowed to be overwritten as long as not HyperlinkedIdentifyField has been used. --- CHANGELOG.md | 6 ++++ rest_framework_json_api/renderers.py | 11 ++++---- rest_framework_json_api/serializers.py | 10 +++++-- tests/conftest.py | 6 ++++ tests/models.py | 8 ++++++ tests/serializers.py | 10 +++++++ tests/test_views.py | 38 ++++++++++++++++++++++++++ tests/views.py | 8 ++++++ 8 files changed, 89 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ca5017..07ea2cd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Fixed + +* Re-enabled overwriting of url field (regression since 7.0.0) + ## [7.0.1] - 2024-06-06 ### Added diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 5980b95d..03a95b30 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -75,13 +75,11 @@ def extract_attributes(cls, fields, resource): and relationships are not returned. """ - invalid_fields = {"id", api_settings.URL_FIELD_NAME} - return { format_field_name(field_name): value for field_name, value in resource.items() if field_name in fields - and field_name not in invalid_fields + and field_name != "id" and not is_relationship_field(fields[field_name]) } @@ -449,7 +447,10 @@ def _filter_sparse_fields(cls, serializer, fields, resource_name): if field.field_name in sparse_fields # URL field is not considered a field in JSON:API spec # but a link so need to keep it - or field.field_name == api_settings.URL_FIELD_NAME + or ( + field.field_name == api_settings.URL_FIELD_NAME + and isinstance(field, relations.HyperlinkedIdentityField) + ) } return fields @@ -486,7 +487,7 @@ def build_json_resource_obj( resource_data["relationships"] = relationships # Add 'self' link if field is present and valid if api_settings.URL_FIELD_NAME in resource and isinstance( - fields[api_settings.URL_FIELD_NAME], relations.RelatedField + fields[api_settings.URL_FIELD_NAME], relations.HyperlinkedIdentityField ): resource_data["links"] = {"self": resource[api_settings.URL_FIELD_NAME]} diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 3ba9de86..370ae37b 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -6,6 +6,7 @@ from django.utils.module_loading import import_string as import_class_from_dotted_path from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ParseError +from rest_framework.relations import HyperlinkedIdentityField # star import defined so `rest_framework_json_api.serializers` can be # a simple drop in for `rest_framework.serializers` @@ -94,9 +95,12 @@ def _readable_fields(self): field for field in readable_fields if field.field_name in sparse_fields - # URL field is not considered a field in JSON:API spec - # but a link so need to keep it - or field.field_name == api_settings.URL_FIELD_NAME + # URL_FIELD_NAME is the field used as self-link to resource + # however only when it is a HyperlinkedIdentityField + or ( + field.field_name == api_settings.URL_FIELD_NAME + and isinstance(field, HyperlinkedIdentityField) + ) # ID is a required field which might have been overwritten # so need to keep it or field.field_name == "id" diff --git a/tests/conftest.py b/tests/conftest.py index 682d8342..865244e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ ManyToManySource, ManyToManyTarget, NestedRelatedSource, + URLModel, ) @@ -36,6 +37,11 @@ def model(db): return BasicModel.objects.create(text="Model") +@pytest.fixture +def url_instance(db): + return URLModel.objects.create(text="Url", url="https://example.com") + + @pytest.fixture def foreign_key_target(db): return ForeignKeyTarget.objects.create(name="Target") diff --git a/tests/models.py b/tests/models.py index 812ee5bf..63cb6434 100644 --- a/tests/models.py +++ b/tests/models.py @@ -18,6 +18,14 @@ class Meta: ordering = ("id",) +class URLModel(DJAModel): + url = models.URLField() + text = models.CharField(max_length=100) + + class Meta: + ordering = ("id",) + + # Models for relations tests # ManyToMany class ManyToManyTarget(DJAModel): diff --git a/tests/serializers.py b/tests/serializers.py index c312b83a..ef8a51cf 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -8,6 +8,7 @@ ManyToManySource, ManyToManyTarget, NestedRelatedSource, + URLModel, ) @@ -17,6 +18,15 @@ class Meta: model = BasicModel +class URLModelSerializer(serializers.ModelSerializer): + class Meta: + fields = ( + "text", + "url", + ) + model = URLModel + + class ForeignKeyTargetSerializer(serializers.ModelSerializer): class Meta: fields = ("name",) diff --git a/tests/test_views.py b/tests/test_views.py index 45f8aaca..6dfde90b 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -21,6 +21,7 @@ ForeignKeyTargetViewSet, ManyToManySourceViewSet, NestedRelatedSourceViewSet, + URLModelViewSet, ) @@ -182,6 +183,42 @@ def test_list_with_default_included_resources(self, client, foreign_key_source): } ] == result["included"] + @pytest.mark.urls(__name__) + def test_list_allow_overwriting_url_field(self, client, url_instance): + """ + Test overwriting of url is possible. + + URL_FIELD_NAME which is set to 'url' per default is used as self in links. + However if field is overwritten and not a HyperlinkedIdentityField it should be allowed + to use as a attribute as well. + """ + + url = reverse("urlmodel-list") + response = client.get(url) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data == [ + { + "type": "URLModel", + "id": str(url_instance.pk), + "attributes": {"text": "Url", "url": "https://example.com"}, + } + ] + + @pytest.mark.urls(__name__) + def test_list_allow_overwiritng_url_with_sparse_fields(self, client, url_instance): + url = reverse("urlmodel-list") + response = client.get(url, data={"fields[URLModel]": "text"}) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data == [ + { + "type": "URLModel", + "id": str(url_instance.pk), + "attributes": {"text": "Url"}, + } + ] + @pytest.mark.urls(__name__) def test_retrieve(self, client, model): url = reverse("basic-model-detail", kwargs={"pk": model.pk}) @@ -495,6 +532,7 @@ def patch(self, request, *args, **kwargs): # configuration in general router = SimpleRouter() router.register(r"basic_models", BasicModelViewSet, basename="basic-model") +router.register(r"url_models", URLModelViewSet) router.register(r"foreign_key_sources", ForeignKeySourceViewSet) router.register(r"foreign_key_targets", ForeignKeyTargetViewSet) router.register( diff --git a/tests/views.py b/tests/views.py index dba769a6..7958c6b9 100644 --- a/tests/views.py +++ b/tests/views.py @@ -5,6 +5,7 @@ ForeignKeyTarget, ManyToManySource, NestedRelatedSource, + URLModel, ) from tests.serializers import ( BasicModelSerializer, @@ -13,6 +14,7 @@ ForeignKeyTargetSerializer, ManyToManySourceSerializer, NestedRelatedSourceSerializer, + URLModelSerializer, ) @@ -22,6 +24,12 @@ class BasicModelViewSet(ModelViewSet): ordering = ["text"] +class URLModelViewSet(ModelViewSet): + serializer_class = URLModelSerializer + queryset = URLModel.objects.all() + ordering = ["url"] + + class ForeignKeySourceViewSet(ModelViewSet): serializer_class = ForeignKeySourceSerializer queryset = ForeignKeySource.objects.all() From d1163cedd81a62cb55f6acab3e85bb7f9e7d1c0f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 28 Jun 2024 13:33:41 +0200 Subject: [PATCH 453/488] Ensured that no fields are returned when sparse fields is set to an empty value (#1240) --- CHANGELOG.md | 1 + rest_framework_json_api/renderers.py | 2 +- rest_framework_json_api/serializers.py | 2 +- tests/test_views.py | 13 +++++++++++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ea2cd8..d60e4861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Re-enabled overwriting of url field (regression since 7.0.0) +* Ensured that no fields are rendered when sparse fields is set to an empty value. (regression since 7.0.0) ## [7.0.1] - 2024-06-06 diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 03a95b30..8c19934f 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -439,7 +439,7 @@ def _filter_sparse_fields(cls, serializer, fields, resource_name): sparse_fieldset_value = request.query_params.get( sparse_fieldset_query_param ) - if sparse_fieldset_value: + if sparse_fieldset_value is not None: sparse_fields = sparse_fieldset_value.split(",") return { field_name: field diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 370ae37b..d59dbd88 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -89,7 +89,7 @@ def _readable_fields(self): sparse_fieldset_value = request.query_params.get( sparse_fieldset_query_param ) - if sparse_fieldset_value: + if sparse_fieldset_value is not None: sparse_fields = sparse_fieldset_value.split(",") return ( field diff --git a/tests/test_views.py b/tests/test_views.py index 6dfde90b..349d37da 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -219,6 +219,19 @@ def test_list_allow_overwiritng_url_with_sparse_fields(self, client, url_instanc } ] + @pytest.mark.urls(__name__) + def test_list_with_sparse_fields_empty_value(self, client, model): + url = reverse("basic-model-list") + response = client.get(url, data={"fields[BasicModel]": ""}) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data == [ + { + "type": "BasicModel", + "id": str(model.pk), + } + ] + @pytest.mark.urls(__name__) def test_retrieve(self, client, model): url = reverse("basic-model-detail", kwargs={"pk": model.pk}) From 971a8796b79fc4fb9daf26c83d406c19219b428d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 28 Jun 2024 17:08:57 +0200 Subject: [PATCH 454/488] Release 7.0.2 (#1241) --- CHANGELOG.md | 4 ++-- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d60e4861..8c3c7720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [7.0.2] - 2024-06-28 ### Fixed -* Re-enabled overwriting of url field (regression since 7.0.0) +* Allow overwriting of url field again (regression since 7.0.0) * Ensured that no fields are rendered when sparse fields is set to an empty value. (regression since 7.0.0) ## [7.0.1] - 2024-06-06 diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 9a8c48a3..3008837c 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "7.0.1" +__version__ = "7.0.2" __author__ = "" __license__ = "BSD" __copyright__ = "" From 6eff72e1be5fd8359f58c519091016f3e6709e23 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 1 Jul 2024 10:39:28 -0700 Subject: [PATCH 455/488] Scheduled biweekly dependency update for week 26 (#1242) * Update flake8 from 7.0.0 to 7.1.0 * Update twine from 5.0.0 to 5.1.1 * Update faker from 25.0.1 to 26.0.0 * Update pytest from 8.2.0 to 8.2.2 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 4c270607..50c073e5 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==24.4.2 -flake8==7.0.0 +flake8==7.1.0 flake8-bugbear==24.4.26 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 489eeb83..e957043a 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==5.0.0 +twine==5.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 1dfa20d4..317a12b0 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ factory-boy==3.3.0 -Faker==25.0.1 -pytest==8.2.0 +Faker==26.0.0 +pytest==8.2.2 pytest-cov==5.0.0 pytest-django==4.8.0 pytest-factoryboy==2.7.0 From 75a5424fb429196675e437889000a9ba40287670 Mon Sep 17 00:00:00 2001 From: Humayun Ahmad Date: Tue, 23 Jul 2024 11:52:45 +0500 Subject: [PATCH 456/488] Handle zero as valid pk/id in get_resource_id util method (#1245) --------- Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 6 ++++++ rest_framework_json_api/utils.py | 10 ++++------ tests/test_utils.py | 7 +++++++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/AUTHORS b/AUTHORS index 7f4a8237..d421d5e6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ David Guillot, for Contexte David Vogt Felix Viernickel Greg Aker +Humayun Ahmad Jamie Bliss Jason Housley Jeppe Fihl-Pearson diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3c7720..4fb30d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Fixed + +* Handled zero as a valid ID for resource (regression since 6.1.0) + ## [7.0.2] - 2024-06-28 ### Fixed diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index e12080ac..805f5f09 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -307,13 +307,11 @@ def get_resource_type_from_serializer(serializer): def get_resource_id(resource_instance, resource): """Returns the resource identifier for a given instance (`id` takes priority over `pk`).""" if resource and "id" in resource: - return resource["id"] and encoding.force_str(resource["id"]) or None + _id = resource["id"] + return encoding.force_str(_id) if _id is not None else None if resource_instance: - return ( - hasattr(resource_instance, "pk") - and encoding.force_str(resource_instance.pk) - or None - ) + pk = getattr(resource_instance, "pk", None) + return encoding.force_str(pk) if pk is not None else None return None diff --git a/tests/test_utils.py b/tests/test_utils.py index 4e103ae2..a3beb12e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -403,6 +403,13 @@ class SerializerWithoutResourceName(serializers.Serializer): (None, {"id": 11}, "11"), (object(), {"pk": 11}, None), (BasicModel(id=6), {"id": 11}, "11"), + (BasicModel(id=0), None, "0"), + (None, {"id": 0}, "0"), + ( + BasicModel(id=0), + {"id": 0}, + "0", + ), ], ) def test_get_resource_id(resource_instance, resource, expected): From e4c587177483a6cc0bc3248c6183da6734e97289 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 5 Aug 2024 10:54:51 -0700 Subject: [PATCH 457/488] Scheduled biweekly dependency update for week 31 (#1246) * Update black from 24.4.2 to 24.8.0 * Update flake8 from 7.1.0 to 7.1.1 * Update sphinx from 7.3.7 to 7.4.7 * Update django-filter from 24.2 to 24.3 * Update faker from 26.0.0 to 26.1.0 * Update pytest from 8.2.2 to 8.3.2 --------- Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 50c073e5..d826f918 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==24.4.2 -flake8==7.1.0 +black==24.8.0 +flake8==7.1.1 flake8-bugbear==24.4.26 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 13a66219..ea5200ab 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==7.3.7 +Sphinx==7.4.7 sphinx_rtd_theme==2.0.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index efc82a58..df0ef24d 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==24.2 +django-filter==24.3 # once next version has been released (>3.1.0) this # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 317a12b0..9c7ebb66 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ factory-boy==3.3.0 -Faker==26.0.0 -pytest==8.2.2 +Faker==26.1.0 +pytest==8.3.2 pytest-cov==5.0.0 pytest-django==4.8.0 pytest-factoryboy==2.7.0 From cecd31faa99575202f9223f8566e27383a05cb64 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 1 Sep 2024 01:15:31 +0400 Subject: [PATCH 458/488] Added support for Django 5.1 (#1249) --- CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 5 +++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb30d1b..3b200cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ any parts of the framework not mentioned in the documentation should generally b * Handled zero as a valid ID for resource (regression since 6.1.0) +### Added + +* Added support for Django 5.1 + ## [7.0.2] - 2024-06-28 ### Fixed diff --git a/README.rst b/README.rst index fd340af6..423f132a 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (4.2, 5.0) +2. Django (4.2, 5.0, 5.1) 3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index fd70c79a..cef2c5b4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (4.2, 5.0) +2. Django (4.2, 5.0, 5.1) 3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/tox.ini b/tox.ini index 68646fb4..0f65b588 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = py{38,39,310,311,312}-django42-drf{314,315,master}, - py{310,311,312}-django50-drf{314,315,master}, + py{310,311,312}-django{50,51}-drf{314,315,master}, black, docs, lint @@ -10,6 +10,7 @@ envlist = deps = django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 + django51: Django>=5.1,<5.2 drf314: djangorestframework>=3.14,<3.15 drf315: djangorestframework>=3.15,<3.16 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip @@ -49,5 +50,5 @@ commands = [testenv:py{38,39,310,311,312}-django42-drfmaster] ignore_outcome = true -[testenv:py{310,311,312}-django50-drfmaster] +[testenv:py{310,311,312}-django{50,51}-drfmaster] ignore_outcome = true From e745d6bee508a7ea10080cd51f7f311d5de6e585 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 10 Sep 2024 15:32:16 +0400 Subject: [PATCH 459/488] Ensured that patching a To-Many relationship correctly raises request error (#1251) --- CHANGELOG.md | 2 ++ example/tests/test_views.py | 21 ++++++++++++++++++--- rest_framework_json_api/parsers.py | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b200cdf..ae0aaf32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Handled zero as a valid ID for resource (regression since 6.1.0) +* Ensured that patching a To-Many relationship with the `RelationshipView` correctly raises request error when passing in `None`. + For emptying a To-Many relationship an empty array should be used as per [JSON:API spec](https://jsonapi.org/format/#crud-updating-to-many-relationships) ### Added diff --git a/example/tests/test_views.py b/example/tests/test_views.py index ad3fe124..47d4adb1 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -2,6 +2,7 @@ from django.test import RequestFactory, override_settings from django.utils import timezone +from rest_framework import status from rest_framework.exceptions import NotFound from rest_framework.request import Request from rest_framework.reverse import reverse @@ -174,16 +175,30 @@ def test_patch_one_to_many_relationship(self): response = self.client.get(url) assert response.data == request_data["data"] - def test_patch_one_to_many_relaitonship_with_none(self): + def test_patch_one_to_many_relaitonship_with_empty(self): url = f"/blogs/{self.first_entry.id}/relationships/entry_set" - request_data = {"data": None} + + request_data = {"data": []} response = self.client.patch(url, data=request_data) - assert response.status_code == 200, response.content.decode() + assert response.status_code == status.HTTP_200_OK assert response.data == [] response = self.client.get(url) assert response.data == [] + def test_patch_one_to_many_relaitonship_with_none(self): + """ + None for a to many relationship is invalid and should return a request error. + + see https://jsonapi.org/format/#crud-updating-to-many-relationships + """ + + url = f"/blogs/{self.first_entry.id}/relationships/entry_set" + + request_data = {"data": None} + response = self.client.patch(url, data=request_data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_patch_many_to_many_relationship(self): url = f"/entries/{self.first_entry.id}/relationships/authors" request_data = { diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index a0a2aeb2..8940c653 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -98,7 +98,7 @@ def parse_data(self, result, parser_context): "Received data contains one or more malformed JSON:API " "Resource Identifier Object(s)" ) - elif not (data.get("id") and data.get("type")): + elif isinstance(data, dict) and not (data.get("id") and data.get("type")): raise ParseError( "Received data is not a valid JSON:API Resource Identifier Object" ) From 5123a267be5abc37dd45c3f982e54c39fe335314 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 10 Sep 2024 11:48:39 -0700 Subject: [PATCH 460/488] Scheduled biweekly dependency update for week 35 (#1250) * Update flake8-bugbear from 24.4.26 to 24.8.19 * Update pyyaml from 6.0.1 to 6.0.2 * Update factory-boy from 3.3.0 to 3.3.1 * Update faker from 26.1.0 to 28.1.0 * Update syrupy from 4.6.1 to 4.7.1 * Updated to Python 3.10 for checks * As for sphinx-rtd-theme Sphinx needs to be lower than 8 --------- Co-authored-by: Oliver Sauder --- .github/workflows/tests.yml | 4 ++-- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- tox.ini | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2f966adc..622e45b9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,10 +43,10 @@ jobs: tox-env: ["black", "lint", "docs"] steps: - uses: actions/checkout@v2 - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index d826f918..fcbf26ec 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==24.8.0 flake8==7.1.1 -flake8-bugbear==24.4.26 +flake8-bugbear==24.8.19 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index df0ef24d..589636e6 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -3,5 +3,5 @@ django-filter==24.3 # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore -pyyaml==6.0.1 +pyyaml==6.0.2 uritemplate==4.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 9c7ebb66..b95d04d8 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ -factory-boy==3.3.0 -Faker==26.1.0 +factory-boy==3.3.1 +Faker==28.1.0 pytest==8.3.2 pytest-cov==5.0.0 pytest-django==4.8.0 pytest-factoryboy==2.7.0 -syrupy==4.6.1 +syrupy==4.7.1 diff --git a/tox.ini b/tox.ini index 0f65b588..2b89dc40 100644 --- a/tox.ini +++ b/tox.ini @@ -25,13 +25,13 @@ commands = pytest --cov --no-cov-on-fail --cov-report xml {posargs} [testenv:black] -basepython = python3.9 +basepython = python3.10 deps = -rrequirements/requirements-codestyle.txt commands = black --check . [testenv:lint] -basepython = python3.9 +basepython = python3.10 deps = -rrequirements/requirements-codestyle.txt -rrequirements/requirements-testing.txt @@ -40,7 +40,7 @@ commands = flake8 [testenv:docs] # keep in sync with .readthedocs.yml -basepython = python3.9 +basepython = python3.10 deps = -rrequirements/requirements-optionals.txt -rrequirements/requirements-documentation.txt From 4ceee03893434056d28318fb6bd4ee85f26d57fe Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 3 Oct 2024 20:42:29 +0400 Subject: [PATCH 461/488] Deprecated built-in support for OpenAPI schema generation (#1253) --- CHANGELOG.md | 4 ++++ docs/usage.md | 20 +++++++++++++++++++- example/tests/test_openapi.py | 3 +++ rest_framework_json_api/schemas/openapi.py | 10 ++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae0aaf32..cebd3f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Django 5.1 +### Deprecated + +* Deprecated built-in support for generating OpenAPI schema. Use [drf-spectacular-json-api](https://github.com/jokiefer/drf-spectacular-json-api/) instead. + ## [7.0.2] - 2024-06-28 ### Fixed diff --git a/docs/usage.md b/docs/usage.md index a50a97f7..1a2cb195 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -31,7 +31,6 @@ REST_FRAMEWORK = { 'rest_framework_json_api.renderers.BrowsableAPIRenderer' ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', - 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.QueryParameterValidationFilter', 'rest_framework_json_api.filters.OrderingFilter', @@ -1062,6 +1061,20 @@ DRF has a [OAS schema functionality](https://www.django-rest-framework.org/api-g DJA extends DRF's schema support to generate an OAS schema in the JSON:API format. +--- + +**Deprecation notice:** + +REST framework's built-in support for generating OpenAPI schemas is +**deprecated** in favor of 3rd party packages that can provide this +functionality instead. Therefore we have also deprecated the schema support in +Django REST framework JSON:API. The built-in support will be retired over the +next releases. + +As a full-fledged replacement, we recommend the [drf-spectacular-json-api] package. + +--- + ### AutoSchema Settings In order to produce an OAS schema that properly represents the JSON:API structure @@ -1187,3 +1200,8 @@ We aim to make creating third party packages as easy as possible, whilst keeping To submit new content, [open an issue](https://github.com/django-json-api/django-rest-framework-json-api/issues/new/choose) or [create a pull request](https://github.com/django-json-api/django-rest-framework-json-api/compare). * [drf-yasg-json-api](https://github.com/glowka/drf-yasg-json-api) - Automated generation of Swagger/OpenAPI 2.0 from Django REST framework JSON:API endpoints. +* [drf-spectacular-json-api] - OpenAPI 3 schema generator for Django REST framework JSON:API based on drf-spectacular. + + + +[drf-spectacular-json-api]: https://github.com/jokiefer/drf-spectacular-json-api/ diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 2333dd6a..fa2f9c73 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -1,6 +1,7 @@ # largely based on DRF's test_openapi import json +import pytest from django.test import RequestFactory, override_settings from django.urls import re_path from rest_framework.request import Request @@ -9,6 +10,8 @@ from example import views +pytestmark = pytest.mark.filterwarnings("ignore:Built-in support") + def create_request(path): factory = RequestFactory() diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index b44ce7a4..6892e991 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -423,6 +423,16 @@ def get_operation(self, path, method): - collections - special handling for POST, PATCH, DELETE """ + + warnings.warn( + DeprecationWarning( + "Built-in support for generating OpenAPI schema is deprecated. " + "Use drf-spectacular-json-api instead see " + "https://github.com/jokiefer/drf-spectacular-json-api/" + ), + stacklevel=2, + ) + operation = {} operation["operationId"] = self.get_operation_id(path, method) operation["description"] = self.get_description(path, method) From b0c6aee1e7e7edc5e32bf5ae992eaa6b3b40840a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 7 Oct 2024 08:17:57 -0700 Subject: [PATCH 462/488] Scheduled biweekly dependency update for week 40 (#1254) * Update sphinx from 7.4.7 to 8.0.2 * Update sphinx_rtd_theme from 2.0.0 to 3.0.0 * Update faker from 28.1.0 to 30.1.0 * Update pytest from 8.3.2 to 8.3.3 * Update pytest-django from 4.8.0 to 4.9.0 * Update syrupy from 4.7.1 to 4.7.2 --- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-testing.txt | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index ea5200ab..4a2139cd 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==7.4.7 -sphinx_rtd_theme==2.0.0 +Sphinx==8.0.2 +sphinx_rtd_theme==3.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index b95d04d8..05c423b4 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.1 -Faker==28.1.0 -pytest==8.3.2 +Faker==30.1.0 +pytest==8.3.3 pytest-cov==5.0.0 -pytest-django==4.8.0 +pytest-django==4.9.0 pytest-factoryboy==2.7.0 -syrupy==4.7.1 +syrupy==4.7.2 From c82ea18d5eb71c1cc0bceee6869eb426c53060f0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 8 Oct 2024 15:26:26 +0400 Subject: [PATCH 463/488] Bumped docs Python version to 3.10 (#1255) --- .readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 997afe3f..fa503604 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,7 +3,7 @@ version: 2 build: os: "ubuntu-22.04" tools: - python: "3.9" + python: "3.10" sphinx: configuration: docs/conf.py From c686ded0559dcb6cddc630c72ab163c720175970 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 8 Oct 2024 15:51:59 +0400 Subject: [PATCH 464/488] Added support for 3.13 (#1256) * Added support for 3.13 This will only be supported on Django 5.1 and above. see https://forum.djangoproject.com/t/backport-python-3-13-support-in-django-5-0/34671 * As Github as not officially released 3.13 yet use latest dev version * There was an upstream change in DRF which as not been release yet so only master till it is released. see https://github.com/encode/django-rest-framework/pull/9527/files * Remove ignoring with drfmaster --- .github/workflows/tests.yml | 4 ++-- CHANGELOG.md | 1 + README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 1 + tox.ini | 7 +------ 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 622e45b9..29a36a3c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13-dev"] env: PYTHON: ${{ matrix.python-version }} steps: @@ -28,7 +28,7 @@ jobs: python -m pip install --upgrade pip pip install tox - name: Run tox targets for ${{ matrix.python-version }} - run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) + run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d . | cut -f 1 -d '-') - name: Upload coverage report uses: codecov/codecov-action@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index cebd3f8a..a0ad16ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Django 5.1 +* Added support for Python 3.13 ### Deprecated diff --git a/README.rst b/README.rst index 423f132a..0c9b842f 100644 --- a/README.rst +++ b/README.rst @@ -92,7 +92,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.8, 3.9, 3.10, 3.11, 3.12) +1. Python (3.8, 3.9, 3.10, 3.11, 3.12, 3.13) 2. Django (4.2, 5.0, 5.1) 3. Django REST framework (3.14, 3.15) diff --git a/docs/getting-started.md b/docs/getting-started.md index cef2c5b4..a7de353a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.8, 3.9, 3.10, 3.11, 3.12) +1. Python (3.8, 3.9, 3.10, 3.11, 3.12, 3.13) 2. Django (4.2, 5.0, 5.1) 3. Django REST framework (3.14, 3.15) diff --git a/setup.py b/setup.py index 95d5ca0b..652ab85b 100755 --- a/setup.py +++ b/setup.py @@ -91,6 +91,7 @@ def get_package_data(package): "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/tox.ini b/tox.ini index 2b89dc40..a2accaad 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = py{38,39,310,311,312}-django42-drf{314,315,master}, py{310,311,312}-django{50,51}-drf{314,315,master}, + py313-django51-drf{master}, black, docs, lint @@ -46,9 +47,3 @@ deps = -rrequirements/requirements-documentation.txt commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html - -[testenv:py{38,39,310,311,312}-django42-drfmaster] -ignore_outcome = true - -[testenv:py{310,311,312}-django{50,51}-drfmaster] -ignore_outcome = true From 9598b0c259b3cb0bc9a35e8c0b1acddca948c47b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 9 Oct 2024 15:21:14 +0400 Subject: [PATCH 465/488] Remove obsolete CodeQL Workflow (#1257) --- .github/workflows/codeql-analysis.yml | 74 --------------------------- 1 file changed, 74 deletions(-) delete mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 8faf1a2f..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,74 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ "main" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] - schedule: - - cron: '39 4 * * 2' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" From dc2354bf221e965cb43c96d4b1fdbc7a5d1b29df Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 9 Oct 2024 21:26:26 +0400 Subject: [PATCH 466/488] Use final release of 3.13 (#1258) This has been released now on Github. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 29a36a3c..ca736986 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13-dev"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] env: PYTHON: ${{ matrix.python-version }} steps: From cedeb3b5bd7c4b342109b0180e39eb15109ac78e Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 21 Oct 2024 08:02:08 -0700 Subject: [PATCH 467/488] Scheduled biweekly dependency update for week 42 (#1259) * Update black from 24.8.0 to 24.10.0 * Update sphinx from 8.0.2 to 8.1.3 * Update sphinx_rtd_theme from 3.0.0 to 3.0.1 * Update faker from 30.1.0 to 30.6.0 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-testing.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index fcbf26ec..f5e5e92c 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==24.8.0 +black==24.10.0 flake8==7.1.1 flake8-bugbear==24.8.19 flake8-isort==6.1.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 4a2139cd..aa120a8e 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==8.0.2 -sphinx_rtd_theme==3.0.0 +Sphinx==8.1.3 +sphinx_rtd_theme==3.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 05c423b4..b56d8185 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ factory-boy==3.3.1 -Faker==30.1.0 +Faker==30.6.0 pytest==8.3.3 pytest-cov==5.0.0 pytest-django==4.9.0 From f2c489bff211cd38d4f5850b2cbc9373958e4037 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 25 Oct 2024 14:26:59 +0400 Subject: [PATCH 468/488] Release 7.1.0 (#1260) --- CHANGELOG.md | 2 +- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ad16ed..07cd7d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [7.1.0] - 2024-10-25 ### Fixed diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 3008837c..a69daa9f 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "7.0.2" +__version__ = "7.1.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From 1654ae1f407dcf44a66a11fc19d68a9d8e2e5de3 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 18 Nov 2024 08:27:37 -0800 Subject: [PATCH 469/488] Scheduled biweekly dependency update for week 46 (#1263) * Update flake8-bugbear from 24.8.19 to 24.10.31 * Update sphinx_rtd_theme from 3.0.1 to 3.0.2 * Update faker from 30.6.0 to 33.0.0 * Update pytest-cov from 5.0.0 to 6.0.0 * Revert pytest-cov currently still need to support 3.8 --------- Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index f5e5e92c..83eab224 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==24.10.0 flake8==7.1.1 -flake8-bugbear==24.8.19 +flake8-bugbear==24.10.31 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index aa120a8e..76f3bf9a 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 Sphinx==8.1.3 -sphinx_rtd_theme==3.0.1 +sphinx_rtd_theme==3.0.2 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index b56d8185..63ed4a7c 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ factory-boy==3.3.1 -Faker==30.6.0 +Faker==33.0.0 pytest==8.3.3 pytest-cov==5.0.0 pytest-django==4.9.0 From 1b5fb9c8846bff9e3b6ba56d01a7cb033cd0a281 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 12 Jan 2025 23:57:26 -0500 Subject: [PATCH 470/488] Removed support for Python 3.8 (#1266) --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 9 +++++++++ README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 3 +-- tox.ini | 2 +- 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ca736986..0852bba1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] env: PYTHON: ${{ matrix.python-version }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 07cd7d8f..f1825191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [unreleased] + +### Removed + +* Removed support for Python 3.8. + + ## [7.1.0] - 2024-10-25 +This is the last release supporting Python 3.8. + ### Fixed * Handled zero as a valid ID for resource (regression since 6.1.0) diff --git a/README.rst b/README.rst index 0c9b842f..bf5daa73 100644 --- a/README.rst +++ b/README.rst @@ -92,7 +92,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.8, 3.9, 3.10, 3.11, 3.12, 3.13) +1. Python (3.9, 3.10, 3.11, 3.12, 3.13) 2. Django (4.2, 5.0, 5.1) 3. Django REST framework (3.14, 3.15) diff --git a/docs/getting-started.md b/docs/getting-started.md index a7de353a..1799337b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.8, 3.9, 3.10, 3.11, 3.12, 3.13) +1. Python (3.9, 3.10, 3.11, 3.12, 3.13) 2. Django (4.2, 5.0, 5.1) 3. Django REST framework (3.14, 3.15) diff --git a/setup.py b/setup.py index 652ab85b..779d00c1 100755 --- a/setup.py +++ b/setup.py @@ -86,7 +86,6 @@ def get_package_data(package): "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -116,6 +115,6 @@ def get_package_data(package): "openapi": ["pyyaml>=5.4", "uritemplate>=3.0.1"], }, setup_requires=wheel, - python_requires=">=3.8", + python_requires=">=3.9", zip_safe=False, ) diff --git a/tox.ini b/tox.ini index a2accaad..504a9d2e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{38,39,310,311,312}-django42-drf{314,315,master}, + py{39,310,311,312}-django42-drf{314,315,master}, py{310,311,312}-django{50,51}-drf{314,315,master}, py313-django51-drf{master}, black, From d297405f79fab39b379e69da8fc93986c36105ed Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 20 Jan 2025 08:32:01 -0800 Subject: [PATCH 471/488] Scheduled biweekly dependency update for week 03 (#1268) * Update flake8-bugbear from 24.10.31 to 24.12.12 * Update twine from 5.1.1 to 6.0.1 * Update faker from 33.0.0 to 33.3.1 * Update pytest from 8.3.3 to 8.3.4 * Update pytest-cov from 5.0.0 to 6.0.0 * Update syrupy from 4.7.2 to 4.8.1 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 83eab224..025c050b 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==24.10.0 flake8==7.1.1 -flake8-bugbear==24.10.31 +flake8-bugbear==24.12.12 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index e957043a..09ce4dfb 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==5.1.1 +twine==6.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 63ed4a7c..e2f9b287 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.1 -Faker==33.0.0 -pytest==8.3.3 -pytest-cov==5.0.0 +Faker==33.3.1 +pytest==8.3.4 +pytest-cov==6.0.0 pytest-django==4.9.0 pytest-factoryboy==2.7.0 -syrupy==4.7.2 +syrupy==4.8.1 From abfda8fd48cb6758dcda5f60f95db7e09cb87ee4 Mon Sep 17 00:00:00 2001 From: Vitaliy Date: Thu, 23 Jan 2025 21:43:50 +0500 Subject: [PATCH 472/488] Update documentation to include --pythonpath to set up the example app (#1270) Add --pythonpath to instructions for setting up example app --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index bf5daa73..83d5d7ab 100644 --- a/README.rst +++ b/README.rst @@ -149,9 +149,9 @@ installed and activated: $ git clone https://github.com/django-json-api/django-rest-framework-json-api.git $ cd django-rest-framework-json-api $ pip install -Ur requirements.txt - $ django-admin migrate --settings=example.settings - $ django-admin loaddata drf_example --settings=example.settings - $ django-admin runserver --settings=example.settings + $ django-admin migrate --settings=example.settings --pythonpath . + $ django-admin loaddata drf_example --settings=example.settings --pythonpath . + $ django-admin runserver --settings=example.settings --pythonpath . Browse to From 0476cbc9f5f5bee226481506340a5f5c06479e24 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 3 Feb 2025 10:13:47 -0800 Subject: [PATCH 473/488] Scheduled biweekly dependency update for week 05 (#1271) * Update black from 24.10.0 to 25.1.0 * Update flake8-isort from 6.1.1 to 6.1.2 * Update isort from 5.13.2 to 6.0.0 * Update twine from 6.0.1 to 6.1.0 * Update factory-boy from 3.3.1 to 3.3.3 * Update faker from 33.3.1 to 35.2.0 --- requirements/requirements-codestyle.txt | 6 +++--- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 025c050b..1799b010 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==24.10.0 +black==25.1.0 flake8==7.1.1 flake8-bugbear==24.12.12 -flake8-isort==6.1.1 -isort==5.13.2 +flake8-isort==6.1.2 +isort==6.0.0 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 09ce4dfb..54882779 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==6.0.1 +twine==6.1.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index e2f9b287..0f58e66a 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ -factory-boy==3.3.1 -Faker==33.3.1 +factory-boy==3.3.3 +Faker==35.2.0 pytest==8.3.4 pytest-cov==6.0.0 pytest-django==4.9.0 From eac2f9f766aa97c6ff27c56b7bdae831366c10d7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 16 Apr 2025 20:44:32 +0400 Subject: [PATCH 474/488] Added support for Django REST framework 3.16 (#1279) --- CHANGELOG.md | 9 +++++++-- README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 2 +- tox.ini | 6 +++--- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1825191..7a6ae7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,16 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [unreleased] +## [Unreleased] + +### Added + +* Added support for Django REST framework 3.16. ### Removed * Removed support for Python 3.8. +* Removed support for Django REST framework 3.14. ## [7.1.0] - 2024-10-25 -This is the last release supporting Python 3.8. +This is the last release supporting Python 3.8 and Django REST framework 3.14. ### Fixed diff --git a/README.rst b/README.rst index 83d5d7ab..21b2e3f0 100644 --- a/README.rst +++ b/README.rst @@ -94,7 +94,7 @@ Requirements 1. Python (3.9, 3.10, 3.11, 3.12, 3.13) 2. Django (4.2, 5.0, 5.1) -3. Django REST framework (3.14, 3.15) +3. Django REST framework (3.15, 3.16) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 1799337b..461419c7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,7 +53,7 @@ like the following: 1. Python (3.9, 3.10, 3.11, 3.12, 3.13) 2. Django (4.2, 5.0, 5.1) -3. Django REST framework (3.14, 3.15) +3. Django REST framework (3.15, 3.16) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/setup.py b/setup.py index 779d00c1..de61b0d1 100755 --- a/setup.py +++ b/setup.py @@ -106,7 +106,7 @@ def get_package_data(package): }, install_requires=[ "inflection>=0.5.0", - "djangorestframework>=3.14", + "djangorestframework>=3.15", "django>=4.2", ], extras_require={ diff --git a/tox.ini b/tox.ini index 504a9d2e..45a4e20b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = - py{39,310,311,312}-django42-drf{314,315,master}, - py{310,311,312}-django{50,51}-drf{314,315,master}, + py{39,310,311,312}-django42-drf{315,316,master}, + py{310,311,312}-django{50,51}-drf{315,316,master}, py313-django51-drf{master}, black, docs, @@ -12,8 +12,8 @@ deps = django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 django51: Django>=5.1,<5.2 - drf314: djangorestframework>=3.14,<3.15 drf315: djangorestframework>=3.15,<3.16 + drf316: djangorestframework>=3.16,<3.17 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt From 76b4c41944bc142ba4241d0fc258ce8070444b41 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 21 Apr 2025 09:32:44 -0700 Subject: [PATCH 475/488] Scheduled biweekly dependency update for week 16 (#1280) * Update flake8 from 7.1.1 to 7.2.0 * Update isort from 6.0.0 to 6.0.1 * Update faker from 35.2.0 to 37.1.0 * Update pytest from 8.3.4 to 8.3.5 * Update pytest-cov from 6.0.0 to 6.1.1 * Update pytest-django from 4.9.0 to 4.11.1 * Update syrupy from 4.8.1 to 4.9.1 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-testing.txt | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 1799b010..1d4184ad 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==25.1.0 -flake8==7.1.1 +flake8==7.2.0 flake8-bugbear==24.12.12 flake8-isort==6.1.2 -isort==6.0.0 +isort==6.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 0f58e66a..35caa0a9 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.3 -Faker==35.2.0 -pytest==8.3.4 -pytest-cov==6.0.0 -pytest-django==4.9.0 +Faker==37.1.0 +pytest==8.3.5 +pytest-cov==6.1.1 +pytest-django==4.11.1 pytest-factoryboy==2.7.0 -syrupy==4.8.1 +syrupy==4.9.1 From 0394cf91b87f6d488be3da8ef585df325786c73a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 21 Apr 2025 21:21:50 +0400 Subject: [PATCH 476/488] Updated Django support (#1281) * Updated Django support Added support for Django 5.2 Removed outdated Django 5.0 * Python 3.13 should not run DRF 3.15 tests * Only DRF 3.16 supports Python 3.13 --- CHANGELOG.md | 4 +++- README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 6 +++--- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a6ae7e9..3ec7ff7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,16 +13,18 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Django REST framework 3.16. +* Added support for Django 5.2. ### Removed * Removed support for Python 3.8. * Removed support for Django REST framework 3.14. +* Removed support for Django 5.0. ## [7.1.0] - 2024-10-25 -This is the last release supporting Python 3.8 and Django REST framework 3.14. +This is the last release supporting Python 3.8, Django 5.0 and Django REST framework 3.14. ### Fixed diff --git a/README.rst b/README.rst index 21b2e3f0..c0e95a19 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.9, 3.10, 3.11, 3.12, 3.13) -2. Django (4.2, 5.0, 5.1) +2. Django (4.2, 5.1, 5.2) 3. Django REST framework (3.15, 3.16) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 461419c7..4052450b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.9, 3.10, 3.11, 3.12, 3.13) -2. Django (4.2, 5.0, 5.1) +2. Django (4.2, 5.1, 5.2) 3. Django REST framework (3.15, 3.16) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/tox.ini b/tox.ini index 45a4e20b..8dd0e759 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = py{39,310,311,312}-django42-drf{315,316,master}, - py{310,311,312}-django{50,51}-drf{315,316,master}, - py313-django51-drf{master}, + py{310,311,312}-django{51,52}-drf{315,316,master}, + py{313}-django{51,52}-drf{316,master}, black, docs, lint @@ -10,8 +10,8 @@ envlist = [testenv] deps = django42: Django>=4.2,<4.3 - django50: Django>=5.0,<5.1 django51: Django>=5.1,<5.2 + django52: Django>=5.2,<5.3 drf315: djangorestframework>=3.15,<3.16 drf316: djangorestframework>=3.16,<3.17 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip From 87108d4d6472b0c76e8c8fff53a6f08a026dd319 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 14 Jul 2025 20:38:45 +0700 Subject: [PATCH 477/488] Ensured that `include` param is properly underscored (#1283) nsured that interpreting `include` query parameter is done in internal Python naming. --- CHANGELOG.md | 5 +++++ rest_framework_json_api/renderers.py | 4 ---- rest_framework_json_api/serializers.py | 3 +-- rest_framework_json_api/utils.py | 12 ++++++++++-- tests/conftest.py | 7 ++++++- tests/test_utils.py | 21 +++++++++++++++++++++ 6 files changed, 43 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec7ff7d..eae89c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Django REST framework 3.16. * Added support for Django 5.2. +### Fixed + +* Ensured that interpreting `include` query parameter is done in internal Python naming. + This adds full support for using multipart field names for includes while configuring `JSON_API_FORMAT_FIELD_NAMES`. + ### Removed * Removed support for Python 3.8. diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 8c19934f..b670338f 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -6,7 +6,6 @@ from collections import defaultdict from collections.abc import Iterable -import inflection from django.db.models import Manager from django.template import loader from django.utils.encoding import force_str @@ -277,9 +276,6 @@ def extract_included( current_serializer, "included_serializers", dict() ) included_resources = copy.copy(included_resources) - included_resources = [ - inflection.underscore(value) for value in included_resources - ] for field_name, field in iter(fields.items()): # Skip URL field diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index d59dbd88..75764a5d 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,6 +1,5 @@ from collections.abc import Mapping -import inflection from django.core.exceptions import ObjectDoesNotExist from django.db.models.query import QuerySet from django.utils.module_loading import import_string as import_class_from_dotted_path @@ -129,7 +128,7 @@ def validate_path(serializer_class, field_path, path): serializers = getattr(serializer_class, "included_serializers", None) if serializers is None: raise ParseError("This endpoint does not support the include parameter") - this_field_name = inflection.underscore(field_path[0]) + this_field_name = field_path[0] this_included_serializer = serializers.get(this_field_name) if this_included_serializer is None: raise ParseError( diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 805f5f09..2dd79677 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -316,10 +316,18 @@ def get_resource_id(resource_instance, resource): def get_included_resources(request, serializer=None): - """Build a list of included resources.""" + """ + Build a list of included resources. + + This method ensures that returned includes are in Python internally used + format. + """ include_resources_param = request.query_params.get("include") if request else None if include_resources_param: - return include_resources_param.split(",") + return [ + undo_format_field_name(include) + for include in include_resources_param.split(",") + ] else: return get_default_included_resources_from_serializer(serializer) diff --git a/tests/conftest.py b/tests/conftest.py index 865244e0..77b3676b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ import pytest -from rest_framework.test import APIClient +from rest_framework.test import APIClient, APIRequestFactory from tests.models import ( BasicModel, @@ -98,3 +98,8 @@ def nested_related_source( @pytest.fixture def client(): return APIClient() + + +@pytest.fixture +def rf(): + return APIRequestFactory() diff --git a/tests/test_utils.py b/tests/test_utils.py index a3beb12e..08e36b6a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,6 +2,7 @@ from rest_framework import status from rest_framework.fields import Field from rest_framework.generics import GenericAPIView +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -13,6 +14,7 @@ format_link_segment, format_resource_type, format_value, + get_included_resources, get_related_resource_type, get_resource_id, get_resource_name, @@ -456,3 +458,22 @@ def test_get_resource_id(resource_instance, resource, expected): ) def test_format_error_object(message, pointer, response, result): assert result == format_error_object(message, pointer, response) + + +@pytest.mark.parametrize( + "format_type,include_param,expected_includes", + [ + ("dasherize", "author-bio", ["author_bio"]), + ("dasherize", "author-bio,author-type", ["author_bio", "author_type"]), + ("dasherize", "author-bio.author-type", ["author_bio.author_type"]), + ("camelize", "authorBio", ["author_bio"]), + ], +) +def test_get_included_resources( + rf, include_param, expected_includes, format_type, settings +): + settings.JSON_API_FORMAT_FIELD_NAMES = format_type + + request = Request(rf.get("/test/", {"include": include_param})) + includes = get_included_resources(request) + assert includes == expected_includes From 2dc557144954c99f7a97e940b253358218917502 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 18 Jul 2025 17:35:40 +0700 Subject: [PATCH 478/488] Ensured that sparse fieldset support formatted field names (#1286) --- CHANGELOG.md | 1 + rest_framework_json_api/serializers.py | 6 +++++- tests/test_serializers.py | 20 ++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eae89c70..9502f476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ any parts of the framework not mentioned in the documentation should generally b * Ensured that interpreting `include` query parameter is done in internal Python naming. This adds full support for using multipart field names for includes while configuring `JSON_API_FORMAT_FIELD_NAMES`. +* Ensured that sparse fieldset fully supports `JSON_API_FORMAT_FIELD_NAMES`. ### Removed diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 75764a5d..26f6b02e 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -26,6 +26,7 @@ get_resource_type_from_instance, get_resource_type_from_model, get_resource_type_from_serializer, + undo_format_field_name, ) @@ -89,7 +90,10 @@ def _readable_fields(self): sparse_fieldset_query_param ) if sparse_fieldset_value is not None: - sparse_fields = sparse_fieldset_value.split(",") + sparse_fields = [ + undo_format_field_name(sparse_field) + for sparse_field in sparse_fieldset_value.split(",") + ] return ( field for field in readable_fields diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 9d4200a3..98cb2850 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,5 +1,6 @@ import pytest from django.db import models +from rest_framework.request import Request from rest_framework.utils import model_meta from rest_framework_json_api import serializers @@ -84,3 +85,22 @@ class Meta: "verified", "uuid", ] + + +def test_readable_fields_with_sparse_fields(client, rf, settings): + class TestSerializer(serializers.Serializer): + name = serializers.CharField() + value = serializers.CharField() + multi_part_name = serializers.CharField() + + class Meta: + resource_name = "test" + + settings.JSON_API_FORMAT_FIELD_NAMES = "camelize" + request = Request(rf.get("/test/", {"fields[test]": "value,multiPartName"})) + context = {"request": request} + serializer = TestSerializer(context=context) + assert [field.field_name for field in serializer._readable_fields] == [ + "value", + "multi_part_name", + ] From 7bb4c086d8e682e771309bc11e01b48713737ad6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 18 Jul 2025 20:08:54 +0700 Subject: [PATCH 479/488] Removed obsolete example requirements.txt file (#1287) --- example/requirements.txt | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 example/requirements.txt diff --git a/example/requirements.txt b/example/requirements.txt deleted file mode 100644 index 2f4213ee..00000000 --- a/example/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Requirements specifically for the example app -Django>=1.11 -django-debug-toolbar -django-polymorphic>=2.0 -djangorestframework -inflection -pluggy -py -pyparsing -pytz -sqlparse -django-filter>=2.0 From e9a7f5fbfda5860ffe9a69a7b494ca00cc2a1ec3 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 21 Jul 2025 21:47:16 +0700 Subject: [PATCH 480/488] Removed built-in OpenAPI support (#1288) * Removed built-in OpenAPI support * Removed django filter schema methods --- CHANGELOG.md | 2 +- README.rst | 3 - docs/getting-started.md | 3 - docs/usage.md | 133 -- example/settings/dev.py | 1 - example/templates/swagger-ui.html | 28 - example/tests/__snapshots__/test_openapi.ambr | 1414 ----------------- example/tests/test_openapi.py | 230 --- .../tests/unit/test_filter_schema_params.py | 107 -- example/urls.py | 22 - requirements/requirements-optionals.txt | 2 - .../django_filters/backends.py | 17 +- rest_framework_json_api/schemas/openapi.py | 905 ----------- setup.cfg | 3 - setup.py | 1 - tests/schemas/test_openapi.py | 11 - 16 files changed, 2 insertions(+), 2880 deletions(-) delete mode 100644 example/templates/swagger-ui.html delete mode 100644 example/tests/__snapshots__/test_openapi.ambr delete mode 100644 example/tests/test_openapi.py delete mode 100644 example/tests/unit/test_filter_schema_params.py delete mode 100644 rest_framework_json_api/schemas/openapi.py delete mode 100644 tests/schemas/test_openapi.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9502f476..63865fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Python 3.8. * Removed support for Django REST framework 3.14. * Removed support for Django 5.0. - +* Removed built-in support for generating OpenAPI schema. Use [drf-spectacular-json-api](https://github.com/jokiefer/drf-spectacular-json-api/) instead. ## [7.1.0] - 2024-10-25 diff --git a/README.rst b/README.rst index c0e95a19..05c01c04 100644 --- a/README.rst +++ b/README.rst @@ -114,7 +114,6 @@ Install using ``pip``... $ # for optional package integrations $ pip install djangorestframework-jsonapi['django-filter'] $ pip install djangorestframework-jsonapi['django-polymorphic'] - $ pip install djangorestframework-jsonapi['openapi'] or from source... @@ -156,8 +155,6 @@ installed and activated: Browse to * http://localhost:8000 for the list of available collections (in a non-JSON:API format!), -* http://localhost:8000/swagger-ui/ for a Swagger user interface to the dynamic schema view, or -* http://localhost:8000/openapi for the schema view's OpenAPI specification document. ----- diff --git a/docs/getting-started.md b/docs/getting-started.md index 4052450b..81040e8e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -69,7 +69,6 @@ Install using `pip`... # for optional package integrations pip install djangorestframework-jsonapi['django-filter'] pip install djangorestframework-jsonapi['django-polymorphic'] - pip install djangorestframework-jsonapi['openapi'] or from source... @@ -100,8 +99,6 @@ and add `rest_framework_json_api` to your `INSTALLED_APPS` setting below `rest_f Browse to * [http://localhost:8000](http://localhost:8000) for the list of available collections (in a non-JSON:API format!), -* [http://localhost:8000/swagger-ui/](http://localhost:8000/swagger-ui/) for a Swagger user interface to the dynamic schema view, or -* [http://localhost:8000/openapi](http://localhost:8000/openapi) for the schema view's OpenAPI specification document. ## Running Tests diff --git a/docs/usage.md b/docs/usage.md index 1a2cb195..00c12ee8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1054,139 +1054,6 @@ The `prefetch_related` case will issue 4 queries, but they will be small and fas ### Errors --> -## Generating an OpenAPI Specification (OAS) 3.0 schema document - -DRF has a [OAS schema functionality](https://www.django-rest-framework.org/api-guide/schemas/) to generate an -[OAS 3.0 schema](https://www.openapis.org/) as a YAML or JSON file. - -DJA extends DRF's schema support to generate an OAS schema in the JSON:API format. - ---- - -**Deprecation notice:** - -REST framework's built-in support for generating OpenAPI schemas is -**deprecated** in favor of 3rd party packages that can provide this -functionality instead. Therefore we have also deprecated the schema support in -Django REST framework JSON:API. The built-in support will be retired over the -next releases. - -As a full-fledged replacement, we recommend the [drf-spectacular-json-api] package. - ---- - -### AutoSchema Settings - -In order to produce an OAS schema that properly represents the JSON:API structure -you have to either add a `schema` attribute to each view class or set the `REST_FRAMEWORK['DEFAULT_SCHEMA_CLASS']` -to DJA's version of AutoSchema. - -#### View-based - -```python -from rest_framework_json_api.schemas.openapi import AutoSchema - -class MyViewset(ModelViewSet): - schema = AutoSchema - ... -``` - -#### Default schema class - -```python -REST_FRAMEWORK = { - # ... - 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', -} -``` - -### Adding additional OAS schema content - -You can extend the OAS schema document by subclassing -[`SchemaGenerator`](https://www.django-rest-framework.org/api-guide/schemas/#schemagenerator) -and extending `get_schema`. - - -Here's an example that adds OAS `info` and `servers` objects. - -```python -from rest_framework_json_api.schemas.openapi import SchemaGenerator as JSONAPISchemaGenerator - - -class MySchemaGenerator(JSONAPISchemaGenerator): - """ - Describe my OAS schema info in detail (overriding what DRF put in) and list the servers where it can be found. - """ - def get_schema(self, request, public): - schema = super().get_schema(request, public) - schema['info'] = { - 'version': '1.0', - 'title': 'my demo API', - 'description': 'A demonstration of [OAS 3.0](https://www.openapis.org)', - 'contact': { - 'name': 'my name' - }, - 'license': { - 'name': 'BSD 2 clause', - 'url': 'https://github.com/django-json-api/django-rest-framework-json-api/blob/main/LICENSE', - } - } - schema['servers'] = [ - {'url': 'http://localhost/v1', 'description': 'local docker'}, - {'url': 'http://localhost:8000/v1', 'description': 'local dev'}, - {'url': 'https://api.example.com/v1', 'description': 'demo server'}, - {'url': '{serverURL}', 'description': 'provide your server URL', - 'variables': {'serverURL': {'default': 'http://localhost:8000/v1'}}} - ] - return schema -``` - -### Generate a Static Schema on Command Line - -See [DRF documentation for generateschema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-static-schema-with-the-generateschema-management-command) -To generate a static OAS schema document, using the `generateschema` management command, you **must override DRF's default** `generator_class` with the DJA-specific version: - -```text -$ ./manage.py generateschema --generator_class rest_framework_json_api.schemas.openapi.SchemaGenerator -``` - -You can then use any number of OAS tools such as -[swagger-ui-watcher](https://www.npmjs.com/package/swagger-ui-watcher) -to render the schema: -```text -$ swagger-ui-watcher myschema.yaml -``` - -Note: Swagger-ui-watcher will complain that "DELETE operations cannot have a requestBody" -but it will still work. This [error](https://github.com/OAI/OpenAPI-Specification/pull/2117) -in the OAS specification will be fixed when [OAS 3.1.0](https://www.openapis.org/blog/2020/06/18/openapi-3-1-0-rc0-its-here) -is published. - -([swagger-ui](https://www.npmjs.com/package/swagger-ui) will work silently.) - -### Generate a Dynamic Schema in a View - -See [DRF documentation for a Dynamic Schema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-dynamic-schema-with-schemaview). - -```python -from rest_framework.schemas import get_schema_view - -urlpatterns = [ - ... - path('openapi', get_schema_view( - title="Example API", - description="API for all things …", - version="1.0.0", - generator_class=MySchemaGenerator, - ), name='openapi-schema'), - path('swagger-ui/', TemplateView.as_view( - template_name='swagger-ui.html', - extra_context={'schema_url': 'openapi-schema'} - ), name='swagger-ui'), - ... -] -``` - ## Third Party Packages ### About Third Party Packages diff --git a/example/settings/dev.py b/example/settings/dev.py index 7b40e61f..05cab4d1 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -83,7 +83,6 @@ "rest_framework_json_api.renderers.BrowsableAPIRenderer", ), "DEFAULT_METADATA_CLASS": "rest_framework_json_api.metadata.JSONAPIMetadata", - "DEFAULT_SCHEMA_CLASS": "rest_framework_json_api.schemas.openapi.AutoSchema", "DEFAULT_FILTER_BACKENDS": ( "rest_framework_json_api.filters.OrderingFilter", "rest_framework_json_api.django_filters.DjangoFilterBackend", diff --git a/example/templates/swagger-ui.html b/example/templates/swagger-ui.html deleted file mode 100644 index 29776491..00000000 --- a/example/templates/swagger-ui.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - Swagger - - - - - -
- - - - \ No newline at end of file diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr deleted file mode 100644 index f72c6ff8..00000000 --- a/example/tests/__snapshots__/test_openapi.ambr +++ /dev/null @@ -1,1414 +0,0 @@ -# serializer version: 1 -# name: test_delete_request - ''' - { - "description": "", - "operationId": "destroy/authors/{id}", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/onlymeta" - } - } - }, - "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)" - }, - "202": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/datum" - } - } - }, - "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" - }, - "204": { - "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_patch_request - ''' - { - "description": "", - "operationId": "update/authors/{id}", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "fullName": { - "maxLength": 50, - "type": "string" - }, - "name": { - "maxLength": 50, - "type": "string" - } - }, - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "properties": { - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationships": { - "properties": { - "authorType": { - "$ref": "#/components/schemas/reltoone" - }, - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "firstEntry": { - "$ref": "#/components/schemas/reltoone" - } - }, - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - } - }, - "required": [ - "data" - ] - } - } - } - }, - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Author" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "update/authors/{id}" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "403": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-updating-responses-404)" - }, - "409": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_path_with_id_parameter - ''' - { - "description": "", - "operationId": "retrieve/authors/{id}/", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - }, - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "author_type", - "in": "query", - "name": "filter[authorType]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "name", - "in": "query", - "name": "filter[name]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/AuthorDetail" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "retrieve/authors/{id}/" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not found" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_path_without_parameters - ''' - { - "description": "", - "operationId": "List/authors/", - "parameters": [ - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "description": "A page number within the paginated result set.", - "in": "query", - "name": "page[number]", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Number of results to return per page.", - "in": "query", - "name": "page[size]", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "author_type", - "in": "query", - "name": "filter[authorType]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "name", - "in": "query", - "name": "filter[name]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/AuthorList" - }, - "type": "array" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "List/authors/" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not found" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_post_request - ''' - { - "description": "", - "operationId": "create/authors/", - "parameters": [], - "requestBody": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "fullName": { - "maxLength": 50, - "type": "string" - }, - "name": { - "maxLength": 50, - "type": "string" - } - }, - "required": [ - "name", - "fullName", - "email" - ], - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "properties": { - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationships": { - "properties": { - "authorType": { - "$ref": "#/components/schemas/reltoone" - }, - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "firstEntry": { - "$ref": "#/components/schemas/reltoone" - } - }, - "required": [ - "bio", - "entries", - "comments" - ], - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type" - ], - "type": "object" - } - }, - "required": [ - "data" - ] - } - } - } - }, - "responses": { - "201": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Author" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-201). Assigned `id` and/or any other changes are in this response." - }, - "202": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/datum" - } - } - }, - "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" - }, - "204": { - "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) with the supplied `id`. No other changes from what was POSTed." - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "403": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-creating-responses-404)" - }, - "409": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_schema_construction - ''' - { - "components": { - "parameters": { - "fields": { - "description": "[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets).\nUse fields[\\]=field1,field2,...,fieldN", - "explode": true, - "in": "query", - "name": "fields", - "required": false, - "schema": { - "type": "object" - }, - "style": "deepObject" - }, - "include": { - "description": "[list of included related resources](https://jsonapi.org/format/#fetching-includes)", - "in": "query", - "name": "include", - "required": false, - "schema": { - "type": "string" - }, - "style": "form" - } - }, - "schemas": { - "AuthorList": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "fullName": { - "maxLength": 50, - "type": "string" - }, - "initials": { - "readOnly": true, - "type": "string" - }, - "name": { - "maxLength": 50, - "type": "string" - } - }, - "required": [ - "name", - "fullName", - "email" - ], - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "properties": { - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationships": { - "properties": { - "authorType": { - "$ref": "#/components/schemas/reltoone" - }, - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "firstEntry": { - "$ref": "#/components/schemas/reltoone" - } - }, - "required": [ - "bio", - "entries", - "comments" - ], - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - }, - "ResourceIdentifierObject": { - "oneOf": [ - { - "$ref": "#/components/schemas/relationshipToOne" - }, - { - "$ref": "#/components/schemas/relationshipToMany" - } - ] - }, - "datum": { - "description": "singular item", - "properties": { - "data": { - "$ref": "#/components/schemas/resource" - } - } - }, - "error": { - "additionalProperties": false, - "properties": { - "code": { - "type": "string" - }, - "detail": { - "type": "string" - }, - "id": { - "type": "string" - }, - "links": { - "$ref": "#/components/schemas/links" - }, - "source": { - "properties": { - "meta": { - "$ref": "#/components/schemas/meta" - }, - "parameter": { - "description": "A string indicating which query parameter caused the error.", - "type": "string" - }, - "pointer": { - "description": "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) to the associated entity in the request document [e.g. `/data` for a primary data object, or `/data/attributes/title` for a specific attribute.", - "type": "string" - } - }, - "type": "object" - }, - "status": { - "type": "string" - }, - "title": { - "type": "string" - } - }, - "type": "object" - }, - "errors": { - "items": { - "$ref": "#/components/schemas/error" - }, - "type": "array", - "uniqueItems": true - }, - "failure": { - "properties": { - "errors": { - "$ref": "#/components/schemas/errors" - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "$ref": "#/components/schemas/links" - }, - "meta": { - "$ref": "#/components/schemas/meta" - } - }, - "required": [ - "errors" - ], - "type": "object" - }, - "id": { - "description": "Each resource object\u2019s type and id pair MUST [identify](https://jsonapi.org/format/#document-resource-object-identification) a single, unique resource.", - "type": "string" - }, - "include": { - "additionalProperties": false, - "properties": { - "attributes": { - "additionalProperties": true, - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "$ref": "#/components/schemas/links" - }, - "meta": { - "$ref": "#/components/schemas/meta" - }, - "relationships": { - "additionalProperties": true, - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - }, - "jsonapi": { - "additionalProperties": false, - "description": "The server's implementation", - "properties": { - "meta": { - "$ref": "#/components/schemas/meta" - }, - "version": { - "type": "string" - } - }, - "type": "object" - }, - "link": { - "oneOf": [ - { - "description": "a string containing the link's URL", - "format": "uri-reference", - "type": "string" - }, - { - "properties": { - "href": { - "description": "a string containing the link's URL", - "format": "uri-reference", - "type": "string" - }, - "meta": { - "$ref": "#/components/schemas/meta" - } - }, - "required": [ - "href" - ], - "type": "object" - } - ] - }, - "linkage": { - "description": "the 'type' and 'id'", - "properties": { - "id": { - "$ref": "#/components/schemas/id" - }, - "meta": { - "$ref": "#/components/schemas/meta" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - }, - "links": { - "additionalProperties": { - "$ref": "#/components/schemas/link" - }, - "type": "object" - }, - "meta": { - "additionalProperties": true, - "type": "object" - }, - "nulltype": { - "default": null, - "nullable": true, - "type": "object" - }, - "onlymeta": { - "additionalProperties": false, - "properties": { - "meta": { - "$ref": "#/components/schemas/meta" - } - } - }, - "pageref": { - "oneOf": [ - { - "format": "uri-reference", - "type": "string" - }, - { - "$ref": "#/components/schemas/nulltype" - } - ] - }, - "pagination": { - "properties": { - "first": { - "$ref": "#/components/schemas/pageref" - }, - "last": { - "$ref": "#/components/schemas/pageref" - }, - "next": { - "$ref": "#/components/schemas/pageref" - }, - "prev": { - "$ref": "#/components/schemas/pageref" - } - }, - "type": "object" - }, - "relationshipLinks": { - "additionalProperties": true, - "description": "optional references to other resource objects", - "properties": { - "related": { - "$ref": "#/components/schemas/link" - }, - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationshipToMany": { - "description": "An array of objects each containing the 'type' and 'id' for to-many relationships", - "items": { - "$ref": "#/components/schemas/linkage" - }, - "type": "array", - "uniqueItems": true - }, - "relationshipToOne": { - "anyOf": [ - { - "$ref": "#/components/schemas/nulltype" - }, - { - "$ref": "#/components/schemas/linkage" - } - ], - "description": "reference to other resource in a to-one relationship" - }, - "reltomany": { - "description": "a multiple 'to-many' relationship", - "properties": { - "data": { - "$ref": "#/components/schemas/relationshipToMany" - }, - "links": { - "$ref": "#/components/schemas/relationshipLinks" - }, - "meta": { - "$ref": "#/components/schemas/meta" - } - }, - "type": "object" - }, - "reltoone": { - "description": "a singular 'to-one' relationship", - "properties": { - "data": { - "$ref": "#/components/schemas/relationshipToOne" - }, - "links": { - "$ref": "#/components/schemas/relationshipLinks" - }, - "meta": { - "$ref": "#/components/schemas/meta" - } - }, - "type": "object" - }, - "resource": { - "additionalProperties": false, - "properties": { - "attributes": { - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "$ref": "#/components/schemas/links" - }, - "meta": { - "$ref": "#/components/schemas/meta" - }, - "relationships": { - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - }, - "type": { - "description": "The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships.", - "type": "string" - } - } - }, - "info": { - "title": "", - "version": "" - }, - "openapi": "3.0.2", - "paths": { - "/authors/": { - "get": { - "description": "", - "operationId": "List/authors/", - "parameters": [ - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "description": "A page number within the paginated result set.", - "in": "query", - "name": "page[number]", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Number of results to return per page.", - "in": "query", - "name": "page[size]", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "author_type", - "in": "query", - "name": "filter[authorType]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "name", - "in": "query", - "name": "filter[name]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/AuthorList" - }, - "type": "array" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "List/authors/" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not found" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - } - } - } - ''' -# --- diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py deleted file mode 100644 index fa2f9c73..00000000 --- a/example/tests/test_openapi.py +++ /dev/null @@ -1,230 +0,0 @@ -# largely based on DRF's test_openapi -import json - -import pytest -from django.test import RequestFactory, override_settings -from django.urls import re_path -from rest_framework.request import Request - -from rest_framework_json_api.schemas.openapi import AutoSchema, SchemaGenerator - -from example import views - -pytestmark = pytest.mark.filterwarnings("ignore:Built-in support") - - -def create_request(path): - factory = RequestFactory() - request = Request(factory.get(path)) - return request - - -def create_view_with_kw(view_cls, method, request, initkwargs): - generator = SchemaGenerator() - view = generator.create_view(view_cls.as_view(initkwargs), method, request) - return view - - -def test_path_without_parameters(snapshot): - path = "/authors/" - method = "GET" - - view = create_view_with_kw( - views.AuthorViewSet, method, create_request(path), {"get": "list"} - ) - inspector = AutoSchema() - inspector.view = view - - operation = inspector.get_operation(path, method) - assert snapshot == json.dumps(operation, indent=2, sort_keys=True) - - -def test_path_with_id_parameter(snapshot): - path = "/authors/{id}/" - method = "GET" - - view = create_view_with_kw( - views.AuthorViewSet, method, create_request(path), {"get": "retrieve"} - ) - inspector = AutoSchema() - inspector.view = view - - operation = inspector.get_operation(path, method) - assert snapshot == json.dumps(operation, indent=2, sort_keys=True) - - -def test_post_request(snapshot): - method = "POST" - path = "/authors/" - - view = create_view_with_kw( - views.AuthorViewSet, method, create_request(path), {"post": "create"} - ) - inspector = AutoSchema() - inspector.view = view - - operation = inspector.get_operation(path, method) - assert snapshot == json.dumps(operation, indent=2, sort_keys=True) - - -def test_patch_request(snapshot): - method = "PATCH" - path = "/authors/{id}" - - view = create_view_with_kw( - views.AuthorViewSet, method, create_request(path), {"patch": "update"} - ) - inspector = AutoSchema() - inspector.view = view - - operation = inspector.get_operation(path, method) - assert snapshot == json.dumps(operation, indent=2, sort_keys=True) - - -def test_delete_request(snapshot): - method = "DELETE" - path = "/authors/{id}" - - view = create_view_with_kw( - views.AuthorViewSet, method, create_request(path), {"delete": "delete"} - ) - inspector = AutoSchema() - inspector.view = view - - operation = inspector.get_operation(path, method) - assert snapshot == json.dumps(operation, indent=2, sort_keys=True) - - -@override_settings( - REST_FRAMEWORK={ - "DEFAULT_SCHEMA_CLASS": "rest_framework_json_api.schemas.openapi.AutoSchema" - } -) -def test_schema_construction(snapshot): - """Construction of the top level dictionary.""" - patterns = [ - re_path("^authors/?$", views.AuthorViewSet.as_view({"get": "list"})), - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request("/") - schema = generator.get_schema(request=request) - - assert snapshot == json.dumps(schema, indent=2, sort_keys=True) - - -def test_schema_id_field(): - """ID field is only included in the root, not the attributes.""" - patterns = [ - re_path("^companies/?$", views.CompanyViewset.as_view({"get": "list"})), - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request("/") - schema = generator.get_schema(request=request) - - company_properties = schema["components"]["schemas"]["Company"]["properties"] - assert company_properties["id"] == {"$ref": "#/components/schemas/id"} - assert "id" not in company_properties["attributes"]["properties"] - - -def test_schema_subserializers(): - """Schema for child Serializers reflects the actual response structure.""" - patterns = [ - re_path( - "^questionnaires/?$", views.QuestionnaireViewset.as_view({"get": "list"}) - ), - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request("/") - schema = generator.get_schema(request=request) - - assert { - "type": "object", - "properties": { - "metadata": { - "type": "object", - "properties": { - "author": {"type": "string"}, - "producer": {"type": "string"}, - }, - "required": ["author"], - }, - "questions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "text": {"type": "string"}, - "required": {"type": "boolean", "default": False}, - }, - "required": ["text"], - }, - }, - "name": {"type": "string", "maxLength": 100}, - }, - "required": ["name", "questions", "metadata"], - } == schema["components"]["schemas"]["Questionnaire"]["properties"]["attributes"] - - -def test_schema_parameters_include(): - """Include paramater is only used when serializer defines included_serializers.""" - patterns = [ - re_path("^authors/?$", views.AuthorViewSet.as_view({"get": "list"})), - re_path("^project-types/?$", views.ProjectTypeViewset.as_view({"get": "list"})), - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request("/") - schema = generator.get_schema(request=request) - - include_ref = {"$ref": "#/components/parameters/include"} - assert include_ref in schema["paths"]["/authors/"]["get"]["parameters"] - assert include_ref not in schema["paths"]["/project-types/"]["get"]["parameters"] - - -def test_schema_serializer_method_resource_related_field(): - """SerializerMethodResourceRelatedField fieds have the correct relation ref.""" - patterns = [ - re_path("^entries/?$", views.EntryViewSet.as_view({"get": "list"})), - ] - generator = SchemaGenerator(patterns=patterns) - - request = Request(RequestFactory().get("/", {"include": "featured"})) - schema = generator.get_schema(request=request) - - entry_schema = schema["components"]["schemas"]["Entry"] - entry_relationships = entry_schema["properties"]["relationships"]["properties"] - - rel_to_many_ref = {"$ref": "#/components/schemas/reltomany"} - assert entry_relationships["suggested"] == rel_to_many_ref - assert entry_relationships["suggestedHyperlinked"] == rel_to_many_ref - - rel_to_one_ref = {"$ref": "#/components/schemas/reltoone"} - assert entry_relationships["featured"] == rel_to_one_ref - assert entry_relationships["featuredHyperlinked"] == rel_to_one_ref - - -def test_schema_related_serializers(): - """ - Confirm that paths are generated for related fields. For example: - /authors/{pk}/{related_field>} - /authors/{id}/comments/ - /authors/{id}/entries/ - /authors/{id}/first_entry/ - and confirm that the schema for the related field is properly rendered - """ - generator = SchemaGenerator() - request = create_request("/") - schema = generator.get_schema(request=request) - # make sure the path's relationship and related {related_field}'s got expanded - assert "/authors/{id}/relationships/{related_field}" in schema["paths"] - assert "/authors/{id}/comments/" in schema["paths"] - assert "/authors/{id}/entries/" in schema["paths"] - assert "/authors/{id}/first_entry/" in schema["paths"] - first_get = schema["paths"]["/authors/{id}/first_entry/"]["get"]["responses"]["200"] - first_schema = first_get["content"]["application/vnd.api+json"]["schema"] - first_props = first_schema["properties"]["data"] - assert "$ref" in first_props - assert first_props["$ref"] == "#/components/schemas/Entry" diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py deleted file mode 100644 index d7cb4fb8..00000000 --- a/example/tests/unit/test_filter_schema_params.py +++ /dev/null @@ -1,107 +0,0 @@ -from rest_framework import filters as drf_filters - -from rest_framework_json_api import filters as dja_filters -from rest_framework_json_api.django_filters import backends - -from example.views import EntryViewSet - - -class DummyEntryViewSet(EntryViewSet): - filter_backends = ( - dja_filters.QueryParameterValidationFilter, - dja_filters.OrderingFilter, - backends.DjangoFilterBackend, - drf_filters.SearchFilter, - ) - filterset_fields = { - "id": ("exact",), - "headline": ("exact", "contains"), - "blog__name": ("contains",), - } - - def __init__(self, **kwargs): - # dummy up self.request since PreloadIncludesMixin expects it to be defined - self.request = None - super().__init__(**kwargs) - - -def test_filters_get_schema_params(): - """ - test all my filters for `get_schema_operation_parameters()` - """ - # list of tuples: (filter, expected result) - filters = [ - (dja_filters.QueryParameterValidationFilter, []), - ( - backends.DjangoFilterBackend, - [ - { - "name": "filter[id]", - "required": False, - "in": "query", - "description": "id", - "schema": {"type": "string"}, - }, - { - "name": "filter[headline]", - "required": False, - "in": "query", - "description": "headline", - "schema": {"type": "string"}, - }, - { - "name": "filter[headline.contains]", - "required": False, - "in": "query", - "description": "headline__contains", - "schema": {"type": "string"}, - }, - { - "name": "filter[blog.name.contains]", - "required": False, - "in": "query", - "description": "blog__name__contains", - "schema": {"type": "string"}, - }, - ], - ), - ( - dja_filters.OrderingFilter, - [ - { - "name": "sort", - "required": False, - "in": "query", - "description": "[list of fields to sort by]" - "(https://jsonapi.org/format/#fetching-sorting)", - "schema": {"type": "string"}, - } - ], - ), - ( - drf_filters.SearchFilter, - [ - { - "name": "filter[search]", - "required": False, - "in": "query", - "description": "A search term.", - "schema": {"type": "string"}, - } - ], - ), - ] - view = DummyEntryViewSet() - - for c, expected in filters: - f = c() - result = f.get_schema_operation_parameters(view) - assert len(result) == len(expected) - if len(result) == 0: - continue - # py35: the result list/dict ordering isn't guaranteed - for res_item in result: - assert "name" in res_item - for exp_item in expected: - if res_item["name"] == exp_item["name"]: - assert res_item == exp_item diff --git a/example/urls.py b/example/urls.py index 413d058d..471fbe81 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,9 +1,5 @@ from django.urls import include, path, re_path -from django.views.generic import TemplateView from rest_framework import routers -from rest_framework.schemas import get_schema_view - -from rest_framework_json_api.schemas.openapi import SchemaGenerator from example.views import ( AuthorRelationshipView, @@ -87,22 +83,4 @@ AuthorRelationshipView.as_view(), name="author-relationships", ), - path( - "openapi", - get_schema_view( - title="Example API", - description="API for all things …", - version="1.0.0", - generator_class=SchemaGenerator, - ), - name="openapi-schema", - ), - path( - "swagger-ui/", - TemplateView.as_view( - template_name="swagger-ui.html", - extra_context={"schema_url": "openapi-schema"}, - ), - name="swagger-ui", - ), ] diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 589636e6..3db600e2 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -3,5 +3,3 @@ django-filter==24.3 # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore -pyyaml==6.0.2 -uritemplate==4.1.1 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index c0044839..70e543c1 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -4,7 +4,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings -from rest_framework_json_api.utils import format_field_name, undo_format_field_name +from rest_framework_json_api.utils import undo_format_field_name class DjangoFilterBackend(DjangoFilterBackend): @@ -129,18 +129,3 @@ def get_filterset_kwargs(self, request, queryset, view): "request": request, "filter_keys": filter_keys, } - - def get_schema_operation_parameters(self, view): - """ - Convert backend filter `name` to JSON:API-style `filter[name]`. - For filters that are relationship paths, rewrite ORM-style `__` to our preferred `.`. - For example: `blog__name__contains` becomes `filter[blog.name.contains]`. - - This is basically the reverse of `get_filterset_kwargs` above. - """ - result = super().get_schema_operation_parameters(view) - for res in result: - if "name" in res: - name = format_field_name(res["name"].replace("__", ".")) - res["name"] = f"filter[{name}]" - return result diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py deleted file mode 100644 index 6892e991..00000000 --- a/rest_framework_json_api/schemas/openapi.py +++ /dev/null @@ -1,905 +0,0 @@ -import warnings -from urllib.parse import urljoin - -from rest_framework.fields import empty -from rest_framework.relations import ManyRelatedField -from rest_framework.schemas import openapi as drf_openapi -from rest_framework.schemas.utils import is_list_view - -from rest_framework_json_api import serializers, views -from rest_framework_json_api.relations import ManySerializerMethodResourceRelatedField -from rest_framework_json_api.utils import format_field_name - - -class SchemaGenerator(drf_openapi.SchemaGenerator): - """ - Extend DRF's SchemaGenerator to implement JSON:API flavored generateschema command. - """ - - #: These JSON:API component definitions are referenced by the generated OAS schema. - #: If you need to add more or change these static component definitions, extend this dict. - jsonapi_components = { - "schemas": { - "jsonapi": { - "type": "object", - "description": "The server's implementation", - "properties": { - "version": {"type": "string"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - "additionalProperties": False, - }, - "resource": { - "type": "object", - "required": ["type", "id"], - "additionalProperties": False, - "properties": { - "type": {"$ref": "#/components/schemas/type"}, - "id": {"$ref": "#/components/schemas/id"}, - "attributes": { - "type": "object", - # ... - }, - "relationships": { - "type": "object", - # ... - }, - "links": {"$ref": "#/components/schemas/links"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - "include": { - "type": "object", - "required": ["type", "id"], - "additionalProperties": False, - "properties": { - "type": {"$ref": "#/components/schemas/type"}, - "id": {"$ref": "#/components/schemas/id"}, - "attributes": { - "type": "object", - "additionalProperties": True, - # ... - }, - "relationships": { - "type": "object", - "additionalProperties": True, - # ... - }, - "links": {"$ref": "#/components/schemas/links"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - "link": { - "oneOf": [ - { - "description": "a string containing the link's URL", - "type": "string", - "format": "uri-reference", - }, - { - "type": "object", - "required": ["href"], - "properties": { - "href": { - "description": "a string containing the link's URL", - "type": "string", - "format": "uri-reference", - }, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - ] - }, - "links": { - "type": "object", - "additionalProperties": {"$ref": "#/components/schemas/link"}, - }, - "reltoone": { - "description": "a singular 'to-one' relationship", - "type": "object", - "properties": { - "links": {"$ref": "#/components/schemas/relationshipLinks"}, - "data": {"$ref": "#/components/schemas/relationshipToOne"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - "relationshipToOne": { - "description": "reference to other resource in a to-one relationship", - "anyOf": [ - {"$ref": "#/components/schemas/nulltype"}, - {"$ref": "#/components/schemas/linkage"}, - ], - }, - "reltomany": { - "description": "a multiple 'to-many' relationship", - "type": "object", - "properties": { - "links": {"$ref": "#/components/schemas/relationshipLinks"}, - "data": {"$ref": "#/components/schemas/relationshipToMany"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - "relationshipLinks": { - "description": "optional references to other resource objects", - "type": "object", - "additionalProperties": True, - "properties": { - "self": {"$ref": "#/components/schemas/link"}, - "related": {"$ref": "#/components/schemas/link"}, - }, - }, - "relationshipToMany": { - "description": "An array of objects each containing the " - "'type' and 'id' for to-many relationships", - "type": "array", - "items": {"$ref": "#/components/schemas/linkage"}, - "uniqueItems": True, - }, - # A RelationshipView uses a ResourceIdentifierObjectSerializer (hence the name - # ResourceIdentifierObject returned by get_component_name()) which serializes type - # and id. These can be lists or individual items depending on whether the - # relationship is toMany or toOne so offer both options since we are not iterating - # over all the possible {related_field}'s but rather rendering one path schema - # which may represent toMany and toOne relationships. - "ResourceIdentifierObject": { - "oneOf": [ - {"$ref": "#/components/schemas/relationshipToOne"}, - {"$ref": "#/components/schemas/relationshipToMany"}, - ] - }, - "linkage": { - "type": "object", - "description": "the 'type' and 'id'", - "required": ["type", "id"], - "properties": { - "type": {"$ref": "#/components/schemas/type"}, - "id": {"$ref": "#/components/schemas/id"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - "pagination": { - "type": "object", - "properties": { - "first": {"$ref": "#/components/schemas/pageref"}, - "last": {"$ref": "#/components/schemas/pageref"}, - "prev": {"$ref": "#/components/schemas/pageref"}, - "next": {"$ref": "#/components/schemas/pageref"}, - }, - }, - "pageref": { - "oneOf": [ - {"type": "string", "format": "uri-reference"}, - {"$ref": "#/components/schemas/nulltype"}, - ] - }, - "failure": { - "type": "object", - "required": ["errors"], - "properties": { - "errors": {"$ref": "#/components/schemas/errors"}, - "meta": {"$ref": "#/components/schemas/meta"}, - "jsonapi": {"$ref": "#/components/schemas/jsonapi"}, - "links": {"$ref": "#/components/schemas/links"}, - }, - }, - "errors": { - "type": "array", - "items": {"$ref": "#/components/schemas/error"}, - "uniqueItems": True, - }, - "error": { - "type": "object", - "additionalProperties": False, - "properties": { - "id": {"type": "string"}, - "status": {"type": "string"}, - "links": {"$ref": "#/components/schemas/links"}, - "code": {"type": "string"}, - "title": {"type": "string"}, - "detail": {"type": "string"}, - "source": { - "type": "object", - "properties": { - "pointer": { - "type": "string", - "description": ( - "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " - "to the associated entity in the request document " - "[e.g. `/data` for a primary data object, or " - "`/data/attributes/title` for a specific attribute." - ), - }, - "parameter": { - "type": "string", - "description": "A string indicating which query parameter " - "caused the error.", - }, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - }, - }, - "onlymeta": { - "additionalProperties": False, - "properties": {"meta": {"$ref": "#/components/schemas/meta"}}, - }, - "meta": {"type": "object", "additionalProperties": True}, - "datum": { - "description": "singular item", - "properties": {"data": {"$ref": "#/components/schemas/resource"}}, - }, - "nulltype": {"type": "object", "nullable": True, "default": None}, - "type": { - "type": "string", - "description": "The [type]" - "(https://jsonapi.org/format/#document-resource-object-identification) " - "member is used to describe resource objects that share common attributes " - "and relationships.", - }, - "id": { - "type": "string", - "description": "Each resource object’s type and id pair MUST " - "[identify]" - "(https://jsonapi.org/format/#document-resource-object-identification) " - "a single, unique resource.", - }, - }, - "parameters": { - "include": { - "name": "include", - "in": "query", - "description": "[list of included related resources]" - "(https://jsonapi.org/format/#fetching-includes)", - "required": False, - "style": "form", - "schema": {"type": "string"}, - }, - # TODO: deepObject not well defined/supported: - # https://github.com/OAI/OpenAPI-Specification/issues/1706 - "fields": { - "name": "fields", - "in": "query", - "description": "[sparse fieldsets]" - "(https://jsonapi.org/format/#fetching-sparse-fieldsets).\n" - "Use fields[\\]=field1,field2,...,fieldN", - "required": False, - "style": "deepObject", - "schema": { - "type": "object", - }, - "explode": True, - }, - }, - } - - def get_schema(self, request=None, public=False): - """ - Generate a JSON:API OpenAPI schema. - Overrides upstream DRF's get_schema. - """ - # TODO: avoid copying so much of upstream get_schema() - schema = super().get_schema(request, public) - - components_schemas = {} - - # Iterate endpoints generating per method path operations. - paths = {} - _, view_endpoints = self._get_paths_and_endpoints(None if public else request) - - #: `expanded_endpoints` is like view_endpoints with one extra field tacked on: - #: - 'action' copy of current view.action (list/fetch) as this gets reset for - # each request. - expanded_endpoints = [] - for path, method, view in view_endpoints: - if hasattr(view, "action") and view.action == "retrieve_related": - expanded_endpoints += self._expand_related( - path, method, view, view_endpoints - ) - else: - expanded_endpoints.append( - (path, method, view, getattr(view, "action", None)) - ) - - for path, method, view, action in expanded_endpoints: - if not self.has_view_permissions(path, method, view): - continue - # kludge to preserve view.action as it is 'list' for the parent ViewSet - # but the related viewset that was expanded may be either 'fetch' (to_one) or 'list' - # (to_many). This patches the view.action appropriately so that - # view.schema.get_operation() "does the right thing" for fetch vs. list. - current_action = None - if hasattr(view, "action"): - current_action = view.action - view.action = action - operation = view.schema.get_operation(path, method) - components = view.schema.get_components(path, method) - for k in components.keys(): - if k not in components_schemas: - continue - if components_schemas[k] == components[k]: - continue - warnings.warn( - f'Schema component "{k}" has been overriden with a different value.', - stacklevel=1, - ) - - components_schemas.update(components) - - if hasattr(view, "action"): - view.action = current_action - # Normalise path for any provided mount url. - if path.startswith("/"): - path = path[1:] - path = urljoin(self.url or "/", path) - - paths.setdefault(path, {}) - paths[path][method.lower()] = operation - - self.check_duplicate_operation_id(paths) - - # Compile final schema, overriding stuff from super class. - schema["paths"] = paths - schema["components"] = self.jsonapi_components - schema["components"]["schemas"].update(components_schemas) - - return schema - - def _expand_related(self, path, method, view, view_endpoints): - """ - Expand path containing .../{id}/{related_field} into list of related fields - and **their** views, making sure toOne relationship's views are a 'fetch' and toMany - relationship's are a 'list'. - :param path - :param method - :param view - :param view_endpoints - :return:list[tuple(path, method, view, action)] - """ - result = [] - serializer = view.get_serializer() - # It's not obvious if it's allowed to have both included_ and related_ serializers, - # so just merge both dicts. - serializers = {} - if hasattr(serializer, "included_serializers"): - serializers = {**serializers, **serializer.included_serializers} - if hasattr(serializer, "related_serializers"): - serializers = {**serializers, **serializer.related_serializers} - related_fields = [fs for fs in serializers.items()] - - for field, related_serializer in related_fields: - related_view = self._find_related_view( - view_endpoints, related_serializer, view - ) - if related_view: - action = self._field_is_one_or_many(field, view) - result.append( - ( - path.replace("{related_field}", field), - method, - related_view, - action, - ) - ) - - return result - - def _find_related_view(self, view_endpoints, related_serializer, parent_view): - """ - For a given related_serializer, try to find it's "parent" view instance. - - :param view_endpoints: list of all view endpoints - :param related_serializer: the related serializer for a given related field - :param parent_view: the parent view (used to find toMany vs. toOne). - TODO: not actually used. - :return:view - """ - for _path, _method, view in view_endpoints: - view_serializer = view.get_serializer() - if isinstance(view_serializer, related_serializer): - return view - - return None - - def _field_is_one_or_many(self, field, view): - serializer = view.get_serializer() - if isinstance(serializer.fields[field], ManyRelatedField): - return "list" - else: - return "fetch" - - -class AutoSchema(drf_openapi.AutoSchema): - """ - Extend DRF's openapi.AutoSchema for JSON:API serialization. - """ - - #: ignore all the media types and only generate a JSON:API schema. - content_types = ["application/vnd.api+json"] - - def get_operation(self, path, method): - """ - JSON:API adds some standard fields to the API response that are not in upstream DRF: - - some that only apply to GET/HEAD methods. - - collections - - special handling for POST, PATCH, DELETE - """ - - warnings.warn( - DeprecationWarning( - "Built-in support for generating OpenAPI schema is deprecated. " - "Use drf-spectacular-json-api instead see " - "https://github.com/jokiefer/drf-spectacular-json-api/" - ), - stacklevel=2, - ) - - operation = {} - operation["operationId"] = self.get_operation_id(path, method) - operation["description"] = self.get_description(path, method) - - serializer = self.get_response_serializer(path, method) - - parameters = [] - parameters += self.get_path_parameters(path, method) - # pagination, filters only apply to GET/HEAD of collections and items - if method in ["GET", "HEAD"]: - parameters += self._get_include_parameters(path, method, serializer) - parameters += self._get_fields_parameters(path, method) - parameters += self.get_pagination_parameters(path, method) - parameters += self.get_filter_parameters(path, method) - operation["parameters"] = parameters - operation["tags"] = self.get_tags(path, method) - - # get request and response code schemas - if method == "GET": - if is_list_view(path, method, self.view): - self._add_get_collection_response(operation, path) - else: - self._add_get_item_response(operation, path) - elif method == "POST": - self._add_post_item_response(operation, path) - elif method == "PATCH": - self._add_patch_item_response(operation, path) - elif method == "DELETE": - # should only allow deleting a resource, not a collection - # TODO: implement delete of a relationship in future release. - self._add_delete_item_response(operation, path) - return operation - - def get_operation_id(self, path, method): - """ - The upstream DRF version creates non-unique operationIDs, because the same view is - used for the main path as well as such as related and relationships. - This concatenates the (mapped) method name and path as the spec allows most any - """ - method_name = getattr(self.view, "action", method.lower()) - if is_list_view(path, method, self.view): - action = "List" - elif method_name not in self.method_mapping: - action = method_name - else: - action = self.method_mapping[method.lower()] - return action + path - - def _get_include_parameters(self, path, method, serializer): - """ - includes parameter: https://jsonapi.org/format/#fetching-includes - """ - if getattr(serializer, "included_serializers", {}): - return [{"$ref": "#/components/parameters/include"}] - return [] - - def _get_fields_parameters(self, path, method): - """ - sparse fieldsets https://jsonapi.org/format/#fetching-sparse-fieldsets - """ - # TODO: See if able to identify the specific types for fields[type]=... and return this: - # name: fields - # in: query - # description: '[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets)' # noqa: B950 - # required: true - # style: deepObject - # schema: - # type: object - # properties: - # hello: - # type: string # noqa F821 - # world: - # type: string # noqa F821 - # explode: true - return [{"$ref": "#/components/parameters/fields"}] - - def _add_get_collection_response(self, operation, path): - """ - Add GET 200 response for a collection to operation - """ - operation["responses"] = { - "200": self._get_toplevel_200_response( - operation, path, "GET", collection=True - ) - } - self._add_get_4xx_responses(operation) - - def _add_get_item_response(self, operation, path): - """ - add GET 200 response for an item to operation - """ - operation["responses"] = { - "200": self._get_toplevel_200_response( - operation, path, "GET", collection=False - ) - } - self._add_get_4xx_responses(operation) - - def _get_toplevel_200_response(self, operation, path, method, collection=True): - """ - return top-level JSON:API GET 200 response - - :param collection: True for collections; False for individual items. - - Uses a $ref to the components.schemas. component definition. - """ - if collection: - data = { - "type": "array", - "items": self.get_reference(self.get_response_serializer(path, method)), - } - else: - data = self.get_reference(self.get_response_serializer(path, method)) - - return { - "description": operation["operationId"], - "content": { - "application/vnd.api+json": { - "schema": { - "type": "object", - "required": ["data"], - "properties": { - "data": data, - "included": { - "type": "array", - "uniqueItems": True, - "items": {"$ref": "#/components/schemas/include"}, - }, - "links": { - "description": "Link members related to primary data", - "allOf": [ - {"$ref": "#/components/schemas/links"}, - {"$ref": "#/components/schemas/pagination"}, - ], - }, - "jsonapi": {"$ref": "#/components/schemas/jsonapi"}, - }, - } - } - }, - } - - def _add_post_item_response(self, operation, path): - """ - add response for POST of an item to operation - """ - operation["requestBody"] = self.get_request_body(path, "POST") - operation["responses"] = { - "201": self._get_toplevel_200_response( - operation, path, "POST", collection=False - ) - } - operation["responses"]["201"]["description"] = ( - "[Created](https://jsonapi.org/format/#crud-creating-responses-201). " - "Assigned `id` and/or any other changes are in this response." - ) - self._add_async_response(operation) - operation["responses"]["204"] = { - "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) " - "with the supplied `id`. No other changes from what was POSTed." - } - self._add_post_4xx_responses(operation) - - def _add_patch_item_response(self, operation, path): - """ - Add PATCH response for an item to operation - """ - operation["requestBody"] = self.get_request_body(path, "PATCH") - operation["responses"] = { - "200": self._get_toplevel_200_response( - operation, path, "PATCH", collection=False - ) - } - self._add_patch_4xx_responses(operation) - - def _add_delete_item_response(self, operation, path): - """ - add DELETE response for item or relationship(s) to operation - """ - # Only DELETE of relationships has a requestBody - if isinstance(self.view, views.RelationshipView): - operation["requestBody"] = self.get_request_body(path, "DELETE") - self._add_delete_responses(operation) - - def get_request_body(self, path, method): - """ - A request body is required by JSON:API for POST, PATCH, and DELETE methods. - """ - serializer = self.get_request_serializer(path, method) - if not isinstance(serializer, (serializers.BaseSerializer,)): - return {} - is_relationship = isinstance(self.view, views.RelationshipView) - - # DRF uses a $ref to the component schema definition, but this - # doesn't work for JSON:API due to the different required fields based on - # the method, so make those changes and inline another copy of the schema. - - # TODO: A future improvement could make this DRYer with multiple component schemas: - # A base schema for each viewset that has no required fields - # One subclassed from the base that requires some fields (`type` but not `id` for POST) - # Another subclassed from base with required type/id but no required attributes (PATCH) - - if is_relationship: - item_schema = {"$ref": "#/components/schemas/ResourceIdentifierObject"} - else: - item_schema = self.map_serializer(serializer) - if method == "POST": - # 'type' and 'id' are both required for: - # - all relationship operations - # - regular PATCH or DELETE - # Only 'type' is required for POST: system may assign the 'id'. - item_schema["required"] = ["type"] - - if "properties" in item_schema and "attributes" in item_schema["properties"]: - # No required attributes for PATCH - if ( - method in ["PATCH", "PUT"] - and "required" in item_schema["properties"]["attributes"] - ): - del item_schema["properties"]["attributes"]["required"] - # No read_only fields for request. - for name, schema in ( - item_schema["properties"]["attributes"]["properties"].copy().items() - ): # noqa E501 - if "readOnly" in schema: - del item_schema["properties"]["attributes"]["properties"][name] - - if "properties" in item_schema and "relationships" in item_schema["properties"]: - # No required relationships for PATCH - if ( - method in ["PATCH", "PUT"] - and "required" in item_schema["properties"]["relationships"] - ): - del item_schema["properties"]["relationships"]["required"] - - return { - "content": { - ct: { - "schema": { - "required": ["data"], - "properties": {"data": item_schema}, - } - } - for ct in self.content_types - } - } - - def map_serializer(self, serializer): - """ - Custom map_serializer that serializes the schema using the JSON:API spec. - - Non-attributes like related and identity fields, are moved to 'relationships' - and 'links'. - """ - # TODO: remove attributes, etc. for relationshipView?? - if isinstance( - serializer.parent, (serializers.ListField, serializers.BaseSerializer) - ): - # Return plain non-JSON:API serializer schema for serializers nested inside - # a Serializer or a ListField, as those don't use the full JSON:API - # serializer schemas. - return super().map_serializer(serializer) - - required = [] - attributes = {} - relationships_required = [] - relationships = {} - - for field in serializer.fields.values(): - if isinstance(field, serializers.HyperlinkedIdentityField): - # the 'url' is not an attribute but rather a self.link, so don't map it here. - continue - if isinstance(field, serializers.HiddenField): - continue - if isinstance( - field, - ( - serializers.ManyRelatedField, - ManySerializerMethodResourceRelatedField, - ), - ): - if field.required: - relationships_required.append(format_field_name(field.field_name)) - relationships[format_field_name(field.field_name)] = { - "$ref": "#/components/schemas/reltomany" - } - continue - if isinstance(field, serializers.RelatedField): - if field.required: - relationships_required.append(format_field_name(field.field_name)) - relationships[format_field_name(field.field_name)] = { - "$ref": "#/components/schemas/reltoone" - } - continue - if field.field_name == "id": - # ID is always provided in the root of JSON:API and removed from the - # attributes in JSONRenderer. - continue - - if field.required: - required.append(format_field_name(field.field_name)) - - schema = self.map_field(field) - if field.read_only: - schema["readOnly"] = True - if field.write_only: - schema["writeOnly"] = True - if field.allow_null: - schema["nullable"] = True - if field.default and field.default != empty and not callable(field.default): - schema["default"] = field.default - if field.help_text: - # Ensure django gettext_lazy is rendered correctly - schema["description"] = str(field.help_text) - self.map_field_validators(field, schema) - - attributes[format_field_name(field.field_name)] = schema - - result = { - "type": "object", - "required": ["type", "id"], - "additionalProperties": False, - "properties": { - "type": {"$ref": "#/components/schemas/type"}, - "id": {"$ref": "#/components/schemas/id"}, - "links": { - "type": "object", - "properties": {"self": {"$ref": "#/components/schemas/link"}}, - }, - }, - } - if attributes: - result["properties"]["attributes"] = { - "type": "object", - "properties": attributes, - } - if required: - result["properties"]["attributes"]["required"] = required - - if relationships: - result["properties"]["relationships"] = { - "type": "object", - "properties": relationships, - } - if relationships_required: - result["properties"]["relationships"][ - "required" - ] = relationships_required - return result - - def _add_async_response(self, operation): - """ - Add async response to operation - """ - operation["responses"]["202"] = { - "description": "Accepted for [asynchronous processing]" - "(https://jsonapi.org/recommendations/#asynchronous-processing)", - "content": { - "application/vnd.api+json": { - "schema": {"$ref": "#/components/schemas/datum"} - } - }, - } - - def _failure_response(self, reason): - """ - Return failure response reason as the description - """ - return { - "description": reason, - "content": { - "application/vnd.api+json": { - "schema": {"$ref": "#/components/schemas/failure"} - } - }, - } - - def _add_generic_failure_responses(self, operation): - """ - Add generic failure response(s) to operation - """ - for code, reason in [ - ("400", "bad request"), - ("401", "not authorized"), - ("429", "too many requests"), - ]: - operation["responses"][code] = self._failure_response(reason) - - def _add_get_4xx_responses(self, operation): - """ - Add generic 4xx GET responses to operation - """ - self._add_generic_failure_responses(operation) - for code, reason in [("404", "not found")]: - operation["responses"][code] = self._failure_response(reason) - - def _add_post_4xx_responses(self, operation): - """ - Add POST 4xx error responses to operation - """ - self._add_generic_failure_responses(operation) - for code, reason in [ - ( - "403", - "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)", - ), - ( - "404", - "[Related resource does not exist]" - "(https://jsonapi.org/format/#crud-creating-responses-404)", - ), - ( - "409", - "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)", - ), - ]: - operation["responses"][code] = self._failure_response(reason) - - def _add_patch_4xx_responses(self, operation): - """ - Add PATCH 4xx error responses to operation - """ - self._add_generic_failure_responses(operation) - for code, reason in [ - ( - "403", - "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)", - ), - ( - "404", - "[Related resource does not exist]" - "(https://jsonapi.org/format/#crud-updating-responses-404)", - ), - ( - "409", - "[Conflict]([Conflict]" - "(https://jsonapi.org/format/#crud-updating-responses-409)", - ), - ]: - operation["responses"][code] = self._failure_response(reason) - - def _add_delete_responses(self, operation): - """ - Add generic DELETE responses to operation - """ - # the 2xx statuses: - operation["responses"] = { - "200": { - "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)", - "content": { - "application/vnd.api+json": { - "schema": {"$ref": "#/components/schemas/onlymeta"} - } - }, - } - } - self._add_async_response(operation) - operation["responses"]["204"] = { - "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)", # noqa: B950 - } - # the 4xx errors: - self._add_generic_failure_responses(operation) - for code, reason in [ - ( - "404", - "[Resource does not exist]" - "(https://jsonapi.org/format/#crud-deleting-responses-404)", - ), - ]: - operation["responses"][code] = self._failure_response(reason) diff --git a/setup.cfg b/setup.cfg index 4230dcbb..92606700 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,9 +63,6 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - # Django filter schema generation. Can be removed once we remove - # schema support - ignore:Built-in schema generation is deprecated. testpaths = example tests diff --git a/setup.py b/setup.py index de61b0d1..0b88f4c9 100755 --- a/setup.py +++ b/setup.py @@ -112,7 +112,6 @@ def get_package_data(package): extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], "django-filter": ["django-filter>=2.4"], - "openapi": ["pyyaml>=5.4", "uritemplate>=3.0.1"], }, setup_requires=wheel, python_requires=">=3.9", diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py deleted file mode 100644 index 427f18fc..00000000 --- a/tests/schemas/test_openapi.py +++ /dev/null @@ -1,11 +0,0 @@ -from rest_framework_json_api.schemas.openapi import AutoSchema -from tests.serializers import CallableDefaultSerializer - - -class TestAutoSchema: - def test_schema_callable_default(self): - inspector = AutoSchema() - result = inspector.map_serializer(CallableDefaultSerializer()) - assert result["properties"]["attributes"]["properties"]["field"] == { - "type": "string", - } From 8d209d9ab402d40639a0a107bf648c57b5aba342 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 21 Jul 2025 21:57:07 +0700 Subject: [PATCH 481/488] Increase required version of optional Polymorphic Models for Django (#1289) Co-authored-by: Alan Crosswell --- CHANGELOG.md | 4 ++++ requirements/requirements-optionals.txt | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63865fbc..f13d267f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ any parts of the framework not mentioned in the documentation should generally b This adds full support for using multipart field names for includes while configuring `JSON_API_FORMAT_FIELD_NAMES`. * Ensured that sparse fieldset fully supports `JSON_API_FORMAT_FIELD_NAMES`. +### Changed + +* Set minimum required version of optional Polymorphic Models for Django to 4.0.0. + ### Removed * Removed support for Python 3.8. diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 3db600e2..279fd95c 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -2,4 +2,4 @@ django-filter==24.3 # once next version has been released (>3.1.0) this # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 -django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore +django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore \ No newline at end of file diff --git a/setup.py b/setup.py index 0b88f4c9..2a2a28dd 100755 --- a/setup.py +++ b/setup.py @@ -110,7 +110,7 @@ def get_package_data(package): "django>=4.2", ], extras_require={ - "django-polymorphic": ["django-polymorphic>=3.0"], + "django-polymorphic": ["django-polymorphic>=4.0.0"], "django-filter": ["django-filter>=2.4"], }, setup_requires=wheel, From fa23bd5bb5a695ea09cfd2106f59e59251cf794d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 22 Jul 2025 17:01:21 +0700 Subject: [PATCH 482/488] Set django polymorphic version to newest (#1290) --- requirements/requirements-optionals.txt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 279fd95c..afbc4754 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,5 +1,2 @@ django-filter==24.3 -# once next version has been released (>3.1.0) this -# should be set to pinned version again -# see https://github.com/django-polymorphic/django-polymorphic/pull/541 -django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore \ No newline at end of file +django-polymorphic==4.1.0 From a6f1f44aeb286af748ac538fe765a097fc48c444 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 24 Jul 2025 18:37:04 +0400 Subject: [PATCH 483/488] Release 8.0.0 (#1291) --- CHANGELOG.md | 7 +++---- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f13d267f..fe7c9e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [8.0.0] - 2025-07-24 ### Added @@ -17,9 +17,8 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed -* Ensured that interpreting `include` query parameter is done in internal Python naming. - This adds full support for using multipart field names for includes while configuring `JSON_API_FORMAT_FIELD_NAMES`. -* Ensured that sparse fieldset fully supports `JSON_API_FORMAT_FIELD_NAMES`. +* Ensured that compound documents' `include` query parameter fully support `JSON_API_FORMAT_FIELD_NAMES`. +* Ensured that sparse fieldset's `fields` query parameter fully supports `JSON_API_FORMAT_FIELD_NAMES`. ### Changed diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index a69daa9f..23f6b8f9 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "7.1.0" +__version__ = "8.0.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From 7e7f1a047a70aa0912790b5f012056188d67cf0e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 3 Aug 2025 20:34:52 +0700 Subject: [PATCH 484/488] Added permissions to github workflow (#1292) --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0852bba1..03a1db78 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,4 +1,6 @@ name: Tests +permissions: + contents: read on: push: branches: ["main"] @@ -6,7 +8,6 @@ on: branches: ["main"] schedule: - cron: '0 4 * * *' - jobs: test: name: Run test From b144953fcd1e556a572fcb2348c9e8f678a6b33f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 3 Aug 2025 20:41:44 +0700 Subject: [PATCH 485/488] Moved from pyup to dependabot (#1293) --- .github/dependabot.yml | 6 ++++++ .pyup.yml | 18 ------------------ 2 files changed, 6 insertions(+), 18 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 .pyup.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..ce74c1f3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/requirements" + schedule: + interval: "monthly" diff --git a/.pyup.yml b/.pyup.yml deleted file mode 100644 index 7ee57975..00000000 --- a/.pyup.yml +++ /dev/null @@ -1,18 +0,0 @@ -search: False -schedule: "every two weeks" -requirements: - - requirements/requirements-codestyle.txt: - update: all - pin: True - - requirements/requirements-documentation.txt: - update: all - pin: True - - requirements/requirements-optionals.txt: - update: all - pin: True - - requirements/requirements-packaging.txt: - update: all - pin: True - - requirements/requirements-testing.txt: - update: all - pin: True From ec1fc3c24f18dfb7f1669917edc5f5be18de6d70 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 3 Aug 2025 23:41:37 +0700 Subject: [PATCH 486/488] Added missing grouping of dependencies with dependabot (#1299) --- .github/dependabot.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ce74c1f3..c2b66bc4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,6 @@ updates: directory: "/requirements" schedule: interval: "monthly" + groups: + all-deps: + patterns: ["*"] From 142c76edadf7bf4d6c6692da307c03355103b1f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 04:30:27 +0700 Subject: [PATCH 487/488] Bump the all-deps group in /requirements with 6 updates (#1300) Bumps the all-deps group in /requirements with 6 updates: | Package | From | To | | --- | --- | --- | | [flake8](https://github.com/pycqa/flake8) | `7.2.0` | `7.3.0` | | [faker](https://github.com/joke2k/faker) | `37.1.0` | `37.5.3` | | [pytest](https://github.com/pytest-dev/pytest) | `8.3.5` | `8.4.1` | | [pytest-cov](https://github.com/pytest-dev/pytest-cov) | `6.1.1` | `6.2.1` | | [pytest-factoryboy](https://github.com/pytest-dev/pytest-factoryboy) | `2.7.0` | `2.8.1` | | [django-filter](https://github.com/carltongibson/django-filter) | `24.3` | `25.1` | Updates `flake8` from 7.2.0 to 7.3.0 - [Commits](https://github.com/pycqa/flake8/compare/7.2.0...7.3.0) Updates `faker` from 37.1.0 to 37.5.3 - [Release notes](https://github.com/joke2k/faker/releases) - [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.md) - [Commits](https://github.com/joke2k/faker/compare/v37.1.0...v37.5.3) Updates `pytest` from 8.3.5 to 8.4.1 - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.3.5...8.4.1) Updates `pytest-cov` from 6.1.1 to 6.2.1 - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v6.1.1...v6.2.1) Updates `pytest-factoryboy` from 2.7.0 to 2.8.1 - [Changelog](https://github.com/pytest-dev/pytest-factoryboy/blob/master/CHANGES.rst) - [Commits](https://github.com/pytest-dev/pytest-factoryboy/compare/2.7.0...2.8.1) Updates `django-filter` from 24.3 to 25.1 - [Release notes](https://github.com/carltongibson/django-filter/releases) - [Changelog](https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst) - [Commits](https://github.com/carltongibson/django-filter/compare/24.3...25.1) --- updated-dependencies: - dependency-name: flake8 dependency-version: 7.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-deps - dependency-name: faker dependency-version: 37.5.3 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-deps - dependency-name: pytest dependency-version: 8.4.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-deps - dependency-name: pytest-cov dependency-version: 6.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-deps - dependency-name: pytest-factoryboy dependency-version: 2.8.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-deps - dependency-name: django-filter dependency-version: '25.1' dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-deps ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 1d4184ad..707bddb9 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==25.1.0 -flake8==7.2.0 +flake8==7.3.0 flake8-bugbear==24.12.12 flake8-isort==6.1.2 isort==6.0.1 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index afbc4754..4eb517ff 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,2 +1,2 @@ -django-filter==24.3 +django-filter==25.1 django-polymorphic==4.1.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 35caa0a9..8fe1e366 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.3 -Faker==37.1.0 -pytest==8.3.5 -pytest-cov==6.1.1 +Faker==37.5.3 +pytest==8.4.1 +pytest-cov==6.2.1 pytest-django==4.11.1 -pytest-factoryboy==2.7.0 +pytest-factoryboy==2.8.1 syrupy==4.9.1 From 88721b9afc2f740d75a6abe812e5ab72ad6efcdc Mon Sep 17 00:00:00 2001 From: Harshal Kalewar Date: Wed, 27 Aug 2025 16:49:04 +0530 Subject: [PATCH 488/488] JSON:API 1.1: return empty 'included' array when ?include= is present (#1301) * JSON:API 1.1: return empty 'included' array when ?include= is present Fixes #1109. Ensures top-level 'included' is returned as [] when the query param ?include= is provided but no related resources exist. Updates integration tests to reflect JSON:API v1.1 spec compliance. * Fix tox lint environment to use setup.cfg flake8 config * fix: ensuring empty array is added when requested and updated tests * Reverted unrelated changes * Reverted unrelated tox changes --------- Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 6 +++ example/tests/integration/test_includes.py | 48 +++++++++++++++++++++- example/tests/test_filters.py | 3 +- rest_framework_json_api/renderers.py | 2 +- 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/AUTHORS b/AUTHORS index d421d5e6..1cfc9206 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ David Guillot, for Contexte David Vogt Felix Viernickel Greg Aker +Harshal Kalewar Humayun Ahmad Jamie Bliss Jason Housley diff --git a/CHANGELOG.md b/CHANGELOG.md index fe7c9e14..2ec34d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Fixed + +* Ensured that an empty `included` array is returned in responses when the `include` query parameter is present but no related resources exist. + ## [8.0.0] - 2025-07-24 ### Added diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index d32ff7e3..238c3076 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -36,7 +36,9 @@ def test_missing_field_not_included(author_bio_factory, author_factory, client): # First author does not have a bio author = author_factory(bio=None) response = client.get(reverse("author-detail", args=[author.pk]) + "?include=bio") - assert "included" not in response.json() + content = response.json() + assert "included" in content + assert content["included"] == [] # Second author does author = author_factory() response = client.get(reverse("author-detail", args=[author.pk]) + "?include=bio") @@ -204,3 +206,47 @@ def test_meta_object_added_to_included_resources(single_entry, client): ) assert response.json()["included"][0].get("meta") assert response.json()["included"][1].get("meta") + + +def test_included_array_empty_when_requested_but_no_data(blog_factory, client): + blog = blog_factory() + response = client.get( + reverse("blog-detail", kwargs={"pk": blog.pk}) + "?include=tags" + ) + content = response.json() + + assert "included" in content + assert content["included"] == [] + + +def test_included_array_populated_when_related_data_exists( + blog_factory, tagged_item_factory, client +): + blog = blog_factory() + tag = tagged_item_factory(tag="django") + blog.tags.add(tag) + + response = client.get( + reverse("blog-detail", kwargs={"pk": blog.pk}) + "?include=tags" + ) + included = response.json()["included"] + + assert included, "Expected included array to be populated" + assert [x.get("type") for x in included] == [ + "taggedItems" + ], "Included types incorrect" + assert included[0]["attributes"]["tag"] == "django" + + +def test_included_array_present_via_jsonapimeta_defaults( + single_entry, comment_factory, author_factory, client +): + author = author_factory() + comment_factory(entry=single_entry, author=author) + + response = client.get(reverse("entry-detail", kwargs={"pk": single_entry.pk})) + + included = response.json()["included"] + + assert included, "Expected included array due to JSONAPIMeta defaults" + assert any(resource["type"] == "comments" for resource in included) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 8e45ded1..ff7bf263 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -530,7 +530,8 @@ def test_search_keywords(self): }, "meta": {"bodyFormat": "text"}, } - ] + ], + "included": [], } assert response.json() == expected_result diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index b670338f..f418b080 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -652,7 +652,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): if not included_cache[obj_type]: del included_cache[obj_type] - if included_cache: + if included_resources: render_data["included"] = list() for included_type in sorted(included_cache.keys()): for included_id in sorted(included_cache[included_type].keys()):