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.
@@ -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
+ [![PyPI version](https://badge.fury.io/py/django-headless.svg)](https://badge.fury.io/py/django-headless)
21
+ [![Python versions](https://img.shields.io/pypi/pyversions/django-headless.svg)](https://pypi.org/project/django-headless/)
22
+ [![Django versions](https://img.shields.io/badge/django-3.10%2B-blue.svg)](https://www.djangoproject.com/)
23
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](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
+ [![PyPI version](https://badge.fury.io/py/django-headless.svg)](https://badge.fury.io/py/django-headless)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/django-headless.svg)](https://pypi.org/project/django-headless/)
5
+ [![Django versions](https://img.shields.io/badge/django-3.10%2B-blue.svg)](https://www.djangoproject.com/)
6
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](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,3 @@
1
+ from rest_framework.routers import DefaultRouter
2
+
3
+ rest_router = DefaultRouter(trailing_slash=False)
@@ -0,0 +1,3 @@
1
+ from .routers import rest_router
2
+
3
+ urlpatterns = rest_router.urls
@@ -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"