django-headless 1.0.0__py3-none-any.whl
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.
- django_headless-1.0.0.dist-info/METADATA +160 -0
- django_headless-1.0.0.dist-info/RECORD +27 -0
- django_headless-1.0.0.dist-info/WHEEL +4 -0
- django_headless-1.0.0.dist-info/licenses/LICENSE +21 -0
- headless/__init__.py +83 -0
- headless/apps.py +49 -0
- headless/checks.py +146 -0
- headless/registry.py +339 -0
- headless/rest/__init__.py +0 -0
- headless/rest/apps.py +71 -0
- headless/rest/authentication.py +73 -0
- headless/rest/builder/__init__.py +5 -0
- headless/rest/builder/base.py +111 -0
- headless/rest/builder/serializer.py +70 -0
- headless/rest/builder/viewset.py +162 -0
- headless/rest/checks.py +200 -0
- headless/rest/filters/__init__.py +2 -0
- headless/rest/filters/base.py +309 -0
- headless/rest/filters/casting.py +65 -0
- headless/rest/pagination.py +29 -0
- headless/rest/routers.py +31 -0
- headless/rest/serializers.py +159 -0
- headless/rest/urls.py +3 -0
- headless/rest/utils.py +59 -0
- headless/rest/viewsets.py +270 -0
- headless/settings.py +196 -0
- headless/utils.py +210 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-headless
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Automagically create a REST API for your Django models
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: django,rest,api,headless,cms
|
|
8
|
+
Author: Leon van der Grient
|
|
9
|
+
Author-email: leon@dwarsbit.nl
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Framework :: Django
|
|
18
|
+
Classifier: Framework :: Django :: 5.2
|
|
19
|
+
Classifier: Framework :: Django :: 6.0
|
|
20
|
+
Classifier: Framework :: Django :: 6.1
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Dist: Django (>=5.2,<7.0)
|
|
24
|
+
Requires-Dist: djangorestframework (>=3.18,<4.0)
|
|
25
|
+
Requires-Dist: drf-flex-fields (>=1.0,<2.0)
|
|
26
|
+
Requires-Dist: rich (>=14)
|
|
27
|
+
Project-URL: Changelog, https://github.com/dwarsbit/django-headless/blob/main/CHANGELOG.md
|
|
28
|
+
Project-URL: Documentation, https://djangoheadless.org/docs/intro
|
|
29
|
+
Project-URL: Homepage, https://djangoheadless.org
|
|
30
|
+
Project-URL: Issues, https://github.com/dwarsbit/django-headless/issues
|
|
31
|
+
Project-URL: Source, https://github.com/dwarsbit/django-headless
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# Django Headless
|
|
35
|
+
|
|
36
|
+
[](https://badge.fury.io/py/django-headless)
|
|
37
|
+
[](https://pypi.org/project/django-headless/)
|
|
38
|
+
[](https://www.djangoproject.com/)
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
|
|
41
|
+
Django Headless generates a REST API for your Django models, so Django can serve as the backend of a headless CMS. Decorate a model with `@expose()` and it gets list, detail, create, update and delete routes, with no serializers, viewsets or URL patterns to write.
|
|
42
|
+
|
|
43
|
+
Read the documentation at [djangoheadless.org](https://djangoheadless.org).
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **Routes per model**: one decorator generates the routes of a model.
|
|
48
|
+
- **Singletons**: site settings and other one-off objects as a single route.
|
|
49
|
+
- **Filtering**: every lookup a field supports, from the query string.
|
|
50
|
+
- **Lookup routes**: fetch an object by a unique field such as a slug, at `/api/blog.article/by_slug/<value>`.
|
|
51
|
+
- **Pagination**: a stable, paginated list envelope with ready-to-fetch links.
|
|
52
|
+
- **Protected by default**: generated routes only answer staff users until you open them up.
|
|
53
|
+
|
|
54
|
+
## Quick start
|
|
55
|
+
|
|
56
|
+
1. Install the package:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install django-headless
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
2. Add `rest_framework`, `headless` and `headless.rest` to `INSTALLED_APPS`:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# mysite/settings.py
|
|
66
|
+
INSTALLED_APPS = [
|
|
67
|
+
# ...
|
|
68
|
+
"rest_framework",
|
|
69
|
+
"headless",
|
|
70
|
+
"headless.rest",
|
|
71
|
+
]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
3. Include the API in your URL configuration:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# mysite/urls.py
|
|
78
|
+
from django.urls import include, path
|
|
79
|
+
|
|
80
|
+
urlpatterns = [
|
|
81
|
+
# ...
|
|
82
|
+
path("api/", include("headless.rest.urls")),
|
|
83
|
+
]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
4. Expose a model:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
# blog/models.py
|
|
90
|
+
from django.db import models
|
|
91
|
+
from headless import expose
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@expose()
|
|
95
|
+
class Article(models.Model):
|
|
96
|
+
title = models.CharField(max_length=200)
|
|
97
|
+
body = models.TextField()
|
|
98
|
+
published = models.BooleanField(default=False)
|
|
99
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
100
|
+
|
|
101
|
+
def __str__(self):
|
|
102
|
+
return self.title
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Run `python manage.py runserver`; the article list is at `/api/blog.article`.
|
|
106
|
+
|
|
107
|
+
Generated routes answer `403 Forbidden` to everyone but staff users, so an exposed model is never public by accident. To configure authentication or open the routes up, see [Permissions](https://djangoheadless.org/docs/permissions).
|
|
108
|
+
|
|
109
|
+
## Documentation
|
|
110
|
+
|
|
111
|
+
- [Installation](https://djangoheadless.org/docs/installation): requirements and setup
|
|
112
|
+
- [Exposing models](https://djangoheadless.org/docs/models): the `@expose()` options
|
|
113
|
+
- [Singletons](https://djangoheadless.org/docs/singletons): single-instance resources such as site settings
|
|
114
|
+
- [Filtering](https://djangoheadless.org/docs/filtering): lookups, exclusions and type casting
|
|
115
|
+
- [Pagination](https://djangoheadless.org/docs/pagination): the list envelope
|
|
116
|
+
- [Authentication](https://djangoheadless.org/docs/authentication): secret key and DRF authentication
|
|
117
|
+
- [Serializers](https://djangoheadless.org/docs/serializers): expanding relations and model properties
|
|
118
|
+
- [Permissions](https://djangoheadless.org/docs/permissions): defaults and overrides for generated routes
|
|
119
|
+
- [Settings reference](https://djangoheadless.org/docs/settings): every `HEADLESS` setting
|
|
120
|
+
- [System checks](https://djangoheadless.org/docs/checks): the checks for risky or broken settings
|
|
121
|
+
|
|
122
|
+
## Contributing
|
|
123
|
+
|
|
124
|
+
To contribute a change:
|
|
125
|
+
|
|
126
|
+
1. Fork the repository.
|
|
127
|
+
2. Create a branch: `git checkout -b fix-boolean-casting`.
|
|
128
|
+
3. Make the change and add tests for it.
|
|
129
|
+
4. Run the test suite: `poetry run python scripts/test_wrapper.py test`.
|
|
130
|
+
5. Commit and push the branch.
|
|
131
|
+
6. Open a pull request.
|
|
132
|
+
|
|
133
|
+
Set up a development environment with Poetry:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
git clone https://github.com/dwarsbit/django-headless.git
|
|
137
|
+
cd django-headless
|
|
138
|
+
poetry install
|
|
139
|
+
poetry run python scripts/test_wrapper.py test
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Writing for the README or the documentation follows [STYLE.md](STYLE.md).
|
|
143
|
+
|
|
144
|
+
## Support
|
|
145
|
+
|
|
146
|
+
- Bug reports: [GitHub Issues](https://github.com/dwarsbit/django-headless/issues)
|
|
147
|
+
- Questions: [GitHub Discussions](https://github.com/dwarsbit/django-headless/discussions)
|
|
148
|
+
- Email: leon@dwarsbit.nl
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
Django Headless is licensed under the MIT License; see [LICENSE](LICENSE).
|
|
153
|
+
|
|
154
|
+
## Links
|
|
155
|
+
|
|
156
|
+
- [Documentation](https://djangoheadless.org)
|
|
157
|
+
- [PyPI](https://pypi.org/project/django-headless/)
|
|
158
|
+
- [GitHub](https://github.com/dwarsbit/django-headless)
|
|
159
|
+
- [Changelog](CHANGELOG.md)
|
|
160
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
headless/__init__.py,sha256=Rc3b_gv61qHpXyzyVY_mRrsGMml1ufizyVfD4vDghCg,2575
|
|
2
|
+
headless/apps.py,sha256=j-l09ASsaXniyIWlM-sSHkDWIEdNwTrDyW5CcQN5R9Y,2058
|
|
3
|
+
headless/checks.py,sha256=V1vbJyZB3z5HxxTN4OvaChPjmwmPklSShHuF7myDR_8,5598
|
|
4
|
+
headless/registry.py,sha256=3DTe_bCgtwpxGeNaRA-snDO7ZcFNN7zUmW58vTZrDzw,12839
|
|
5
|
+
headless/rest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
headless/rest/apps.py,sha256=7f5rvno6KsmY4YAsYLZuHnaQkE_us0YFxKHZuRoEerQ,2699
|
|
7
|
+
headless/rest/authentication.py,sha256=4cG216egF9P2DWblFvJa1qNCxEhbDl99dDzJFRi3pP0,2921
|
|
8
|
+
headless/rest/builder/__init__.py,sha256=MOlZGDmja0NbQ0tZ32XvGWUdt823D_4VloyNsS2JDww,298
|
|
9
|
+
headless/rest/builder/base.py,sha256=AJ9QuEYrhSapdGTKFZDp3aA3jaYhi6XoOvZdb4aFIqw,3855
|
|
10
|
+
headless/rest/builder/serializer.py,sha256=etjEb64TC1cRfBgYDq8wxaDlNGVEkR28t41IOCigC8o,2062
|
|
11
|
+
headless/rest/builder/viewset.py,sha256=H1iHDYzgvP9RHIT2rO0NJgKCkQi_GKAJhZ-IqWol2Tc,6586
|
|
12
|
+
headless/rest/checks.py,sha256=SwSzGhidK8_YNLRxQhrsYO2ihbgN50oUORiQxZvnXrg,8414
|
|
13
|
+
headless/rest/filters/__init__.py,sha256=sGvGpgvYbWozR8AAYJm2UCBSMA2j_cXOaVD2e096uBo,98
|
|
14
|
+
headless/rest/filters/base.py,sha256=c0NgkDLDw2Tpg5PzE_N5k4rEjlqdqalotHRbLZKlMcQ,13110
|
|
15
|
+
headless/rest/filters/casting.py,sha256=w80B1FLjQ1OmQobNhQzzEYmkPSuZgE2BGdSDoWxk78o,2250
|
|
16
|
+
headless/rest/pagination.py,sha256=KK19C0RFsxuBUuoBP-c-b4PGIXkRCnCo1RGx3gzFDaQ,1101
|
|
17
|
+
headless/rest/routers.py,sha256=XXxSN-Kk6C15b_2mj4F-2B6CM85sC6yiUmbvV-slghg,963
|
|
18
|
+
headless/rest/serializers.py,sha256=oTyajk1ojqwcNEuodH5OagsUEwGCvuM03mMTN8VahUM,6495
|
|
19
|
+
headless/rest/urls.py,sha256=9dqkaWdrmRRp-cBq-URaiC1vagSnGlSHtQsRNEpdrSA,98
|
|
20
|
+
headless/rest/utils.py,sha256=NOIxO6TduOpLpUl3uAMt-muykWjG26n2AYbk30rn0RM,2081
|
|
21
|
+
headless/rest/viewsets.py,sha256=vQUDA3gdK6jc13UzHVi8_M3xbHeBDOAnV-1P5jw2BAc,9995
|
|
22
|
+
headless/settings.py,sha256=oa1adqZL-jiWt52N1TJALLSKkvj6KSd1h98Jao1MRU0,6634
|
|
23
|
+
headless/utils.py,sha256=TYOVAGIKL64uD2LvwMV72pgk26U4vhFfXJESNIrO23s,6438
|
|
24
|
+
django_headless-1.0.0.dist-info/METADATA,sha256=J_p3K4fWeiFX_EAg7L0NCyypHBRVHFbQHBSSVq8pWMc,5997
|
|
25
|
+
django_headless-1.0.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
26
|
+
django_headless-1.0.0.dist-info/licenses/LICENSE,sha256=qRtDtp8ZN6dF4bSkXAPDl__SQ1lcp2gG6PluTi4q_tQ,1075
|
|
27
|
+
django_headless-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leon van der Grient
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
headless/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
__title__ = "Django Headless"
|
|
2
|
+
__version__ = "1.0.0"
|
|
3
|
+
__author__ = "Leon van der Grient"
|
|
4
|
+
__license__ = "MIT"
|
|
5
|
+
|
|
6
|
+
from typing import Type
|
|
7
|
+
|
|
8
|
+
from django.db import models
|
|
9
|
+
|
|
10
|
+
from .registry import headless_registry
|
|
11
|
+
|
|
12
|
+
# Version synonym
|
|
13
|
+
VERSION = __version__
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def expose(singleton=False, search_fields=None, fields=None, exclude=None, read_only=False, lookup_fields=None):
|
|
17
|
+
"""
|
|
18
|
+
Decorator to register a Django model to the headless registry.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
singleton: If True, the model will be treated as a singleton (single instance).
|
|
22
|
+
search_fields: List of field names to enable search functionality on.
|
|
23
|
+
fields: Restrict the exposed serializer to these field names.
|
|
24
|
+
exclude: Exclude these field names from the exposed serializer.
|
|
25
|
+
read_only: If True, the model is exposed read-only (no create, update or delete).
|
|
26
|
+
lookup_fields: Names of unique fields to expose a retrieve route for, at by_<field>/<value>.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
@expose()
|
|
30
|
+
class MyModel(models.Model):
|
|
31
|
+
pass
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def decorator(model_class: Type[models.Model]):
|
|
35
|
+
expose_model(
|
|
36
|
+
model_class,
|
|
37
|
+
singleton=singleton,
|
|
38
|
+
search_fields=search_fields,
|
|
39
|
+
fields=fields,
|
|
40
|
+
exclude=exclude,
|
|
41
|
+
read_only=read_only,
|
|
42
|
+
lookup_fields=lookup_fields,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return model_class
|
|
46
|
+
|
|
47
|
+
return decorator
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def expose_model(
|
|
51
|
+
model_class: Type[models.Model],
|
|
52
|
+
singleton=False,
|
|
53
|
+
search_fields=None,
|
|
54
|
+
fields=None,
|
|
55
|
+
exclude=None,
|
|
56
|
+
read_only=False,
|
|
57
|
+
lookup_fields=None,
|
|
58
|
+
):
|
|
59
|
+
"""
|
|
60
|
+
Register a Django model to the headless registry.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
model_class: The Django model class to expose via the REST API.
|
|
64
|
+
singleton: If True, the model will be treated as a singleton (single instance).
|
|
65
|
+
search_fields: List of field names to enable search functionality on.
|
|
66
|
+
fields: Restrict the exposed serializer to these field names.
|
|
67
|
+
exclude: Exclude these field names from the exposed serializer.
|
|
68
|
+
read_only: If True, the model is exposed read-only (no create, update or delete).
|
|
69
|
+
lookup_fields: Names of unique fields to expose a retrieve route for, at by_<field>/<value>.
|
|
70
|
+
|
|
71
|
+
Usage:
|
|
72
|
+
expose_model(MyModel, singleton=False, search_fields=['name', 'description'])
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
headless_registry.register(
|
|
76
|
+
model_class,
|
|
77
|
+
singleton=singleton,
|
|
78
|
+
search_fields=search_fields,
|
|
79
|
+
fields=fields,
|
|
80
|
+
exclude=exclude,
|
|
81
|
+
read_only=read_only,
|
|
82
|
+
lookup_fields=lookup_fields,
|
|
83
|
+
)
|
headless/apps.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
from . import VERSION
|
|
4
|
+
from .utils import (
|
|
5
|
+
log,
|
|
6
|
+
is_boot_log_enabled,
|
|
7
|
+
get_latest_version,
|
|
8
|
+
is_newer_version,
|
|
9
|
+
normalize_version,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DjangoHeadlessConfig(AppConfig):
|
|
14
|
+
name = "headless"
|
|
15
|
+
label = "headless"
|
|
16
|
+
|
|
17
|
+
def ready(self):
|
|
18
|
+
# Register the system checks of the registry and settings. The headless
|
|
19
|
+
# app manages the registry; apps such as headless.rest serve the models.
|
|
20
|
+
from . import checks # noqa: F401
|
|
21
|
+
|
|
22
|
+
if is_boot_log_enabled():
|
|
23
|
+
self.log_boot()
|
|
24
|
+
|
|
25
|
+
def log_boot(self):
|
|
26
|
+
"""
|
|
27
|
+
Log the version and the exposed models. Exposure apps such as
|
|
28
|
+
headless.rest log their own part of the boot log after this.
|
|
29
|
+
"""
|
|
30
|
+
from headless.settings import headless_settings
|
|
31
|
+
from .registry import headless_registry
|
|
32
|
+
|
|
33
|
+
log("")
|
|
34
|
+
log("[bold magenta]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold magenta]")
|
|
35
|
+
log("[bold cyan]Django Headless[/bold cyan]")
|
|
36
|
+
|
|
37
|
+
# Check for a newer version, unless the check is turned off
|
|
38
|
+
latest_version = get_latest_version() if headless_settings.VERSION_CHECK else None
|
|
39
|
+
if latest_version and is_newer_version(latest_version, VERSION):
|
|
40
|
+
log(f"[yellow]⚠️ New version available[/yellow]")
|
|
41
|
+
log(f"Current: [bold]{normalize_version(VERSION)}[/bold] → Latest: {normalize_version(latest_version)}")
|
|
42
|
+
else:
|
|
43
|
+
log(f"[bold]Version {normalize_version(VERSION)}[/bold]")
|
|
44
|
+
|
|
45
|
+
log("[bold magenta]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold magenta]")
|
|
46
|
+
log(f":gift: Found [bold green]{len(headless_registry)}[/bold green] exposed models:")
|
|
47
|
+
for model_config in headless_registry.get_models():
|
|
48
|
+
model = model_config["model"]
|
|
49
|
+
log(f" [cyan]•[/cyan] {model._meta.verbose_name} ([dim]{model._meta.label_lower}[/dim])")
|
headless/checks.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django system checks for the registry and settings of Django Headless,
|
|
3
|
+
whatever app serves the exposed models. The checks of the REST routes
|
|
4
|
+
live in headless.rest.checks.
|
|
5
|
+
|
|
6
|
+
They run with runserver, migrate and manage.py check. Silence a single
|
|
7
|
+
check with SILENCED_SYSTEM_CHECKS, e.g. ["headless.W005"].
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from difflib import get_close_matches
|
|
11
|
+
|
|
12
|
+
from django.apps import apps
|
|
13
|
+
from django.core import checks
|
|
14
|
+
|
|
15
|
+
from .registry import headless_registry
|
|
16
|
+
from .settings import DEFAULTS, SETTINGS_NAMESPACE, headless_settings
|
|
17
|
+
|
|
18
|
+
# Apps that serve the exposed models
|
|
19
|
+
EXPOSURE_APPS = ["headless.rest"]
|
|
20
|
+
|
|
21
|
+
# Parts of a field name (split on "_") that mark it as a credential
|
|
22
|
+
SENSITIVE_NAME_PARTS = {"password", "passwd", "pwd", "secret", "token", "otp", "salt", "apikey"}
|
|
23
|
+
|
|
24
|
+
# Consecutive parts of a field name that mark it as a credential
|
|
25
|
+
SENSITIVE_NAME_PAIRS = {("api", "key"), ("private", "key"), ("access", "key")}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_sensitive_field_name(name: str) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
Check whether a field name looks like a credential, such as password_hash,
|
|
31
|
+
access_token or api_key. Only whole parts of the name count, so names
|
|
32
|
+
like sort_key or tokenizer do not match.
|
|
33
|
+
"""
|
|
34
|
+
parts = name.lower().split("_")
|
|
35
|
+
return any(part in SENSITIVE_NAME_PARTS for part in parts) or any(
|
|
36
|
+
pair in SENSITIVE_NAME_PAIRS for pair in zip(parts, parts[1:])
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _implicitly_serialized_fields(config):
|
|
41
|
+
"""
|
|
42
|
+
Return the names of the fields of an exposed model that are serialized
|
|
43
|
+
without being named in its fields option: all forward fields minus the
|
|
44
|
+
excluded ones. Fields named in a fields option are a deliberate choice.
|
|
45
|
+
"""
|
|
46
|
+
if config["fields"] is not None:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
model = config["model"]
|
|
50
|
+
excluded = set(config["exclude"] or [])
|
|
51
|
+
return [field.name for field in (*model._meta.fields, *model._meta.many_to_many) if field.name not in excluded]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@checks.register()
|
|
55
|
+
def check_settings(app_configs=None, **kwargs):
|
|
56
|
+
"""W005: the HEADLESS setting has a key that is not a Django Headless setting."""
|
|
57
|
+
# A HEADLESS setting that is not a dictionary raises ImproperlyConfigured
|
|
58
|
+
# on first use, at startup, so it never reaches the checks.
|
|
59
|
+
warnings = []
|
|
60
|
+
for key in headless_settings.user_settings:
|
|
61
|
+
if key in DEFAULTS:
|
|
62
|
+
continue
|
|
63
|
+
matches = get_close_matches(str(key), DEFAULTS, n=1)
|
|
64
|
+
hint = f'Did you mean "{matches[0]}"?' if matches else "Remove it, or see the settings reference."
|
|
65
|
+
warnings.append(
|
|
66
|
+
checks.Warning(
|
|
67
|
+
f'{SETTINGS_NAMESPACE}["{key}"] is not a Django Headless setting, so it has no effect.',
|
|
68
|
+
hint=hint,
|
|
69
|
+
id="headless.W005",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return warnings
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@checks.register()
|
|
77
|
+
def check_registrations(app_configs=None, **kwargs):
|
|
78
|
+
"""
|
|
79
|
+
W008: a model is exposed more than once with different options.
|
|
80
|
+
W010: models are exposed, but no app that serves them is installed.
|
|
81
|
+
"""
|
|
82
|
+
warnings = []
|
|
83
|
+
|
|
84
|
+
for label in sorted(headless_registry.get_conflicting_labels()):
|
|
85
|
+
warnings.append(
|
|
86
|
+
checks.Warning(
|
|
87
|
+
"This model is exposed more than once with different options, for example with both @expose() "
|
|
88
|
+
"and expose_model(). Only the last registration is in effect.",
|
|
89
|
+
hint="Expose the model once, with all its options.",
|
|
90
|
+
obj=headless_registry.get_model(label)["model"],
|
|
91
|
+
id="headless.W008",
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if headless_registry.get_models() and not any(apps.is_installed(app) for app in EXPOSURE_APPS):
|
|
96
|
+
warnings.append(
|
|
97
|
+
checks.Warning(
|
|
98
|
+
"Models are exposed, but no app that serves them is installed, so the API has no routes.",
|
|
99
|
+
hint='Add "headless.rest" to INSTALLED_APPS, after "headless".',
|
|
100
|
+
id="headless.W010",
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return warnings
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@checks.register(checks.Tags.security)
|
|
108
|
+
def check_sensitive_fields(app_configs=None, **kwargs):
|
|
109
|
+
"""
|
|
110
|
+
W006: an exposed model serializes a field that looks like a credential.
|
|
111
|
+
W007: a user model is exposed without a fields allowlist.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
from django.contrib.auth.base_user import AbstractBaseUser
|
|
115
|
+
except ImportError: # pragma: no cover
|
|
116
|
+
AbstractBaseUser = None
|
|
117
|
+
|
|
118
|
+
warnings = []
|
|
119
|
+
|
|
120
|
+
for config in headless_registry.get_models():
|
|
121
|
+
model = config["model"]
|
|
122
|
+
|
|
123
|
+
if AbstractBaseUser is not None and issubclass(model, AbstractBaseUser) and config["fields"] is None:
|
|
124
|
+
warnings.append(
|
|
125
|
+
checks.Warning(
|
|
126
|
+
"This user model is exposed without a fields option, so the API serializes every field "
|
|
127
|
+
"that is not excluded, which can include the password hash, email address and permissions.",
|
|
128
|
+
hint='List the fields the API may serialize, e.g. @expose(fields=["username", "first_name"]).',
|
|
129
|
+
obj=model,
|
|
130
|
+
id="headless.W007",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
for name in _implicitly_serialized_fields(config):
|
|
135
|
+
if is_sensitive_field_name(name):
|
|
136
|
+
warnings.append(
|
|
137
|
+
checks.Warning(
|
|
138
|
+
f"The API serializes the field {name}, which looks like a credential.",
|
|
139
|
+
hint=f'Add "{name}" to the exclude option, or list it in the fields option '
|
|
140
|
+
"if the API should serialize it.",
|
|
141
|
+
obj=model,
|
|
142
|
+
id="headless.W006",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return warnings
|