drf-flex-fields2 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ MIT License
2
+ ===========
3
+
4
+ Copyright © 2016 – 2023 Robert Singer <br>
5
+ Copyright © 2026 Dennis Schulmeister-Zimolong
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.1
2
+ Name: drf-flex-fields2
3
+ Version: 2.0.0
4
+ Summary: Flexible, dynamic fields and nested resources for Django REST Framework serializers (forked from drf-flex-fields).
5
+ Home-page: https://drf-flex-fields2.readthedocs.io/en/stable/
6
+ License: MIT
7
+ Keywords: django,rest,api,dynamic,fields
8
+ Author: Robert Singer
9
+ Maintainer: Dennis Schulmeister-Zimolong
10
+ Requires-Python: >=3.12,<4
11
+ Classifier: Framework :: Django
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Dist: django (>=5.0,<=6.0.3)
17
+ Requires-Dist: djangorestframework (>=3.14.0,<3.17.1)
18
+ Project-URL: Repository, https://github.com/openbook-education/drf-flex-fields2
19
+ Description-Content-Type: text/markdown
20
+
21
+ # drf-flex-fields2
22
+
23
+ [![Package version](https://badge.fury.io/py/drf-flex-fields2.svg)](https://pypi.org/project/drf-flex-fields2/)
24
+
25
+ Flexible dynamic fields and nested resources for Django REST Framework serializers.
26
+
27
+ This project is for people building APIs, people integrating them, and people
28
+ maintaining the ecosystem around them. If you are new to flexible serializers,
29
+ welcome. If you are evaluating this for production, welcome. If you want to
30
+ contribute fixes, docs, tests, or ideas, welcome.
31
+
32
+ 1. [Migration from drf-flex-fields](#migration-from-drf-flex-fields)
33
+ 1. [Documentation](#documentation)
34
+ 1. [Installation](#installation)
35
+ 1. [Quick Example](#quick-example)
36
+ 1. [Highlights](#highlights)
37
+ 1. [License](#license)
38
+ 1. [History](#history)
39
+
40
+ ## Migration from drf-flex-fields
41
+
42
+ This is a fork of `drf-flex-fields` developed and maintained by Robert Singer
43
+ between 2018 and 2023. For more details on why this fork exists, see
44
+ [History](#history) below. See the [Migration Guide](https://drf-flex-fields2.readthedocs.io/en/latest/getting-started/migration/)
45
+ in the documentation for detailed instructions. The short version is:
46
+
47
+ 1. Upgrade Django and DRF dependencies, if not done already.
48
+ 2. Install `drf-flex-fields2` instead of `drf-flex-fields`.
49
+ 3. Fix package name in import paths: `rest_flex_fields2` instead of `rest_flex_fields`
50
+ 4. Change package-level imports to deep imports, e.g.: `from rest_flex_fields2.serializers import FlexFieldsModelSerializer`
51
+ 5. Rename `REST_FLEX_FIELDS` to `REST_FLEX_FIELDS2` in Django settings.
52
+
53
+ The `drf-flex-fields2` API is stable and compatible with the original `drf-flex-fields`
54
+ package. There are currently no plans to break the existing API. However, if breaking
55
+ changes become necessary in the future, they will follow [semantic versioning](https://semver.org/)
56
+ guidelines and the major version number will be incremented accordingly.
57
+
58
+ Users, community contributors, and maintainers are warmly welcome to keep this
59
+ package useful and maintained.
60
+
61
+ ## Documentation
62
+
63
+ The full documentation is published on Read the Docs: <https://drf-flex-fields2.readthedocs.io/>
64
+
65
+ ## Installation
66
+
67
+ ```bash
68
+ pip install drf-flex-fields2
69
+ ```
70
+
71
+ ## Quick Example
72
+
73
+ ```python
74
+ from rest_flex_fields2.serializers import FlexFieldsModelSerializer
75
+
76
+
77
+ class StateSerializer(FlexFieldsModelSerializer):
78
+ class Meta:
79
+ model = State
80
+ fields = ("id", "name")
81
+
82
+
83
+ class CountrySerializer(FlexFieldsModelSerializer):
84
+ class Meta:
85
+ model = Country
86
+ fields = ("id", "name", "population", "states")
87
+ expandable_fields = {
88
+ "states": (StateSerializer, {"many": True}),
89
+ }
90
+
91
+
92
+ class PersonSerializer(FlexFieldsModelSerializer):
93
+ class Meta:
94
+ model = Person
95
+ fields = ("id", "name", "country", "occupation")
96
+ expandable_fields = {
97
+ "country": CountrySerializer,
98
+ }
99
+ ```
100
+
101
+ Default response:
102
+
103
+ ```json
104
+ {
105
+ "id": 142,
106
+ "name": "Jim Halpert",
107
+ "country": 1
108
+ }
109
+ ```
110
+
111
+ Expanded response for `GET /people/142/?expand=country.states`:
112
+
113
+ ```json
114
+ {
115
+ "id": 142,
116
+ "name": "Jim Halpert",
117
+ "country": {
118
+ "id": 1,
119
+ "name": "United States",
120
+ "states": [
121
+ {
122
+ "id": 23,
123
+ "name": "Ohio"
124
+ },
125
+ {
126
+ "id": 2,
127
+ "name": "Pennsylvania"
128
+ }
129
+ ]
130
+ }
131
+ }
132
+ ```
133
+
134
+ ## Highlights
135
+
136
+ - Expand nested relations with `?expand=`.
137
+ - Limit response payloads with `?fields=` and `?omit=`.
138
+ - Use dot notation for nested expansion and sparse fieldsets.
139
+ - Reuse serializers by passing `expand`, `fields`, and `omit` directly.
140
+
141
+ ## License
142
+
143
+ MIT. See [LICENSE.md](LICENSE.md).
144
+
145
+ ## History
146
+
147
+ The original `drf-flex-fields` was developed and maintained by Robert Singer
148
+ between 2018 and 2023. However, in 2023 maintenance appeared to stop with no
149
+ further commits and issues and pull-requests remaining unanswered.
150
+
151
+ In March 2026, Django REST Framework 3.17.0 removed coreapi support, which
152
+ unfortunately broke the existing package. Although the immediate fix was
153
+ simple, the project was due for broader modernization, including tooling updates,
154
+ Python 2 to 3 cleanup, dependency version maintenance and proper documentation.
155
+
156
+ This fork exists because `drf-flex-fields` is used in the
157
+ [OpenBook project](https://github.com/openbook-education/openbook), and we want to
158
+ reduce supply-chain risk from outdated dependencies while keeping this
159
+ package healthy and maintained. Please join the community and help us with
160
+ this mission. Oh, and keep your own packages up to date and maintained, will
161
+ you? :-)
162
+
@@ -0,0 +1,141 @@
1
+ # drf-flex-fields2
2
+
3
+ [![Package version](https://badge.fury.io/py/drf-flex-fields2.svg)](https://pypi.org/project/drf-flex-fields2/)
4
+
5
+ Flexible dynamic fields and nested resources for Django REST Framework serializers.
6
+
7
+ This project is for people building APIs, people integrating them, and people
8
+ maintaining the ecosystem around them. If you are new to flexible serializers,
9
+ welcome. If you are evaluating this for production, welcome. If you want to
10
+ contribute fixes, docs, tests, or ideas, welcome.
11
+
12
+ 1. [Migration from drf-flex-fields](#migration-from-drf-flex-fields)
13
+ 1. [Documentation](#documentation)
14
+ 1. [Installation](#installation)
15
+ 1. [Quick Example](#quick-example)
16
+ 1. [Highlights](#highlights)
17
+ 1. [License](#license)
18
+ 1. [History](#history)
19
+
20
+ ## Migration from drf-flex-fields
21
+
22
+ This is a fork of `drf-flex-fields` developed and maintained by Robert Singer
23
+ between 2018 and 2023. For more details on why this fork exists, see
24
+ [History](#history) below. See the [Migration Guide](https://drf-flex-fields2.readthedocs.io/en/latest/getting-started/migration/)
25
+ in the documentation for detailed instructions. The short version is:
26
+
27
+ 1. Upgrade Django and DRF dependencies, if not done already.
28
+ 2. Install `drf-flex-fields2` instead of `drf-flex-fields`.
29
+ 3. Fix package name in import paths: `rest_flex_fields2` instead of `rest_flex_fields`
30
+ 4. Change package-level imports to deep imports, e.g.: `from rest_flex_fields2.serializers import FlexFieldsModelSerializer`
31
+ 5. Rename `REST_FLEX_FIELDS` to `REST_FLEX_FIELDS2` in Django settings.
32
+
33
+ The `drf-flex-fields2` API is stable and compatible with the original `drf-flex-fields`
34
+ package. There are currently no plans to break the existing API. However, if breaking
35
+ changes become necessary in the future, they will follow [semantic versioning](https://semver.org/)
36
+ guidelines and the major version number will be incremented accordingly.
37
+
38
+ Users, community contributors, and maintainers are warmly welcome to keep this
39
+ package useful and maintained.
40
+
41
+ ## Documentation
42
+
43
+ The full documentation is published on Read the Docs: <https://drf-flex-fields2.readthedocs.io/>
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install drf-flex-fields2
49
+ ```
50
+
51
+ ## Quick Example
52
+
53
+ ```python
54
+ from rest_flex_fields2.serializers import FlexFieldsModelSerializer
55
+
56
+
57
+ class StateSerializer(FlexFieldsModelSerializer):
58
+ class Meta:
59
+ model = State
60
+ fields = ("id", "name")
61
+
62
+
63
+ class CountrySerializer(FlexFieldsModelSerializer):
64
+ class Meta:
65
+ model = Country
66
+ fields = ("id", "name", "population", "states")
67
+ expandable_fields = {
68
+ "states": (StateSerializer, {"many": True}),
69
+ }
70
+
71
+
72
+ class PersonSerializer(FlexFieldsModelSerializer):
73
+ class Meta:
74
+ model = Person
75
+ fields = ("id", "name", "country", "occupation")
76
+ expandable_fields = {
77
+ "country": CountrySerializer,
78
+ }
79
+ ```
80
+
81
+ Default response:
82
+
83
+ ```json
84
+ {
85
+ "id": 142,
86
+ "name": "Jim Halpert",
87
+ "country": 1
88
+ }
89
+ ```
90
+
91
+ Expanded response for `GET /people/142/?expand=country.states`:
92
+
93
+ ```json
94
+ {
95
+ "id": 142,
96
+ "name": "Jim Halpert",
97
+ "country": {
98
+ "id": 1,
99
+ "name": "United States",
100
+ "states": [
101
+ {
102
+ "id": 23,
103
+ "name": "Ohio"
104
+ },
105
+ {
106
+ "id": 2,
107
+ "name": "Pennsylvania"
108
+ }
109
+ ]
110
+ }
111
+ }
112
+ ```
113
+
114
+ ## Highlights
115
+
116
+ - Expand nested relations with `?expand=`.
117
+ - Limit response payloads with `?fields=` and `?omit=`.
118
+ - Use dot notation for nested expansion and sparse fieldsets.
119
+ - Reuse serializers by passing `expand`, `fields`, and `omit` directly.
120
+
121
+ ## License
122
+
123
+ MIT. See [LICENSE.md](LICENSE.md).
124
+
125
+ ## History
126
+
127
+ The original `drf-flex-fields` was developed and maintained by Robert Singer
128
+ between 2018 and 2023. However, in 2023 maintenance appeared to stop with no
129
+ further commits and issues and pull-requests remaining unanswered.
130
+
131
+ In March 2026, Django REST Framework 3.17.0 removed coreapi support, which
132
+ unfortunately broke the existing package. Although the immediate fix was
133
+ simple, the project was due for broader modernization, including tooling updates,
134
+ Python 2 to 3 cleanup, dependency version maintenance and proper documentation.
135
+
136
+ This fork exists because `drf-flex-fields` is used in the
137
+ [OpenBook project](https://github.com/openbook-education/openbook), and we want to
138
+ reduce supply-chain risk from outdated dependencies while keeping this
139
+ package healthy and maintained. Please join the community and help us with
140
+ this mission. Oh, and keep your own packages up to date and maintained, will
141
+ you? :-)
@@ -0,0 +1,46 @@
1
+ [tool.poetry]
2
+ name = "drf-flex-fields2"
3
+ version = "2.0.0"
4
+ description = "Flexible, dynamic fields and nested resources for Django REST Framework serializers (forked from drf-flex-fields)."
5
+ authors = ["Robert Singer", "Dennis Schulmeister-Zimolong"]
6
+ maintainers = ["Dennis Schulmeister-Zimolong"]
7
+ license = "MIT"
8
+ readme = "README.md"
9
+ homepage = "https://drf-flex-fields2.readthedocs.io/en/stable/"
10
+ repository = "https://github.com/openbook-education/drf-flex-fields2"
11
+ keywords = ["django", "rest", "api", "dynamic", "fields"]
12
+ packages = [{ include = "rest_flex_fields2", from = "src" }]
13
+ classifiers = [
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Framework :: Django",
17
+ ]
18
+
19
+ [tool.poetry.dependencies]
20
+ python = ">=3.12,<4"
21
+ django = ">=5.0,<=6.0.3"
22
+ djangorestframework = ">=3.14.0,<3.17.1"
23
+
24
+ [tool.poetry.group.docs.dependencies]
25
+ sphinx = "^8.0"
26
+ sphinx-rtd-theme = "^3.0"
27
+ sphinx-autoapi = "^3.0"
28
+
29
+ [tool.poetry.group.dev.dependencies]
30
+ coverage = "^7.8"
31
+
32
+ [tool.coverage.run]
33
+ branch = true
34
+ source = ["src/rest_flex_fields2"]
35
+
36
+ [tool.coverage.report]
37
+ fail_under = 90
38
+ show_missing = true
39
+ skip_covered = true
40
+ omit = [
41
+ "*/__pycache__/*",
42
+ ]
43
+
44
+ [build-system]
45
+ requires = ["poetry-core>=1.9.0"]
46
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,58 @@
1
+ """Runtime configuration for ``REST_FLEX_FIELDS2``.
2
+
3
+ Reads the optional ``REST_FLEX_FIELDS2`` dict from Django settings and
4
+ exposes validated constants used throughout the package. Raises
5
+ ``AssertionError`` or ``ValueError`` on invalid configuration so
6
+ errors surface at import time rather than at request time.
7
+ """
8
+
9
+ from django.conf import settings
10
+
11
+ FLEX_FIELDS_OPTIONS = getattr(settings, "REST_FLEX_FIELDS2", {})
12
+ """Raw ``REST_FLEX_FIELDS2`` dictionary from Django settings."""
13
+
14
+ EXPAND_PARAM = FLEX_FIELDS_OPTIONS.get("EXPAND_PARAM", "expand")
15
+ """Query parameter name used to request expandable fields."""
16
+
17
+ FIELDS_PARAM = FLEX_FIELDS_OPTIONS.get("FIELDS_PARAM", "fields")
18
+ """Query parameter name used to include only selected fields."""
19
+
20
+ OMIT_PARAM = FLEX_FIELDS_OPTIONS.get("OMIT_PARAM", "omit")
21
+ """Query parameter name used to omit selected fields."""
22
+
23
+ MAXIMUM_EXPANSION_DEPTH = FLEX_FIELDS_OPTIONS.get("MAXIMUM_EXPANSION_DEPTH", None)
24
+ """Maximum nested expansion depth. ``None`` means unlimited."""
25
+
26
+ RECURSIVE_EXPANSION_PERMITTED = FLEX_FIELDS_OPTIONS.get("RECURSIVE_EXPANSION_PERMITTED", True)
27
+ """Whether recursive field expansion is allowed."""
28
+
29
+ WILDCARD_ALL = "~all"
30
+ """Wildcard token that expands all fields."""
31
+
32
+ WILDCARD_ASTERISK = "*"
33
+ """Wildcard token alternative that expands all fields."""
34
+
35
+ if "WILDCARD_EXPAND_VALUES" in FLEX_FIELDS_OPTIONS:
36
+ wildcard_values = FLEX_FIELDS_OPTIONS["WILDCARD_EXPAND_VALUES"]
37
+ elif "WILDCARD_VALUES" in FLEX_FIELDS_OPTIONS:
38
+ wildcard_values = FLEX_FIELDS_OPTIONS["WILDCARD_VALUES"]
39
+ else:
40
+ wildcard_values = [WILDCARD_ALL, WILDCARD_ASTERISK]
41
+
42
+ WILDCARD_VALUES = wildcard_values
43
+ """Allowed wildcard tokens for expansion, configurable via Django settings."""
44
+
45
+ assert isinstance(EXPAND_PARAM, str), "'EXPAND_PARAM' should be a string"
46
+ assert isinstance(FIELDS_PARAM, str), "'FIELDS_PARAM' should be a string"
47
+ assert isinstance(OMIT_PARAM, str), "'OMIT_PARAM' should be a string"
48
+
49
+ if not isinstance(WILDCARD_VALUES, (list, type(None))):
50
+ raise ValueError(
51
+ "'WILDCARD_EXPAND_VALUES' or 'WILDCARD_VALUES' should be a list of strings or None"
52
+ )
53
+
54
+ if not isinstance(MAXIMUM_EXPANSION_DEPTH, (int, type(None))):
55
+ raise ValueError("'MAXIMUM_EXPANSION_DEPTH' should be a int or None")
56
+
57
+ if not isinstance(RECURSIVE_EXPANSION_PERMITTED, bool):
58
+ raise ValueError("'RECURSIVE_EXPANSION_PERMITTED' should be a bool")
@@ -0,0 +1,331 @@
1
+ """DRF filter backends for flex-fields.
2
+
3
+ Provides two backends:
4
+
5
+ - ``FlexFieldsDocsFilterBackend`` — a no-op backend whose only purpose is to
6
+ inject the ``fields``, ``omit``, and ``expand`` query parameters into the
7
+ generated API schema (e.g. drf-spectacular / drf-yasg). Downstream projects
8
+ normally shouldn't use this.
9
+
10
+ - ``FlexFieldsFilterBackend`` — extends the docs backend with actual queryset
11
+ optimisation: it resolves the active field selection and expansion options
12
+ and applies ``only()`` / ``select_related()`` / ``prefetch_related()`` to
13
+ the queryset automatically.
14
+ """
15
+
16
+ from functools import lru_cache
17
+ import importlib
18
+ from typing import Any, Optional, TYPE_CHECKING
19
+
20
+ from django.core.exceptions import FieldDoesNotExist
21
+ from django.db import models
22
+ from django.db.models import QuerySet
23
+ from rest_framework import serializers
24
+ from rest_framework.filters import BaseFilterBackend
25
+
26
+ if TYPE_CHECKING:
27
+ from rest_framework.viewsets import GenericViewSet
28
+ from rest_framework.request import Request
29
+
30
+ from .config import (
31
+ EXPAND_PARAM,
32
+ FIELDS_PARAM,
33
+ OMIT_PARAM,
34
+ WILDCARD_VALUES,
35
+ )
36
+ from .serializers import FlexFieldsSerializerMixin
37
+
38
+ WILDCARD_VALUES_JOINED = ",".join(WILDCARD_VALUES or [])
39
+
40
+
41
+ class FlexFieldsDocsFilterBackend(BaseFilterBackend):
42
+ """No-op filter backend that adds flex-fields parameters to the API schema.
43
+
44
+ Does not modify the queryset. Its sole purpose is to expose the
45
+ ``fields``, ``omit``, and ``expand`` query parameters in the OpenAPI
46
+ schema generated by tools such as drf-spectacular. Downstream projects
47
+ normally shouldn't use this; use ``FlexFieldsFilterBackend`` instead.
48
+ """
49
+
50
+ def filter_queryset(self, request, queryset, view):
51
+ """Return `queryset` unchanged."""
52
+ return queryset
53
+
54
+ @staticmethod
55
+ @lru_cache()
56
+ def _get_model_field(field_name: str, model: models.Model) -> Optional[models.Field]:
57
+ """Return the Django model field for `field_name`, or ``None``.
58
+
59
+ Result is cached per ``(field_name, model)`` pair via ``lru_cache``.
60
+ Returns ``None`` when the field does not exist on the model (e.g. for
61
+ serializer-only or method fields).
62
+ """
63
+ try:
64
+ # noinspection PyProtectedMember
65
+ return model._meta.get_field(field_name)
66
+ except FieldDoesNotExist:
67
+ return None
68
+
69
+ @classmethod
70
+ def _get_expandable_fields(
71
+ cls, serializer_class: Any, parents: tuple = (), prefix: str = ""
72
+ ) -> list:
73
+ """Return a flat list of all expandable field paths for `serializer_class`.
74
+
75
+ Traverses nested `FlexFieldsSerializerMixin` subclasses recursively
76
+ and builds dot-separated paths (e.g. ``['author', 'author.profile']``).
77
+ Lazy string serializer paths are resolved before traversal.
78
+ """
79
+ if serializer_class in parents:
80
+ # Break endless recursion
81
+ return []
82
+
83
+ meta = getattr(serializer_class, "Meta", None)
84
+
85
+ if meta is not None and hasattr(meta, "expandable_fields"):
86
+ expandable_fields_dict = meta.expandable_fields
87
+ elif hasattr(serializer_class, "expandable_fields"):
88
+ expandable_fields_dict = serializer_class.expandable_fields
89
+ else:
90
+ return []
91
+
92
+ expandable_fields = list(expandable_fields_dict.items())
93
+ expand_list = []
94
+
95
+ while expandable_fields:
96
+ key, field_options = expandable_fields.pop()
97
+
98
+ if isinstance(field_options, tuple):
99
+ nested_serializer_class = field_options[0]
100
+ else:
101
+ nested_serializer_class = field_options
102
+
103
+ if isinstance(nested_serializer_class, str):
104
+ nested_serializer_class = FlexFieldsDocsFilterBackend._get_serializer_class_from_lazy_string(
105
+ nested_serializer_class
106
+ )
107
+
108
+ expand_list.append(f"{prefix}{key}")
109
+
110
+ expand_list += cls._get_expandable_fields(
111
+ serializer_class = nested_serializer_class,
112
+ parents = parents + (serializer_class,),
113
+ prefix = f"{prefix}{key}.",
114
+ )
115
+
116
+ return expand_list
117
+
118
+ @staticmethod
119
+ def _get_serializer_class_from_lazy_string(full_lazy_path: str):
120
+ """Resolve a dotted string path to a serializer class.
121
+
122
+ Tries the exact path first; if that fails and the path does not
123
+ already end in ``.serializers``, appends ``.serializers`` and retries.
124
+ Raises ``Exception`` when the class cannot be found.
125
+ """
126
+ def _import_serializer_class(path: str, class_name: str):
127
+ """Import `class_name` from the module at `path`."""
128
+ try:
129
+ module = importlib.import_module(path)
130
+ except ImportError:
131
+ return None
132
+
133
+ serializer_class = getattr(module, class_name, None)
134
+
135
+ if isinstance(serializer_class, type) and issubclass(serializer_class, serializers.Serializer):
136
+ return serializer_class
137
+
138
+ return None
139
+
140
+ path_parts = full_lazy_path.split(".")
141
+ class_name = path_parts.pop()
142
+ path = ".".join(path_parts)
143
+
144
+ serializer_class = _import_serializer_class(path, class_name)
145
+ if serializer_class is None and not path.endswith(".serializers"):
146
+ serializer_class = _import_serializer_class(
147
+ f"{path}.serializers", class_name
148
+ )
149
+
150
+ if serializer_class:
151
+ return serializer_class
152
+
153
+ raise Exception(f"Could not resolve serializer class '{class_name}' from path '{path}'.")
154
+
155
+ @staticmethod
156
+ def _get_serializer_fields(serializer_class):
157
+ """Return a comma-joined string of the field names declared on `serializer_class`.
158
+
159
+ Reads ``Meta.fields`` and converts it into an example-friendly string
160
+ for schema generation. Returns an empty string for ``"__all__"`` or
161
+ unsupported field declarations.
162
+ """
163
+ meta = getattr(serializer_class, "Meta", None)
164
+
165
+ if meta is not None and hasattr(meta, "fields"):
166
+ fields = getattr(serializer_class.Meta, "fields", [])
167
+
168
+ if isinstance(fields, str):
169
+ return "" if fields == "__all__" else fields
170
+
171
+ if isinstance(fields, (list, tuple)):
172
+ serializer_fields = [field_name for field_name in fields if isinstance(field_name, str)]
173
+ return ",".join(serializer_fields)
174
+
175
+ return ""
176
+ else:
177
+ return ""
178
+
179
+ def get_schema_operation_parameters(self, view):
180
+ """Return the OpenAPI query parameter definitions for the flex-fields params.
181
+
182
+ Emits ``fields``, ``omit``, and ``expand`` parameter objects for the
183
+ view's serializer when it is a `FlexFieldsSerializerMixin` subclass.
184
+ Returns an empty list for views that do not use flex fields.
185
+ """
186
+ serializer_class = view.get_serializer_class()
187
+
188
+ if not issubclass(serializer_class, FlexFieldsSerializerMixin):
189
+ return []
190
+
191
+ fields = self._get_serializer_fields(serializer_class)
192
+ expandable_fields = self._get_expandable_fields(serializer_class)
193
+ expandable_fields.extend(WILDCARD_VALUES or [])
194
+
195
+ parameters = [
196
+ {
197
+ "name": FIELDS_PARAM,
198
+ "required": False,
199
+ "in": "query",
200
+ "description": "Specify required fields by comma",
201
+ "schema": {
202
+ "title": "Selected fields",
203
+ "type": "string",
204
+ },
205
+ "example": (fields or "field1,field2,nested.field") + "," + WILDCARD_VALUES_JOINED,
206
+ },
207
+ {
208
+ "name": OMIT_PARAM,
209
+ "required": False,
210
+ "in": "query",
211
+ "description": "Specify omitted fields by comma",
212
+ "schema": {
213
+ "title": "Omitted fields",
214
+ "type": "string",
215
+ },
216
+ "example": (fields or "field1,field2,nested.field") + "," + WILDCARD_VALUES_JOINED,
217
+ },
218
+ {
219
+ "name": EXPAND_PARAM,
220
+ "required": False,
221
+ "in": "query",
222
+ "description": "Select fields to expand",
223
+ "style": "form",
224
+ "explode": False,
225
+ "schema": {
226
+ "title": "Expanded fields",
227
+ "type": "array",
228
+ "items": {
229
+ "type": "string",
230
+ "enum": expandable_fields
231
+ }
232
+ },
233
+ },
234
+ ]
235
+
236
+ return parameters
237
+
238
+
239
+ class FlexFieldsFilterBackend(FlexFieldsDocsFilterBackend):
240
+ """Filter backend that optimises querysets based on the active flex-fields options.
241
+
242
+ Extends `FlexFieldsDocsFilterBackend` with actual queryset manipulation:
243
+ applies ``only()`` to restrict fetched columns and ``select_related()`` and
244
+ ``prefetch_related()`` for expanded relation fields. Only active for ``GET``
245
+ requests on views whose serializer is a ``FlexFieldsSerializerMixin`` subclass.
246
+
247
+ View-level opt-out attributes:
248
+
249
+ - ``auto_remove_fields_from_query`` (default ``True``): disable ``only()`` optimisation.
250
+ - ``auto_select_related_on_query`` (default ``True``): disable relation prefetching.
251
+ - ``required_query_fields`` (default ``[]``): extra field names always included in the ``only()`` call.
252
+ """
253
+ def filter_queryset(
254
+ self, request: "Request", queryset: "QuerySet", view: "GenericViewSet"
255
+ ):
256
+ """Apply field-selection and relation-prefetch optimisations to `queryset`.
257
+
258
+ Resolves the active ``fields`` / ``omit`` / ``expand`` options from
259
+ the request, then calls ``only()``, ``select_related()``, and
260
+ ``prefetch_related()`` as appropriate. Returns `queryset` unchanged
261
+ when the view's serializer is not a `FlexFieldsSerializerMixin`
262
+ subclass, or when the request method is not ``GET``.
263
+ """
264
+ # Early exit: only process GET requests on flex-fields serializers.
265
+ if not issubclass(view.get_serializer_class(), FlexFieldsSerializerMixin) or request.method != "GET":
266
+ return queryset
267
+
268
+ # Retrieve view-level configuration for query optimisation.
269
+ auto_remove_fields_from_query = getattr(view, "auto_remove_fields_from_query", True)
270
+ auto_select_related_on_query = getattr(view, "auto_select_related_on_query", True)
271
+ required_query_fields = list(getattr(view, "required_query_fields", []))
272
+
273
+ # Instantiate serializer and apply active flex-fields options.
274
+ serializer = view.get_serializer(context=view.get_serializer_context()) # type: FlexFieldsSerializerMixin
275
+ serializer.apply_flex_fields(serializer.fields, serializer._flex_options_rep_only)
276
+ serializer._flex_fields_rep_applied = True
277
+
278
+ # Classify model fields: regular fields and nested relations.
279
+ model_fields = []
280
+ nested_model_fields = []
281
+
282
+ for field in serializer.fields.values():
283
+ model_field = self._get_model_field(field.source, queryset.model)
284
+
285
+ if model_field:
286
+ model_fields.append(model_field)
287
+
288
+ if (
289
+ (field.field_name in serializer.expanded_fields) or
290
+ (model_field.is_relation and not model_field.many_to_one) or
291
+ (model_field.is_relation and model_field.many_to_one and not model_field.concrete)
292
+ ): # Include GenericForeignKey
293
+ nested_model_fields.append(model_field)
294
+
295
+ # Optimise queryset: restrict fetched columns via only().
296
+ if auto_remove_fields_from_query:
297
+ queryset = queryset.only(
298
+ *(
299
+ required_query_fields
300
+ + [
301
+ model_field.name
302
+ for model_field in model_fields if (
303
+ not model_field.is_relation or
304
+ model_field.many_to_one and model_field.concrete)
305
+ ]
306
+ )
307
+ )
308
+
309
+ # Optimise queryset: prefetch relations via select_related() and prefetch_related().
310
+ if auto_select_related_on_query and nested_model_fields:
311
+ queryset = queryset.select_related(
312
+ *(
313
+ model_field.name
314
+ for model_field in nested_model_fields if (
315
+ model_field.is_relation and
316
+ model_field.many_to_one and
317
+ model_field.concrete) # Exclude GenericForeignKey
318
+ )
319
+ )
320
+
321
+ queryset = queryset.prefetch_related(
322
+ *(
323
+ model_field.name
324
+ for model_field in nested_model_fields if
325
+ (model_field.is_relation and not model_field.many_to_one) or
326
+ (model_field.is_relation and model_field.many_to_one and not model_field.concrete) # Include GenericForeignKey)
327
+ )
328
+ )
329
+
330
+ return queryset
331
+
@@ -0,0 +1,449 @@
1
+ """Flex-fields serializer mixin and model serializer.
2
+
3
+ Provides `FlexFieldsSerializerMixin` which adds ``fields``, ``omit``, and
4
+ ``expand`` support to any DRF serializer, and the ready-to-use
5
+ `FlexFieldsModelSerializer` that combines the mixin with
6
+ ``serializers.ModelSerializer``.
7
+ """
8
+
9
+ import copy
10
+ import importlib
11
+ from typing import List, Optional, Tuple, Type
12
+
13
+ from rest_framework.serializers import (Serializer, ModelSerializer, ValidationError)
14
+
15
+ from .config import (
16
+ EXPAND_PARAM,
17
+ FIELDS_PARAM,
18
+ MAXIMUM_EXPANSION_DEPTH,
19
+ OMIT_PARAM,
20
+ RECURSIVE_EXPANSION_PERMITTED,
21
+ WILDCARD_VALUES,
22
+ )
23
+ from .utils import split_levels
24
+
25
+
26
+ class FlexFieldsSerializerMixin(Serializer):
27
+ """Mixin that adds sparse-fieldset and nested-expansion support to a serializer.
28
+
29
+ Accepts the ``fields``, ``omit``, and ``expand`` keyword arguments (names
30
+ are configurable via ``REST_FLEX_FIELDS2`` settings) both as constructor
31
+ kwargs and as query-string parameters on the current request. Query
32
+ parameters are only read on the root serializer; nested serializers
33
+ receive their options through constructor kwargs propagated by the parent.
34
+
35
+ Declare expandable relations either on ``Meta.expandable_fields`` (preferred)
36
+ or directly on ``expandable_fields`` for backwards compatibility.
37
+ """
38
+
39
+ expandable_fields = {}
40
+ maximum_expansion_depth: Optional[int] = None
41
+ recursive_expansion_permitted: Optional[bool] = None
42
+
43
+ def __init__(self, *args, **kwargs):
44
+ """Initialize flex-fields options from kwargs and request query params."""
45
+ expand = list(kwargs.pop(EXPAND_PARAM, []))
46
+ fields = list(kwargs.pop(FIELDS_PARAM, []))
47
+ omit = list(kwargs.pop(OMIT_PARAM, []))
48
+ parent = kwargs.pop("parent", None)
49
+
50
+ super().__init__(*args, **kwargs)
51
+
52
+ self.parent = parent
53
+ self.expanded_fields = []
54
+ self._flex_fields_rep_applied = False
55
+
56
+ self._flex_options_base = {
57
+ "expand": expand,
58
+ "fields": fields,
59
+ "omit": omit,
60
+ }
61
+
62
+ self._flex_options_rep_only = {
63
+ "expand": self._get_permitted_expands_from_query_param(EXPAND_PARAM) if not expand else [],
64
+ "fields": self._get_query_param_value(FIELDS_PARAM) if not fields else [],
65
+ "omit": self._get_query_param_value(OMIT_PARAM) if not omit else [],
66
+ }
67
+
68
+ self._flex_options_all = {
69
+ "expand": self._flex_options_base["expand"] + self._flex_options_rep_only["expand"],
70
+ "fields": self._flex_options_base["fields"] + self._flex_options_rep_only["fields"],
71
+ "omit": self._flex_options_base["omit"] + self._flex_options_rep_only["omit"],
72
+ }
73
+
74
+ def get_maximum_expansion_depth(self) -> Optional[int]:
75
+ """Return the effective maximum expansion depth.
76
+
77
+ Uses the serializer-level ``maximum_expansion_depth`` attribute when
78
+ set, otherwise falls back to the ``MAXIMUM_EXPANSION_DEPTH`` setting.
79
+ """
80
+ return self.maximum_expansion_depth or MAXIMUM_EXPANSION_DEPTH
81
+
82
+ def get_recursive_expansion_permitted(self) -> bool:
83
+ """Return whether recursive expansion is allowed.
84
+
85
+ Uses the serializer-level ``recursive_expansion_permitted`` attribute
86
+ when set, otherwise falls back to the ``RECURSIVE_EXPANSION_PERMITTED``
87
+ setting.
88
+ """
89
+ if self.recursive_expansion_permitted is not None:
90
+ return self.recursive_expansion_permitted
91
+ else:
92
+ return RECURSIVE_EXPANSION_PERMITTED
93
+
94
+ def to_representation(self, instance):
95
+ """Apply request-sourced flex-fields options once, then delegate to super."""
96
+ if not self._flex_fields_rep_applied:
97
+ self.apply_flex_fields(self.fields, self._flex_options_rep_only)
98
+ self._flex_fields_rep_applied = True
99
+
100
+ return super().to_representation(instance)
101
+
102
+ def get_fields(self):
103
+ """Return fields after applying constructor-sourced flex-fields options."""
104
+ fields = super().get_fields()
105
+ self.apply_flex_fields(fields, self._flex_options_base)
106
+ return fields
107
+
108
+ def apply_flex_fields(self, fields, flex_options):
109
+ """Apply sparse-fieldset and expansion options to `fields` in place.
110
+
111
+ Removes fields that are excluded by ``omit`` or not present in
112
+ ``fields`` (sparse-fieldset), then replaces fields listed in
113
+ ``expand`` with their nested serializer instances.
114
+ Returns the modified `fields` mapping.
115
+ """
116
+ expand_fields, next_expand_fields = split_levels(flex_options["expand"])
117
+ sparse_fields, next_sparse_fields = split_levels(flex_options["fields"])
118
+ omit_fields, next_omit_fields = split_levels(flex_options["omit"])
119
+
120
+ for field_name in self._get_fields_names_to_remove(
121
+ list(fields.keys()), omit_fields, sparse_fields, list(next_omit_fields.keys())
122
+ ):
123
+ fields.pop(field_name)
124
+
125
+ for name in self._get_expanded_field_names(
126
+ expand_fields, omit_fields, sparse_fields, list(next_omit_fields.keys())
127
+ ):
128
+ self.expanded_fields.append(name)
129
+
130
+ fields[name] = self._make_expanded_field_serializer(
131
+ name, next_expand_fields, next_sparse_fields, next_omit_fields
132
+ )
133
+
134
+ return fields
135
+
136
+ def _make_expanded_field_serializer(
137
+ self, name, nested_expand, nested_fields, nested_omit
138
+ ):
139
+ """Build and return the nested serializer instance for an expanded field.
140
+
141
+ Looks up `name` in ``_expandable_fields``, resolves any lazy string
142
+ path to a concrete class, then instantiates the serializer with the
143
+ appropriate ``context``, ``parent``, ``expand``, ``fields``, and
144
+ ``omit`` kwargs for the next nesting level.
145
+ """
146
+ field_options = self._expandable_fields[name]
147
+
148
+ if isinstance(field_options, tuple):
149
+ serializer_class = field_options[0]
150
+ settings = copy.deepcopy(field_options[1]) if len(field_options) > 1 else {}
151
+ else:
152
+ serializer_class = field_options
153
+ settings = {}
154
+
155
+ if isinstance(serializer_class, str):
156
+ serializer_class = self._get_serializer_class_from_lazy_string(serializer_class)
157
+
158
+ if issubclass(serializer_class, Serializer):
159
+ settings["context"] = self.context
160
+
161
+ if issubclass(serializer_class, FlexFieldsSerializerMixin):
162
+ settings["parent"] = self
163
+
164
+ if name in nested_expand:
165
+ settings[EXPAND_PARAM] = nested_expand[name]
166
+
167
+ if name in nested_fields:
168
+ settings[FIELDS_PARAM] = nested_fields[name]
169
+
170
+ if name in nested_omit:
171
+ settings[OMIT_PARAM] = nested_omit[name]
172
+
173
+ return serializer_class(**settings)
174
+
175
+ def _get_serializer_class_from_lazy_string(self, full_lazy_path: str) -> Type[Serializer]:
176
+ """Resolve a dotted string path to a serializer class.
177
+
178
+ Tries the exact path first; if that fails and the path does not
179
+ already end in ``.serializers``, appends ``.serializers`` and retries.
180
+ Raises ``Exception`` when the class cannot be found.
181
+ """
182
+ path_parts = full_lazy_path.split(".")
183
+ class_name = path_parts.pop()
184
+ path = ".".join(path_parts)
185
+ serializer_class, error = self._import_serializer_class(path, class_name)
186
+
187
+ if error and not path.endswith(".serializers"):
188
+ serializer_class, error = self._import_serializer_class(
189
+ path + ".serializers", class_name
190
+ )
191
+
192
+ if serializer_class:
193
+ return serializer_class
194
+
195
+ raise Exception(error)
196
+
197
+ def _import_serializer_class(self, path: str, class_name: str) -> Tuple[Optional[Type[Serializer]], Optional[str]]:
198
+ """Import `class_name` from the module at `path`.
199
+
200
+ Returns a ``(serializer_class, None)`` tuple on success, or
201
+ ``(None, error_message)`` when the module cannot be imported, the
202
+ attribute does not exist, or the attribute is not a
203
+ ``Serializer`` subclass.
204
+ """
205
+ try:
206
+ module = importlib.import_module(path)
207
+ except ImportError:
208
+ return None, f"No module found at path: {path} when trying to import {class_name}"
209
+
210
+ try:
211
+ resolved = getattr(module, class_name)
212
+ except AttributeError:
213
+ return None, f"No class {class_name} class found in module {path}"
214
+
215
+ # Validate that the resolved attribute is actually a serializer class
216
+ if not isinstance(resolved, type):
217
+ return None, f"Attribute {class_name} in module {path} is not a class"
218
+
219
+ if not issubclass(resolved, Serializer):
220
+ return None, f"Class {class_name} in module {path} is not a Serializer subclass"
221
+
222
+ return resolved, None
223
+
224
+ def _get_fields_names_to_remove(
225
+ self,
226
+ current_fields: List[str],
227
+ omit_fields: List[str],
228
+ sparse_fields: List[str],
229
+ next_level_omits: List[str],
230
+ ) -> List[str]:
231
+ """Return a list of field names that should be removed from the serializer.
232
+
233
+ A field is removed when it appears in `omit_fields`, or when
234
+ `sparse_fields` is non-empty and the field is not listed there.
235
+ Fields in `next_level_omits` are never removed at this level because
236
+ their omit rule targets a deeper nesting (e.g. ``omit=house.rooms.kitchen``
237
+ must not remove ``house`` or ``rooms``).
238
+ """
239
+ sparse = len(sparse_fields) > 0
240
+ to_remove = []
241
+
242
+ if not sparse and len(omit_fields) == 0:
243
+ return to_remove
244
+
245
+ for field_name in current_fields:
246
+ should_exist = self._should_field_exist(field_name, omit_fields, sparse_fields, next_level_omits)
247
+
248
+ if not should_exist:
249
+ to_remove.append(field_name)
250
+
251
+ return to_remove
252
+
253
+ def _should_field_exist(
254
+ self,
255
+ field_name: str,
256
+ omit_fields: List[str],
257
+ sparse_fields: List[str],
258
+ next_level_omits: List[str],
259
+ ) -> bool:
260
+ """Return whether `field_name` should be kept in the serializer output.
261
+
262
+ `next_level_omits` contains field names whose omit rule targets a
263
+ deeper nesting level; they must not be removed at the current level
264
+ (e.g. ``omit=house.rooms.kitchen`` must preserve ``house`` and
265
+ ``rooms``).
266
+ """
267
+ if field_name in omit_fields and field_name not in next_level_omits:
268
+ return False
269
+ elif self._contains_wildcard_value(sparse_fields):
270
+ return True
271
+ elif len(sparse_fields) > 0 and field_name not in sparse_fields:
272
+ return False
273
+ else:
274
+ return True
275
+
276
+ def _get_expanded_field_names(
277
+ self,
278
+ expand_fields: List[str],
279
+ omit_fields: List[str],
280
+ sparse_fields: List[str],
281
+ next_level_omits: List[str],
282
+ ) -> List[str]:
283
+ """Return the validated list of field names to expand.
284
+
285
+ Wildcards are resolved to all declared expandable field names.
286
+ Fields not present in ``_expandable_fields``, or excluded by the
287
+ sparse-fieldset / omit rules, are silently skipped.
288
+ """
289
+ if len(expand_fields) == 0:
290
+ return []
291
+
292
+ if self._contains_wildcard_value(expand_fields):
293
+ expand_fields = list(self._expandable_fields.keys())
294
+
295
+ expanded_field_names = []
296
+
297
+ for name in expand_fields:
298
+ if name not in self._expandable_fields:
299
+ continue
300
+
301
+ if not self._should_field_exist(
302
+ name, omit_fields, sparse_fields, next_level_omits
303
+ ):
304
+ continue
305
+
306
+ expanded_field_names.append(name)
307
+
308
+ return expanded_field_names
309
+
310
+ @property
311
+ def _expandable_fields(self) -> dict:
312
+ """Return the mapping of expandable field names to their serializer config.
313
+
314
+ Prefers ``Meta.expandable_fields`` for consistency with the DRF
315
+ convention of placing serializer metadata on the inner ``Meta`` class.
316
+ Falls back to the class-level ``expandable_fields`` attribute for
317
+ backwards compatibility.
318
+ """
319
+ meta = getattr(self, "Meta", None)
320
+
321
+ if meta is not None and hasattr(meta, "expandable_fields"):
322
+ return meta.expandable_fields
323
+
324
+ return self.expandable_fields
325
+
326
+ def _get_query_param_value(self, field: str) -> List[str]:
327
+ """Return the parsed query-parameter values for `field`.
328
+
329
+ Only reads query parameters on the root serializer (i.e. when
330
+ ``self.parent`` is ``None``). Supports both plain repeated params
331
+ (``field=a,b``) and bracket-style params (``field[]=a``). Runs
332
+ recursive-expansion and depth validation on every returned path.
333
+ Returns an empty list when the parameter is absent or this is a
334
+ nested serializer.
335
+ """
336
+ if self.parent:
337
+ return []
338
+
339
+ if not hasattr(self, "context") or not self.context.get("request"):
340
+ return []
341
+
342
+ values = self.context["request"].query_params.getlist(field)
343
+
344
+ if not values:
345
+ values = self.context["request"].query_params.getlist(f"{field}[]")
346
+
347
+ if values and len(values) == 1:
348
+ values = values[0].split(",")
349
+
350
+ for expand_path in values:
351
+ self._validate_recursive_expansion(expand_path)
352
+ self._validate_expansion_depth(expand_path)
353
+
354
+ return values or []
355
+
356
+ def _split_expand_field(self, expand_path: str) -> List[str]:
357
+ """Split a dot-separated expand path into its individual segments."""
358
+ return expand_path.split(".") # noqa: E501
359
+
360
+ def recursive_expansion_not_permitted(self):
361
+ """Raise a validation error indicating recursive expansion.
362
+
363
+ Override this method to raise a custom exception instead of the
364
+ default ``ValidationError``.
365
+ """
366
+ raise ValidationError(detail="Recursive expansion found")
367
+
368
+ def _validate_recursive_expansion(self, expand_path: str) -> None:
369
+ """Raise when `expand_path` contains a repeated segment.
370
+
371
+ Parses the dot-separated `expand_path` and checks for duplicate
372
+ segments. Does nothing when recursive expansion is permitted
373
+ (``RECURSIVE_EXPANSION_PERMITTED`` is ``True``).
374
+ """
375
+ recursive_expansion_permitted = self.get_recursive_expansion_permitted()
376
+ if recursive_expansion_permitted is True:
377
+ return
378
+
379
+ expansion_path = self._split_expand_field(expand_path)
380
+ expansion_length = len(expansion_path)
381
+ expansion_length_unique = len(set(expansion_path))
382
+
383
+ if expansion_length != expansion_length_unique:
384
+ self.recursive_expansion_not_permitted()
385
+
386
+ def expansion_depth_exceeded(self):
387
+ """Raise a validation error indicating the expansion depth limit was exceeded.
388
+
389
+ Override this method to raise a custom exception instead of the
390
+ default ``ValidationError``.
391
+ """
392
+ raise ValidationError(detail="Expansion depth exceeded")
393
+
394
+ def _validate_expansion_depth(self, expand_path: str) -> None:
395
+ """Raise when `expand_path` exceeds the configured maximum depth.
396
+
397
+ Counts the dot-separated segments of `expand_path` and compares
398
+ against ``get_maximum_expansion_depth()``. Does nothing when no
399
+ maximum depth is configured.
400
+ """
401
+ maximum_expansion_depth = self.get_maximum_expansion_depth()
402
+ if maximum_expansion_depth is None:
403
+ return
404
+
405
+ expansion_path = self._split_expand_field(expand_path)
406
+ if len(expansion_path) > maximum_expansion_depth:
407
+ self.expansion_depth_exceeded()
408
+
409
+ def _get_permitted_expands_from_query_param(self, expand_param: str) -> List[str]:
410
+ """Return the expand list filtered by ``permitted_expands`` from context.
411
+
412
+ When ``permitted_expands`` is present in the serializer context (e.g.
413
+ set by `FlexFieldsMixin` for list actions), wildcard expansion is
414
+ resolved to the full permitted list, and any other values are
415
+ intersected with it. Returns the unfiltered expand list when no
416
+ permission constraint is active.
417
+ """
418
+ expand = self._get_query_param_value(expand_param)
419
+
420
+ if "permitted_expands" in self.context:
421
+ permitted_expands = self.context["permitted_expands"]
422
+
423
+ if self._contains_wildcard_value(expand):
424
+ return permitted_expands
425
+ else:
426
+ return list(set(expand) & set(permitted_expands))
427
+
428
+ return expand
429
+
430
+ def _contains_wildcard_value(self, expand_values: List[str]) -> bool:
431
+ """Return whether `expand_values` contains any configured wildcard token.
432
+
433
+ Always returns ``False`` when ``WILDCARD_VALUES`` is ``None``
434
+ (wildcards disabled).
435
+ """
436
+ if WILDCARD_VALUES is None:
437
+ return False
438
+
439
+ intersecting_values = list(set(expand_values) & set(WILDCARD_VALUES))
440
+ return len(intersecting_values) > 0
441
+
442
+
443
+ class FlexFieldsModelSerializer(FlexFieldsSerializerMixin, ModelSerializer):
444
+ """Convenience serializer combining `FlexFieldsSerializerMixin` with ``ModelSerializer``.
445
+
446
+ Drop-in replacement for ``serializers.ModelSerializer`` that adds
447
+ sparse-fieldset (``fields``, ``omit``) and nested-expansion (``expand``)
448
+ support out of the box.
449
+ """
@@ -0,0 +1,92 @@
1
+ """Utility helpers for ``rest_flex_fields2``.
2
+
3
+ Provides request-inspection functions (`is_expanded`, `is_included`) and
4
+ the `split_levels` helper that partitions dot-notation field lists into
5
+ current-level and next-level fragments.
6
+ """
7
+
8
+ from collections.abc import Iterable
9
+
10
+ from .config import EXPAND_PARAM, FIELDS_PARAM, OMIT_PARAM, WILDCARD_VALUES
11
+
12
+
13
+ def is_expanded(request, field: str) -> bool:
14
+ """Return whether `field` is requested for expansion.
15
+
16
+ Inspects the ``expand`` query parameter on `request`. Returns
17
+ ``True`` when `field` appears in the comma-separated expand list, or
18
+ when a wildcard value (e.g. ``*`` or ``~all``) is present.
19
+ """
20
+ expand_value = request.query_params.get(EXPAND_PARAM)
21
+ expand_fields = []
22
+
23
+ if expand_value:
24
+ for f in expand_value.split(","):
25
+ expand_fields.extend([_ for _ in f.split(".")])
26
+
27
+ wildcard_values = WILDCARD_VALUES or []
28
+ return any(field for field in expand_fields if field in wildcard_values) or field in expand_fields
29
+
30
+
31
+ def is_included(request, field: str) -> bool:
32
+ """Return whether `field` should be included in the response.
33
+
34
+ Returns ``False`` when the ``fields`` sparse-fieldset parameter is
35
+ present and `field` is not listed, or when the ``omit`` parameter is
36
+ present and `field` is listed. Returns ``True`` otherwise.
37
+ """
38
+ sparse_value = request.query_params.get(FIELDS_PARAM)
39
+ omit_value = request.query_params.get(OMIT_PARAM)
40
+ sparse_fields, omit_fields = [], []
41
+
42
+ if sparse_value:
43
+ for f in sparse_value.split(","):
44
+ sparse_fields.extend([_ for _ in f.split(".")])
45
+
46
+ if omit_value:
47
+ for f in omit_value.split(","):
48
+ omit_fields.extend([_ for _ in f.split(".")])
49
+
50
+ if len(sparse_fields) > 0 and field not in sparse_fields:
51
+ return False
52
+
53
+ if len(omit_fields) > 0 and field in omit_fields:
54
+ return False
55
+
56
+ return True
57
+
58
+
59
+ def split_levels(
60
+ fields: str | Iterable[str],
61
+ ) -> tuple[list[str], dict[str, list[str]]]:
62
+ """Split a dot-notation field list into current-level and next-level parts.
63
+
64
+ Given an iterable such as ``['a', 'a.b', 'a.d', 'c']``, returns a
65
+ tuple ``(first_level, next_level)`` where ``first_level`` is the
66
+ deduplicated list of top-level names (e.g. ``['a', 'c']``) and
67
+ ``next_level`` is a dict mapping each name to its remaining path
68
+ fragments (e.g. ``{'a': ['b', 'd']}``). A plain string is treated
69
+ as a comma-separated field list.
70
+ """
71
+ first_level_fields: list[str] = []
72
+ next_level_fields: dict[str, list[str]] = {}
73
+
74
+ if not fields:
75
+ return first_level_fields, next_level_fields
76
+
77
+ assert isinstance(
78
+ fields, Iterable
79
+ ), "`fields` must be iterable (e.g. list, tuple, or generator)"
80
+
81
+ if isinstance(fields, str):
82
+ fields = [a.strip() for a in fields.split(",") if a.strip()]
83
+ for e in fields:
84
+ if "." in e:
85
+ first_level, next_level = e.split(".", 1)
86
+ first_level_fields.append(first_level)
87
+ next_level_fields.setdefault(first_level, []).append(next_level)
88
+ else:
89
+ first_level_fields.append(e)
90
+
91
+ first_level_fields = list(set(first_level_fields))
92
+ return first_level_fields, next_level_fields
@@ -0,0 +1,44 @@
1
+ """Flex-fields view mixin and model view set.
2
+
3
+ Provides `FlexFieldsMixin` for controlling per-action expansion permissions
4
+ and the ready-to-use `FlexFieldsModelViewSet` that combines the mixin with
5
+ ``ModelViewSet`` from Django REST Framework.
6
+ """
7
+
8
+ from typing import Any
9
+ from rest_framework.viewsets import ModelViewSet
10
+
11
+
12
+ class FlexFieldsMixin:
13
+ """View mixin that restricts which fields may be expanded on list actions.
14
+
15
+ Set ``permit_list_expands`` to a list of field names that are allowed to
16
+ be expanded when the view is handling a ``list`` request. The allowed
17
+ names are passed to the serializer via ``context['permitted_expands']``
18
+ so that `FlexFieldsSerializerMixin` can enforce the constraint.
19
+ """
20
+ permit_list_expands: list[str] = []
21
+ action: str | None = None
22
+
23
+ def get_serializer_context(self) -> dict[str, Any]:
24
+ """Extend the serializer context with ``permitted_expands`` for list actions.
25
+
26
+ When the current action is ``list``, adds
27
+ ``context['permitted_expands']`` populated from ``permit_list_expands``
28
+ so the serializer can restrict expansion accordingly.
29
+ """
30
+ default_context = super().get_serializer_context() # type: ignore[misc]
31
+
32
+ if hasattr(self, "action") and self.action == "list":
33
+ default_context["permitted_expands"] = self.permit_list_expands
34
+
35
+ return default_context
36
+
37
+
38
+ class FlexFieldsModelViewSet(FlexFieldsMixin, ModelViewSet):
39
+ """Convenience view set combining `FlexFieldsMixin` with ``ModelViewSet``.
40
+
41
+ Drop-in replacement for ``viewsets.ModelViewSet`` with list-action
42
+ expansion control provided by `FlexFieldsMixin`.
43
+ """
44
+