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.
- drf_flex_fields2-2.0.0/LICENSE.md +23 -0
- drf_flex_fields2-2.0.0/PKG-INFO +162 -0
- drf_flex_fields2-2.0.0/README.md +141 -0
- drf_flex_fields2-2.0.0/pyproject.toml +46 -0
- drf_flex_fields2-2.0.0/src/rest_flex_fields2/__init__.py +0 -0
- drf_flex_fields2-2.0.0/src/rest_flex_fields2/config.py +58 -0
- drf_flex_fields2-2.0.0/src/rest_flex_fields2/filter_backends.py +331 -0
- drf_flex_fields2-2.0.0/src/rest_flex_fields2/serializers.py +449 -0
- drf_flex_fields2-2.0.0/src/rest_flex_fields2/utils.py +92 -0
- drf_flex_fields2-2.0.0/src/rest_flex_fields2/views.py +44 -0
|
@@ -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
|
+
[](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
|
+
[](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"
|
|
File without changes
|
|
@@ -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
|
+
|