django-headless 1.0.0b1__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.
- django_headless-1.0.0b1/LICENSE +21 -0
- django_headless-1.0.0b1/PKG-INFO +273 -0
- django_headless-1.0.0b1/README.md +256 -0
- django_headless-1.0.0b1/headless/__init__.py +30 -0
- django_headless-1.0.0b1/headless/apps.py +27 -0
- django_headless-1.0.0b1/headless/registry.py +52 -0
- django_headless-1.0.0b1/headless/rest/__init__.py +1 -0
- django_headless-1.0.0b1/headless/rest/apps.py +73 -0
- django_headless-1.0.0b1/headless/rest/filters.py +121 -0
- django_headless-1.0.0b1/headless/rest/routers.py +3 -0
- django_headless-1.0.0b1/headless/rest/urls.py +3 -0
- django_headless-1.0.0b1/headless/rest/viewsets.py +41 -0
- django_headless-1.0.0b1/headless/settings.py +144 -0
- django_headless-1.0.0b1/headless/tests.py +1 -0
- django_headless-1.0.0b1/headless/utils.py +53 -0
- django_headless-1.0.0b1/pyproject.toml +25 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 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.
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: django-headless
|
|
3
|
+
Version: 1.0.0b1
|
|
4
|
+
Summary: Automagically create a REST API for you Django models
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Leon van der Grient
|
|
7
|
+
Author-email: leon@devtastic.io
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: rich
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Django Headless
|
|
19
|
+
|
|
20
|
+
[](https://badge.fury.io/py/django-headless)
|
|
21
|
+
[](https://pypi.org/project/django-headless/)
|
|
22
|
+
[](https://www.djangoproject.com/)
|
|
23
|
+
[](LICENSE)
|
|
24
|
+
|
|
25
|
+
With Django Headless you quickly create a REST API for your models, making it easy to turn Django into a powerful headless CMS.
|
|
26
|
+
|
|
27
|
+
## ✨ Features
|
|
28
|
+
|
|
29
|
+
- **🎯 Zero configuration**: Add `@headless` decorator to any model and get instant REST endpoints
|
|
30
|
+
- **🤝 Plays nice**: Seamlessly integrates with existing Django applications
|
|
31
|
+
- **💈 Supports singletons**: Special handling for singleton models (settings, configurations, etc.)
|
|
32
|
+
- **🔍 Flexible filtering**: Optional filtering backend based on Django ORM lookups
|
|
33
|
+
- **🛡️ Secure**: Inherits Django's security features and permissions system
|
|
34
|
+
|
|
35
|
+
## 🚀 Quick Start
|
|
36
|
+
|
|
37
|
+
### Installation
|
|
38
|
+
|
|
39
|
+
☝️ Django Headless depends on Django and Django Rest Framework.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install django-headless
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Add to Django Settings
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
# settings.py
|
|
49
|
+
INSTALLED_APPS = [
|
|
50
|
+
'django.contrib.admin',
|
|
51
|
+
'django.contrib.auth',
|
|
52
|
+
'django.contrib.contenttypes',
|
|
53
|
+
'django.contrib.sessions',
|
|
54
|
+
'django.contrib.messages',
|
|
55
|
+
'django.contrib.staticfiles',
|
|
56
|
+
'rest_framework',
|
|
57
|
+
'headless', # Add this
|
|
58
|
+
# ... your apps
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
# Optional: add the lookup filter backend
|
|
62
|
+
REST_FRAMEWORK = {
|
|
63
|
+
"DEFAULT_FILTER_BACKENDS": [
|
|
64
|
+
"headless.rest.filters.LookupFilter",
|
|
65
|
+
#... other DRF config
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Create Your First Headless Model
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# models.py
|
|
73
|
+
from django.db import models
|
|
74
|
+
from headless import headless
|
|
75
|
+
|
|
76
|
+
@headless()
|
|
77
|
+
class BlogPost(models.Model):
|
|
78
|
+
title = models.CharField(max_length=200)
|
|
79
|
+
content = models.TextField()
|
|
80
|
+
published = models.BooleanField(default=False)
|
|
81
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
82
|
+
|
|
83
|
+
def __str__(self):
|
|
84
|
+
return self.title
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Add URLs
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
# urls.py
|
|
91
|
+
from django.contrib import admin
|
|
92
|
+
from django.urls import path, include
|
|
93
|
+
|
|
94
|
+
urlpatterns = [
|
|
95
|
+
path('admin/', admin.site.urls),
|
|
96
|
+
path('api/', include('headless.urls')),
|
|
97
|
+
]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
That's it! 🎉 Your model is now available via REST API at `/api/blog-post/`
|
|
101
|
+
|
|
102
|
+
## 📖 Usage Examples
|
|
103
|
+
|
|
104
|
+
### Basic Model Registration
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from headless import headless
|
|
108
|
+
|
|
109
|
+
@headless()
|
|
110
|
+
class Article(models.Model):
|
|
111
|
+
title = models.CharField(max_length=200)
|
|
112
|
+
content = models.TextField()
|
|
113
|
+
author = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Generated endpoints:**
|
|
117
|
+
- `GET /api/article/` - List all articles
|
|
118
|
+
- `POST /api/article/` - Create new article
|
|
119
|
+
- `GET /api/article/{id}/` - Retrieve specific article
|
|
120
|
+
- `PUT /api/article/{id}/` - Update article
|
|
121
|
+
- `PATCH /api/article/{id}/` - Partially update article
|
|
122
|
+
- `DELETE /api/article/{id}/` - Delete article
|
|
123
|
+
|
|
124
|
+
### Singleton Models
|
|
125
|
+
|
|
126
|
+
Perfect for site settings, configurations, or any model that should have only one instance:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
@headless(singleton=True)
|
|
130
|
+
class SiteConfiguration(models.Model):
|
|
131
|
+
site_name = models.CharField(max_length=100)
|
|
132
|
+
maintenance_mode = models.BooleanField(default=False)
|
|
133
|
+
contact_email = models.EmailField()
|
|
134
|
+
|
|
135
|
+
class Meta:
|
|
136
|
+
verbose_name = "Site Configuration"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Generated endpoints:**
|
|
140
|
+
- `GET /api/site-configuration/` - Get configuration
|
|
141
|
+
- `PUT /api/site-configuration/` - Update configuration
|
|
142
|
+
- `PATCH /api/site-configuration/` - Partial update
|
|
143
|
+
|
|
144
|
+
### Advanced Filtering
|
|
145
|
+
|
|
146
|
+
Django Headless supports Django ORM lookups for powerful filtering.
|
|
147
|
+
Add the LookupFilter backend to your default filter backends:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
REST_FRAMEWORK = {
|
|
151
|
+
"DEFAULT_FILTER_BACKENDS": [
|
|
152
|
+
"headless.rest.filters.LookupFilter",
|
|
153
|
+
#... other DRF config
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Filter examples:**
|
|
158
|
+
```bash
|
|
159
|
+
# Basic filtering
|
|
160
|
+
GET /api/blogpost/?published=true
|
|
161
|
+
|
|
162
|
+
# Field lookups
|
|
163
|
+
GET /api/blogpost/?title__icontains=django
|
|
164
|
+
GET /api/blogpost/?created_at__gte=2023-01-01
|
|
165
|
+
GET /api/blogpost/?author__username=john
|
|
166
|
+
|
|
167
|
+
# Multiple filters
|
|
168
|
+
GET /api/blogpost/?published=true&created_at__year=2023
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Values are automatically cast based on the field type. Booleans can be represented
|
|
172
|
+
as `true`, `1` or `on` (and `false`, `0` or `off`). Multi-value lookups can be comma-separated (e.g. `id__in=1,2,3`).
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
## 🎛️ Configuration Options
|
|
176
|
+
|
|
177
|
+
The `@headless` decorator accepts the following configuration options:
|
|
178
|
+
|
|
179
|
+
| Option | Type | Default | Description |
|
|
180
|
+
|--------|------|---------|--------------------------------------------------------------------|
|
|
181
|
+
| `singleton` | `bool` | `False` | Creates singleton endpoints (no create, list and delete endpoints) |
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
### Global Settings
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
# settings.py
|
|
189
|
+
HEADLESS = {
|
|
190
|
+
"NON_FILTER_FIELDS": [
|
|
191
|
+
"search",
|
|
192
|
+
"limit",
|
|
193
|
+
"page",
|
|
194
|
+
"fields",
|
|
195
|
+
"exclude",
|
|
196
|
+
"expand",
|
|
197
|
+
],
|
|
198
|
+
"FILTER_EXCLUSION_SYMBOL": "~",
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## 🛠️ Requirements
|
|
203
|
+
|
|
204
|
+
- Python 3.10+
|
|
205
|
+
- Django 5.0+
|
|
206
|
+
- Django REST Framework 3.16+
|
|
207
|
+
|
|
208
|
+
## 🤝 Contributing
|
|
209
|
+
|
|
210
|
+
We welcome contributions! Here's how to get started:
|
|
211
|
+
|
|
212
|
+
1. Fork the repository
|
|
213
|
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
214
|
+
3. Make your changes
|
|
215
|
+
4. Add tests for your changes
|
|
216
|
+
5. Run the test suite (`python manage.py test`)
|
|
217
|
+
6. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
218
|
+
7. Push to the branch (`git push origin feature/amazing-feature`)
|
|
219
|
+
8. Open a Pull Request
|
|
220
|
+
|
|
221
|
+
### Development Setup
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
# Clone the repository
|
|
225
|
+
git clone https://github.com/StructuralRealist/django-headless.git
|
|
226
|
+
cd django-headless
|
|
227
|
+
|
|
228
|
+
# Create virtual environment
|
|
229
|
+
python -m venv venv
|
|
230
|
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
231
|
+
|
|
232
|
+
# Install dependencies
|
|
233
|
+
pip install -r requirements-dev.txt
|
|
234
|
+
|
|
235
|
+
# Run tests
|
|
236
|
+
python manage.py test
|
|
237
|
+
|
|
238
|
+
# Run example project
|
|
239
|
+
cd example_project
|
|
240
|
+
python manage.py migrate
|
|
241
|
+
python manage.py runserver
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## 📚 Documentation
|
|
245
|
+
|
|
246
|
+
For detailed documentation, visit [djangoheadless.org](https://djangoheadless.org)
|
|
247
|
+
|
|
248
|
+
## 🐛 Issues & Support
|
|
249
|
+
|
|
250
|
+
- 🐛 **Bug Reports**: [GitHub Issues](https://github.com/StructuralRealist/django-headless/issues)
|
|
251
|
+
- 💬 **Discussions**: [GitHub Discussions](https://github.com/StructuralRealist/django-headless/discussions)
|
|
252
|
+
- 📧 **Email**: leon@devtastic.io
|
|
253
|
+
|
|
254
|
+
## 📄 License
|
|
255
|
+
|
|
256
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
257
|
+
|
|
258
|
+
## 🙏 Acknowledgments
|
|
259
|
+
|
|
260
|
+
- Built on the shoulders of [Django](https://www.djangoproject.com/) and [Django REST Framework](https://www.django-rest-framework.org/)
|
|
261
|
+
- Inspired by the headless CMS and Jamstack movement
|
|
262
|
+
- Thanks to all contributors and the Django community
|
|
263
|
+
|
|
264
|
+
## 🔗 Links
|
|
265
|
+
|
|
266
|
+
- [PyPI Package](https://pypi.org/project/django-headless/)
|
|
267
|
+
- [Documentation](https:/djangoheadless.org)
|
|
268
|
+
- [GitHub Repository](https://github.com/StructuralRealist/django-headless)
|
|
269
|
+
- [Changelog](CHANGELOG.md)
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
Made in Europe 🇪🇺 with 💚 for Django
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# Django Headless
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/py/django-headless)
|
|
4
|
+
[](https://pypi.org/project/django-headless/)
|
|
5
|
+
[](https://www.djangoproject.com/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
With Django Headless you quickly create a REST API for your models, making it easy to turn Django into a powerful headless CMS.
|
|
9
|
+
|
|
10
|
+
## ✨ Features
|
|
11
|
+
|
|
12
|
+
- **🎯 Zero configuration**: Add `@headless` decorator to any model and get instant REST endpoints
|
|
13
|
+
- **🤝 Plays nice**: Seamlessly integrates with existing Django applications
|
|
14
|
+
- **💈 Supports singletons**: Special handling for singleton models (settings, configurations, etc.)
|
|
15
|
+
- **🔍 Flexible filtering**: Optional filtering backend based on Django ORM lookups
|
|
16
|
+
- **🛡️ Secure**: Inherits Django's security features and permissions system
|
|
17
|
+
|
|
18
|
+
## 🚀 Quick Start
|
|
19
|
+
|
|
20
|
+
### Installation
|
|
21
|
+
|
|
22
|
+
☝️ Django Headless depends on Django and Django Rest Framework.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install django-headless
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Add to Django Settings
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
# settings.py
|
|
32
|
+
INSTALLED_APPS = [
|
|
33
|
+
'django.contrib.admin',
|
|
34
|
+
'django.contrib.auth',
|
|
35
|
+
'django.contrib.contenttypes',
|
|
36
|
+
'django.contrib.sessions',
|
|
37
|
+
'django.contrib.messages',
|
|
38
|
+
'django.contrib.staticfiles',
|
|
39
|
+
'rest_framework',
|
|
40
|
+
'headless', # Add this
|
|
41
|
+
# ... your apps
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Optional: add the lookup filter backend
|
|
45
|
+
REST_FRAMEWORK = {
|
|
46
|
+
"DEFAULT_FILTER_BACKENDS": [
|
|
47
|
+
"headless.rest.filters.LookupFilter",
|
|
48
|
+
#... other DRF config
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Create Your First Headless Model
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
# models.py
|
|
56
|
+
from django.db import models
|
|
57
|
+
from headless import headless
|
|
58
|
+
|
|
59
|
+
@headless()
|
|
60
|
+
class BlogPost(models.Model):
|
|
61
|
+
title = models.CharField(max_length=200)
|
|
62
|
+
content = models.TextField()
|
|
63
|
+
published = models.BooleanField(default=False)
|
|
64
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
65
|
+
|
|
66
|
+
def __str__(self):
|
|
67
|
+
return self.title
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Add URLs
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
# urls.py
|
|
74
|
+
from django.contrib import admin
|
|
75
|
+
from django.urls import path, include
|
|
76
|
+
|
|
77
|
+
urlpatterns = [
|
|
78
|
+
path('admin/', admin.site.urls),
|
|
79
|
+
path('api/', include('headless.urls')),
|
|
80
|
+
]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
That's it! 🎉 Your model is now available via REST API at `/api/blog-post/`
|
|
84
|
+
|
|
85
|
+
## 📖 Usage Examples
|
|
86
|
+
|
|
87
|
+
### Basic Model Registration
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from headless import headless
|
|
91
|
+
|
|
92
|
+
@headless()
|
|
93
|
+
class Article(models.Model):
|
|
94
|
+
title = models.CharField(max_length=200)
|
|
95
|
+
content = models.TextField()
|
|
96
|
+
author = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Generated endpoints:**
|
|
100
|
+
- `GET /api/article/` - List all articles
|
|
101
|
+
- `POST /api/article/` - Create new article
|
|
102
|
+
- `GET /api/article/{id}/` - Retrieve specific article
|
|
103
|
+
- `PUT /api/article/{id}/` - Update article
|
|
104
|
+
- `PATCH /api/article/{id}/` - Partially update article
|
|
105
|
+
- `DELETE /api/article/{id}/` - Delete article
|
|
106
|
+
|
|
107
|
+
### Singleton Models
|
|
108
|
+
|
|
109
|
+
Perfect for site settings, configurations, or any model that should have only one instance:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
@headless(singleton=True)
|
|
113
|
+
class SiteConfiguration(models.Model):
|
|
114
|
+
site_name = models.CharField(max_length=100)
|
|
115
|
+
maintenance_mode = models.BooleanField(default=False)
|
|
116
|
+
contact_email = models.EmailField()
|
|
117
|
+
|
|
118
|
+
class Meta:
|
|
119
|
+
verbose_name = "Site Configuration"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Generated endpoints:**
|
|
123
|
+
- `GET /api/site-configuration/` - Get configuration
|
|
124
|
+
- `PUT /api/site-configuration/` - Update configuration
|
|
125
|
+
- `PATCH /api/site-configuration/` - Partial update
|
|
126
|
+
|
|
127
|
+
### Advanced Filtering
|
|
128
|
+
|
|
129
|
+
Django Headless supports Django ORM lookups for powerful filtering.
|
|
130
|
+
Add the LookupFilter backend to your default filter backends:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
REST_FRAMEWORK = {
|
|
134
|
+
"DEFAULT_FILTER_BACKENDS": [
|
|
135
|
+
"headless.rest.filters.LookupFilter",
|
|
136
|
+
#... other DRF config
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**Filter examples:**
|
|
141
|
+
```bash
|
|
142
|
+
# Basic filtering
|
|
143
|
+
GET /api/blogpost/?published=true
|
|
144
|
+
|
|
145
|
+
# Field lookups
|
|
146
|
+
GET /api/blogpost/?title__icontains=django
|
|
147
|
+
GET /api/blogpost/?created_at__gte=2023-01-01
|
|
148
|
+
GET /api/blogpost/?author__username=john
|
|
149
|
+
|
|
150
|
+
# Multiple filters
|
|
151
|
+
GET /api/blogpost/?published=true&created_at__year=2023
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Values are automatically cast based on the field type. Booleans can be represented
|
|
155
|
+
as `true`, `1` or `on` (and `false`, `0` or `off`). Multi-value lookups can be comma-separated (e.g. `id__in=1,2,3`).
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
## 🎛️ Configuration Options
|
|
159
|
+
|
|
160
|
+
The `@headless` decorator accepts the following configuration options:
|
|
161
|
+
|
|
162
|
+
| Option | Type | Default | Description |
|
|
163
|
+
|--------|------|---------|--------------------------------------------------------------------|
|
|
164
|
+
| `singleton` | `bool` | `False` | Creates singleton endpoints (no create, list and delete endpoints) |
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
### Global Settings
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
# settings.py
|
|
172
|
+
HEADLESS = {
|
|
173
|
+
"NON_FILTER_FIELDS": [
|
|
174
|
+
"search",
|
|
175
|
+
"limit",
|
|
176
|
+
"page",
|
|
177
|
+
"fields",
|
|
178
|
+
"exclude",
|
|
179
|
+
"expand",
|
|
180
|
+
],
|
|
181
|
+
"FILTER_EXCLUSION_SYMBOL": "~",
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## 🛠️ Requirements
|
|
186
|
+
|
|
187
|
+
- Python 3.10+
|
|
188
|
+
- Django 5.0+
|
|
189
|
+
- Django REST Framework 3.16+
|
|
190
|
+
|
|
191
|
+
## 🤝 Contributing
|
|
192
|
+
|
|
193
|
+
We welcome contributions! Here's how to get started:
|
|
194
|
+
|
|
195
|
+
1. Fork the repository
|
|
196
|
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
197
|
+
3. Make your changes
|
|
198
|
+
4. Add tests for your changes
|
|
199
|
+
5. Run the test suite (`python manage.py test`)
|
|
200
|
+
6. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
201
|
+
7. Push to the branch (`git push origin feature/amazing-feature`)
|
|
202
|
+
8. Open a Pull Request
|
|
203
|
+
|
|
204
|
+
### Development Setup
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
# Clone the repository
|
|
208
|
+
git clone https://github.com/StructuralRealist/django-headless.git
|
|
209
|
+
cd django-headless
|
|
210
|
+
|
|
211
|
+
# Create virtual environment
|
|
212
|
+
python -m venv venv
|
|
213
|
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
214
|
+
|
|
215
|
+
# Install dependencies
|
|
216
|
+
pip install -r requirements-dev.txt
|
|
217
|
+
|
|
218
|
+
# Run tests
|
|
219
|
+
python manage.py test
|
|
220
|
+
|
|
221
|
+
# Run example project
|
|
222
|
+
cd example_project
|
|
223
|
+
python manage.py migrate
|
|
224
|
+
python manage.py runserver
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## 📚 Documentation
|
|
228
|
+
|
|
229
|
+
For detailed documentation, visit [djangoheadless.org](https://djangoheadless.org)
|
|
230
|
+
|
|
231
|
+
## 🐛 Issues & Support
|
|
232
|
+
|
|
233
|
+
- 🐛 **Bug Reports**: [GitHub Issues](https://github.com/StructuralRealist/django-headless/issues)
|
|
234
|
+
- 💬 **Discussions**: [GitHub Discussions](https://github.com/StructuralRealist/django-headless/discussions)
|
|
235
|
+
- 📧 **Email**: leon@devtastic.io
|
|
236
|
+
|
|
237
|
+
## 📄 License
|
|
238
|
+
|
|
239
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
240
|
+
|
|
241
|
+
## 🙏 Acknowledgments
|
|
242
|
+
|
|
243
|
+
- Built on the shoulders of [Django](https://www.djangoproject.com/) and [Django REST Framework](https://www.django-rest-framework.org/)
|
|
244
|
+
- Inspired by the headless CMS and Jamstack movement
|
|
245
|
+
- Thanks to all contributors and the Django community
|
|
246
|
+
|
|
247
|
+
## 🔗 Links
|
|
248
|
+
|
|
249
|
+
- [PyPI Package](https://pypi.org/project/django-headless/)
|
|
250
|
+
- [Documentation](https:/djangoheadless.org)
|
|
251
|
+
- [GitHub Repository](https://github.com/StructuralRealist/django-headless)
|
|
252
|
+
- [Changelog](CHANGELOG.md)
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
Made in Europe 🇪🇺 with 💚 for Django
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
__title__ = "Django Headless"
|
|
2
|
+
__version__ = "1.0.0-beta.1"
|
|
3
|
+
__author__ = "Leon van der Grient"
|
|
4
|
+
__license__ = "MIT"
|
|
5
|
+
|
|
6
|
+
from typing import Type
|
|
7
|
+
|
|
8
|
+
from django.db import models
|
|
9
|
+
from .registry import headless_registry
|
|
10
|
+
|
|
11
|
+
# Version synonym
|
|
12
|
+
VERSION = __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def headless(singleton=False):
|
|
16
|
+
"""
|
|
17
|
+
Decorator to register a Django model to a registry.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
@headless()
|
|
21
|
+
class MyModel(models.Model):
|
|
22
|
+
pass
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def decorator(model_class: Type[models.Model]):
|
|
26
|
+
headless_registry.register(model_class, singleton=singleton)
|
|
27
|
+
|
|
28
|
+
return model_class
|
|
29
|
+
|
|
30
|
+
return decorator
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
from . import VERSION
|
|
4
|
+
from .utils import is_runserver, log
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DjangoHeadlessConfig(AppConfig):
|
|
8
|
+
name = "headless"
|
|
9
|
+
label = "headless"
|
|
10
|
+
|
|
11
|
+
def ready(self):
|
|
12
|
+
from .registry import headless_registry
|
|
13
|
+
|
|
14
|
+
if is_runserver():
|
|
15
|
+
log("\n")
|
|
16
|
+
log("----------------------------------------")
|
|
17
|
+
log("Django Headless")
|
|
18
|
+
log(f"Version {VERSION}")
|
|
19
|
+
log("----------------------------------------")
|
|
20
|
+
log(":mag:", "Registering models...")
|
|
21
|
+
log(
|
|
22
|
+
":white_check_mark:",
|
|
23
|
+
f"[green]Found {len(headless_registry)} registered models:[/green]",
|
|
24
|
+
)
|
|
25
|
+
for model_config in headless_registry.get_models():
|
|
26
|
+
model = model_config["model"]
|
|
27
|
+
print(f" - {model._meta.verbose_name} ({model.__name__})")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import Type, Dict, Optional, TypedDict
|
|
2
|
+
|
|
3
|
+
from django.db import models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ModelConfig(TypedDict):
|
|
7
|
+
model: Type[models.Model]
|
|
8
|
+
singleton: bool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HeadlessRegistry:
|
|
12
|
+
"""
|
|
13
|
+
A registry to store registered Django models.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self._models: Dict[str, ModelConfig] = {}
|
|
18
|
+
|
|
19
|
+
def register(self, model_class: Type[models.Model], singleton=False):
|
|
20
|
+
"""
|
|
21
|
+
Register a model in the registry.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
model_class: The Django model class to register
|
|
25
|
+
singleton: Whether the model should be registered as a singleton
|
|
26
|
+
"""
|
|
27
|
+
self._models[model_class._meta.label_lower] = {
|
|
28
|
+
"model": model_class,
|
|
29
|
+
"singleton": singleton,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def get_model(self, label: str) -> Optional[ModelConfig]:
|
|
33
|
+
"""
|
|
34
|
+
Get a model by label.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
label: The label of the model to get.
|
|
38
|
+
"""
|
|
39
|
+
return self._models.get(label.lower())
|
|
40
|
+
|
|
41
|
+
def get_models(self) -> list[ModelConfig]:
|
|
42
|
+
"""
|
|
43
|
+
Get all registered models.
|
|
44
|
+
"""
|
|
45
|
+
return list(self._models.values())
|
|
46
|
+
|
|
47
|
+
def __len__(self):
|
|
48
|
+
return len(self._models)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Create a default registry
|
|
52
|
+
headless_registry = HeadlessRegistry()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
default_app_config = "headless.rest.apps.DjangoHeadlessRestConfig"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
from ..utils import is_runserver, camel_to_kebab
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DjangoHeadlessRestConfig(AppConfig):
|
|
7
|
+
name = "headless.rest"
|
|
8
|
+
label = "headless_rest"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
from django.urls import path
|
|
12
|
+
from rest_framework import serializers
|
|
13
|
+
from rest_framework.viewsets import ModelViewSet
|
|
14
|
+
from ..registry import headless_registry
|
|
15
|
+
from ..utils import log
|
|
16
|
+
from .routers import rest_router
|
|
17
|
+
from .urls import urlpatterns
|
|
18
|
+
from .viewsets import SingletonViewSet
|
|
19
|
+
|
|
20
|
+
if is_runserver():
|
|
21
|
+
log(":building_construction:", "Setting up REST routes")
|
|
22
|
+
models = headless_registry.get_models()
|
|
23
|
+
|
|
24
|
+
for model_config in models:
|
|
25
|
+
model_class = model_config["model"]
|
|
26
|
+
singleton = model_config["singleton"]
|
|
27
|
+
base_path = camel_to_kebab(model_class.__name__)
|
|
28
|
+
|
|
29
|
+
class Serializer(serializers.ModelSerializer):
|
|
30
|
+
class Meta:
|
|
31
|
+
model = model_class
|
|
32
|
+
fields = "__all__"
|
|
33
|
+
|
|
34
|
+
if singleton:
|
|
35
|
+
|
|
36
|
+
class ViewSet(SingletonViewSet):
|
|
37
|
+
queryset = model_class.objects.first()
|
|
38
|
+
serializer_class = Serializer
|
|
39
|
+
|
|
40
|
+
log(" ---", f"{model_class._meta.verbose_name}")
|
|
41
|
+
log(" |---", f"GET /{base_path}")
|
|
42
|
+
log(" |---", f"PUT /{base_path}")
|
|
43
|
+
log(" |---", f"PATCH /{base_path}")
|
|
44
|
+
log("\n")
|
|
45
|
+
urlpatterns.append(
|
|
46
|
+
path(
|
|
47
|
+
base_path,
|
|
48
|
+
ViewSet.as_view(
|
|
49
|
+
{
|
|
50
|
+
"get": "retrieve",
|
|
51
|
+
"put": "update",
|
|
52
|
+
"patch": "partial_update",
|
|
53
|
+
}
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
else:
|
|
59
|
+
|
|
60
|
+
class ViewSet(ModelViewSet):
|
|
61
|
+
queryset = model_class.objects.all()
|
|
62
|
+
serializer_class = Serializer
|
|
63
|
+
|
|
64
|
+
log(" ---", f"{model_class._meta.verbose_name}")
|
|
65
|
+
log(" |--", f"GET /{base_path}")
|
|
66
|
+
log(" |--", f"GET /{base_path}/{{id}}")
|
|
67
|
+
log(" |--", f"PUT /{base_path}/{{id}}")
|
|
68
|
+
log(" |--", f"PATCH /{base_path}/{{id}}")
|
|
69
|
+
log(" |--", f"POST /{base_path}")
|
|
70
|
+
log(" |--", f"DELETE /{base_path}/{{id}}")
|
|
71
|
+
log("\n")
|
|
72
|
+
|
|
73
|
+
rest_router.register(base_path, ViewSet)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
|
|
3
|
+
from django.db import models
|
|
4
|
+
from rest_framework.exceptions import ParseError
|
|
5
|
+
from rest_framework.filters import BaseFilterBackend
|
|
6
|
+
|
|
7
|
+
from ..settings import headless_settings
|
|
8
|
+
from ..utils import flatten
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LookupFilter(BaseFilterBackend):
|
|
12
|
+
"""
|
|
13
|
+
A permissive filter backend that basically allows every supported lookup
|
|
14
|
+
for a given field. Automatically handles multi-value lookups and booleans.
|
|
15
|
+
Also supports exclusion.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
MULTI_VALUE_LOOKUPS = ["in", "range"]
|
|
19
|
+
|
|
20
|
+
EXCLUDE_SYMBOL = headless_settings.FILTER_EXCLUSION_SYMBOL
|
|
21
|
+
|
|
22
|
+
NON_FILTER_FIELDS = headless_settings.NON_FILTER_FIELDS
|
|
23
|
+
|
|
24
|
+
def filter_queryset(self, request, queryset, view):
|
|
25
|
+
"""
|
|
26
|
+
Build the queryset based on the query params and the view's model.
|
|
27
|
+
Only apply filters in list endpoints.
|
|
28
|
+
"""
|
|
29
|
+
if getattr(view, "action", None) == "list":
|
|
30
|
+
try:
|
|
31
|
+
filter_kwargs, exclude_kwargs = self.get_filter_kwargs(
|
|
32
|
+
model_class=view.queryset.model,
|
|
33
|
+
query_params=request.query_params,
|
|
34
|
+
)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print(e)
|
|
37
|
+
raise ParseError(detail="Invalid filter parameters")
|
|
38
|
+
return queryset.filter(**filter_kwargs).exclude(**exclude_kwargs).distinct()
|
|
39
|
+
|
|
40
|
+
return queryset
|
|
41
|
+
|
|
42
|
+
def get_filter_kwargs(self, model_class, query_params):
|
|
43
|
+
filter_kwargs = {}
|
|
44
|
+
exclude_kwargs = {}
|
|
45
|
+
|
|
46
|
+
field_lookups = self.get_field_lookups(model_class=model_class)
|
|
47
|
+
|
|
48
|
+
for key, value in query_params.lists():
|
|
49
|
+
if key in self.NON_FILTER_FIELDS:
|
|
50
|
+
continue
|
|
51
|
+
# By default Django supports repeated multi-values (e.g. `a=1&a=2`)
|
|
52
|
+
# but we allow for comma-seperated multi-values as well (e.g. `a=1,2`).
|
|
53
|
+
value = flatten([param.split(",") for param in value])
|
|
54
|
+
is_exclude = key.startswith(self.EXCLUDE_SYMBOL)
|
|
55
|
+
# The first part of a key is considered the field name
|
|
56
|
+
field_name = key.split("__")[0]
|
|
57
|
+
# Get the model field.
|
|
58
|
+
field = model_class._meta.get_field(field_name)
|
|
59
|
+
# Get the allowed lookups for this field.
|
|
60
|
+
lookups = field_lookups.get(field_name, [])
|
|
61
|
+
try:
|
|
62
|
+
# The last part of a key is considered its lookup
|
|
63
|
+
# but it's not required.
|
|
64
|
+
lookup = key.split("__")[-1]
|
|
65
|
+
if lookup not in lookups:
|
|
66
|
+
lookup = None
|
|
67
|
+
except IndexError:
|
|
68
|
+
lookup = None
|
|
69
|
+
|
|
70
|
+
# Some lookups allow multiple values, otherwise
|
|
71
|
+
# the first value is used.
|
|
72
|
+
is_multi = lookup in self.MULTI_VALUE_LOOKUPS
|
|
73
|
+
# Depending on the field type we cast the value to
|
|
74
|
+
# its correct type (i.e. number, boolean, etc.).
|
|
75
|
+
if is_multi:
|
|
76
|
+
casted_value = [self.cast_field_value(v, field) for v in value]
|
|
77
|
+
elif lookup == "isnull":
|
|
78
|
+
casted_value = value[0] in ["1", "true", "on"]
|
|
79
|
+
else:
|
|
80
|
+
casted_value = self.cast_field_value(value[0], field)
|
|
81
|
+
|
|
82
|
+
if is_exclude:
|
|
83
|
+
exclude_kwargs[key] = casted_value
|
|
84
|
+
else:
|
|
85
|
+
filter_kwargs[key] = casted_value
|
|
86
|
+
|
|
87
|
+
return filter_kwargs, exclude_kwargs
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def get_field_lookups(model_class):
|
|
91
|
+
"""
|
|
92
|
+
Allow all supported lookups.
|
|
93
|
+
"""
|
|
94
|
+
field_lookups = {}
|
|
95
|
+
for model_field in model_class._meta.get_fields():
|
|
96
|
+
lookup_list = model_field.get_lookups().keys()
|
|
97
|
+
field_lookups[model_field.name] = lookup_list
|
|
98
|
+
return field_lookups
|
|
99
|
+
|
|
100
|
+
def cast_field_value(self, value: str, field):
|
|
101
|
+
value = value.strip().lower()
|
|
102
|
+
|
|
103
|
+
if isinstance(field, models.BooleanField):
|
|
104
|
+
if value in ["1", "true", "on"]:
|
|
105
|
+
return True
|
|
106
|
+
if value in ["0", "false", "off"]:
|
|
107
|
+
return False
|
|
108
|
+
if isinstance(field, models.NullBooleanField):
|
|
109
|
+
if value in ["null", "none", "empty"]:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
if isinstance(field, models.IntegerField):
|
|
113
|
+
return int(value)
|
|
114
|
+
|
|
115
|
+
if isinstance(field, models.DecimalField):
|
|
116
|
+
return Decimal(value)
|
|
117
|
+
|
|
118
|
+
if isinstance(field, models.FloatField):
|
|
119
|
+
return float(value)
|
|
120
|
+
|
|
121
|
+
return value
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from rest_framework.exceptions import NotFound
|
|
2
|
+
from rest_framework.viewsets import GenericViewSet
|
|
3
|
+
from rest_framework.mixins import (
|
|
4
|
+
CreateModelMixin,
|
|
5
|
+
UpdateModelMixin,
|
|
6
|
+
RetrieveModelMixin,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SingletonViewSet(
|
|
11
|
+
GenericViewSet,
|
|
12
|
+
CreateModelMixin,
|
|
13
|
+
UpdateModelMixin,
|
|
14
|
+
RetrieveModelMixin,
|
|
15
|
+
):
|
|
16
|
+
"""
|
|
17
|
+
A model view set for singleton objects.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def update(self, request, *args, **kwargs):
|
|
21
|
+
"""
|
|
22
|
+
If a singleton doesn't exist, it will be created.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
return super().update(request, *args, **kwargs)
|
|
26
|
+
except NotFound:
|
|
27
|
+
return self.create(request, *args, **kwargs)
|
|
28
|
+
|
|
29
|
+
def get_object(self):
|
|
30
|
+
"""
|
|
31
|
+
Get the first object of the queryset (assuming there is only one object).
|
|
32
|
+
If a singleton doesn't exist, it will raise a NotFound exception.
|
|
33
|
+
"""
|
|
34
|
+
obj = self.get_queryset().first()
|
|
35
|
+
|
|
36
|
+
if not obj:
|
|
37
|
+
raise NotFound()
|
|
38
|
+
|
|
39
|
+
self.check_object_permissions(self.request, obj)
|
|
40
|
+
|
|
41
|
+
return obj
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Settings for Django Headless are all namespaced in the HEADLESS setting.
|
|
3
|
+
For example, your project's `settings.py` file might look like this:
|
|
4
|
+
|
|
5
|
+
HEADLESS = {
|
|
6
|
+
'FILTER_EXCLUSION_SYMBOL': 'exclude_'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
This module provides the `headless_settings` object, that is used to access
|
|
10
|
+
Django Headless settings, checking for user settings first, then falling
|
|
11
|
+
back to the defaults.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from django.conf import settings
|
|
15
|
+
|
|
16
|
+
from django.core.signals import setting_changed
|
|
17
|
+
from django.utils.module_loading import import_string
|
|
18
|
+
|
|
19
|
+
SETTINGS_NAMESPACE = "HEADLESS"
|
|
20
|
+
|
|
21
|
+
DEFAULTS = {
|
|
22
|
+
"NON_FILTER_FIELDS": [
|
|
23
|
+
"search",
|
|
24
|
+
"limit",
|
|
25
|
+
"page",
|
|
26
|
+
"fields",
|
|
27
|
+
"exclude",
|
|
28
|
+
"expand",
|
|
29
|
+
],
|
|
30
|
+
"FILTER_EXCLUSION_SYMBOL": "~",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# List of settings that may be in string import notation.
|
|
35
|
+
IMPORT_STRINGS = []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# List of settings that have been removed
|
|
39
|
+
REMOVED_SETTINGS = []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def perform_import(val, setting_name):
|
|
43
|
+
"""
|
|
44
|
+
If the given setting is a string import notation,
|
|
45
|
+
then perform the necessary import or imports.
|
|
46
|
+
"""
|
|
47
|
+
if val is None:
|
|
48
|
+
return None
|
|
49
|
+
elif isinstance(val, str):
|
|
50
|
+
return import_from_string(val, setting_name)
|
|
51
|
+
elif isinstance(val, (list, tuple)):
|
|
52
|
+
return [import_from_string(item, setting_name) for item in val]
|
|
53
|
+
return val
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def import_from_string(val, setting_name):
|
|
57
|
+
"""
|
|
58
|
+
Attempt to import a class from a string representation.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
return import_string(val)
|
|
62
|
+
except ImportError as e:
|
|
63
|
+
msg = "Could not import '%s' for Headless setting '%s'. %s: %s." % (
|
|
64
|
+
val,
|
|
65
|
+
setting_name,
|
|
66
|
+
e.__class__.__name__,
|
|
67
|
+
e,
|
|
68
|
+
)
|
|
69
|
+
raise ImportError(msg)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class HeadlessSettings:
|
|
73
|
+
"""
|
|
74
|
+
A settings object that allows Django Headless settings to be accessed as
|
|
75
|
+
properties. For example:
|
|
76
|
+
|
|
77
|
+
from headless.settings import headless_settings
|
|
78
|
+
print(headless_settings.SINGLETON_ATTR)
|
|
79
|
+
|
|
80
|
+
Any setting with string import paths will be automatically resolved
|
|
81
|
+
and return the class, rather than the string literal.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, user_settings=None, defaults=None, import_strings=None):
|
|
85
|
+
if user_settings:
|
|
86
|
+
self._user_settings = self.__check_user_settings(user_settings)
|
|
87
|
+
self.defaults = defaults or DEFAULTS
|
|
88
|
+
self.import_strings = import_strings or IMPORT_STRINGS
|
|
89
|
+
self._cached_attrs = set()
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def user_settings(self):
|
|
93
|
+
if not hasattr(self, "_user_settings"):
|
|
94
|
+
self._user_settings = getattr(settings, SETTINGS_NAMESPACE, {})
|
|
95
|
+
return self._user_settings
|
|
96
|
+
|
|
97
|
+
def __getattr__(self, attr):
|
|
98
|
+
if attr not in self.defaults:
|
|
99
|
+
raise AttributeError("Invalid Headless setting: '%s'" % attr)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
# Check if present in user settings
|
|
103
|
+
val = self.user_settings[attr]
|
|
104
|
+
except KeyError:
|
|
105
|
+
# Fall back to defaults
|
|
106
|
+
val = self.defaults[attr]
|
|
107
|
+
|
|
108
|
+
# Coerce import strings into classes
|
|
109
|
+
if attr in self.import_strings:
|
|
110
|
+
val = perform_import(val, attr)
|
|
111
|
+
|
|
112
|
+
# Cache the result
|
|
113
|
+
self._cached_attrs.add(attr)
|
|
114
|
+
setattr(self, attr, val)
|
|
115
|
+
return val
|
|
116
|
+
|
|
117
|
+
def __check_user_settings(self, user_settings):
|
|
118
|
+
SETTINGS_DOC = "https://www.djangoheadless.org/"
|
|
119
|
+
for setting in REMOVED_SETTINGS:
|
|
120
|
+
if setting in user_settings:
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
"The '%s' setting has been removed. Please refer to '%s' for available settings."
|
|
123
|
+
% (setting, SETTINGS_DOC)
|
|
124
|
+
)
|
|
125
|
+
return user_settings
|
|
126
|
+
|
|
127
|
+
def reload(self):
|
|
128
|
+
for attr in self._cached_attrs:
|
|
129
|
+
delattr(self, attr)
|
|
130
|
+
self._cached_attrs.clear()
|
|
131
|
+
if hasattr(self, "_user_settings"):
|
|
132
|
+
delattr(self, "_user_settings")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
headless_settings = HeadlessSettings(None, DEFAULTS, IMPORT_STRINGS)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def reload_settings(*args, **kwargs):
|
|
139
|
+
setting = kwargs["setting"]
|
|
140
|
+
if setting == SETTINGS_NAMESPACE:
|
|
141
|
+
headless_settings.reload()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
setting_changed.connect(reload_settings)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Create your tests here.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def log(*args, **kwargs):
|
|
11
|
+
console.print(*args, **kwargs)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_jsonable(x):
|
|
15
|
+
try:
|
|
16
|
+
json.dumps(x)
|
|
17
|
+
return True
|
|
18
|
+
except (TypeError, OverflowError):
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_runserver():
|
|
23
|
+
"""
|
|
24
|
+
Checks if the Django application is started as a server.
|
|
25
|
+
We'll also assume it started if manage.py is not used (e.g. when Django is started using wsgi/asgi).
|
|
26
|
+
The main purpose of this check is to not run certain code on other management commands such
|
|
27
|
+
as `migrate`.
|
|
28
|
+
"""
|
|
29
|
+
is_manage_cmd = sys.argv[0].endswith("/manage.py")
|
|
30
|
+
|
|
31
|
+
return not is_manage_cmd or sys.argv[1] == "runserver"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def flatten(xss):
|
|
35
|
+
return [x for xs in xss for x in xs]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def camel_to_kebab(text: str) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Simpler function that handles basic PascalCase/camelCase conversion.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
text (str): The input string in PascalCase or camelCase
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
str: The converted string in kebab-case
|
|
47
|
+
"""
|
|
48
|
+
if not text:
|
|
49
|
+
return text
|
|
50
|
+
|
|
51
|
+
# Insert hyphen before any uppercase letter that follows a lowercase letter
|
|
52
|
+
result = re.sub(r"([a-z])([A-Z])", r"\1-\2", text)
|
|
53
|
+
return result.lower()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "django-headless"
|
|
3
|
+
version = "1.0.0-beta.1"
|
|
4
|
+
description = "Automagically create a REST API for you Django models"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Leon van der Grient",email = "leon@devtastic.io"}
|
|
7
|
+
]
|
|
8
|
+
license = {text = "MIT"}
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"rich"
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.poetry]
|
|
16
|
+
packages = [{include = "headless"}]
|
|
17
|
+
|
|
18
|
+
[tool.poetry.group.dev.dependencies]
|
|
19
|
+
black = "^25.1.0"
|
|
20
|
+
Django= "^5.2.4"
|
|
21
|
+
djangorestframework= "^3.16.0"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
25
|
+
build-backend = "poetry.core.masonry.api"
|