django-conjure 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_conjure-0.1.0/.gitignore +46 -0
- django_conjure-0.1.0/LICENSE +21 -0
- django_conjure-0.1.0/PKG-INFO +126 -0
- django_conjure-0.1.0/README.md +85 -0
- django_conjure-0.1.0/conjure/__init__.py +19 -0
- django_conjure-0.1.0/conjure/admin_config.py +12 -0
- django_conjure-0.1.0/conjure/apps.py +18 -0
- django_conjure-0.1.0/conjure/audit.py +55 -0
- django_conjure-0.1.0/conjure/auth.py +123 -0
- django_conjure-0.1.0/conjure/conf.py +90 -0
- django_conjure-0.1.0/conjure/discovery.py +14 -0
- django_conjure-0.1.0/conjure/management/__init__.py +0 -0
- django_conjure-0.1.0/conjure/management/commands/__init__.py +0 -0
- django_conjure-0.1.0/conjure/management/commands/conjure_dump_schema.py +44 -0
- django_conjure-0.1.0/conjure/migrations/0001_initial.py +69 -0
- django_conjure-0.1.0/conjure/migrations/__init__.py +0 -0
- django_conjure-0.1.0/conjure/mixins.py +35 -0
- django_conjure-0.1.0/conjure/models.py +56 -0
- django_conjure-0.1.0/conjure/permissions.py +59 -0
- django_conjure-0.1.0/conjure/registry.py +130 -0
- django_conjure-0.1.0/conjure/schema.py +138 -0
- django_conjure-0.1.0/conjure/serializers.py +71 -0
- django_conjure-0.1.0/conjure/static/conjure/PLACEHOLDER.txt +3 -0
- django_conjure-0.1.0/conjure/urls.py +33 -0
- django_conjure-0.1.0/conjure/viewsets.py +344 -0
- django_conjure-0.1.0/conjure/widgets.py +81 -0
- django_conjure-0.1.0/pyproject.toml +78 -0
- django_conjure-0.1.0/tests/__init__.py +0 -0
- django_conjure-0.1.0/tests/conftest.py +11 -0
- django_conjure-0.1.0/tests/settings.py +35 -0
- django_conjure-0.1.0/tests/test_auth_widgets.py +93 -0
- django_conjure-0.1.0/tests/test_commands.py +31 -0
- django_conjure-0.1.0/tests/test_crud.py +87 -0
- django_conjure-0.1.0/tests/test_schema.py +42 -0
- django_conjure-0.1.0/tests/testapp/__init__.py +0 -0
- django_conjure-0.1.0/tests/testapp/admin_config.py +26 -0
- django_conjure-0.1.0/tests/testapp/apps.py +7 -0
- django_conjure-0.1.0/tests/testapp/models.py +45 -0
- django_conjure-0.1.0/tests/urls.py +5 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
*.egg
|
|
6
|
+
.eggs/
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
.tox/
|
|
10
|
+
.nox/
|
|
11
|
+
.coverage
|
|
12
|
+
.coverage.*
|
|
13
|
+
htmlcov/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
.venv/
|
|
18
|
+
venv/
|
|
19
|
+
*.sqlite3
|
|
20
|
+
db.sqlite3
|
|
21
|
+
|
|
22
|
+
# Node
|
|
23
|
+
node_modules/
|
|
24
|
+
.pnpm-store/
|
|
25
|
+
*.tsbuildinfo
|
|
26
|
+
.vite/
|
|
27
|
+
.astro/
|
|
28
|
+
|
|
29
|
+
# Build outputs
|
|
30
|
+
apps/landing/dist/
|
|
31
|
+
apps/docs/site/
|
|
32
|
+
packages/web/dist/
|
|
33
|
+
packages/conjure/conjure/static/conjure/assets/
|
|
34
|
+
|
|
35
|
+
# Env
|
|
36
|
+
.env
|
|
37
|
+
.env.local
|
|
38
|
+
.env.*.local
|
|
39
|
+
!.env.example
|
|
40
|
+
|
|
41
|
+
# OS / editor
|
|
42
|
+
.DS_Store
|
|
43
|
+
.idea/
|
|
44
|
+
.vscode/*
|
|
45
|
+
!.vscode/extensions.json
|
|
46
|
+
*.swp
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Terrace Lab
|
|
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,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-conjure
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Conjure your Django admin — read your models, summon a CRUD API and dashboard.
|
|
5
|
+
Project-URL: Homepage, https://conjure.terracelab.dev
|
|
6
|
+
Project-URL: Documentation, https://docs.conjure.terracelab.dev
|
|
7
|
+
Project-URL: Repository, https://github.com/terracelab/django-conjure
|
|
8
|
+
Project-URL: Changelog, https://github.com/terracelab/django-conjure/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/terracelab/django-conjure/issues
|
|
10
|
+
Author-email: Terrace Lab <hello@terracelab.dev>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: admin,codegen,crud,dashboard,django,introspection,rest
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Web Environment
|
|
16
|
+
Classifier: Framework :: Django
|
|
17
|
+
Classifier: Framework :: Django :: 4.2
|
|
18
|
+
Classifier: Framework :: Django :: 5.0
|
|
19
|
+
Classifier: Framework :: Django :: 5.1
|
|
20
|
+
Classifier: Intended Audience :: Developers
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: django>=4.2
|
|
29
|
+
Requires-Dist: djangorestframework>=3.15
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
32
|
+
Requires-Dist: django>=4.2; extra == 'dev'
|
|
33
|
+
Requires-Dist: djangorestframework-simplejwt>=5.3; extra == 'dev'
|
|
34
|
+
Requires-Dist: djangorestframework>=3.15; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest-django>=4.8; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
38
|
+
Provides-Extra: jwt
|
|
39
|
+
Requires-Dist: djangorestframework-simplejwt>=5.3; extra == 'jwt'
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
# django-conjure
|
|
43
|
+
|
|
44
|
+
> Conjure your Django admin — read your models, summon a CRUD API + schema.
|
|
45
|
+
|
|
46
|
+
The Python package behind [Conjure](https://conjure.terracelab.dev). It introspects your
|
|
47
|
+
models and serves a generic admin **REST API** (`/conjure/`) plus a schema endpoint that a
|
|
48
|
+
frontend — or codegen — can read. A matching React dashboard lives in the repo and is in
|
|
49
|
+
active development; for the `0.1.x` line, point any client at the API below.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install django-conjure # session auth (default)
|
|
55
|
+
pip install "django-conjure[jwt]" # add JWT auth mode
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# settings.py
|
|
60
|
+
INSTALLED_APPS += ["conjure"]
|
|
61
|
+
|
|
62
|
+
CONJURE = {
|
|
63
|
+
"AUTH": "session", # or "jwt"
|
|
64
|
+
"BRAND": {"name": "My Admin", "accent": "#4f46e5"},
|
|
65
|
+
# "USER_PAYLOAD": "myapp.hooks.payload", # custom user model? return any dict
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
# urls.py
|
|
71
|
+
urlpatterns += [path("conjure/", include("conjure.urls"))]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
python manage.py migrate conjure # AdminAuditLog table (audit log, optional but recommended)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Register a model
|
|
79
|
+
|
|
80
|
+
Drop an `admin_config.py` into any app — it's discovered automatically, like `admin.py`:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from conjure import register, AdminConfig
|
|
84
|
+
from myapp.models import Product
|
|
85
|
+
|
|
86
|
+
@register(Product)
|
|
87
|
+
class ProductConfig(AdminConfig):
|
|
88
|
+
list_display = ["name", "price", "is_active"]
|
|
89
|
+
search_fields = ["name"]
|
|
90
|
+
list_filter = ["is_active"]
|
|
91
|
+
inlines = [(ProductImage, "product")] # inline child editing
|
|
92
|
+
is_readonly = False # True => log/history models, blocks writes
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Anything you leave unset is inferred from the model's fields.
|
|
96
|
+
|
|
97
|
+
## What you get
|
|
98
|
+
|
|
99
|
+
- `GET /conjure/schema/` + `…/schema/{app.Model}/` — model introspection (codegen + runtime source)
|
|
100
|
+
- `GET/POST /conjure/r/{app.Model}/` and `…/{pk}/` — generic CRUD
|
|
101
|
+
- `…/autocomplete/`, `…/bulk/` (atomic inline ops), `…/{pk}/related/` (delete impact)
|
|
102
|
+
- Django-permission gating (`view/add/change/delete`, shared with Django admin), `is_staff` required
|
|
103
|
+
- Audit log with before/after diff, staff auth (session or JWT), dashboard widget registry
|
|
104
|
+
|
|
105
|
+
## Extend without forking
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from conjure import register_widget
|
|
109
|
+
|
|
110
|
+
@register_widget("signup-trend")
|
|
111
|
+
def signup_trend(request):
|
|
112
|
+
... # return any JSON; served at /conjure/widgets/signup-trend/
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Full docs, the settings reference, the REST contract, and the extension SDK live at
|
|
116
|
+
**[docs.conjure.terracelab.dev](https://docs.conjure.terracelab.dev)**.
|
|
117
|
+
|
|
118
|
+
## Develop
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pip install -e ".[dev]"
|
|
122
|
+
pytest
|
|
123
|
+
ruff check . && ruff format --check .
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
MIT © Terrace Lab
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# django-conjure
|
|
2
|
+
|
|
3
|
+
> Conjure your Django admin — read your models, summon a CRUD API + schema.
|
|
4
|
+
|
|
5
|
+
The Python package behind [Conjure](https://conjure.terracelab.dev). It introspects your
|
|
6
|
+
models and serves a generic admin **REST API** (`/conjure/`) plus a schema endpoint that a
|
|
7
|
+
frontend — or codegen — can read. A matching React dashboard lives in the repo and is in
|
|
8
|
+
active development; for the `0.1.x` line, point any client at the API below.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install django-conjure # session auth (default)
|
|
14
|
+
pip install "django-conjure[jwt]" # add JWT auth mode
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
# settings.py
|
|
19
|
+
INSTALLED_APPS += ["conjure"]
|
|
20
|
+
|
|
21
|
+
CONJURE = {
|
|
22
|
+
"AUTH": "session", # or "jwt"
|
|
23
|
+
"BRAND": {"name": "My Admin", "accent": "#4f46e5"},
|
|
24
|
+
# "USER_PAYLOAD": "myapp.hooks.payload", # custom user model? return any dict
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# urls.py
|
|
30
|
+
urlpatterns += [path("conjure/", include("conjure.urls"))]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python manage.py migrate conjure # AdminAuditLog table (audit log, optional but recommended)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Register a model
|
|
38
|
+
|
|
39
|
+
Drop an `admin_config.py` into any app — it's discovered automatically, like `admin.py`:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from conjure import register, AdminConfig
|
|
43
|
+
from myapp.models import Product
|
|
44
|
+
|
|
45
|
+
@register(Product)
|
|
46
|
+
class ProductConfig(AdminConfig):
|
|
47
|
+
list_display = ["name", "price", "is_active"]
|
|
48
|
+
search_fields = ["name"]
|
|
49
|
+
list_filter = ["is_active"]
|
|
50
|
+
inlines = [(ProductImage, "product")] # inline child editing
|
|
51
|
+
is_readonly = False # True => log/history models, blocks writes
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Anything you leave unset is inferred from the model's fields.
|
|
55
|
+
|
|
56
|
+
## What you get
|
|
57
|
+
|
|
58
|
+
- `GET /conjure/schema/` + `…/schema/{app.Model}/` — model introspection (codegen + runtime source)
|
|
59
|
+
- `GET/POST /conjure/r/{app.Model}/` and `…/{pk}/` — generic CRUD
|
|
60
|
+
- `…/autocomplete/`, `…/bulk/` (atomic inline ops), `…/{pk}/related/` (delete impact)
|
|
61
|
+
- Django-permission gating (`view/add/change/delete`, shared with Django admin), `is_staff` required
|
|
62
|
+
- Audit log with before/after diff, staff auth (session or JWT), dashboard widget registry
|
|
63
|
+
|
|
64
|
+
## Extend without forking
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from conjure import register_widget
|
|
68
|
+
|
|
69
|
+
@register_widget("signup-trend")
|
|
70
|
+
def signup_trend(request):
|
|
71
|
+
... # return any JSON; served at /conjure/widgets/signup-trend/
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Full docs, the settings reference, the REST contract, and the extension SDK live at
|
|
75
|
+
**[docs.conjure.terracelab.dev](https://docs.conjure.terracelab.dev)**.
|
|
76
|
+
|
|
77
|
+
## Develop
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install -e ".[dev]"
|
|
81
|
+
pytest
|
|
82
|
+
ruff check . && ruff format --check .
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
MIT © Terrace Lab
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Conjure — conjure your Django admin.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
from conjure import register, AdminConfig
|
|
5
|
+
from conjure import register_widget
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
from conjure.registry import AdminConfig, admin_register, register, registry
|
|
11
|
+
from conjure.widgets import register_widget
|
|
12
|
+
|
|
13
|
+
__all__ = ["AdminConfig", "registry", "register", "admin_register", "register_widget"]
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
# Single source of truth = the installed package metadata (pyproject version).
|
|
17
|
+
__version__ = version("django-conjure")
|
|
18
|
+
except PackageNotFoundError: # running from source without an install
|
|
19
|
+
__version__ = "0.0.0+local"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Conjure registers its own audit-log model (read-only) so the dashboard's audit page works."""
|
|
2
|
+
|
|
3
|
+
from conjure import AdminConfig, register
|
|
4
|
+
from conjure.models import AdminAuditLog
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register(AdminAuditLog)
|
|
8
|
+
class AdminAuditLogConfig(AdminConfig):
|
|
9
|
+
is_readonly = True
|
|
10
|
+
list_display = ["id", "created_at", "actor", "action", "model_label", "object_pk", "object_repr"]
|
|
11
|
+
search_fields = ["model_label", "object_repr", "object_pk"]
|
|
12
|
+
list_filter = ["action"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ConjureConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "conjure"
|
|
7
|
+
verbose_name = "Conjure"
|
|
8
|
+
|
|
9
|
+
def ready(self):
|
|
10
|
+
# Register built-in widgets.
|
|
11
|
+
from conjure import widgets # noqa: F401
|
|
12
|
+
from conjure.conf import conjure_settings
|
|
13
|
+
|
|
14
|
+
# Discover each app's admin_config module (like admin.autodiscover()).
|
|
15
|
+
if conjure_settings.AUTODISCOVER:
|
|
16
|
+
from conjure.discovery import autodiscover
|
|
17
|
+
|
|
18
|
+
autodiscover()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Domain: conjure / audit log
|
|
3
|
+
@BusinessLogic: Helper to record an audit entry for each write. CRUD must keep working even when
|
|
4
|
+
the AdminAuditLog table hasn't been migrated yet, so write failures are swallowed and logged.
|
|
5
|
+
@Context: DECOUPLING — uses the standard library ``logging`` (logger name from settings) instead
|
|
6
|
+
of any project-specific logger. Honors CONJURE["AUDIT"] (off => no-op).
|
|
7
|
+
@Connection: conjure/viewsets.py, conjure/models.py, conjure/conf.py
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from conjure.conf import conjure_settings
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(conjure_settings.LOGGER_NAME)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_client_ip(request):
|
|
18
|
+
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
19
|
+
if forwarded:
|
|
20
|
+
return forwarded.split(",")[0].strip()
|
|
21
|
+
return request.META.get("REMOTE_ADDR")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_diff(before, after):
|
|
25
|
+
"""Compare before/after serializer dicts -> {field: [old, new]}. Skips internal fields."""
|
|
26
|
+
diff = {}
|
|
27
|
+
keys = set(before.keys()) | set(after.keys())
|
|
28
|
+
for key in keys:
|
|
29
|
+
if key.startswith("_") or key.endswith("_label") or key.endswith("_display"):
|
|
30
|
+
continue
|
|
31
|
+
b, a = before.get(key), after.get(key)
|
|
32
|
+
if b != a:
|
|
33
|
+
diff[key] = [b, a]
|
|
34
|
+
return diff
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def record(request, model_label, object_pk, action, object_repr="", diff=None):
|
|
38
|
+
"""Record one audit entry — failure here never blocks the underlying write."""
|
|
39
|
+
if not conjure_settings.AUDIT:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
from conjure.models import AdminAuditLog
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
AdminAuditLog.objects.create(
|
|
46
|
+
actor=request.user if request.user.is_authenticated else None,
|
|
47
|
+
model_label=model_label,
|
|
48
|
+
object_pk=str(object_pk),
|
|
49
|
+
object_repr=str(object_repr)[:200],
|
|
50
|
+
action=action,
|
|
51
|
+
diff=diff or None,
|
|
52
|
+
ip=get_client_ip(request),
|
|
53
|
+
)
|
|
54
|
+
except Exception: # table not migrated yet, etc. — auditing must not break CRUD
|
|
55
|
+
logger.exception("[conjure] failed to write audit log")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Domain: conjure / auth
|
|
3
|
+
@BusinessLogic: Staff-only login for the admin frontend. Two modes selected at request time by
|
|
4
|
+
CONJURE["AUTH"]: "session" (reuse Django's session login — works in any project, the default)
|
|
5
|
+
and "jwt" (SimpleJWT, requires the optional [jwt] extra). Non-staff accounts are always
|
|
6
|
+
rejected. The login/me response body comes from CONJURE["USER_PAYLOAD"].
|
|
7
|
+
@Context: DECOUPLING — the payload is a configurable callable defaulting to get_username()/
|
|
8
|
+
get_full_name(), so it never assumes project-specific user fields (e.g. nickname/real_name).
|
|
9
|
+
@Connection: conjure/urls.py, conjure/conf.py, packages/web/src/lib/auth.ts
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from django.contrib.auth import authenticate
|
|
13
|
+
from django.contrib.auth import login as django_login
|
|
14
|
+
from django.contrib.auth import logout as django_logout
|
|
15
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
16
|
+
from django.http import Http404
|
|
17
|
+
from rest_framework import status
|
|
18
|
+
from rest_framework.exceptions import AuthenticationFailed
|
|
19
|
+
from rest_framework.permissions import AllowAny
|
|
20
|
+
from rest_framework.response import Response
|
|
21
|
+
from rest_framework.views import APIView
|
|
22
|
+
|
|
23
|
+
from conjure.conf import conjure_settings
|
|
24
|
+
from conjure.mixins import ConjureAuthMixin
|
|
25
|
+
from conjure.permissions import IsStaffUser
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_user_payload(user):
|
|
29
|
+
"""Project-agnostic default — only uses the User API guaranteed by contrib.auth."""
|
|
30
|
+
full_name = (user.get_full_name() or "").strip()
|
|
31
|
+
return {
|
|
32
|
+
"id": user.pk,
|
|
33
|
+
"username": user.get_username(),
|
|
34
|
+
"name": full_name or user.get_username(),
|
|
35
|
+
"is_superuser": user.is_superuser,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def user_payload(user):
|
|
40
|
+
fn = conjure_settings.USER_PAYLOAD
|
|
41
|
+
return fn(user) if callable(fn) else default_user_payload(user)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _require_staff(user):
|
|
45
|
+
if not (user and user.is_active and user.is_staff):
|
|
46
|
+
raise AuthenticationFailed("This account has no admin access.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _jwt_module():
|
|
50
|
+
try:
|
|
51
|
+
import rest_framework_simplejwt.serializers as ser
|
|
52
|
+
except ImportError as e: # pragma: no cover - guarded path
|
|
53
|
+
raise ImproperlyConfigured(
|
|
54
|
+
"CONJURE['AUTH'] == 'jwt' requires the optional dependency: pip install 'django-conjure[jwt]'"
|
|
55
|
+
) from e
|
|
56
|
+
return ser
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LoginView(APIView):
|
|
60
|
+
"""POST /auth/login/ — dispatches on CONJURE['AUTH']."""
|
|
61
|
+
|
|
62
|
+
permission_classes = [AllowAny]
|
|
63
|
+
authentication_classes = []
|
|
64
|
+
swagger_schema = None
|
|
65
|
+
|
|
66
|
+
def post(self, request):
|
|
67
|
+
if conjure_settings.AUTH == "jwt":
|
|
68
|
+
serializer_cls = _jwt_module().TokenObtainPairSerializer
|
|
69
|
+
serializer = serializer_cls(data=request.data, context={"request": request})
|
|
70
|
+
serializer.is_valid(raise_exception=True)
|
|
71
|
+
_require_staff(serializer.user)
|
|
72
|
+
data = dict(serializer.validated_data)
|
|
73
|
+
data["user"] = user_payload(serializer.user)
|
|
74
|
+
return Response(data)
|
|
75
|
+
|
|
76
|
+
# session mode
|
|
77
|
+
user = authenticate(
|
|
78
|
+
request._request,
|
|
79
|
+
username=request.data.get("username"),
|
|
80
|
+
password=request.data.get("password"),
|
|
81
|
+
)
|
|
82
|
+
if user is None:
|
|
83
|
+
raise AuthenticationFailed("Invalid credentials.")
|
|
84
|
+
_require_staff(user)
|
|
85
|
+
django_login(request._request, user)
|
|
86
|
+
return Response({"user": user_payload(user)})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RefreshView(APIView):
|
|
90
|
+
"""POST /auth/refresh/ — JWT mode only."""
|
|
91
|
+
|
|
92
|
+
permission_classes = [AllowAny]
|
|
93
|
+
authentication_classes = []
|
|
94
|
+
swagger_schema = None
|
|
95
|
+
|
|
96
|
+
def post(self, request):
|
|
97
|
+
if conjure_settings.AUTH != "jwt":
|
|
98
|
+
raise Http404
|
|
99
|
+
serializer = _jwt_module().TokenRefreshSerializer(data=request.data)
|
|
100
|
+
serializer.is_valid(raise_exception=True)
|
|
101
|
+
return Response(serializer.validated_data)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class LogoutView(ConjureAuthMixin, APIView):
|
|
105
|
+
"""POST /auth/logout/ — session mode (no-op for JWT, client just drops the token)."""
|
|
106
|
+
|
|
107
|
+
permission_classes = [IsStaffUser]
|
|
108
|
+
swagger_schema = None
|
|
109
|
+
|
|
110
|
+
def post(self, request):
|
|
111
|
+
if conjure_settings.AUTH != "jwt":
|
|
112
|
+
django_logout(request._request)
|
|
113
|
+
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class MeView(ConjureAuthMixin, APIView):
|
|
117
|
+
"""GET /auth/me/ — the currently authenticated staff user."""
|
|
118
|
+
|
|
119
|
+
permission_classes = [IsStaffUser]
|
|
120
|
+
swagger_schema = None
|
|
121
|
+
|
|
122
|
+
def get(self, request):
|
|
123
|
+
return Response(user_payload(request.user))
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Domain: conjure / settings
|
|
3
|
+
@BusinessLogic: Single accessor for all Conjure settings. Project overrides live under the
|
|
4
|
+
``CONJURE`` dict in Django settings and are merged over DEFAULTS. This is the decoupling
|
|
5
|
+
layer — every project-specific knob (auth mode, brand, user payload, page size, audit) is
|
|
6
|
+
read through ``conjure_settings`` instead of being hardcoded, so the app attaches to any
|
|
7
|
+
project without edits.
|
|
8
|
+
@Connection: conjure/auth.py (AUTH, USER_PAYLOAD), conjure/audit.py (AUDIT, LOGGER_NAME),
|
|
9
|
+
conjure/viewsets.py (PAGE_SIZE), conjure/apps.py (AUTODISCOVER)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from django.conf import settings
|
|
15
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
16
|
+
from django.utils.module_loading import import_string
|
|
17
|
+
|
|
18
|
+
DEFAULTS = {
|
|
19
|
+
# "session" (reuse Django login, works anywhere) or "jwt" (SimpleJWT — requires the [jwt] extra)
|
|
20
|
+
"AUTH": "session",
|
|
21
|
+
# Header / login title + accent color surfaced to the frontend config endpoint.
|
|
22
|
+
"BRAND": {"name": "Conjure", "accent": "#4f46e5"},
|
|
23
|
+
# Dotted path or callable -> dict, used as the login/me response. None = built-in default
|
|
24
|
+
# (id / username / name via get_username()/get_full_name() — works with any AUTH_USER_MODEL).
|
|
25
|
+
"USER_PAYLOAD": None,
|
|
26
|
+
# List pagination.
|
|
27
|
+
"PAGE_SIZE": 50,
|
|
28
|
+
"MAX_PAGE_SIZE": 200,
|
|
29
|
+
# Write the audit log (requires `migrate conjure`). When False, writes are skipped entirely.
|
|
30
|
+
"AUDIT": True,
|
|
31
|
+
# Auto-import every installed app's ``admin_config`` module on ready() (like admin.autodiscover).
|
|
32
|
+
"AUTODISCOVER": True,
|
|
33
|
+
# Logger used for non-fatal internal errors (e.g. audit write failures).
|
|
34
|
+
"LOGGER_NAME": "conjure",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Keys whose value is a dotted import path that should be resolved to the referenced object.
|
|
38
|
+
_IMPORT_STRINGS = {"USER_PAYLOAD"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConjureSettings:
|
|
42
|
+
"""Lazy, cached accessor over ``settings.CONJURE`` merged onto DEFAULTS."""
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self._cache: dict = {}
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def _user(self) -> dict:
|
|
49
|
+
user = getattr(settings, "CONJURE", {})
|
|
50
|
+
if not isinstance(user, dict):
|
|
51
|
+
raise ImproperlyConfigured("settings.CONJURE must be a dict.")
|
|
52
|
+
return user
|
|
53
|
+
|
|
54
|
+
def __getattr__(self, name: str):
|
|
55
|
+
if name.startswith("_"):
|
|
56
|
+
raise AttributeError(name)
|
|
57
|
+
if name not in DEFAULTS:
|
|
58
|
+
raise AttributeError(f"Unknown Conjure setting: {name!r}")
|
|
59
|
+
if name in self._cache:
|
|
60
|
+
return self._cache[name]
|
|
61
|
+
|
|
62
|
+
default = DEFAULTS[name]
|
|
63
|
+
value = self._user.get(name, default)
|
|
64
|
+
# Shallow-merge dict defaults (e.g. BRAND) so a partial override keeps the rest.
|
|
65
|
+
if isinstance(default, dict) and isinstance(value, dict):
|
|
66
|
+
value = {**default, **value}
|
|
67
|
+
if name in _IMPORT_STRINGS and isinstance(value, str):
|
|
68
|
+
value = import_string(value)
|
|
69
|
+
|
|
70
|
+
self._cache[name] = value
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
def reload(self):
|
|
74
|
+
"""Drop the cache — used by tests that override settings."""
|
|
75
|
+
self._cache.clear()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
conjure_settings = ConjureSettings()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Keep settings live under override_settings(CONJURE=...) (same pattern DRF uses for its own settings).
|
|
82
|
+
from django.test.signals import setting_changed # noqa: E402
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _reload_conjure_settings(*, setting, **kwargs):
|
|
86
|
+
if setting == "CONJURE":
|
|
87
|
+
conjure_settings.reload()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
setting_changed.connect(_reload_conjure_settings)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Domain: conjure / discovery
|
|
3
|
+
@BusinessLogic: Auto-import each installed app's ``admin_config`` module on startup, exactly like
|
|
4
|
+
Django's ``admin.autodiscover()`` does for ``admin``. This is what lets a project register
|
|
5
|
+
models by dropping an ``admin_config.py`` into any app — no central import list.
|
|
6
|
+
@Context: DECOUPLING — replaces the old hardcoded ``registrations.py`` single-file import.
|
|
7
|
+
@Connection: conjure/apps.py
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from django.utils.module_loading import autodiscover_modules
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def autodiscover():
|
|
14
|
+
autodiscover_modules("admin_config")
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Domain: conjure / codegen
|
|
3
|
+
@BusinessLogic: Dump the registered models' admin schema to JSON — the snapshot the frontend
|
|
4
|
+
codegen reads. Replaces the ad-hoc shell one-liner. Permissions are forced on so the dump
|
|
5
|
+
is complete regardless of who runs it.
|
|
6
|
+
@Connection: conjure/schema.py (model_schema), packages/web/codegen/schema-snapshot.json
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from django.core.management.base import BaseCommand
|
|
12
|
+
|
|
13
|
+
from conjure.registry import registry
|
|
14
|
+
from conjure.schema import model_schema
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _AllPerms:
|
|
18
|
+
"""Stand-in user so the schema dump includes every model's full permission flags."""
|
|
19
|
+
|
|
20
|
+
is_superuser = True
|
|
21
|
+
|
|
22
|
+
def has_perm(self, perm, obj=None):
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Command(BaseCommand):
|
|
27
|
+
help = "Dump the registered models' admin schema to JSON (the codegen snapshot)."
|
|
28
|
+
|
|
29
|
+
def add_arguments(self, parser):
|
|
30
|
+
parser.add_argument("-o", "--output", default=None, help="Write to this path instead of stdout.")
|
|
31
|
+
parser.add_argument("--indent", type=int, default=2, help="JSON indent (default: 2).")
|
|
32
|
+
|
|
33
|
+
def handle(self, *args, **options):
|
|
34
|
+
user = _AllPerms()
|
|
35
|
+
data = {key: model_schema(key, config, user) for key, config in registry.items()}
|
|
36
|
+
text = json.dumps(data, ensure_ascii=False, indent=options["indent"])
|
|
37
|
+
|
|
38
|
+
output = options["output"]
|
|
39
|
+
if output:
|
|
40
|
+
with open(output, "w", encoding="utf-8") as fh:
|
|
41
|
+
fh.write(text)
|
|
42
|
+
self.stderr.write(self.style.SUCCESS(f"Wrote {len(data)} models to {output}"))
|
|
43
|
+
else:
|
|
44
|
+
self.stdout.write(text)
|