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 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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..c2b66bc4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/requirements" + schedule: + interval: "monthly" + groups: + all-deps: + patterns: ["*"] 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/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..03a1db78 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,58 @@ +name: Tests +permissions: + contents: read +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: '0 4 * * *' +jobs: + test: + name: Run test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + env: + PYTHON: ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + 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 . | cut -f 1 -d '-') + - name: Upload coverage report + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + env_vars: PYTHON + 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.10 + uses: actions/setup-python@v2 + with: + python-version: "3.10" + - 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/.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] 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 diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..fa503604 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +version: 2 + +build: + os: "ubuntu-22.04" + tools: + python: "3.10" + +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: requirements/requirements-optionals.txt + - requirements: requirements/requirements-documentation.txt + - method: pip + path: . diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 301ed0cc..00000000 --- a/.travis.yml +++ /dev/null @@ -1,101 +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=py35-django111-drfmaster - - env: TOXENV=py36-django111-drfmaster - - env: TOXENV=py35-django21-drfmaster - - env: TOXENV=py36-django21-drfmaster - - env: TOXENV=py37-django21-drfmaster - - env: TOXENV=py35-django22-drfmaster - - env: TOXENV=py36-django22-drfmaster - - env: TOXENV=py37-django22-drfmaster - - env: TOXENV=py38-django22-drfmaster - - env: TOXENV=py36-django30-drfmaster - - env: TOXENV=py37-django30-drfmaster - - env: TOXENV=py38-django30-drfmaster - - include: - - python: 3.6 - env: TOXENV=lint - - python: 3.6 - env: TOXENV=docs - - - python: 3.5 - env: TOXENV=py35-django111-drf310 - - python: 3.5 - env: TOXENV=py35-django111-drf311 - - python: 3.5 - env: TOXENV=py35-django111-drfmaster - - python: 3.5 - env: TOXENV=py35-django21-drf310 - - python: 3.5 - env: TOXENV=py35-django21-drf311 - - python: 3.5 - env: TOXENV=py35-django21-drfmaster - - python: 3.5 - env: TOXENV=py35-django22-drf310 - - python: 3.5 - env: TOXENV=py35-django22-drf311 - - python: 3.5 - env: TOXENV=py35-django22-drfmaster - - - python: 3.6 - env: TOXENV=py36-django111-drf310 - - python: 3.6 - env: TOXENV=py36-django111-drf311 - - python: 3.6 - env: TOXENV=py36-django111-drfmaster - - python: 3.6 - env: TOXENV=py36-django21-drf310 - - python: 3.6 - env: TOXENV=py36-django21-drf311 - - python: 3.6 - env: TOXENV=py36-django21-drfmaster - - python: 3.6 - env: TOXENV=py36-django22-drf310 - - python: 3.6 - env: TOXENV=py36-django22-drf311 - - python: 3.6 - env: TOXENV=py36-django22-drfmaster - - python: 3.6 - env: TOXENV=py36-django30-drf311 - - python: 3.6 - env: TOXENV=py36-django30-drfmaster - - - python: 3.7 - env: TOXENV=py37-django21-drf310 - - python: 3.7 - env: TOXENV=py37-django21-drf311 - - python: 3.7 - env: TOXENV=py37-django21-drfmaster - - python: 3.7 - env: TOXENV=py37-django22-drf310 - - python: 3.7 - env: TOXENV=py37-django22-drf311 - - python: 3.7 - env: TOXENV=py37-django22-drfmaster - - python: 3.7 - env: TOXENV=py37-django30-drf311 - - python: 3.7 - env: TOXENV=py37-django30-drfmaster - - - python: 3.8 - env: TOXENV=py38-django22-drf311 - - python: 3.8 - env: TOXENV=py38-django22-drfmaster - - python: 3.8 - env: TOXENV=py38-django30-drf311 - - python: 3.8 - env: TOXENV=py38-django30-drfmaster - -install: - - pip install tox -script: - - tox -after_success: - - pip install codecov - - codecov -e TOXENV --required diff --git a/AUTHORS b/AUTHORS index 17d3de18..1cfc9206 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,34 +1,56 @@ Adam Wróbel Adam Ziolkowski Alan Crosswell +Alex Seidmann +Antoine Auger Anton Shutik +Arttu Perälä +Ashley Loewen +Asif Saif Uddin Beni Keller Boris Pleshakov Charlie Allatson Christian Zosel +David Guillot, for Contexte David Vogt Felix Viernickel Greg Aker +Harshal Kalewar +Humayun Ahmad Jamie Bliss Jason Housley +Jeppe Fihl-Pearson Jerel Unruh +Jonas Kiefer +Jonas Metzener +Jonathan Hiles Jonathan Senecal Joseba Mendivil +Kal +Kevin Partington +Kieran Evans Léo S. Luc Cary +Mansi Dhruv Matt Layman +Mehdy Khoshnoody Michael Haselton Mohammed Ali Zubair Nathanael Gordon +Nick Kozhenin Ola Tarkowska Oliver Sauder Raphael Cohen René Kälin Roberto Barreda Rohith PR +Safa AlFulaij santiavenda Sergey Kolomenkin Stas S. +Steven A. +Swaraj Baral Tim Selman Tom Glowka +Ulrich Schuster Yaniv Peer diff --git a/CHANGELOG.md b/CHANGELOG.md index 2efa0f40..2ec34d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,326 @@ 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] +### 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 + +* Added support for Django REST framework 3.16. +* Added support for Django 5.2. + +### Fixed + +* 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 + +* Set minimum required version of optional Polymorphic Models for Django to 4.0.0. + +### Removed + +* 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 + +This is the last release supporting Python 3.8, Django 5.0 and Django REST framework 3.14. + +### 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 + +* Added support for Django 5.1 +* Added support for Python 3.13 + +### 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 + +* 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 + +### Added + +* 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 + +* Added support for Python 3.12 +* Added support for Django 5.0 +* Added support for Django REST framework 3.15 + +### 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 +* 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 + +* 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. +* Removed obsolete compat `NullBooleanField` and `get_reference` definitions. + +## [6.1.0] - 2023-08-25 + +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 + +* 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 + +* 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`. +* 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 + ``` + +* `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 + +* 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 + 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 + +### 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. +* Prevented overwriting of pointer in custom error object +* Adhered to field naming format setting when generating schema parameters and required fields. + +### Added + +* Added support for Django 4.1. +* Added support for Django REST framework 3.14. +* 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. +* 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 and Django REST framework 3.12. + +### Added + +* Added support for Django REST framework 3.13. + +### Changed + +* Adjusted to only use f-strings for slight performance improvement. +* 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 + +* 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 + +This is the last release supporting Django 3.0, Django 3.1 and Python 3.6. + +### Added + +* Added support for Django 4.0. +* Added support for Python 3.10. + +### Fixed + +* 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. +* Added missing inflection to the generated OpenAPI schema. +* Added missing error message when `resource_name` is not properly configured. + +### 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. +* 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 + +### Fixed + +* 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 + +### Added + +* Added support for Django 3.2. +* Added support for tags in OAS schema + +### 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. +* 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'), + ``` +* 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 `rest_framework_json_api.utils.format_value` instead. + +## [4.1.0] - 2021-03-08 + +### 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_RELATED_LINKS` setting. + +### 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 +* Render `meta_fields` in included resources + + +## [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 initial optional support for [openapi](https://www.openapis.org/) schema generation. Enable with: + ``` + pip install djangorestframework-jsonapi['openapi'] + ``` + This first release is a start at implementing OAS schema generation. To use the generated schema you may + still need to manually add some schema attributes but can expect future improvements here and as + upstream DRF's OAS schema generation continues to mature. + +### Removed + + +* 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 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). + +## [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. + ### 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 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. ### Fixed * Avoid `AttributeError` for PUT and PATCH methods when using `APIView` * Clear many-to-many relationships instead of deleting related objects during PATCH on `RelationshipView` -* Allow POST, PATCH, DELETE for actions in `ReadOnlyModelViewSet`. It was problematic since 2.8.0. +* Allow POST, PATCH, DELETE for actions in `ReadOnlyModelViewSet`. Regression since version `2.8.0`. +* Properly format nested errors ### Changed @@ -71,7 +377,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 @@ -88,7 +394,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 @@ -106,7 +412,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 @@ -139,7 +445,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 @@ -165,9 +471,9 @@ 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). + 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 @@ -193,13 +499,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) @@ -226,7 +532,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/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] diff --git a/README.rst b/README.rst index 5541eba9..05c01c04 100644 --- a/README.rst +++ b/README.rst @@ -1,34 +1,37 @@ ================================== -JSON API and Django Rest Framework +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://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 :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 - :target: https://gitter.im/django-json-api/django-rest-framework-json-api +.. image:: https://img.shields.io/pypi/v/djangorestframework-jsonapi.svg + :alt: PyPi Version + :target: https://pypi.org/project/djangorestframework-jsonapi/ -------- 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/ +* 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, - "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", @@ -37,18 +40,20 @@ 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:: +However, for an ``identity`` model in JSON:API format the response should look +like the following: + +.. code:: JSON { "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", - "id": 3, + "id": "3", "attributes": { "username": "john", "full-name": "John Coltrane" @@ -66,13 +71,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 +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 @@ -80,27 +85,30 @@ 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/ +.. _JSON:API: https://jsonapi.org +.. _Django REST framework: https://www.django-rest-framework.org/ ------------ Requirements ------------ -1. Python (3.5, 3.6, 3.7, 3.8) -2. Django (1.11, 2.1, 2.2, 3.0) -3. Django REST Framework (3.10, 3.11) +1. Python (3.9, 3.10, 3.11, 3.12, 3.13) +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. -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. + +For optional dependencies such as Django Filter only the latest release is officially supported even though lower versions should work as well. ------------ Installation ------------ -From PyPI -^^^^^^^^^ +Install using ``pip``... -:: +.. code:: sh $ pip install djangorestframework-jsonapi $ # for optional package integrations @@ -108,45 +116,46 @@ From PyPI $ pip install djangorestframework-jsonapi['django-polymorphic'] -From Source -^^^^^^^^^^^ +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 $ pip install -e . +and add ``rest_framework_json_api`` to your ``INSTALLED_APPS`` setting below ``rest_framework``. + +.. code:: python + + INSTALLED_APPS = [ + ... + 'rest_framework', + 'rest_framework_json_api', + ... + ] + + 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 - $ pip install -U -e . -r requirements.txt - $ 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 - - -Running Tests and linting -^^^^^^^^^^^^^^^^^^^^^^^^^ + $ pip install -Ur requirements.txt + $ django-admin migrate --settings=example.settings --pythonpath . + $ django-admin loaddata drf_example --settings=example.settings --pythonpath . + $ django-admin runserver --settings=example.settings --pythonpath . -It is recommended to create a virtualenv for testing. Assuming it is already -installed and activated: +Browse to -:: +* http://localhost:8000 for the list of available collections (in a non-JSON:API format!), - $ pip install -Ur requirements.txt - $ flake8 - $ pytest ----- Usage @@ -154,7 +163,7 @@ Usage ``rest_framework_json_api`` assumes you are using class-based views in Django -Rest Framework. +REST framework. Settings @@ -164,7 +173,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, @@ -178,7 +187,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': ( @@ -196,4 +205,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/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..5cb25b29 --- /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**. + +Use the security advisory to [report a vulnerability](https://github.com/django-json-api/django-rest-framework-json-api/security/advisories/new) instead. + +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 5ab82991..0715c364 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,30 +1,71 @@ -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 + +### 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 +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 + +### 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/) 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/conf.py b/docs/conf.py index ee435d36..80a85e4b 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 @@ -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", "sphinx_rtd_theme"] +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 = 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 # |version| and |release|, also used in various other places throughout the @@ -79,41 +78,41 @@ # # 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: -#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"] # 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,145 @@ # 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 # 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 +268,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 +287,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/docs/getting-started.md b/docs/getting-started.md index 00d77c61..81040e8e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,16 +1,16 @@ # 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, - "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", @@ -20,19 +20,19 @@ 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 { "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", - "id": 3, + "id": "3", "attributes": { "username": "john", "full-name": "John Coltrane" @@ -51,39 +51,54 @@ like the following: ## Requirements -1. Python (3.5, 3.6, 3.7, 3.8) -2. Django (1.11, 2.1, 2.2, 3.0) -3. Django REST Framework (3.10, 3.11) +1. Python (3.9, 3.10, 3.11, 3.12, 3.13) +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. + +Generally Python and Django series are supported till the official end of life. For Django REST framework the last two series are supported. -We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. +For optional dependencies such as Django Filter only the latest release is officially supported even though lower versions should work as well. ## Installation -From PyPI +Install using `pip`... pip install djangorestframework-jsonapi # for optional package integrations pip install djangorestframework-jsonapi['django-filter'] pip install djangorestframework-jsonapi['django-polymorphic'] -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 cd django-rest-framework-json-api python3 -m venv env source env/bin/activate - pip install -U -e . r requirements.txt + 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 -Browse to http://localhost:8000 +Browse to +* [http://localhost:8000](http://localhost:8000) for the list of available collections (in a non-JSON:API format!), ## Running Tests diff --git a/docs/index.rst b/docs/index.rst index b18b8b6e..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/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 cdb335d6..00c12ee8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,10 +1,9 @@ - # Usage 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` @@ -29,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_FILTER_BACKENDS': ( @@ -116,9 +115,9 @@ 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\.\-]+\])?$') +query_regex = re.compile(r"^(sort|include)$|^(?Pfilter|fields|page)(\[[\w\.\-]+\])?$") ``` For example: ```python @@ -126,7 +125,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), @@ -134,8 +133,8 @@ 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). +`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`, it **MUST** return `400 Bad Request`." For example, for `?sort=abc,foo,def` where `foo` is a valid @@ -159,14 +158,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: @@ -205,7 +204,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 @@ -237,6 +236,28 @@ class MyViewset(ModelViewSet): ``` +### 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). + +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 @@ -256,18 +277,53 @@ class MyModelSerializer(serializers.ModelSerializer): # ... ``` -### Setting the resource_name +### Overwriting the resource object's id + +Per default the primary key property `pk` on the instance is used as the resource identifier. -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`: +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 +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. @@ -279,12 +335,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 @@ -302,12 +355,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](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 +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 @@ -331,7 +397,7 @@ Example - Without format conversion: { "data": [{ "type": "identities", - "id": 3, + "id": "3", "attributes": { "username": "john", "first_name": "John", @@ -352,7 +418,7 @@ Example - With format conversion set to `dasherize`: { "data": [{ "type": "identities", - "id": 3, + "id": "3", "attributes": { "username": "john", "first-name": "John", @@ -382,7 +448,7 @@ Example without format conversion: { "data": [{ "type": "blog_identity", - "id": 3, + "id": "3", "attributes": { ... }, @@ -405,7 +471,7 @@ When set to dasherize: { "data": [{ "type": "blog-identity", - "id": 3, + "id": "3", "attributes": { ... }, @@ -431,7 +497,7 @@ Example without pluralization: { "data": [{ "type": "identity", - "id": 3, + "id": "3", "attributes": { ... }, @@ -439,7 +505,7 @@ Example without pluralization: "home_towns": { "data": [{ "type": "home_town", - "id": 3 + "id": "3" }] } } @@ -454,7 +520,7 @@ When set to pluralize: { "data": [{ "type": "identities", - "id": 3, + "id": "3", "attributes": { ... }, @@ -462,7 +528,7 @@ When set to pluralize: "home_towns": { "data": [{ "type": "home_towns", - "id": 3 + "id": "3" }] } } @@ -470,12 +536,54 @@ When set to pluralize: } ``` +#### Related URL segments + +Serializer properties in relationship and related resource URLs may be infected using the `JSON_API_FORMAT_RELATED_LINKS` setting. + +``` python +JSON_API_FORMAT_RELATED_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_RELATED_LINKS` setting. + +
+ Note: + When using this setting make sure that your url pattern matches the formatted url segement. +
+ ### Related fields #### 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 @@ -503,7 +611,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 @@ -572,14 +680,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 @@ -657,7 +765,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'), ``` @@ -696,15 +804,23 @@ 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 -[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 @@ -722,7 +838,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' ) @@ -838,9 +954,9 @@ 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](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, @@ -884,7 +1000,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: @@ -937,3 +1053,22 @@ The `prefetch_related` case will issue 4 queries, but they will be small and fas ### Relationships ### Errors --> + +## 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. +* [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/api/resources/identity.py b/example/api/resources/identity.py index 6785e5d9..12705553 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' - return super(Identity, self).retrieve(request, args, kwargs) + self.resource_name = "data" + return super().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..5e2a42e3 100644 --- a/example/api/serializers/identity.py +++ b/example/api/serializers/identity.py @@ -9,22 +9,32 @@ 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 + 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 = ( - '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..87698fa2 100644 --- a/example/factories.py +++ b/example/factories.py @@ -1,5 +1,3 @@ -# -*- encoding: utf-8 -*- - import factory from faker import Factory as FakerFactory @@ -14,8 +12,9 @@ Company, Entry, ProjectType, + Questionnaire, ResearchProject, - TaggedItem + TaggedItem, ) faker = FakerFactory.create() @@ -38,23 +37,27 @@ class Meta: class AuthorFactory(factory.django.DjangoModelFactory): class Meta: + skip_postgeneration_save = True model = Author name = factory.LazyAttribute(lambda x: faker.name()) email = factory.LazyAttribute(lambda x: faker.email()) - bio = factory.RelatedFactory('example.factories.AuthorBioFactory', 'author') - type = factory.SubFactory(AuthorTypeFactory) + bio = factory.RelatedFactory("example.factories.AuthorBioFactory", "author") + author_type = factory.SubFactory(AuthorTypeFactory) class AuthorBioFactory(factory.django.DjangoModelFactory): class Meta: model = AuthorBio + skip_postgeneration_save = True 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): @@ -68,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()) @@ -129,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) @@ -140,3 +145,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/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/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/migrations/0001_initial.py b/example/migrations/0001_initial.py index 38cc0be9..18805099 100644 --- a/example/migrations/0001_initial.py +++ b/example/migrations/0001_initial.py @@ -1,94 +1,150 @@ -# -*- coding: utf-8 -*- -# 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..62b1cf81 100644 --- a/example/migrations/0002_taggeditem.py +++ b/example/migrations/0002_taggeditem.py @@ -1,31 +1,40 @@ -# -*- coding: utf-8 -*- -# 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..70c62ddd 100644 --- a/example/migrations/0003_polymorphics.py +++ b/example/migrations/0003_polymorphics.py @@ -1,76 +1,121 @@ -# -*- coding: utf-8 -*- -# 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..51db8e3f 100644 --- a/example/migrations/0004_auto_20171011_0631.py +++ b/example/migrations/0004_auto_20171011_0631.py @@ -1,62 +1,69 @@ -# -*- coding: utf-8 -*- -# 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..52c3a0ac 100644 --- a/example/migrations/0005_auto_20180922_1508.py +++ b/example/migrations/0005_auto_20180922_1508.py @@ -1,43 +1,54 @@ # 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..3ef2678e 100644 --- a/example/migrations/0006_auto_20181228_0752.py +++ b/example/migrations/0006_auto_20181228_0752.py @@ -1,32 +1,52 @@ # 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..4ed2d7a9 100644 --- a/example/migrations/0007_artproject_description.py +++ b/example/migrations/0007_artproject_description.py @@ -4,15 +4,14 @@ 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..290c2cb8 100644 --- a/example/migrations/0008_labresults.py +++ b/example/migrations/0008_labresults.py @@ -1,23 +1,37 @@ # 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/migrations/0009_labresults_author.py b/example/migrations/0009_labresults_author.py new file mode 100644 index 00000000..6c805ab0 --- /dev/null +++ b/example/migrations/0009_labresults_author.py @@ -0,0 +1,24 @@ +# 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/migrations/0010_auto_20210714_0809.py b/example/migrations/0010_auto_20210714_0809.py new file mode 100644 index 00000000..cd96cccc --- /dev/null +++ b/example/migrations/0010_auto_20210714_0809.py @@ -0,0 +1,16 @@ +# 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/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..cc2f5546 --- /dev/null +++ b/example/migrations/0011_rename_type_author_author_type_and_more.py @@ -0,0 +1,30 @@ +# 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/migrations/0012_author_full_name.py b/example/migrations/0012_author_full_name.py new file mode 100644 index 00000000..0486d041 --- /dev/null +++ b/example/migrations/0012_author_full_name.py @@ -0,0 +1,18 @@ +# 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/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 4df4dc27..35a15a8e 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 @@ -11,6 +8,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 +20,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 +38,7 @@ def __str__(self): return self.name class Meta: - ordering = ('id',) + ordering = ("id",) class AuthorType(BaseModel): @@ -50,44 +48,48 @@ def __str__(self): return self.name class Meta: - ordering = ('id',) + ordering = ("id",) class Author(BaseModel): name = models.CharField(max_length=50) + full_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 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 +98,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 +108,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 +136,7 @@ def __str__(self): return self.name class Meta: - ordering = ('id',) + ordering = ("id",) class Project(PolymorphicModel): @@ -153,16 +155,34 @@ 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() + author = models.ForeignKey( + Author, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="lab_results", + ) + + class Meta: + ordering = ("id",) 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): return self.name + + +class Questionnaire(models.Model): + name = models.CharField(max_length=100) + questions = models.JSONField() + metadata = models.JSONField() 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 diff --git a/example/serializers.py b/example/serializers.py index 2af5eb70..94fe8556 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -18,53 +18,54 @@ LabResults, Project, ProjectType, + Questionnaire, 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): copyright = serializers.SerializerMethodField() - tags = TaggedItemSerializer(many=True, read_only=True) + tags = relations.ResourceRelatedField(many=True, read_only=True) - include_serializers = { - 'tags': 'example.serializers.TaggedItemSerializer', + included_serializers = { + "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,68 +73,65 @@ 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): 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') - 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,13 +139,13 @@ 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 = TaggedItemSerializer(many=True, read_only=True) + tags = relations.ResourceRelatedField(many=True, read_only=True) def get_suggested(self, obj): return Entry.objects.exclude(pk=obj.pk) @@ -156,143 +154,203 @@ 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 + 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", + ) + initials = serializers.SerializerMethodField() included_serializers = { - 'bio': AuthorBioSerializer, - 'type': AuthorTypeSerializer + "bio": AuthorBioSerializer, + "author_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", + "author_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') + fields = ( + "name", + "full_name", + "email", + "bio", + "entries", + "comments", + "first_entry", + "author_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 + + +class AuthorDetailSerializer(AuthorSerializer): + pass + 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) + modified_days_ago = serializers.SerializerMethodField() 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',) + meta_fields = ("modified_days_ago",) + + class JSONAPIMeta: + included_resources = ("writer",) + + def get_modified_days_ago(self, obj): + return (datetime.now() - obj.modified_at).days 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): @@ -301,37 +359,37 @@ class ResearchProjectSerializer(BaseProjectSerializer): class Meta: model = ResearchProject - exclude = ('polymorphic_ctype',) + exclude = ("polymorphic_ctype",) class LabResultsSerializer(serializers.ModelSerializer): + included_serializers = {"author": AuthorSerializer} + class Meta: model = LabResults - fields = ('date', 'measurements') + fields = ("date", "measurements", "author") 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) + obj = super().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: @@ -342,21 +400,44 @@ 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__" + + +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/settings/dev.py b/example/settings/dev.py index ade24139..05cab4d1 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -4,98 +4,93 @@ DEBUG = True MEDIA_ROOT = os.path.normcase(os.path.dirname(os.path.abspath(__file__))) -MEDIA_URL = '/media/' +MEDIA_URL = "/media/" +USE_TZ = False +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" -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', - '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", + "django_filters", + "tests", ] 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', ) +INTERNAL_IPS = ("127.0.0.1",) -MIDDLEWARE = ( - 'debug_toolbar.middleware.DebugToolbarMiddleware', -) - -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", # noqa: B950 + "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.renderers.BrowsableAPIRenderer', + "rest_framework_json_api.renderers.BrowsableAPIRenderer", ), - 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', - '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_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..2b92b5b3 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: F405 + { # noqa + "PAGE_SIZE": 1, + } +) diff --git a/example/tests/__init__.py b/example/tests/__init__.py index 30a04e22..94a86486 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 @@ -12,18 +11,15 @@ 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=''): + 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/__snapshots__/test_errors.ambr b/example/tests/__snapshots__/test_errors.ambr new file mode 100644 index 00000000..5dca2771 --- /dev/null +++ b/example/tests/__snapshots__/test_errors.ambr @@ -0,0 +1,186 @@ +# serializer version: 1 +# name: test_first_level_attribute_error + dict({ + 'errors': list([ + dict({ + 'code': 'required', + 'detail': 'This field is required.', + 'source': dict({ + 'pointer': '/data/attributes/headline', + }), + 'status': '400', + }), + ]), + }) +# --- +# name: test_first_level_custom_attribute_error + dict({ + 'errors': list([ + dict({ + 'detail': 'Too short', + 'source': dict({ + 'pointer': '/data/attributes/body-text', + }), + 'title': 'Too Short title', + }), + ]), + }) +# --- +# name: test_many_third_level_dict_errors + dict({ + 'errors': list([ + dict({ + 'code': 'required', + 'detail': 'This field is required.', + 'source': dict({ + 'pointer': '/data/attributes/comments/0/attachment/data', + }), + 'status': '400', + }), + dict({ + 'code': 'required', + 'detail': 'This field is required.', + 'source': dict({ + 'pointer': '/data/attributes/comments/0/body', + }), + 'status': '400', + }), + ]), + }) +# --- +# 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/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', + }), + ]), + }) +# --- +# name: test_second_level_array_error + dict({ + 'errors': list([ + dict({ + 'code': 'required', + 'detail': 'This field is required.', + 'source': dict({ + 'pointer': '/data/attributes/comments/0/body', + }), + 'status': '400', + }), + ]), + }) +# --- +# name: test_second_level_dict_error + dict({ + 'errors': list([ + dict({ + 'code': 'required', + 'detail': 'This field is required.', + 'source': dict({ + 'pointer': '/data/attributes/comment/body', + }), + 'status': '400', + }), + ]), + }) +# --- +# name: test_third_level_array_error + dict({ + 'errors': list([ + dict({ + 'code': 'required', + 'detail': 'This field is required.', + 'source': dict({ + 'pointer': '/data/attributes/comments/0/attachments/0/data', + }), + 'status': '400', + }), + ]), + }) +# --- +# name: test_third_level_custom_array_error + dict({ + 'errors': list([ + dict({ + 'code': 'invalid', + 'detail': 'Too short data', + 'source': dict({ + 'pointer': '/data/attributes/comments/0/attachments/0/data', + }), + 'status': '400', + }), + ]), + }) +# --- +# name: test_third_level_dict_error + dict({ + 'errors': list([ + dict({ + 'code': 'required', + 'detail': 'This field is required.', + 'source': dict({ + 'pointer': '/data/attributes/comments/0/attachment/data', + }), + 'status': '400', + }), + ]), + }) +# --- diff --git a/example/tests/conftest.py b/example/tests/conftest.py index de2bea19..6e4b05ba 100644 --- a/example/tests/conftest.py +++ b/example/tests/conftest.py @@ -12,8 +12,9 @@ CommentFactory, CompanyFactory, EntryFactory, + QuestionnaireFactory, ResearchProjectFactory, - TaggedItemFactory + TaggedItemFactory, ) register(BlogFactory) @@ -27,11 +28,11 @@ register(ArtProjectFactory) register(ResearchProjectFactory) register(CompanyFactory) +register(QuestionnaireFactory) @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 +41,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(),)), @@ -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 new file mode 100644 index 00000000..14ebe3fb --- /dev/null +++ b/example/tests/integration/test_browsable_api.py @@ -0,0 +1,36 @@ +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_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) + assert response.status_code == 200 + 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..238c3076 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -4,178 +4,249 @@ 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( - 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") + 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') + 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" ) - 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' + 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"] + ) + 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"]) + 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() # 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") + + +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/integration/test_meta.py b/example/tests/integration/test_meta.py index 05856865..c63a6d9b 100644 --- a/example/tests/integration/test_meta.py +++ b/example/tests/integration/test_meta.py @@ -7,36 +7,27 @@ 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": { - "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")) @@ -45,31 +36,18 @@ def test_top_level_meta_for_list_view(blog, client): def test_top_level_meta_for_detail_view(blog, client): - expected = { "data": { "type": "blogs", "id": "1", - "attributes": { - "name": blog.name - }, - "relationships": { - "tags": { - "data": [] - } - }, - "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..b2fda333 100644 --- a/example/tests/integration/test_model_resource_name.py +++ b/example/tests/integration/test_model_resource_name.py @@ -18,90 +18,99 @@ 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 +122,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) @@ -138,40 +146,61 @@ 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(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 +210,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 9f1f532e..6434b7a7 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -7,138 +7,123 @@ @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 = { "data": [ { - "type": "posts", + "type": "entries", "id": "1", - "attributes": - { + "attributes": { "headline": multiple_entries[0].headline, "bodyText": multiple_entries[0].body_text, "pubDate": None, - "modDate": None - }, - "meta": { - "bodyFormat": "text" + "modDate": None, }, - "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", # noqa: B950 } }, "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", # noqa: B950 } }, "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", + }, + "meta": {"count": 1}, }, "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", # noqa: B950 } }, - "tags": { - "data": [] - } - } + "tags": {"data": [], "meta": {"count": 0}}, + }, }, { - "type": "posts", + "type": "entries", "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", - "self": "http://testserver/entries/2/relationships/blog_hyperlinked", + "self": "http://testserver/entries/2/relationships/blog_hyperlinked", # noqa: B950 } }, "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", # noqa: B950 } }, "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", + }, + "meta": {"count": 1}, }, "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", # noqa: B950 } }, - "tags": { - "data": [] - } - } + "tags": {"data": [], "meta": {"count": 0}}, + }, }, ] } diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 25d01c44..0f5ac17e 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -7,96 +7,79 @@ @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 = { "data": [ { - "type": "posts", + "type": "entries", "id": "1", - "attributes": - { + "attributes": { "headline": single_entry.headline, "bodyText": single_entry.body_text, "pubDate": None, - "modDate": None - }, - "meta": { - "bodyFormat": "text" + "modDate": None, }, - "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", # noqa: B950 } }, "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", # noqa: B950 } }, "suggested": { "data": [], "links": { "related": "http://testserver/entries/1/suggested/", - "self": "http://testserver/entries/1/relationships/suggested" - } + "self": "http://testserver/entries/1/relationships/suggested", + }, + "meta": {"count": 0}, }, "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", # noqa: B950 } }, "tags": { - "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..836a9046 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -10,153 +10,154 @@ 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 { + rel["type"] + for rel in content["data"]["relationships"]["futureProjects"]["data"] + } == {"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" # noqa: B950 + ) 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 { + rel["type"] + for rel in content["data"]["relationships"]["futureProjects"]["data"] + } == {"researchProjects", "artProjects"} + assert {x.get("type") for x in content.get("included")} == { + "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 = 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) 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 = f"New test topic {random.randint(0, 999999)}" + test_artist = f"test-{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 = f"New test topic {random.randint(0, 999999)}" + test_artist = f"test-{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 +166,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 " # 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 " \ + assert ( + content["errors"][0]["detail"] + == "The resource object's type (invalidProjects) is not the type that constitute the " # noqa: B950 "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 c76f1efd..cf9cee20 100644 --- a/example/tests/integration/test_sparse_fieldsets.py +++ b/example/tests/integration/test_sparse_fieldsets.py @@ -1,12 +1,39 @@ import pytest from django.urls import reverse +from rest_framework import status pytestmark = pytest.mark.django_db -def test_sparse_fieldset_ordered_dict_error(multiple_entries, client): - base_url = reverse('entry-list') - querystring = '?fields[entries]=blog,headline' - # RuntimeError: OrderedDict mutated during iteration - response = client.get(base_url + querystring) - assert response.status_code == 200 # succeed if we didn't fail due to the above RuntimeError +def test_sparse_fieldset_valid_fields(client, entry): + 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"] + + assert len(data) == 1 + entry = data[0] + assert entry["attributes"].keys() == {"headline"} + assert entry["relationships"].keys() == {"blog"} + assert "meta" not in entry + + +@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. + + 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"}) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + + assert len(data) == 1 + entry = data[0] + assert entry["attributes"].keys() != {"headline"} + assert entry["relationships"].keys() != {"blog"} diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py new file mode 100644 index 00000000..72e742c7 --- /dev/null +++ b/example/tests/test_errors.py @@ -0,0 +1,263 @@ +import pytest +from django.test import override_settings +from django.urls import path, reverse +from rest_framework import generics + +from rest_framework_json_api import serializers + +from example.models import Author, Blog + + +# serializers +class CommentAttachmentSerializer(serializers.Serializer): + data = serializers.CharField(allow_null=False, required=True) + + def validate_data(self, value): + if value and len(value) < 10: + raise serializers.ValidationError("Too short data") + + +class CommentSerializer(serializers.Serializer): + attachments = CommentAttachmentSerializer(many=True, required=False) + attachment = CommentAttachmentSerializer(required=False) + one_more_attachment = CommentAttachmentSerializer(required=False) + body = serializers.CharField(allow_null=False, required=True) + + +class EntrySerializer(serializers.Serializer): + blog = serializers.IntegerField() + comments = CommentSerializer(many=True, required=False) + comment = CommentSerializer(required=False) + headline = serializers.CharField(allow_null=True, required=True) + body_text = serializers.CharField() + 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"] + if len(body_text) < 5: + raise serializers.ValidationError( + {"body_text": {"title": "Too Short title", "detail": "Too short"}} + ) + + +# view +class DummyTestView(generics.CreateAPIView): + serializer_class = EntrySerializer + resource_name = "entries" + + def get_serializer_context(self): + return {} + + +urlpatterns = [ + path("entries-nested", DummyTestView.as_view(), name="entries-nested-list") +] + + +@pytest.fixture(scope="function") +def some_blog(db): + 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") + response = client.post(url, data=data) + + return response.json() + + +def test_first_level_attribute_error(client, some_blog, snapshot): + data = { + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + }, + } + } + assert snapshot == perform_error_test(client, data) + + +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", + }, + } + } + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): + assert snapshot == 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": [{}], + }, + } + } + + assert snapshot == perform_error_test(client, data) + + +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": {}, + }, + } + } + + assert snapshot == perform_error_test(client, data) + + +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": [{}]}], + }, + } + } + + assert snapshot == perform_error_test(client, data) + + +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"}]} + ], + }, + } + } + + assert snapshot == perform_error_test(client, data) + + +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": {}}], + }, + } + } + + assert snapshot == perform_error_test(client, data) + + +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": {}}], + }, + } + } + + assert snapshot == perform_error_test(client, data) + + +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", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + }, + "relationships": { + "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"}]}, + }, + } + } + + assert snapshot == perform_error_test(client, data) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index b422ed11..ff7bf263 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,25 +93,29 @@ 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) 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'): - 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,86 +464,74 @@ 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": "entries", + "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: B950 + "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: B950 + "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'} - ] - }, - 'suggestedHyperlinked': { - 'links': { - 'self': 'http://testserver/entries/7/relationships/suggested_hyperlinked', # noqa: E501 - '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"}, + ], + "meta": {"count": 11}, }, - 'tags': { - 'data': [] + "suggestedHyperlinked": { + "links": { + "self": "http://testserver/entries/7/relationships/suggested_hyperlinked", # noqa: B950 + "related": "http://testserver/entries/7/suggested/", + } }, - '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: B950 + "related": "http://testserver/entries/7/featured", } - } + }, }, - 'meta': { - 'bodyFormat': 'text' - } + "meta": {"bodyFormat": "text"}, } - ] + ], + "included": [], } assert response.json() == expected_result @@ -496,51 +552,71 @@ 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 ("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 = {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): """ @@ -548,38 +624,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 deleted file mode 100644 index ba3f4920..00000000 --- a/example/tests/test_format_keys.py +++ /dev/null @@ -1,62 +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(FormatKeysSetTests, self).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', 'email', 'bio', 'entries', 'firstEntry', 'type', 'comments'} - 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..1c3561e7 100644 --- a/example/tests/test_generic_validation.py +++ b/example/tests/test_generic_validation.py @@ -9,8 +9,8 @@ class GenericValidationTest(TestBase): """ def setUp(self): - super(GenericValidationTest, self).setUp() - self.url = reverse('user-validation', kwargs={'pk': self.miles.pk}) + super().setUp() + 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 a9abdd53..40812d6e 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", + }, } } @@ -55,37 +55,41 @@ 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': [ + "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,29 +98,66 @@ def test_custom_validation_exceptions(self): Exceptions should be able to be formatted manually """ expected = { - 'errors': [ + "errors": [ { - 'id': 'armageddon101', - 'detail': 'Hey! You need a last name!', - 'meta': 'something', + "status": "400", + "source": { + "pointer": "/data/attributes/email", + }, + "detail": "Enter a valid email address.", + "code": "invalid", }, { - 'status': '400', - 'source': { - 'pointer': '/data/attributes/email', + "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", + }, + } + }, + ) + + 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': 'Enter a valid email address.', - 'code': 'invalid', + "detail": "First name cannot be the same as last name!", + "code": "invalid", }, ] } - response = self.client.post('/identities', { - 'data': { - 'type': 'users', - 'attributes': { - 'email': 'bar', 'last_name': 'alajflajaljalajlfjafljalj' + 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/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index 1ce8336d..ce6c7ba5 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 @@ -15,46 +14,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}) + super().setUp() + 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 +57,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 +91,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 +132,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 +155,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 +167,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,64 +182,40 @@ 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}) - errors = { - 'errors': [ - {'detail': 'Not found.', 'status': '404', 'code': 'not_found'} - ] - } + self.client.login(username="miles", password="pw") + not_found_url = reverse("user-detail", kwargs={"pk": 12345}) response = self.client.get(not_found_url) assert 404 == response.status_code - assert errors == response.json() + 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 -@pytest.mark.django_db -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' - } - } - } - } - - response = client.patch(url, data=data) - - assert response.status_code == 200 + errors = {"errors": [{"status": "404", "code": "not_found"}]} + assert errors == response.json() diff --git a/example/tests/test_parsers.py b/example/tests/test_parsers.py deleted file mode 100644 index be2b2ce3..00000000 --- a/example/tests/test_parsers.py +++ /dev/null @@ -1,153 +0,0 @@ -import json -from io import BytesIO - -from django.conf.urls import url -from django.test import TestCase, override_settings -from django.urls import 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 = [ - url(r'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/example/tests/test_performance.py b/example/tests/test_performance.py index ac8b5956..ec4a81b2 100644 --- a/example/tests/test_performance.py +++ b/example/tests/test_performance.py @@ -1,51 +1,59 @@ +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 -from example.models import Author, Blog, Comment, Entry +from example.factories import CommentFactory, EntryFactory +from example.models import Author, Blog, Comment, Entry, LabResults, ResearchProject 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) + EntryFactory.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 five queries: 1. Primary resource COUNT query 2. Primary resource SELECT @@ -54,15 +62,57 @@ 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) + + 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) + + 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/tests/test_relations.py b/example/tests/test_relations.py deleted file mode 100644 index ef1dfb02..00000000 --- a/example/tests/test_relations.py +++ /dev/null @@ -1,309 +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.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() - 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/example/tests/test_serializers.py b/example/tests/test_serializers.py index 5f277f2f..9ad487e5 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 @@ -11,7 +12,7 @@ DateField, ModelSerializer, ResourceIdentifierObjectSerializer, - empty + empty, ) from rest_framework_json_api.utils import format_resource_type @@ -25,37 +26,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 = 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): - to_representation_method = 'example.serializers.BlogSerializer.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,57 +70,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([('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', + ("id", 1), + ( + "blog", + dict( + [ + ("name", "Some Blog"), + ("tags", []), + ("copyright", datetime.now().year), + ("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 @@ -126,29 +147,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 @@ -159,39 +179,80 @@ 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": { "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)} + }, + }, + "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})) + response = client.get(reverse("comment-detail", kwargs={"pk": comment.pk})) + + 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() diff --git a/example/tests/test_sideload_resources.py b/example/tests/test_sideload_resources.py index 69641af7..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 @@ -13,7 +14,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 +23,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_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/test_views.py b/example/tests/test_views.py index 1f47245b..47d4adb1 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -1,262 +1,326 @@ 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 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 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) + 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)) - 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(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)}, + ] + + 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(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)}, + ] 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) - response = self.client.patch(url, data={'data': {'invalid': ''}}) + 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) - response = self.client.patch(url, data={'data': {'invalid': ''}}) + url = f"/entries/{self.first_entry.id}/relationships/blog" + 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 = 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 = { - '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 = f"/entries/{self.first_entry.id}/relationships/blog" 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 = f"/blogs/{self.first_entry.id}/relationships/entry_set" 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 - } + def test_patch_one_to_many_relaitonship_with_empty(self): + url = f"/blogs/{self.first_entry.id}/relationships/entry_set" + + 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 = '/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) - }, + "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 = f"/entries/{self.first_entry.id}/relationships/blog" 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 = f"/entries/{self.first_entry.id}/relationships/comments" 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 = f"/entries/{self.first_entry.id}/relationships/comments" 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 = f"/entries/{self.first_entry.id}/relationships/blog" 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 = f"/comments/{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 = f"/entries/{self.first_entry.id}/relationships/comments" 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 = f"/entries/{self.first_entry.id}/relationships/comments" 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 = f"/authors/{self.author.id}/relationships/comments" 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 +329,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 = f"/authors/{self.author.id}/relationships/comments" 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 +349,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 +365,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 +385,109 @@ 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_serializer_class(self): - kwargs = {'pk': self.author.id, 'related_field': 'bio'} + 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): - kwargs = {'pk': self.author.id, 'related_field': 'entries'} + 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): - 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.serializer_class.related_serializers - delattr(view.serializer_class, 'related_serializers') - got = view.get_serializer_class() + 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.get_serializer_class().related_serializers = related_serializers - view.serializer_class.related_serializers = related_serializers - - def test_get_serializer_class_raises_error(self): - kwargs = {'pk': self.author.id, 'related_field': 'unknown'} + 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'}) - resp = self.client.get(url) + url = reverse( + "author-related", kwargs={"pk": self.author.pk, "related_field": "bio"} + ) + resp = self.client.get(url, data={"include": "metadata"}) 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)}, + }, + "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) 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.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) - }, + "data": { + "type": "authorTypes", + "id": str(self.author.author_type.id), + "attributes": {"name": str(self.author.author_type.name)}, } } self.assertEqual(resp.status_code, 200) @@ -429,288 +495,101 @@ 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}) + + @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': None}) + self.assertEqual(resp.json()["data"]["id"], str(first_entry.id)) 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") - - def test_no_content_response(self): - 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.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': datetime.now().year}, - '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) - - -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/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index 680f6a8a..87231896 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 @@ -41,45 +44,38 @@ 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()}) + 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) + 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"} @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,15 +90,14 @@ def test_blog_create(client): blog = blog.first() expected = { - 'data': { - 'attributes': {'name': blog.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": blog.name, "tags": []}, + "id": f"{blog.id}", + "links": {"self": f"http://testserver/blogs/{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,19 +106,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}, - '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": blog.name, "tags": []}, + "id": f"{blog.id}", + "links": {"self": f"http://testserver/blogs/{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 @@ -131,21 +124,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": f"{blog.id}", + "links": {"self": f"http://testserver/blogs/{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) @@ -153,15 +145,14 @@ def test_get_object_patches_correct_blog(client, blog, entry): 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': datetime.now().year}, - 'relationships': {'tags': {'data': []}}, - 'type': 'blogs' + "data": { + "attributes": {"name": new_name, "tags": []}, + "id": f"{blog.id}", + "links": {"self": f"http://testserver/blogs/{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 @@ -169,8 +160,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) @@ -179,40 +169,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': {}, - 'relationships': { - 'tags': { - 'data': [] - } + "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 deleted file mode 100644 index 8acc9e74..00000000 --- a/example/tests/unit/test_factories.py +++ /dev/null @@ -1,41 +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' diff --git a/example/tests/unit/test_pagination.py b/example/tests/unit/test_pagination.py deleted file mode 100644 index aeb5f87e..00000000 --- a/example/tests/unit/test_pagination.py +++ /dev/null @@ -1,78 +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(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/example/tests/unit/test_renderer_class_methods.py b/example/tests/unit/test_renderer_class_methods.py index 7a9230d3..838f6819 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 @@ -11,55 +12,55 @@ 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', - } + resource = {"username": "Alice", "version": "1.0.0"} - 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"}, + "meta": {"version": "1.0.0"}, } - assert JSONRenderer.build_json_resource_obj( - serializer.fields, resource, resource_instance, 'user') == output + assert ( + JSONRenderer.build_json_resource_obj( + get_serializer_fields(serializer), + resource, + resource_instance, + "user", + serializer, + ) + == output + ) def test_can_override_methods(): """ Make sure extract_attributes and extract_relationships can be overriden. """ - resource = { - 'pk': 1, - 'username': 'Alice', - } - serializer = ResourceSerializer(data={'username': 'Alice'}) + resource = {"username": "Alice", "version": "1.0.0"} + 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"}, + "meta": {"version": "1.0.0"}, } class CustomRenderer(JSONRenderer): @@ -69,44 +70,48 @@ 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 - ) - - assert CustomRenderer.build_json_resource_obj( - serializer.fields, resource, resource_instance, 'user') == output + return super().extract_relationships(fields, resource, resource_instance) + + assert ( + CustomRenderer.build_json_resource_obj( + get_serializer_fields(serializer), + resource, + resource_instance, + "user", + serializer, + ) + == 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(), + "id": serializers.Field(), + "username": serializers.Field(), + "deleted": serializers.ReadOnlyField(), } - 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' + 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 ( + JSONRenderer.extract_attributes(fields, {}) == {} + ), "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 +119,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 b088ee6e..f34ce8d6 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -1,7 +1,6 @@ import json import pytest -from django.test import override_settings from django.utils import timezone from rest_framework_json_api import serializers, views @@ -12,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): @@ -53,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',) @@ -62,7 +62,7 @@ class AuthorWithNestedFieldsSerializer(serializers.ModelSerializer): class Meta: model = Author - fields = ('name', 'email', 'comments') + fields = ("name", "email", "comments") # views @@ -79,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(): - ''' +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()) + 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 = ("headline", "comments", "rating") class WriteOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): queryset = Entry.objects.all() @@ -140,8 +135,9 @@ 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 "headline" in result["data"]["attributes"] + assert "relationships" not in result["data"] def test_render_empty_relationship_reverse_lookup(): @@ -150,7 +146,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() @@ -158,9 +154,10 @@ 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 "attributes" not in result["data"] + assert "relationships" in result["data"] + assert "bio" in result["data"]["relationships"] + assert result["data"]["relationships"]["bio"] == {"data": None} @pytest.mark.django_db @@ -168,38 +165,34 @@ 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_attribute_rendering_strategy(db): +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() ) - with override_settings( - JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True): - rendered = render_dummy_test_serialized_view(AuthorWithNestedFieldsViewSet, author) - result = json.loads(rendered.decode()) + rendered = render_dummy_test_serialized_view(AuthorWithNestedFieldsViewSet, author) + result = json.loads(rendered.decode()) expected = { "data": { @@ -212,13 +205,13 @@ def test_attribute_rendering_strategy(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 22935ebb..1be56f47 100644 --- a/example/tests/unit/test_serializer_method_field.py +++ b/example/tests/unit/test_serializer_method_field.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import - -import pytest from rest_framework import serializers from rest_framework_json_api.relations import SerializerMethodResourceRelatedField @@ -14,53 +11,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' - ) - - class Meta: - model = Blog - fields = ['one_entry'] - - def get_custom_entry(self, instance): - return Entry(id=100) - - serializer = BlogSerializer(instance=Blog()) - assert serializer.data['one_entry']['id'] == '100' - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_source(): - class BlogSerializer(serializers.ModelSerializer): - one_entry = SerializerMethodResourceRelatedField( - model=Entry, - source='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' - - -@pytest.mark.filterwarnings("error::DeprecationWarning") -def test_source_is_deprecated(): - with pytest.raises(DeprecationWarning): - SerializerMethodResourceRelatedField(model=Entry, source='get_custom_entry') + assert serializer.data["one_entry"]["id"] == "100" 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/example/urls.py b/example/urls.py index 72788060..471fbe81 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,5 +1,4 @@ -from django.conf import settings -from django.conf.urls import include, url +from django.urls import include, path, re_path from rest_framework import routers from example.views import ( @@ -12,62 +11,76 @@ CompanyViewset, EntryRelationshipView, EntryViewSet, + LabResultViewSet, NonPaginatedEntryViewSet, ProjectTypeViewset, - ProjectViewset + ProjectViewset, + QuestionnaireViewset, ) 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) +router.register(r"lab-results", LabResultViewSet) +router.register(r"questionnaires", QuestionnaireViewset) 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+)$', + path("", include(router.urls)), + re_path( + r"^entries/(?P[^/.]+)/suggested/$", + EntryViewSet.as_view({"get": "list"}), + name="entry-suggested", + ), + 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[^/.]+)/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'), - url(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)$', + name="entry-relationships", + ), + re_path( + r"^blogs/(?P[^/.]+)/relationships/(?P\w+)$", BlogRelationshipView.as_view(), - name='blog-relationships'), - url(r'^comments/(?P[^/.]+)/relationships/(?P\w+)$', + name="blog-relationships", + ), + re_path( + r"^comments/(?P[^/.]+)/relationships/(?P\w+)$", CommentRelationshipView.as_view(), - name='comment-relationships'), - url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', + name="comment-relationships", + ), + re_path( + r"^authors/(?P[^/.]+)/relationships/(?P\w+)$", AuthorRelationshipView.as_view(), - name='author-relationships'), + name="author-relationships", + ), ] - - -if settings.DEBUG: - import debug_toolbar - urlpatterns = [ - url(r'^__debug__/', include(debug_toolbar.urls)), - ] + urlpatterns diff --git a/example/urls_test.py b/example/urls_test.py index 020ab2f3..26d4db21 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -1,4 +1,4 @@ -from django.conf.urls import include, url +from django.urls import path, re_path from rest_framework import routers from .api.resources.identity import GenericIdentity, Identity @@ -15,76 +15,97 @@ EntryRelationshipView, EntryViewSet, FiltersetEntryViewSet, + LabResultViewSet, NoFiltersetEntryViewSet, NonPaginatedEntryViewSet, ProjectTypeViewset, - ProjectViewset + ProjectViewset, + QuestionnaireViewset, ) 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) +router.register(r"lab-results", LabResultViewSet) +router.register(r"questionnaires", QuestionnaireViewset) # for the old tests -router.register(r'identities', Identity) +router.register(r"identities", Identity) urlpatterns = [ - url(r'^', include(router.urls)), - # old tests - url(r'identities/default/(?P\d+)$', - 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'^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'), - 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+)$', + path( + "identities/default/", + 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'), - url(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)$', + name="entry-relationships", + ), + re_path( + r"^blogs/(?P[^/.]+)/relationships/(?P[^/.]+)$", BlogRelationshipView.as_view(), - name='blog-relationships'), - url(r'^comments/(?P[^/.]+)/relationships/(?P\w+)$', + name="blog-relationships", + ), + re_path( + r"^comments/(?P[^/.]+)/relationships/(?P\w+)$", CommentRelationshipView.as_view(), - name='comment-relationships'), - url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', + name="comment-relationships", + ), + re_path( + r"^authors/(?P[^/.]+)/relationships/(?P\w+)$", AuthorRelationshipView.as_view(), - name='author-relationships'), + 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 8c80d145..da171698 100644 --- a/example/views.py +++ b/example/views.py @@ -8,13 +8,32 @@ 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 +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, + Questionnaire, +) from example.serializers import ( + AuthorDetailSerializer, + AuthorListSerializer, AuthorSerializer, BlogDRFSerializer, BlogSerializer, @@ -22,8 +41,10 @@ CompanySerializer, EntryDRFSerializers, EntrySerializer, + LabResultsSerializer, ProjectSerializer, - ProjectTypeSerializer + ProjectTypeSerializer, + QuestionnaireSerializer, ) HTTP_422_UNPROCESSABLE_ENTITY = 422 @@ -34,24 +55,24 @@ 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 - return super(BlogViewSet, self).get_object() + return super().get_object() 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) 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): @@ -60,6 +81,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, @@ -78,7 +100,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) @@ -90,24 +112,27 @@ 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 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() - return super(EntryViewSet, self).get_object() + return super().get_object() 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 @@ -115,7 +140,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): @@ -124,32 +149,44 @@ 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`. - 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',) + # 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", + ) 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(), ) @@ -157,10 +194,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",), } @@ -168,16 +205,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 @@ -185,26 +227,35 @@ class NoFiltersetEntryViewSet(EntryViewSet): class AuthorViewSet(ModelViewSet): queryset = Author.objects.all() - serializer_class = AuthorSerializer + filterset_fields = ("author_type", "name") + + def get_serializer_class(self): + serializer_classes = { + "list": AuthorListSerializer, + "retrieve": AuthorDetailSerializer, + } + + action = getattr(self, "action", "") + return serializer_classes.get(action, AuthorSerializer) 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) + 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): @@ -213,7 +264,7 @@ class CompanyViewset(ModelViewSet): class ProjectViewset(ModelViewSet): - queryset = Project.objects.all().order_by('pk') + queryset = Project.objects.all().order_by("pk") serializer_class = ProjectSerializer @@ -236,4 +287,18 @@ class CommentRelationshipView(RelationshipView): class AuthorRelationshipView(RelationshipView): queryset = Author.objects.all() - self_link_view_name = 'author-relationships' + 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"], + } + + +class QuestionnaireViewset(ModelViewSet): + queryset = Questionnaire.objects.all() + serializer_class = QuestionnaireSerializer diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index ebf0e544..00000000 --- a/pytest.ini +++ /dev/null @@ -1,6 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE=example.settings.test -filterwarnings = - error::DeprecationWarning - error::PendingDeprecationWarning - ignore::DeprecationWarning:rest_framework_json_api.serializers \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 862b4aa8..ba98a85d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,12 @@ -# 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. # Laying these out as separate requirements files, allows us to -# only included the relevant sets when running tox, and ensures +# only include the relevant sets when running tox, and ensures # we are only ever declaring our dependencies in one place. +-e . -r requirements/requirements-optionals.txt -r requirements/requirements-testing.txt -r requirements/requirements-documentation.txt diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 8f66dcab..707bddb9 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,3 +1,5 @@ -flake8==3.8.3 -flake8-isort==3.0.0 -isort==4.3.21 +black==25.1.0 +flake8==7.3.0 +flake8-bugbear==24.12.12 +flake8-isort==6.1.2 +isort==6.0.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index ddbfe30b..76f3bf9a 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ -recommonmark==0.6.0 -Sphinx==3.1.1 -sphinx_rtd_theme==0.4.3 +recommonmark==0.7.1 +Sphinx==8.1.3 +sphinx_rtd_theme==3.0.2 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 1acdef65..4eb517ff 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,2 +1,2 @@ -django-filter==2.3.0 -django-polymorphic==2.1.2 +django-filter==25.1 +django-polymorphic==4.1.0 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 53d9454a..54882779 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.1.1 +twine==6.1.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 8f710ebf..8fe1e366 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ -django-debug-toolbar==2.2 -factory-boy==2.12.0 -Faker==4.1.0 -pytest==5.4.3 -pytest-cov==2.10.0 -pytest-django==3.9.0 -pytest-factoryboy==2.0.3 +factory-boy==3.3.3 +Faker==37.5.3 +pytest==8.4.1 +pytest-cov==6.2.1 +pytest-django==4.11.1 +pytest-factoryboy==2.8.1 +syrupy==4.9.1 diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index a15ece29..23f6b8f9 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,10 +1,8 @@ -# -*- coding: utf-8 -*- - -__title__ = 'djangorestframework-jsonapi' -__version__ = '3.1.0' -__author__ = '' -__license__ = 'BSD' -__copyright__ = '' +__title__ = "djangorestframework-jsonapi" +__version__ = "8.0.0" +__author__ = "" +__license__ = "BSD" +__copyright__ = "" # Version synonym VERSION = __version__ diff --git a/rest_framework_json_api/compat.py b/rest_framework_json_api/compat.py new file mode 100644 index 00000000..e69de29b diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 29acfa5c..70e543c1 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -4,18 +4,18 @@ 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): """ 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 - 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: @@ -44,26 +44,31 @@ 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 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. """ + 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 # 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,8 +79,8 @@ 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)): - raise ValidationError("invalid filter[{}]".format(k)) + if (not filterset_class) or (k not in filterset_class.base_filters): + raise ValidationError(f"invalid filter[{k}]") def get_filterset(self, request, queryset, view): """ @@ -86,7 +91,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,22 +108,24 @@ 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'] != ']'): - raise ValidationError("invalid query parameter: {}".format(qp)) + if m and ( + not m.groupdict()["assoc"] + or m.groupdict()["ldelim"] != "[" + or m.groupdict()["rdelim"] != "]" + ): + 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)) - # 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') + 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) 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, } diff --git a/rest_framework_json_api/exceptions.py b/rest_framework_json_api/exceptions.py index 938a0c77..1c7acbdb 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 @@ -17,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 @@ -28,16 +30,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 - is_json_api_view = rendered_with_json_api(context['view']) + # 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) @@ -46,4 +48,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..ead6f84b 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -3,24 +3,28 @@ 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): """ - 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 :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) """ + #: override :py:attr:`rest_framework.filters.OrderingFilter.ordering_param` #: with JSON:API-compliant query parameter name. - ordering_param = 'sort' + 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): """ @@ -31,16 +35,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 undo_format_field_name(term.replace(".", "__").lstrip("-")) + 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 +57,14 @@ 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")) + "-" + 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) + return super().remove_invalid_fields(queryset, underscore_fields, view, request) class QueryParameterValidationFilter(BaseFilterBackend): @@ -66,11 +75,14 @@ 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\.\-]+\])?$') + query_regex = re.compile( + r"^(sort|include)$|^(?Pfilter|fields|page)(\[[\w\.\-]+\])?$" + ) def validate_query_params(self, request): """ @@ -79,15 +91,17 @@ 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) 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( - 'repeated query parameter not allowed: {}'.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(f"repeated query parameter not allowed: {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..e761e919 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 @@ -7,7 +5,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): @@ -17,56 +15,64 @@ 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.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 = {} + 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 +80,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,27 +88,27 @@ 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)) + 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): - 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 = serializer.Meta.model + field_info["relationship_type"] = self.relation_type_lookup[ getattr(serializer_model, field.field_name) ] except KeyError: @@ -110,40 +116,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..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 @@ -10,16 +9,17 @@ class JsonApiPageNumberPagination(PageNumberPagination): """ - A json-api compatible pagination format. + 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 +31,24 @@ 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": { + "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), + }, + } + ) class JsonApiLimitOffsetPagination(LimitOffsetPagination): @@ -59,8 +61,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 +88,21 @@ 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": { + "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(), + }, + } + ) diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 88c4f522..8940c653 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -1,11 +1,12 @@ """ Parsers """ + 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 +from rest_framework_json_api.utils import get_resource_name, undo_format_field_names class JSONParser(parsers.JSONParser): @@ -13,7 +14,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 @@ -31,41 +32,30 @@ 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') - 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() 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,94 +65,112 @@ 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 {} - 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(JSONParser, self).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") - 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") + parser_context = parser_context or {} + view = parser_context.get("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 + # 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 ( - 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 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') + 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" + ) return data - request = parser_context.get('request') + request = parser_context.get("request") + method = request and request.method # 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 JSON:API Resource Identifier Object" + ) # Check for inconsistencies - if request.method in ('PUT', 'POST', 'PATCH'): - resource_name = utils.get_resource_name( - parser_context, expand_polymorphic_types=True) + if method in ("PUT", "POST", "PATCH"): + resource_name = get_resource_name( + 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 method in ("PATCH", "PUT"): + raise ParseError( + "The resource identifier object must contain an 'id' member" + ) + + if 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 {} - # `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 = {"id": data.get("id")} if "id" in data else {} + 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)) 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) diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index eb7ff3a1..0742c1ec 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -1,6 +1,4 @@ import json -import warnings -from collections import OrderedDict import inflection from django.core.exceptions import ImproperlyConfigured @@ -16,28 +14,28 @@ from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.utils import ( Hyperlink, - get_included_serializers, + format_link_segment, 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", ] -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 @@ -50,10 +48,10 @@ 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' + 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: @@ -62,10 +60,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. @@ -73,7 +71,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): """ @@ -92,7 +90,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) @@ -102,41 +100,48 @@ 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) - return_data = OrderedDict() + def get_links(self, obj=None, lookup_field="pk"): + request = self.context.get("request", None) + view = self.context.get("view", None) + return_data = {} + + 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]} + 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_link = self.get_url('self', self.self_link_view_name, self_kwargs, request) + 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: # 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': + # 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: - 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): """ @@ -156,7 +161,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] @@ -167,29 +172,31 @@ 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_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_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 - super(ResourceRelatedField, self).__init__(**kwargs) + super().__init__(**kwargs) def use_pk_only_optimization(self): # We need the real object to determine its type... @@ -214,9 +221,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() @@ -224,32 +231,36 @@ 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().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 OrderedDict([('type', resource_type), ('id', str(pk))]) + 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): """ @@ -263,9 +274,9 @@ 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) + includes = getattr(parent, "included_serializers", dict()) for field in field_names: if field in includes.keys(): return get_resource_type_from_serializer(includes[field]) @@ -273,7 +284,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 @@ -293,13 +304,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) - ) + return { + json.dumps(self.to_representation(item)): self.display_value(item) for item in queryset - ]) + } class PolymorphicResourceRelatedField(ResourceRelatedField): @@ -310,14 +318,19 @@ 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 - super(PolymorphicResourceRelatedField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def use_pk_only_optimization(self): return False @@ -328,39 +341,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): - if not method_name and kwargs.get('source'): - method_name = kwargs.pop('source') - warnings.warn(DeprecationWarning( - "'source' argument of {cls} is deprecated, use 'method_name' " - "as in SerializerMethodField".format(cls=self.__class__.__name__)), stacklevel=3) 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 = f"get_{field_name}" if self.method_name is None: self.method_name = default_method_name super().bind(field_name, parent) @@ -370,40 +381,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 ccd71510..f418b080 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -1,23 +1,41 @@ """ 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.utils import encoding +from django.template import loader +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 -from rest_framework.serializers import BaseSerializer, ListSerializer, Serializer +from rest_framework.serializers import ListSerializer, Serializer 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, ResourceRelatedField, SkipDataMixin -from rest_framework_json_api.settings import json_api_settings +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): @@ -25,7 +43,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 @@ -33,7 +51,7 @@ class JSONRenderer(renderers.JSONRenderer): "data": [ { "type": "companies", - "id": 1, + "id": "1", "attributes": { "name": "Mozilla", "slug": "mozilla", @@ -44,46 +62,25 @@ 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): """ - Builds the `attributes` object of the JSON API resource object. - """ - data = OrderedDict() - render_nested_as_attribute = json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE - 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 isinstance( - field, (relations.RelatedField, relations.ManyRelatedField) - ): - continue - - if isinstance(field, BaseSerializer) and not render_nested_as_attribute: - 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 + Builds the `attributes` object of the JSON:API resource object. - data.update({ - field_name: resource.get(field_name) - }) + Ensures that ID which is always provided in a JSON:API resource object + and relationships are not returned. + """ - 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 != "id" + and not is_relationship_field(fields[field_name]) + } @classmethod def extract_relationships(cls, fields, resource, resource_instance): @@ -93,8 +90,7 @@ def extract_relationships(cls, fields, resource, resource_instance): # Avoid circular deps from rest_framework_json_api.relations import ResourceRelatedField - data = OrderedDict() - render_nested_as_attribute = json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE + data = {} # Don't try to extract relationships from a non-existent resource if resource_instance is None: @@ -110,19 +106,14 @@ def extract_relationships(cls, fields, resource, resource_instance): continue # Skip fields without relations - if not isinstance( - field, (relations.RelatedField, relations.ManyRelatedField, BaseSerializer) - ): - continue - - if isinstance(field, BaseSerializer) and render_nested_as_attribute: + 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: @@ -131,73 +122,73 @@ 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() - - for related_object in relation_queryset: - relation_data.append( - OrderedDict([ - ('type', relation_type), - ('id', encoding.force_str(related_object.pk)) - ]) - ) + relation_queryset = ( + relation_instance if relation_instance is not None else list() + ) - data.update({field_name: { - 'links': { - "related": resource.get(field_name)}, - 'data': relation_data, - 'meta': { - 'count': len(relation_data) + relation_data = [ + {"type": relation_type, "id": force_str(related_object.pk)} + for related_object in relation_queryset + ] + 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)}) + + if isinstance(field, ManySerializerMethodResourceRelatedField): + relation_data.update( + {"meta": {"count": len(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 + resolved, relation = get_relation_instance( + resource_instance, f"{source}_id", 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) - } + 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) 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 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: @@ -207,25 +198,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}) @@ -234,75 +220,27 @@ 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 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( + { + "type": nested_resource_instance_type, + "id": force_str(nested_resource_instance.pk), } - } - }) - continue - - if isinstance(field, ListSerializer): - resolved, relation_instance = utils.get_relation_instance( - resource_instance, source, field.parent - ) - if not resolved: - continue - - relation_data = list() - - serializer_data = resource.get(field_name) - resource_instance_queryset = list(relation_instance) - if isinstance(serializer_data, list): - for position in range(len(serializer_data)): - nested_resource_instance = resource_instance_queryset[position] - nested_resource_instance_type = ( - 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}}) - continue - - if isinstance(field, Serializer): - 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 relation_instance is not None: - relation_instance_id = relation_instance.pk - - data.update({ - field_name: { - 'data': ( - OrderedDict([ - ('type', relation_type), - ('id', encoding.force_str(relation_instance_id)) - ]) if resource.get(field_name) else None) + data.update( + { + field_name: { + "data": relation_data, + "meta": {"count": len(relation_data)}, + } } - }) + ) continue - return utils.format_field_names(data) + return format_field_names(data) @classmethod def extract_relation_instance(cls, field, resource_instance): @@ -321,8 +259,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 @@ -333,10 +272,10 @@ def extract_included(cls, fields, resource, resource_instance, included_resource 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] - render_nested_as_attribute = json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE for field_name, field in iter(fields.items()): # Skip URL field @@ -344,12 +283,7 @@ def extract_included(cls, fields, resource, resource_instance, included_resource continue # Skip fields without relations - if not isinstance( - field, (relations.RelatedField, relations.ManyRelatedField, BaseSerializer) - ): - continue - - if isinstance(field, BaseSerializer) and render_nested_as_attribute: + if not is_relationship_field(field): continue try: @@ -357,12 +291,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() @@ -377,11 +311,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 @@ -390,13 +327,15 @@ 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(f"{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) + relation_type = get_resource_type_from_serializer(serializer) relation_queryset = list(relation_instance) if serializer_data: @@ -404,10 +343,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 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 ) @@ -417,10 +356,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) + 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, @@ -430,21 +370,21 @@ def extract_included(cls, fields, resource, resource_instance, included_resource ) 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, 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 + field, + getattr(field, "_poly_force_type_resolution", False), ) + included_cache[new_item["type"]][new_item["id"]] = new_item + cls.extract_included( serializer_fields, serializer_data, @@ -459,16 +399,15 @@ 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', []) - data = OrderedDict() + meta = getattr(serializer, "Meta", None) + 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 @@ -477,170 +416,227 @@ 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 _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 is not None: + sparse_fields = sparse_fieldset_value.split(",") + return { + field_name: field + for field_name, field, in fields.items() + 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 + and isinstance(field, relations.HyperlinkedIdentityField) + ) + } + + return fields + + @classmethod + def build_json_resource_obj( + cls, + fields, + resource, + resource_instance, + resource_name, + serializer, + 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) - resource_data = [ - ('type', resource_name), - ('id', encoding.force_str(resource_instance.pk) if resource_instance else None), - ('attributes', cls.extract_attributes(fields, resource)), - ] + resource_name = get_resource_type_from_instance(resource_instance) + resource_data = { + "type": resource_name, + "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 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]})) - return OrderedDict(resource_data) + if api_settings.URL_FIELD_NAME in resource and isinstance( + fields[api_settings.URL_FIELD_NAME], relations.HyperlinkedIdentityField + ): + resource_data["links"] = {"self": resource[api_settings.URL_FIELD_NAME]} + + meta = cls.extract_meta(serializer, resource) + if meta: + resource_data["meta"] = format_field_names(meta) + + return 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 = {"data": data} links = view.get_links() if links: - render_data.update({'links': links}), - return super(JSONRenderer, self).render( - render_data, accepted_media_type, renderer_context - ) + 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): - return super(JSONRenderer, self).render( - utils.format_errors(data), accepted_media_type, renderer_context + return super().render( + format_errors(data), accepted_media_type, renderer_context ) def render(self, data, accepted_media_type=None, renderer_context=None): - renderer_context = renderer_context or {} view = renderer_context.get("view", 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': + 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 - ) + return super().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. 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 - 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) + included_resources = 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)) - 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) + fields = 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, + 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( - 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) + fields = 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 + fields, + 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, 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() + render_data = {} - 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): @@ -649,23 +645,75 @@ 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() + 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()): - 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"] = 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): + 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().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 getattr( + serializer, "included_serializers", dict() + ).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: + if "related_field" in view.kwargs: + serializer_class = view.get_related_serializer_class() + else: + 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/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/serializers.py b/rest_framework_json_api/serializers.py index 3266acd2..26f6b02e 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,104 +1,142 @@ -import warnings +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 -from rest_framework.serializers import * # noqa: F403 +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` +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 -from rest_framework_json_api.settings import json_api_settings 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 + get_resource_type_from_serializer, + undo_format_field_name, ) 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) + super().__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): - def __init__(self, *args, **kwargs): - super(SparseFieldsetsMixin, self).__init__(*args, **kwargs) - context = kwargs.get('context') - request = context.get('request') if context else None +class SparseFieldsetsMixin: + """ + A serializer mixin that adds support for sparse fieldsets through `fields` query parameter. + + Specification: https://jsonapi.org/format/#fetching-sparse-fieldsets + """ + + @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 in 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 is not None: + sparse_fields = [ + undo_format_field_name(sparse_field) + for sparse_field in sparse_fieldset_value.split(",") + ] + return ( + field + for field in readable_fields + if field.field_name in sparse_fields + # 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" + ) + except AttributeError: + # no type on serializer, may only be used nested pass - else: - 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 - continue - if field_name not in fieldset: - self.fields.pop(field_name) - - -class IncludedResourcesValidationMixin(object): + + return readable_fields + + +class IncludedResourcesValidationMixin: + """ + A serializer mixin that adds validation of `include` query parameter to + support compound documents. + + Specification: https://jsonapi.org/format/#document-compound-documents) + """ + 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) + 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]) + raise ParseError("This endpoint does not support the include parameter") + this_field_name = 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 ) ) @@ -110,49 +148,123 @@ 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('.') - this_serializer_class = view.get_serializer_class() + included_field_path = included_field_name.split(".") + 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) + validate_path( + this_serializer_class, included_field_path, included_field_name + ) - super(IncludedResourcesValidationMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) -class SerializerMetaclass(SerializerMetaclass): +class ReservedFieldNamesMixin: + """Ensures that reserved field names are not used and an error raised instead.""" + + _reserved_field_names = { + "meta", + "results", + "type", + api_settings.NON_FIELD_ERRORS_KEY, + } + + def get_fields(self): + fields = super().get_fields() + + found_reserved_field_names = self._reserved_field_names.intersection( + fields.keys() + ) + 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))}" + ) - @classmethod - def _get_declared_fields(cls, bases, attrs): - fields = super()._get_declared_fields(bases, attrs) - for field_name, field in fields.items(): - if isinstance(field, BaseSerializer) and \ - not json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE: - clazz = '{}.{}'.format(attrs['__module__'], attrs['__qualname__']) - if isinstance(field, ListSerializer): - nested_class = type(field.child).__name__ - else: - nested_class = type(field).__name__ - - warnings.warn(DeprecationWarning( - "Rendering nested serializer as relationship is deprecated. " - "Use `ResourceRelatedField` instead if {} in serializer {} should remain " - "a relationship. Otherwise set " - "JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE to True to render nested " - "serializer as nested json attribute".format(nested_class, clazz))) return fields +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): + def __new__(cls, name, bases, attrs): + serializer = super().__new__(cls, name, bases, attrs) + + if attrs.get("included_serializers", None): + serializer.included_serializers = LazySerializersDict( + serializer, attrs["included_serializers"] + ) + + if attrs.get("related_serializers", None): + serializer.related_serializers = LazySerializersDict( + serializer, attrs["related_serializers"] + ) + + return serializer + + # 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, + ReservedFieldNamesMixin, + Serializer, + metaclass=SerializerMetaclass, ): + """ + A `Serializer` is a model-less serializer class with additional + support for JSON:API spec features. + + 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. + + Included Mixins: + + * A mixin class to enable sparse fieldsets is included + * A mixin class to enable validation of included resources is included + """ + pass class HyperlinkedModelSerializer( - IncludedResourcesValidationMixin, SparseFieldsetsMixin, HyperlinkedModelSerializer, - metaclass=SerializerMetaclass + IncludedResourcesValidationMixin, + SparseFieldsetsMixin, + ReservedFieldNamesMixin, + HyperlinkedModelSerializer, + metaclass=SerializerMetaclass, ): """ A type of `ModelSerializer` that uses hyperlinked relationships instead @@ -168,8 +280,13 @@ class HyperlinkedModelSerializer( """ -class ModelSerializer(IncludedResourcesValidationMixin, SparseFieldsetsMixin, ModelSerializer, - metaclass=SerializerMetaclass): +class ModelSerializer( + IncludedResourcesValidationMixin, + SparseFieldsetsMixin, + ReservedFieldNamesMixin, + ModelSerializer, + metaclass=SerializerMetaclass, +): """ A `ModelSerializer` is just a regular `Serializer`, except that: @@ -191,6 +308,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): @@ -198,64 +316,15 @@ 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', []) - - declared = OrderedDict() - for field_name in set(declared_fields.keys()): - 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) - return list(fields) + list(getattr(self.Meta, 'meta_fields', list())) - - def to_representation(self, instance): - """ - Object instance -> Dict of primitive datatypes. - """ - ret = OrderedDict() - readable_fields = [ - field for field in self.fields.values() - if not field.write_only - ] + meta_fields = getattr(self.Meta, "meta_fields", []) - for field in readable_fields: - try: - field_representation = self._get_field_representation(field, instance) - ret[field.field_name] = field_representation - except SkipField: - continue - - return ret - - def _get_field_representation(self, field, instance): - request = self.context.get('request') - is_included = field.source in get_included_resources(request) - render_nested_as_attribute = json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE - if not is_included and \ - isinstance(field, ModelSerializer) and \ - hasattr(instance, field.source + '_id') and \ - not render_nested_as_attribute: - 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) + 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())) class PolymorphicSerializerMetaclass(SerializerMetaclass): @@ -265,7 +334,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). @@ -273,17 +342,20 @@ def __new__(cls, name, bases, attrs): if not parents: 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.") + polymorphic_serializers = getattr(new_class, "polymorphic_serializers", None) + 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} + 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 @@ -296,22 +368,31 @@ 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") - return super(PolymorphicModelSerializer, self).get_fields() + raise Exception( + "Cannot get fields from a polymorphic serializer given a queryset" + ) + return super().get_fields() @classmethod def get_polymorphic_serializer_for_instance(cls, instance): @@ -325,7 +406,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): @@ -338,7 +421,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): @@ -351,7 +437,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)) + f"No polymorphic serializer has been found for type {obj_type}" + ) @classmethod def get_polymorphic_model_for_type(cls, obj_type): @@ -361,7 +448,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): @@ -375,21 +463,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 74e4e8d3..83228359 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -1,26 +1,26 @@ """ 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 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, - 'SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE': False + "FORMAT_FIELD_NAMES": False, + "FORMAT_TYPES": False, + "FORMAT_RELATED_LINKS": False, + "PLURALIZE_TYPES": False, + "UNIFORM_EXCEPTIONS": False, } -class JSONAPISettings(object): +class JSONAPISettings: """ - 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,9 +30,11 @@ 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]) + value = getattr( + self.user_settings, JSON_API_SETTINGS_PREFIX + attr, self.defaults[attr] + ) # Cache the result setattr(self, attr, value) @@ -43,9 +45,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/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 %} + + + diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index b3932651..2dd79677 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -1,26 +1,24 @@ -import copy import inspect import operator -from collections import OrderedDict import inflection from django.conf import settings 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 -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 import exceptions, relations from rest_framework.exceptions import APIException +from rest_framework.settings import api_settings 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 +30,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,15 +44,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 = view.resource_name except AttributeError: try: - serializer = view.get_serializer_class() - if expand_polymorphic_types and issubclass(serializer, PolymorphicModelSerializer): + 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: return get_resource_type_from_serializer(serializer) @@ -75,15 +79,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 = serializer.child.fields + meta = getattr(serializer.child, "Meta", None) + if hasattr(serializer, "fields"): + fields = 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) @@ -103,27 +107,74 @@ 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 -def format_value(value, format_type=None): - if format_type is None: - format_type = json_api_settings.FORMAT_FIELD_NAMES - if format_type == 'dasherize': +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): + """ + 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' + """ + format_type = json_api_settings.FORMAT_RELATED_LINKS + 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): + 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 @@ -136,7 +187,6 @@ 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 @@ -144,44 +194,51 @@ 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')): - # 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): 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: @@ -193,7 +250,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: @@ -202,17 +259,21 @@ 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)) + # 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( + _(f"Could not resolve resource type for relation {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): @@ -220,7 +281,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) @@ -229,46 +290,53 @@ 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() + raise AttributeError( + f"can not detect 'resource_name' on serializer {serializer.__class__.__name__!r}" + f" in module {serializer.__class__.__module__!r}" + ) + + +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: + _id = resource["id"] + return encoding.force_str(_id) if _id is not None else None + if resource_instance: + pk = getattr(resource_instance, "pk", None) + return encoding.force_str(pk) if pk is not None else 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 + """ + 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) 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', [])) - - -def get_included_serializers(serializer): - 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': - included_serializers[name] = ( - serializer if isinstance(serializer, type) else serializer.__class__ - ) - else: - included_serializers[name] = import_class_from_dotted_path(value) - - return included_serializers + 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_relation_instance(resource_instance, source, serializer): @@ -278,7 +346,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 @@ -289,6 +357,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. @@ -299,8 +371,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 @@ -312,52 +384,102 @@ 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.append(format_error_object(message, '/data', response)) + errors.extend(format_error_object(message, "/data", response)) # handle all errors thrown from serializers else: + try: + serializer = context["view"].get_serializer() + fields = get_serializer_fields(serializer) or dict() + relationship_fields = [ + format_field_name(name) + 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(): - field = format_value(field) - pointer = '/data/attributes/{}'.format(field) - # see if they passed a dictionary to ValidationError manually - if isinstance(error, dict): - errors.append(error) - elif isinstance(exc, Http404) and isinstance(error, str): + non_field_error = field == api_settings.NON_FIELD_ERRORS_KEY + field = format_field_name(field) + pointer = None + if non_field_error: + # Serializer error does not refer to a specific field. + pointer = "/data" + 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}" + if isinstance(exc, Http404) and isinstance(error, str): # 404 errors don't have a pointer - errors.append(format_error_object(error, None, response)) + errors.extend(format_error_object(error, None, response)) elif isinstance(error, str): 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' - errors.append(format_error_object(error, pointer, response)) + pointer = "/data" + errors.extend(format_error_object(error, pointer, response)) elif isinstance(error, list): - for message in error: - errors.append(format_error_object(message, pointer, response)) + errors.extend(format_error_object(error, pointer, response)) else: - errors.append(format_error_object(error, pointer, response)) + errors.extend(format_error_object(error, pointer, response)) - context['view'].resource_name = 'errors' + context["view"].resource_name = "errors" response.data = errors return response def format_error_object(message, pointer, response): - error_obj = { - 'detail': message, - 'status': encoding.force_str(response.status_code), - } - if pointer is not None: - error_obj['source'] = { - 'pointer': pointer, + 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( + [ + isinstance(value, str) + for key, value in message.items() + if key not in ["links", "source", "meta"] + ] + ) + + if is_custom_error: + if "source" not in message: + message["source"] = {} + if "pointer" not in message["source"]: + message["source"]["pointer"] = pointer + errors.append(message) + else: + for k, v in message.items(): + 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 + f"/{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), } - code = getattr(message, "code", None) - if code is not None: - error_obj['code'] = code - return error_obj + if pointer is not None: + error_obj["source"] = { + "pointer": pointer, + } + code = getattr(message, "code", None) + if code is not None: + error_obj["code"] = code + errors.append(error_obj) + + return errors 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 2e0b22ef..90bb5574 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -6,12 +6,11 @@ ForwardManyToOneDescriptor, ManyToManyDescriptor, ReverseManyToOneDescriptor, - ReverseOneToOneDescriptor + ReverseOneToOneDescriptor, ) 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 @@ -24,13 +23,13 @@ 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 + get_resource_type_from_instance, + undo_format_link_segment, ) -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. @@ -54,17 +53,18 @@ 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__']: + qs = super().get_queryset(*args, **kwargs) + included_resources = get_included_resources( + 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) @@ -76,17 +76,19 @@ 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) + """This mixin adds automatic prefetching for OneToOne and ManyToMany fields.""" + qs = super().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__']: + 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 +96,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 @@ -106,7 +108,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: @@ -118,21 +119,24 @@ 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 -class RelatedMixin(object): +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): serializer_kwargs = {} instance = self.get_related_instance() - if hasattr(instance, 'all'): + if hasattr(instance, "all"): instance = instance.all() if callable(instance): @@ -142,44 +146,53 @@ 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_serializer(instance, **serializer_kwargs) + serializer = self.get_related_serializer(instance, **serializer_kwargs) return Response(serializer.data) - def get_serializer_class(self): - parent_serializer_class = super(RelatedMixin, self).get_serializer_class() + 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 = self.get_serializer_class() + + if "related_field" in self.kwargs: + field_name = self.get_related_field_name() - if 'related_field' in self.kwargs: - field_name = self.kwargs['related_field'] + 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(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' - - 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'] + field_name = self.kwargs["related_field"] + return undo_format_link_segment(field_name) 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) @@ -200,17 +213,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, PreloadIncludesMixin, RelatedMixin, viewsets.ReadOnlyModelViewSet +): + http_method_names = ["get", "post", "patch", "delete", "head", "options"] class RelationshipView(generics.GenericAPIView): @@ -218,15 +230,15 @@ 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 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. @@ -249,10 +261,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) @@ -262,16 +274,18 @@ def get_url(self, name, view_name, kwargs, request): return Hyperlink(url, name) def get_links(self): - return_data = OrderedDict() - self_link = self.get_url('self', self.self_link_view_name, self.kwargs, self.request) + return_data = {} + 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["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): @@ -286,7 +300,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() @@ -307,25 +321,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) @@ -338,11 +360,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) @@ -362,11 +386,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) @@ -377,7 +401,9 @@ 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"] + 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 @@ -392,7 +418,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.cfg b/setup.cfg index ef25b7bd..92606700 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,28 +5,36 @@ 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, 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, - 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 @@ -49,3 +57,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/setup.py b/setup.py index 73e86fb1..2a2a28dd 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 @@ -7,15 +6,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)) as f: return f.read() @@ -23,7 +22,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 +30,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 +42,78 @@ 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.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', + "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.9", + "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", ], + 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.3.0', - 'djangorestframework>=3.10', - 'django>=1.11', + "inflection>=0.5.0", + "djangorestframework>=3.15", + "django>=4.2", ], extras_require={ - 'django-polymorphic': ['django-polymorphic>=2.0'], - 'django-filter': ['django-filter>=2.0'] + "django-polymorphic": ["django-polymorphic>=4.0.0"], + "django-filter": ["django-filter>=2.4"], }, setup_requires=wheel, - python_requires=">=3.5", + python_requires=">=3.9", zip_safe=False, ) 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..77b3676b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,105 @@ +import pytest +from rest_framework.test import APIClient, APIRequestFactory + +from tests.models import ( + BasicModel, + ForeignKeySource, + ForeignKeyTarget, + ManyToManySource, + ManyToManyTarget, + NestedRelatedSource, + URLModel, +) + + +@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 + + +@pytest.fixture +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") + + +@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") + 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"), + ] + + +@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() + + +@pytest.fixture +def rf(): + return APIRequestFactory() diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 00000000..63cb6434 --- /dev/null +++ b/tests/models.py @@ -0,0 +1,60 @@ +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) + + 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): + 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 + ) + + +class NestedRelatedSource(DJAModel): + m2m_sources = models.ManyToManyField(ManyToManySource, related_name="nested_source") + fk_source = models.ForeignKey( + ForeignKeySource, related_name="nested_source", on_delete=models.CASCADE + ) + m2m_targets = models.ManyToManyField(ManyToManyTarget, related_name="nested_target") + fk_target = models.ForeignKey( + ForeignKeyTarget, related_name="nested_target", on_delete=models.CASCADE + ) diff --git a/tests/serializers.py b/tests/serializers.py new file mode 100644 index 00000000..ef8a51cf --- /dev/null +++ b/tests/serializers.py @@ -0,0 +1,94 @@ +from rest_framework.settings import api_settings + +from rest_framework_json_api import serializers +from tests.models import ( + BasicModel, + ForeignKeySource, + ForeignKeyTarget, + ManyToManySource, + ManyToManyTarget, + NestedRelatedSource, + URLModel, +) + + +class BasicModelSerializer(serializers.ModelSerializer): + class Meta: + fields = ("text",) + model = BasicModel + + +class URLModelSerializer(serializers.ModelSerializer): + class Meta: + fields = ( + "text", + "url", + ) + model = URLModel + + +class ForeignKeyTargetSerializer(serializers.ModelSerializer): + class Meta: + fields = ("name",) + model = ForeignKeyTarget + + +class ForeignKeySourceSerializer(serializers.ModelSerializer): + included_serializers = {"target": ForeignKeyTargetSerializer} + + class Meta: + model = ForeignKeySource + fields = ( + "name", + "target", + ) + + +class ForeignKeySourcetHyperlinkedSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = ForeignKeySource + fields = ( + "name", + "target", + api_settings.URL_FIELD_NAME, + ) + + +class ManyToManyTargetSerializer(serializers.ModelSerializer): + class Meta: + fields = ("name",) + model = ManyToManyTarget + + +class ManyToManySourceSerializer(serializers.ModelSerializer): + included_serializers = {"targets": "tests.serializers.ManyToManyTargetSerializer"} + + class Meta: + model = ManyToManySource + fields = ("targets",) + + +class ManyToManySourceReadOnlySerializer(serializers.ModelSerializer): + class Meta: + model = ManyToManySource + fields = ("targets",) + + +class NestedRelatedSourceSerializer(serializers.ModelSerializer): + included_serializers = { + "m2m_sources": ManyToManySourceSerializer, + "fk_source": ForeignKeySourceSerializer, + "m2m_targets": ManyToManyTargetSerializer, + "fk_target": ForeignKeyTargetSerializer, + } + + class Meta: + model = NestedRelatedSource + fields = ("m2m_sources", "fk_source", "m2m_targets", "fk_target") + + +class CallableDefaultSerializer(serializers.Serializer): + field = serializers.CharField(default=serializers.CreateOnlyDefault("default")) + + class Meta: + fields = ("field",) diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 00000000..10f0ebbf --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,43 @@ +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": { + "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": { + "count": count, + "limit": limit, + "offset": offset, + } + }, + } + + assert content == expected_content diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 00000000..45ac5232 --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,144 @@ +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): + def parse_wrapper(data, parser_context): + 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", + [ + False, + "dasherize", + "camelize", + "capitalize", + "underscore", + ], + ) + def test_parse_formats_field_names( + self, + settings, + format_field_names, + parse, + parser_context, + ): + 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, parser_context) + assert result == { + "id": "123", + "type": "BasicModel", + "test_attribute": "test-value", + "test_relationship": {"id": "123", "type": "TestRelationship"}, + } + + def test_parse_extracts_meta(self, parse, parser_context): + data = { + "data": { + "type": "BasicModel", + }, + "meta": {"random_key": "random_value"}, + } + + result = parse(data, parser_context) + assert result["_meta"] == data["meta"] + + def test_parse_with_default_arguments(self, parse): + data = { + "data": { + "type": "BasicModel", + }, + } + result = parse(data, None) + assert result == {"type": "BasicModel"} + + def test_parse_preserves_json_value_field_names( + self, settings, parse, parser_context + ): + settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" + + data = { + "data": { + "type": "BasicModel", + "attributes": {"json-value": {"JsonKey": "JsonValue"}}, + }, + } + + result = parse(data, parser_context) + assert result["json_value"] == {"JsonKey": "JsonValue"} + + def test_parse_raises_error_on_empty_data(self, parse, parser_context): + data = [] + + with pytest.raises(ParseError) as excinfo: + 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, parser_context): + data = { + "data": [ + { + "type": "BasicModel", + "attributes": {"json-value": {"JsonKey": "JsonValue"}}, + } + ], + } + + with pytest.raises(ParseError) as excinfo: + parse(data, parser_context) + + 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): + parser_context["request"] = rf.patch("/") + data = { + "data": { + "type": "BasicModel", + }, + } + + with pytest.raises(ParseError) as excinfo: + parse(data, parser_context) + + 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 new file mode 100644 index 00000000..5f8883be1 --- /dev/null +++ b/tests/test_relations.py @@ -0,0 +1,320 @@ +import pytest +from django.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, + 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, ForeignKeySource, ForeignKeyTarget +from tests.serializers import ( + ForeignKeySourceSerializer, + ManyToManySourceReadOnlySerializer, + ManyToManySourceSerializer, +) +from tests.views import BasicModelViewSet + + +@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, "name": "Test"} + ) + expected = { + "type": resource_type, + "id": str(foreign_key_target.pk), + } + + 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", + [ + (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)}, + "name": "Test", + } + ) + + 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, "name": "Test"} + ) + assert not serializer.is_valid() + 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", + [ + False, + "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 + + +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_serializers.py b/tests/test_serializers.py new file mode 100644 index 00000000..98cb2850 --- /dev/null +++ b/tests/test_serializers.py @@ -0,0 +1,106 @@ +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 +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 + + +def test_reserved_field_names(): + with pytest.raises(AssertionError) as e: + + 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 " + "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", + ] + + +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", + ] diff --git a/example/tests/unit/test_settings.py b/tests/test_settings.py similarity index 72% rename from example/tests/unit/test_settings.py rename to tests/test_settings.py index e6b82a24..666eae4a 100644 --- a/example/tests/unit/test_settings.py +++ b/tests/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/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..08e36b6a --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,479 @@ +import pytest +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 + +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, + format_resource_type, + format_value, + get_included_resources, + get_related_resource_type, + get_resource_id, + get_resource_name, + get_resource_type_from_serializer, + undo_format_field_name, + undo_format_field_names, + undo_format_link_segment, +) +from tests.models import ( + BasicModel, + ForeignKeySource, + ForeignKeyTarget, + ManyToManySource, + ManyToManyTarget, + NestedRelatedSource, +) +from tests.serializers import BasicModelSerializer + + +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 +): + 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 +): + 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", + [ + (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"}}), + ("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", + [ + (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_link_segment(settings, format_type, output): + settings.JSON_API_FORMAT_RELATED_LINKS = format_type + assert format_link_segment("first_Name") == output + + +@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", + [ + (False, "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 + + +@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", + [ + ({"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 + + +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'" + ) + + +@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"), + (BasicModel(id=0), None, "0"), + (None, {"id": 0}, "0"), + ( + BasicModel(id=0), + {"id": 0}, + "0", + ), + ], +) +def test_get_resource_id(resource_instance, resource, expected): + assert get_resource_id(resource_instance, resource) == expected + + +@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) + + +@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 diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 00000000..349d37da --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,574 @@ +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.routers import SimpleRouter +from rest_framework.views import APIView + +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_link_segment +from rest_framework_json_api.views import ModelViewSet, ReadOnlyModelViewSet +from tests.models import BasicModel, ForeignKeySource +from tests.serializers import BasicModelSerializer, ForeignKeyTargetSerializer +from tests.views import ( + BasicModelViewSet, + ForeignKeySourcetHyperlinkedViewSet, + ForeignKeySourceViewSet, + ForeignKeyTargetViewSet, + ManyToManySourceViewSet, + NestedRelatedSourceViewSet, + URLModelViewSet, +) + + +class TestModelViewSet: + @pytest.mark.parametrize( + "format_links", + [ + False, + "dasherize", + "camelize", + "capitalize", + "underscore", + ], + ) + def test_get_related_field_name_handles_formatted_link_segments( + 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" + + class RelatedFieldNameSerializer(serializers.ModelSerializer): + related_model_field = ResourceRelatedField(queryset=BasicModel.objects) + + def __init__(self, *args, **kwargs): + self.related_model_field.field_name = related_model_field_name + super().__init(*args, **kwargs) + + class Meta: + model = BasicModel + + class RelatedFieldNameView(ModelViewSet): + serializer_class = RelatedFieldNameSerializer + + url_segment = format_link_segment(related_model_field_name) + + request = rf.get(f"/basic_models/1/{url_segment}") + + view = RelatedFieldNameView() + view.setup(request, related_field=url_segment) + + 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_list_with_include_foreign_key(self, client, foreign_key_source): + url = reverse("foreignkeysource-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_invalid_include(self, client, foreign_key_source): + url = reverse("foreignkeysource-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") + 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_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_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}) + 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_retrieve_with_include_foreign_key(self, client, foreign_key_source): + 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() + 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_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 = { + "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 + + @pytest.mark.urls(__name__) + def test_create_with_sparse_fields(self, client, foreign_key_target): + url = reverse("foreignkeysource-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( + "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): + data = { + "data": { + "id": 123, + "type": "custom", + "attributes": {"body": "hello"}, + } + } + + url = reverse("custom") + + response = client.patch(url, data=data) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "custom", + "id": "123", + "attributes": {"body": "hello"}, + } + } + + @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"}, + } + } + + @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 + + +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(): + 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 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] + 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) + + 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 = 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"url_models", URLModelViewSet) +router.register(r"foreign_key_sources", ForeignKeySourceViewSet) +router.register(r"foreign_key_targets", ForeignKeyTargetViewSet) +router.register( + r"foreign_key_sources_hyperlinked", + ForeignKeySourcetHyperlinkedViewSet, + "foreignkeysourcehyperlinked", +) +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"), + path("custom-id", CustomIdAPIView.as_view(), name="custom-id"), +] +urlpatterns += router.urls diff --git a/tests/views.py b/tests/views.py new file mode 100644 index 00000000..7958c6b9 --- /dev/null +++ b/tests/views.py @@ -0,0 +1,60 @@ +from rest_framework_json_api.views import ModelViewSet +from tests.models import ( + BasicModel, + ForeignKeySource, + ForeignKeyTarget, + ManyToManySource, + NestedRelatedSource, + URLModel, +) +from tests.serializers import ( + BasicModelSerializer, + ForeignKeySourceSerializer, + ForeignKeySourcetHyperlinkedSerializer, + ForeignKeyTargetSerializer, + ManyToManySourceSerializer, + NestedRelatedSourceSerializer, + URLModelSerializer, +) + + +class BasicModelViewSet(ModelViewSet): + serializer_class = BasicModelSerializer + queryset = BasicModel.objects.all() + ordering = ["text"] + + +class URLModelViewSet(ModelViewSet): + serializer_class = URLModelSerializer + queryset = URLModel.objects.all() + ordering = ["url"] + + +class ForeignKeySourceViewSet(ModelViewSet): + serializer_class = ForeignKeySourceSerializer + queryset = ForeignKeySource.objects.all() + 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() + ordering = ["name"] + + +class NestedRelatedSourceViewSet(ModelViewSet): + serializer_class = NestedRelatedSourceSerializer + queryset = NestedRelatedSource.objects.all() + ordering = ["id"] diff --git a/tox.ini b/tox.ini index 04b970ac..8dd0e759 100644 --- a/tox.ini +++ b/tox.ini @@ -1,19 +1,19 @@ [tox] envlist = - py{35,36}-django{111}-drf{310,311,master}, - py{35,36,37}-django{21,22}-drf{310,311,master}, - py38-django22-drf{311,master}, - py{36,37,38}-django{30}-drf{311,master}, - lint,docs + py{39,310,311,312}-django42-drf{315,316,master}, + py{310,311,312}-django{51,52}-drf{315,316,master}, + py{313}-django{51,52}-drf{316,master}, + black, + docs, + lint [testenv] deps = - django111: Django>=1.11,<1.12 - django21: Django>=2.1,<2.2 - django22: Django>=2.2,<2.3 - django30: Django>=3.0,<3.1 - drf310: djangorestframework>=3.10.2,<3.11 - drf311: djangorestframework>=3.11,<3.12 + django42: Django>=4.2,<4.3 + 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 -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt @@ -25,8 +25,14 @@ setenv = commands = pytest --cov --no-cov-on-fail --cov-report xml {posargs} +[testenv:black] +basepython = python3.10 +deps = + -rrequirements/requirements-codestyle.txt +commands = black --check . + [testenv:lint] -basepython = python3.6 +basepython = python3.10 deps = -rrequirements/requirements-codestyle.txt -rrequirements/requirements-testing.txt @@ -34,9 +40,9 @@ deps = commands = flake8 [testenv:docs] -basepython = python3.6 +# keep in sync with .readthedocs.yml +basepython = python3.10 deps = - -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt -rrequirements/requirements-documentation.txt commands =