django-snapadmin 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_snapadmin-0.1.0a1.dist-info/METADATA +197 -0
- django_snapadmin-0.1.0a1.dist-info/RECORD +35 -0
- django_snapadmin-0.1.0a1.dist-info/WHEEL +4 -0
- django_snapadmin-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- snapadmin/__init__.py +0 -0
- snapadmin/admin.py +111 -0
- snapadmin/api/__init__.py +0 -0
- snapadmin/api/authentication.py +81 -0
- snapadmin/api/graphql.py +52 -0
- snapadmin/api/health.py +55 -0
- snapadmin/api/serializers.py +84 -0
- snapadmin/api/tasks.py +27 -0
- snapadmin/api/views.py +199 -0
- snapadmin/apps.py +22 -0
- snapadmin/fields.py +325 -0
- snapadmin/init.py +0 -0
- snapadmin/logging_config.py +155 -0
- snapadmin/management/__init__.py +0 -0
- snapadmin/management/commands/__init__.py +0 -0
- snapadmin/migrations/__init__.py +0 -0
- snapadmin/models.py +755 -0
- snapadmin/static/snapadmin/__init__.py +1 -0
- snapadmin/static/snapadmin/css/admin.css +226 -0
- snapadmin/static/snapadmin/css/select2.min.css +1 -0
- snapadmin/static/snapadmin/js/admin.js +78 -0
- snapadmin/static/snapadmin/js/jquery_bridge.js +1 -0
- snapadmin/static/snapadmin/js/model_selector.js +79 -0
- snapadmin/static/snapadmin/js/select2.min.js +2 -0
- snapadmin/static/snapadmin/snap-logo.svg +5 -0
- snapadmin/templates/snapadmin/dashboard.html +321 -0
- snapadmin/templates/snapadmin/widgets/smart_model_selector.html +30 -0
- snapadmin/urls.py +81 -0
- snapadmin/validators.py +115 -0
- snapadmin/views.py +148 -0
- snapadmin/widgets.py +52 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-snapadmin
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Automatic and customizable admin interface for Django projects.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: django,admin,ui,Snap,extra-settings
|
|
8
|
+
Author: Alexander Wiese
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Dist: Django (>=5.2)
|
|
18
|
+
Requires-Dist: colorama (>=0.4.6)
|
|
19
|
+
Requires-Dist: django-admin-autocomplete-filter (>=0.7.1)
|
|
20
|
+
Requires-Dist: django-admin-rangefilter (>=0.13.3)
|
|
21
|
+
Requires-Dist: django-ckeditor-5 (>=0.0.12)
|
|
22
|
+
Requires-Dist: django-colorfield (>=0.11.0)
|
|
23
|
+
Requires-Dist: django-cors-headers (>=4.4.0)
|
|
24
|
+
Requires-Dist: django-extra-settings (>=0.12.0)
|
|
25
|
+
Requires-Dist: django-unfold (>=0.40.0)
|
|
26
|
+
Requires-Dist: djangorestframework (>=3.15.0)
|
|
27
|
+
Requires-Dist: djangorestframework-simplejwt (>=5.3.0)
|
|
28
|
+
Requires-Dist: drf-spectacular (>=0.27.0)
|
|
29
|
+
Requires-Dist: graphene-django (>=3.2.0)
|
|
30
|
+
Requires-Dist: python-dotenv (>=1.0.0)
|
|
31
|
+
Requires-Dist: structlog (>=24.1.0)
|
|
32
|
+
Project-URL: Homepage, https://github.com/drofji/django-snapadmin
|
|
33
|
+
Project-URL: Repository, https://github.com/drofji/django-snapadmin
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# 🚀 SnapAdmin — Declarative Django Admin & API Package
|
|
37
|
+
|
|
38
|
+
**SnapAdmin** is a high-performance, declarative Django package that eliminates admin and API boilerplate. Define your model fields once — get a feature-rich, beautiful Django admin (powered by Unfold), a full REST API, and a dynamic GraphQL API automatically.
|
|
39
|
+
|
|
40
|
+
[](https://python.org)
|
|
41
|
+
[](https://djangoproject.com)
|
|
42
|
+
[](LICENSE)
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 📦 SnapAdmin Package Features
|
|
47
|
+
|
|
48
|
+
The core `snapadmin` package provides everything you need to bootstrap your project's admin and API:
|
|
49
|
+
|
|
50
|
+
| Feature | Description |
|
|
51
|
+
|---------|-------------|
|
|
52
|
+
| **Declarative Admin** | Configure `list_display`, `search_fields`, `list_filter` directly in your models using `SnapField`. |
|
|
53
|
+
| **Beautiful UI** | Native integration with `django-unfold` for a modern, responsive admin experience. |
|
|
54
|
+
| **Status Badges** | Easily add color-coded HTML badges for choices and status fields. |
|
|
55
|
+
| **Advanced Layout** | Support for horizontal field rows and tabbed interfaces within the admin form. |
|
|
56
|
+
| **Range Filters** | Built-in date and numeric range filters for efficient data exploration. |
|
|
57
|
+
| **Change Logging** | Automatic tracking of field-level changes (`old → new`) with a dedicated history view. |
|
|
58
|
+
| **Automatic REST API** | Instantly generated CRUD endpoints for every `SnapModel` with zero extra code. |
|
|
59
|
+
| **Dynamic GraphQL API** | Automatically generated GraphQL schema with support for complex data fetching. |
|
|
60
|
+
| **Token Auth** | Secure, expirable API tokens with granular model-level access control. |
|
|
61
|
+
| **Configurable** | Easily enable/disable REST API, GraphQL, Swagger docs, and search modes via settings. |
|
|
62
|
+
| **Elasticsearch Ready** | Multi-mode storage (`DB_ONLY`, `DUAL`, `ES_ONLY`) for blazing fast search. |
|
|
63
|
+
| **Structured Logging** | Integrated `structlog` for readable local logs and JSON logs in production. |
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 🏗 Package Architecture
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
snapadmin/
|
|
71
|
+
├── api/ # REST & GraphQL API core: views, serializers, auth
|
|
72
|
+
├── management/ # Custom management commands
|
|
73
|
+
├── migrations/ # Core package migrations (e.g., APIToken)
|
|
74
|
+
├── static/ # UI assets (CSS, JS, SVG logos)
|
|
75
|
+
├── templates/ # Custom admin templates & dashboard
|
|
76
|
+
├── fields.py # SnapField definitions with admin introspection
|
|
77
|
+
├── models.py # SnapModel base, EsManager, and core logic
|
|
78
|
+
└── urls.py # Auto-configurable API and documentation routes
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 🚀 Quickstart: Installation
|
|
84
|
+
|
|
85
|
+
### From PyPI (Recommended)
|
|
86
|
+
```bash
|
|
87
|
+
pip install django-snapadmin
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### From GitHub (Latest/Development)
|
|
91
|
+
```bash
|
|
92
|
+
pip install git+https://github.com/drofji/django-snapadmin.git
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 🛠 Usage & Configuration
|
|
98
|
+
|
|
99
|
+
### 1. Configure Settings
|
|
100
|
+
Add required apps to `INSTALLED_APPS` in `settings.py`:
|
|
101
|
+
```python
|
|
102
|
+
INSTALLED_APPS = [
|
|
103
|
+
"unfold",
|
|
104
|
+
"snapadmin",
|
|
105
|
+
"rest_framework",
|
|
106
|
+
"drf_spectacular",
|
|
107
|
+
"graphene_django",
|
|
108
|
+
# ...
|
|
109
|
+
]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 2. Define your Model
|
|
113
|
+
```python
|
|
114
|
+
from snapadmin import fields as snap, models as snap_models
|
|
115
|
+
|
|
116
|
+
class Product(snap_models.SnapModel):
|
|
117
|
+
name = snap.SnapCharField(max_length=200, searchable=True, show_in_list=True)
|
|
118
|
+
# Group fields into a single horizontal row
|
|
119
|
+
price = snap.SnapDecimalField(max_digits=10, decimal_places=2, row="pricing")
|
|
120
|
+
available = snap.SnapBooleanField(default=True, row="pricing")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### 3. Register Admin
|
|
124
|
+
```python
|
|
125
|
+
# admin.py
|
|
126
|
+
from snapadmin.models import SnapModel
|
|
127
|
+
SnapModel.register_all_admins()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## ⚙️ Advanced Settings
|
|
133
|
+
|
|
134
|
+
Control core features via Django settings:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
SNAPADMIN_REST_API_ENABLED = True # Enable/Disable the REST API
|
|
138
|
+
SNAPADMIN_GRAPHQL_ENABLED = True # Enable/Disable the GraphQL API
|
|
139
|
+
SNAPADMIN_SWAGGER_ENABLED = True # Enable/Disable Swagger UI documentation
|
|
140
|
+
ELASTICSEARCH_ENABLED = False # Toggle ES search engine support
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 🌟 Demo Application Features
|
|
146
|
+
|
|
147
|
+
The repository includes a `demo/` app and a `sandbox/` project to showcase SnapAdmin's power:
|
|
148
|
+
|
|
149
|
+
- **Complete Project Setup**: Ready-to-use Docker environment with PostgreSQL, Redis, and Elasticsearch.
|
|
150
|
+
- **Example Domain Models**: Product, Customer, and Order models showing complex relationships.
|
|
151
|
+
- **Interactive Dashboard**: A custom system dashboard with health checks and environment stats.
|
|
152
|
+
- **Seeder Command**: `python manage.py seed_demo` to instantly populate your environment.
|
|
153
|
+
- **Celery Integration**: Example background tasks for data indexing and stats generation.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 🐳 Running the Demo (Docker)
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
git clone https://github.com/drofji/django-snapadmin.git
|
|
161
|
+
cd django-snapadmin
|
|
162
|
+
cp dist.env .env
|
|
163
|
+
docker compose up --build
|
|
164
|
+
```
|
|
165
|
+
- **Admin**: http://localhost:8000/admin/ (admin / admin)
|
|
166
|
+
- **REST API Docs**: http://localhost:8000/api/docs/
|
|
167
|
+
- **GraphQL API**: http://localhost:8000/api/graphql/
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## 💻 Local Development Setup
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# Clone and setup environment
|
|
175
|
+
git clone https://github.com/drofji/django-snapadmin.git
|
|
176
|
+
cd django-snapadmin
|
|
177
|
+
python -m venv .venv
|
|
178
|
+
source .venv/bin/activate
|
|
179
|
+
|
|
180
|
+
# Install in editable mode
|
|
181
|
+
pip install -r requirements.txt
|
|
182
|
+
pip install -e .
|
|
183
|
+
|
|
184
|
+
# Initialize DB and run
|
|
185
|
+
python manage.py migrate
|
|
186
|
+
python manage.py seed_demo
|
|
187
|
+
python manage.py runserver
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## 📜 License
|
|
193
|
+
|
|
194
|
+
MIT License — see [LICENSE](LICENSE).
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
snapadmin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
snapadmin/admin.py,sha256=2_rnohqRkroMNkj1ejNRds0JUjeCBq-JXQZyvpIdlYU,3485
|
|
3
|
+
snapadmin/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
snapadmin/api/authentication.py,sha256=ktutur2Hczc6FddK_ZsRMnLL71KcNeAA9Ith1FzKvyc,2400
|
|
5
|
+
snapadmin/api/graphql.py,sha256=bHYyTwZagupiIIxe4KmshyXrN5ffn4tXl9SZgccyInE,2396
|
|
6
|
+
snapadmin/api/health.py,sha256=sbY6PRPm051saDzBQY4UtKPlB6Obu5SW-WZq8fp2nJM,1926
|
|
7
|
+
snapadmin/api/serializers.py,sha256=ipw6bOQHp3R2vncZrniXKpiBIarUbYhkd0ndfMBqprI,2360
|
|
8
|
+
snapadmin/api/tasks.py,sha256=ktk9feCjbPTL7pPRqTEla7B_FP7ojdEdAhKtvEoT98w,677
|
|
9
|
+
snapadmin/api/views.py,sha256=MGNeqETTXU6J3n_eh2-_GBDBSh9-EYsn6-UhsY-mAGA,7038
|
|
10
|
+
snapadmin/apps.py,sha256=l-5MPVxGK7a_4Su0O6RZeyQdwgBruvWehJhzRs3b-sc,656
|
|
11
|
+
snapadmin/fields.py,sha256=mfdOQyLDZxAE-NviX4eX3atZ5M2-IWMj4zSh8xB38ho,13455
|
|
12
|
+
snapadmin/init.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
snapadmin/logging_config.py,sha256=PxK8L5cTr3ssIvdNl0etRA8MHj7W1aQ4EVh1GT2ui1U,4948
|
|
14
|
+
snapadmin/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
snapadmin/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
snapadmin/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
snapadmin/models.py,sha256=18zAuago4evZ2Z23ta5oG6JcduCWTli4Oy1PycHNI4Q,30600
|
|
18
|
+
snapadmin/static/snapadmin/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
19
|
+
snapadmin/static/snapadmin/css/admin.css,sha256=LqOh99rYVxXSZJXWT3kj7DLL2zAT36lynxidVd09gG8,5151
|
|
20
|
+
snapadmin/static/snapadmin/css/select2.min.css,sha256=pkvUefja_UodiarFG3vnvcNsuwFQeC1c9ny4L7ENyiw,16263
|
|
21
|
+
snapadmin/static/snapadmin/js/admin.js,sha256=LnyvEJIqosb0jP_-_-BQTlorKAt_oZK_TEVij8vs_Rk,2917
|
|
22
|
+
snapadmin/static/snapadmin/js/jquery_bridge.js,sha256=N24-Srv8O2xA2pYfdVRsJEqYQL-4BM4J54t9_j_suNc,41
|
|
23
|
+
snapadmin/static/snapadmin/js/model_selector.js,sha256=SdpqRRHbpoD5gfL1VAqDpIOIqLa7WHi-rg5IXtYuR4A,3265
|
|
24
|
+
snapadmin/static/snapadmin/js/select2.min.js,sha256=9yRP_2EFlblE92vzCA10469Ctd0jT48HnmmMw5rJZrA,73163
|
|
25
|
+
snapadmin/static/snapadmin/snap-logo.svg,sha256=CSSIPXwvI2JX4WxPLtZ6d4cpLoRCHgxBtycFdYiVLzo,322
|
|
26
|
+
snapadmin/templates/snapadmin/dashboard.html,sha256=WmdAW02V_3AploVColcXzljmnWDhKV-o0lKJ9IcaLoA,10516
|
|
27
|
+
snapadmin/templates/snapadmin/widgets/smart_model_selector.html,sha256=yPAPD1zj9kfor1lmwJAc8sFBalnCg6loHN1Mew1wEZk,1537
|
|
28
|
+
snapadmin/urls.py,sha256=2XyjfX6T8F87R5_zQ4LdQUxNPMdy2txyiqp8yzPe_kw,2609
|
|
29
|
+
snapadmin/validators.py,sha256=51Ijp9H67Sp_3Ffc4vuyyWvuj-lAnaR3hGwyrj240Z0,3834
|
|
30
|
+
snapadmin/views.py,sha256=8W3o9x1mpXVJlTbfa5goRsS8D_FkBVs8J0dcX6efTk4,5218
|
|
31
|
+
snapadmin/widgets.py,sha256=fl72i01LslGjrFsFBiCK69clH6taLOJZ87FMra95-mk,1819
|
|
32
|
+
django_snapadmin-0.1.0a1.dist-info/METADATA,sha256=QWIzT_HifVKWilOyzzphkb8eP4U9hzDRdsC3HMhWBgA,6912
|
|
33
|
+
django_snapadmin-0.1.0a1.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
|
|
34
|
+
django_snapadmin-0.1.0a1.dist-info/licenses/LICENSE,sha256=kqHaMN1Qp29lpNatNRZxhhsJOIEKQ6kia-tkqFwMJ6w,1072
|
|
35
|
+
django_snapadmin-0.1.0a1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Wiese
|
|
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.
|
snapadmin/__init__.py
ADDED
|
File without changes
|
snapadmin/admin.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from django.utils.html import format_html
|
|
3
|
+
from django.utils.translation import gettext_lazy as _
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
if 'unfold' not in settings.INSTALLED_APPS:
|
|
8
|
+
raise ImportError("Unfold not in INSTALLED_APPS")
|
|
9
|
+
|
|
10
|
+
from unfold.admin import ModelAdmin, TabularInline, StackedInline
|
|
11
|
+
from unfold.contrib.filters.admin import RelatedDropdownFilter, ChoicesDropdownFilter
|
|
12
|
+
from unfold.decorators import display
|
|
13
|
+
UNFOLD_INSTALLED = True
|
|
14
|
+
except (ImportError, RuntimeError):
|
|
15
|
+
from django.contrib.admin import ModelAdmin, TabularInline, StackedInline
|
|
16
|
+
RelatedDropdownFilter = admin.RelatedFieldListFilter
|
|
17
|
+
ChoicesDropdownFilter = admin.ChoicesFieldListFilter
|
|
18
|
+
UNFOLD_INSTALLED = False
|
|
19
|
+
|
|
20
|
+
def display(description=None, header=False, label=False, **kwargs):
|
|
21
|
+
def decorator(func):
|
|
22
|
+
if description:
|
|
23
|
+
func.short_description = description
|
|
24
|
+
return func
|
|
25
|
+
return decorator
|
|
26
|
+
|
|
27
|
+
from snapadmin.models import APIToken
|
|
28
|
+
from snapadmin.widgets import SmartModelSelectorWidget
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SnapTabularInline(TabularInline):
|
|
32
|
+
"""
|
|
33
|
+
Standard inline class for SnapAdmin. Fallback to Django admin if Unfold is missing.
|
|
34
|
+
"""
|
|
35
|
+
extra = 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SnapStackedInline(StackedInline):
|
|
39
|
+
"""
|
|
40
|
+
Standard stacked inline class for SnapAdmin. Fallback to Django admin if Unfold is missing.
|
|
41
|
+
"""
|
|
42
|
+
extra = 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class APITokenAdmin(ModelAdmin):
|
|
46
|
+
"""
|
|
47
|
+
Admin interface for managing API tokens using Unfold.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
list_display = [
|
|
51
|
+
"token_name",
|
|
52
|
+
"user",
|
|
53
|
+
"masked_key",
|
|
54
|
+
"expiration_date",
|
|
55
|
+
"is_active",
|
|
56
|
+
"status_badge",
|
|
57
|
+
"last_used_at",
|
|
58
|
+
"created_at",
|
|
59
|
+
]
|
|
60
|
+
list_filter = [
|
|
61
|
+
("is_active", ChoicesDropdownFilter),
|
|
62
|
+
("user", RelatedDropdownFilter),
|
|
63
|
+
]
|
|
64
|
+
search_fields = ["token_name", "user__username"]
|
|
65
|
+
readonly_fields = ["token_key", "created_at", "last_used_at"]
|
|
66
|
+
ordering = ["-created_at"]
|
|
67
|
+
|
|
68
|
+
warn_unsaved_form = True
|
|
69
|
+
list_filter_submit = True
|
|
70
|
+
|
|
71
|
+
fieldsets = [
|
|
72
|
+
(None, {
|
|
73
|
+
"fields": ["token_name", "user", "token_key"],
|
|
74
|
+
}),
|
|
75
|
+
(_("Access Control"), {
|
|
76
|
+
"fields": ["is_active", "expiration_date", "allowed_models"],
|
|
77
|
+
}),
|
|
78
|
+
(_("Audit"), {
|
|
79
|
+
"fields": ["created_at", "last_used_at"],
|
|
80
|
+
"classes": ["collapse"],
|
|
81
|
+
}),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
def formfield_for_dbfield(self, db_field, request, **kwargs):
|
|
85
|
+
if db_field.name == "allowed_models":
|
|
86
|
+
kwargs["widget"] = SmartModelSelectorWidget()
|
|
87
|
+
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
|
88
|
+
|
|
89
|
+
@display(description=_("Token Key"), header=True)
|
|
90
|
+
def masked_key(self, obj: APIToken):
|
|
91
|
+
"""Show only the first 8 characters of the token key."""
|
|
92
|
+
val = f"{obj.token_key[:8]}••••••••"
|
|
93
|
+
if UNFOLD_INSTALLED:
|
|
94
|
+
return [val, None, None]
|
|
95
|
+
return val
|
|
96
|
+
|
|
97
|
+
@display(description=_("Status"), label=True)
|
|
98
|
+
def status_badge(self, obj: APIToken):
|
|
99
|
+
"""Render a coloured pill badge reflecting the token's current state."""
|
|
100
|
+
if not obj.is_active:
|
|
101
|
+
res = (_("Disabled"), "danger")
|
|
102
|
+
elif obj.is_expired:
|
|
103
|
+
res = (_("Expired"), "warning")
|
|
104
|
+
else:
|
|
105
|
+
res = (_("Active"), "success")
|
|
106
|
+
|
|
107
|
+
if UNFOLD_INSTALLED:
|
|
108
|
+
return res
|
|
109
|
+
|
|
110
|
+
# Fallback for standard admin: just return the label
|
|
111
|
+
return res[0]
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
snapadmin/api/authentication.py
|
|
3
|
+
|
|
4
|
+
Custom DRF authentication backend for SnapAdmin API Tokens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from django.contrib.auth.models import User
|
|
10
|
+
from rest_framework import authentication, exceptions
|
|
11
|
+
|
|
12
|
+
from snapadmin.models import APIToken
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("snapadmin.api.auth")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class APITokenAuthentication(authentication.BaseAuthentication):
|
|
18
|
+
keyword = "Token"
|
|
19
|
+
|
|
20
|
+
def authenticate(self, request):
|
|
21
|
+
auth_header = authentication.get_authorization_header(request).split()
|
|
22
|
+
|
|
23
|
+
if not auth_header or auth_header[0].lower() != b"token":
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
if len(auth_header) == 1:
|
|
27
|
+
raise exceptions.AuthenticationFailed("Invalid token header: no token key provided.")
|
|
28
|
+
if len(auth_header) > 2:
|
|
29
|
+
raise exceptions.AuthenticationFailed("Invalid token header: spaces are not allowed in token keys.")
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
token_key = auth_header[1].decode("utf-8")
|
|
33
|
+
except UnicodeDecodeError:
|
|
34
|
+
raise exceptions.AuthenticationFailed("Invalid token header: token key contained invalid characters.")
|
|
35
|
+
|
|
36
|
+
return self._validate_token(token_key)
|
|
37
|
+
|
|
38
|
+
def _validate_token(self, token_key: str):
|
|
39
|
+
try:
|
|
40
|
+
token = (
|
|
41
|
+
APIToken.objects
|
|
42
|
+
.select_related("user")
|
|
43
|
+
.get(token_key=token_key)
|
|
44
|
+
)
|
|
45
|
+
except APIToken.DoesNotExist:
|
|
46
|
+
raise exceptions.AuthenticationFailed("Invalid token.")
|
|
47
|
+
|
|
48
|
+
if not token.is_active:
|
|
49
|
+
raise exceptions.AuthenticationFailed("Token has been disabled.")
|
|
50
|
+
|
|
51
|
+
if token.is_expired:
|
|
52
|
+
raise exceptions.AuthenticationFailed("Token has expired.")
|
|
53
|
+
|
|
54
|
+
if not token.user.is_active:
|
|
55
|
+
raise exceptions.AuthenticationFailed("User account is disabled.")
|
|
56
|
+
|
|
57
|
+
token.touch()
|
|
58
|
+
|
|
59
|
+
logger.debug(
|
|
60
|
+
"api_token_authenticated",
|
|
61
|
+
extra={"token_name": token.token_name, "user": token.user.username},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return (token.user, token)
|
|
65
|
+
|
|
66
|
+
def authenticate_header(self, request):
|
|
67
|
+
return self.keyword
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def token_has_permission(
|
|
71
|
+
token: APIToken,
|
|
72
|
+
user: User,
|
|
73
|
+
app_label: str,
|
|
74
|
+
model_name: str,
|
|
75
|
+
action: str,
|
|
76
|
+
) -> bool:
|
|
77
|
+
if not token.can_access_model(app_label, model_name):
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
perm_codename = f"{app_label}.{action}_{model_name.lower()}"
|
|
81
|
+
return user.has_perm(perm_codename)
|
snapadmin/api/graphql.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
|
|
2
|
+
import graphene
|
|
3
|
+
from graphene_django import DjangoObjectType
|
|
4
|
+
from django.apps import apps
|
|
5
|
+
from snapadmin.models import SnapModel, EsStorageMode
|
|
6
|
+
|
|
7
|
+
def get_dynamic_graphql_schema():
|
|
8
|
+
class Query(graphene.ObjectType):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
for model in apps.get_models():
|
|
12
|
+
if issubclass(model, SnapModel) and model is not SnapModel:
|
|
13
|
+
try:
|
|
14
|
+
type_name = f"{model._meta.app_label.capitalize()}{model.__name__}Type"
|
|
15
|
+
|
|
16
|
+
# Create the DjangoObjectType dynamically
|
|
17
|
+
# We use a closure for model_class to avoid the late binding issue in loops
|
|
18
|
+
meta_attr = type('Meta', (), {'model': model, 'fields': "__all__"})
|
|
19
|
+
object_type = type(type_name, (DjangoObjectType,), {'Meta': meta_attr})
|
|
20
|
+
|
|
21
|
+
# Add fields to Query
|
|
22
|
+
field_name = f"{model._meta.app_label}_{model.__name__.lower()}"
|
|
23
|
+
list_field_name = f"all_{model._meta.app_label}_{model.__name__.lower()}s"
|
|
24
|
+
|
|
25
|
+
# Use factories for resolvers to correctly bind the model class
|
|
26
|
+
def make_single_resolver(m):
|
|
27
|
+
def resolve_single(self, info, id):
|
|
28
|
+
if getattr(m, 'es_storage_mode', None) == EsStorageMode.ES_ONLY:
|
|
29
|
+
# For ES_ONLY, try to find in ES
|
|
30
|
+
return m.objects.get(pk=id)
|
|
31
|
+
return m.objects.get(pk=id)
|
|
32
|
+
return resolve_single
|
|
33
|
+
|
|
34
|
+
def make_list_resolver(m):
|
|
35
|
+
def resolve_list(self, info):
|
|
36
|
+
# If DUAL or ES_ONLY, we could potentially use snap_search here
|
|
37
|
+
# But objects.all() for SnapModel is already ES-aware if ES_ONLY
|
|
38
|
+
return m.objects.all()
|
|
39
|
+
return resolve_list
|
|
40
|
+
|
|
41
|
+
setattr(Query, field_name, graphene.Field(object_type, id=graphene.ID(required=True)))
|
|
42
|
+
setattr(Query, f"resolve_{field_name}", make_single_resolver(model))
|
|
43
|
+
|
|
44
|
+
setattr(Query, list_field_name, graphene.List(object_type))
|
|
45
|
+
setattr(Query, f"resolve_{list_field_name}", make_list_resolver(model))
|
|
46
|
+
except Exception:
|
|
47
|
+
# Skip models that can't be introspected (e.g. no DB table for non-managed)
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
return graphene.Schema(query=Query)
|
|
51
|
+
|
|
52
|
+
schema = get_dynamic_graphql_schema()
|
snapadmin/api/health.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
|
|
2
|
+
import logging
|
|
3
|
+
from django.db import connections
|
|
4
|
+
from django.db.utils import OperationalError
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from rest_framework.views import APIView
|
|
7
|
+
from rest_framework.response import Response
|
|
8
|
+
from rest_framework.permissions import AllowAny
|
|
9
|
+
from drf_spectacular.utils import extend_schema
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("snapadmin.api.health")
|
|
12
|
+
|
|
13
|
+
class HealthCheckView(APIView):
|
|
14
|
+
"""
|
|
15
|
+
Check the health of the system.
|
|
16
|
+
"""
|
|
17
|
+
permission_classes = [AllowAny]
|
|
18
|
+
|
|
19
|
+
@extend_schema(summary="Health check for services")
|
|
20
|
+
def get(self, request):
|
|
21
|
+
health_status = {
|
|
22
|
+
"status": "healthy",
|
|
23
|
+
"services": {
|
|
24
|
+
"database": "offline",
|
|
25
|
+
"elasticsearch": "offline",
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Check Database
|
|
30
|
+
try:
|
|
31
|
+
db_conn = connections['default']
|
|
32
|
+
db_conn.cursor()
|
|
33
|
+
health_status["services"]["database"] = "online"
|
|
34
|
+
except OperationalError:
|
|
35
|
+
health_status["status"] = "unhealthy"
|
|
36
|
+
health_status["services"]["database"] = "offline"
|
|
37
|
+
|
|
38
|
+
# Check Elasticsearch
|
|
39
|
+
if getattr(settings, "ELASTICSEARCH_ENABLED", False):
|
|
40
|
+
try:
|
|
41
|
+
from elasticsearch import Elasticsearch
|
|
42
|
+
url = getattr(settings, "ELASTICSEARCH_URL", "http://localhost:9200")
|
|
43
|
+
es = Elasticsearch([url], request_timeout=2)
|
|
44
|
+
if es.ping():
|
|
45
|
+
health_status["services"]["elasticsearch"] = "online"
|
|
46
|
+
else:
|
|
47
|
+
health_status["status"] = "degraded"
|
|
48
|
+
health_status["services"]["elasticsearch"] = "offline"
|
|
49
|
+
except Exception:
|
|
50
|
+
health_status["status"] = "degraded"
|
|
51
|
+
health_status["services"]["elasticsearch"] = "offline"
|
|
52
|
+
else:
|
|
53
|
+
health_status["services"]["elasticsearch"] = "disabled"
|
|
54
|
+
|
|
55
|
+
return Response(health_status)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
snapadmin/api/serializers.py
|
|
3
|
+
|
|
4
|
+
DRF serializers for the SnapAdmin auto-generated REST API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from django.apps import apps
|
|
8
|
+
from django.contrib.auth.models import User
|
|
9
|
+
from rest_framework import serializers
|
|
10
|
+
|
|
11
|
+
from snapadmin.models import APIToken
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APITokenSerializer(serializers.ModelSerializer):
|
|
15
|
+
owner_username = serializers.CharField(source="user.username", read_only=True)
|
|
16
|
+
is_expired = serializers.BooleanField(read_only=True)
|
|
17
|
+
is_valid = serializers.BooleanField(read_only=True)
|
|
18
|
+
|
|
19
|
+
class Meta:
|
|
20
|
+
model = APIToken
|
|
21
|
+
fields = [
|
|
22
|
+
"id",
|
|
23
|
+
"token_name",
|
|
24
|
+
"token_key",
|
|
25
|
+
"owner_username",
|
|
26
|
+
"expiration_date",
|
|
27
|
+
"allowed_models",
|
|
28
|
+
"is_active",
|
|
29
|
+
"is_expired",
|
|
30
|
+
"is_valid",
|
|
31
|
+
"created_at",
|
|
32
|
+
"last_used_at",
|
|
33
|
+
]
|
|
34
|
+
read_only_fields = ["token_key", "created_at", "last_used_at"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class APITokenCreateSerializer(serializers.ModelSerializer):
|
|
38
|
+
expires_in_days = serializers.IntegerField(
|
|
39
|
+
required=False,
|
|
40
|
+
allow_null=True,
|
|
41
|
+
write_only=True,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
class Meta:
|
|
45
|
+
model = APIToken
|
|
46
|
+
fields = ["token_name", "allowed_models", "expires_in_days"]
|
|
47
|
+
|
|
48
|
+
def create(self, validated_data):
|
|
49
|
+
expires_in_days = validated_data.pop("expires_in_days", None)
|
|
50
|
+
request = self.context["request"]
|
|
51
|
+
return APIToken.create_for_user(
|
|
52
|
+
user=request.user,
|
|
53
|
+
token_name=validated_data["token_name"],
|
|
54
|
+
allowed_models=validated_data.get("allowed_models", []),
|
|
55
|
+
expires_in_days=expires_in_days,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_model_serializer(model_class):
|
|
60
|
+
meta_class = type(
|
|
61
|
+
"Meta",
|
|
62
|
+
(),
|
|
63
|
+
{
|
|
64
|
+
"model": model_class,
|
|
65
|
+
"fields": "__all__",
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
serializer_class = type(
|
|
69
|
+
f"{model_class.__name__}Serializer",
|
|
70
|
+
(serializers.ModelSerializer,),
|
|
71
|
+
{"Meta": meta_class},
|
|
72
|
+
)
|
|
73
|
+
return serializer_class
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_serializer_cache: dict = {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_serializer_for_model(app_label: str, model_name: str):
|
|
80
|
+
cache_key = f"{app_label}.{model_name}"
|
|
81
|
+
if cache_key not in _serializer_cache:
|
|
82
|
+
model_class = apps.get_model(app_label, model_name)
|
|
83
|
+
_serializer_cache[cache_key] = build_model_serializer(model_class)
|
|
84
|
+
return _serializer_cache[cache_key]
|
snapadmin/api/tasks.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
snapadmin/api/tasks.py
|
|
3
|
+
|
|
4
|
+
Celery background tasks for the API module.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from celery import shared_task
|
|
10
|
+
from django.utils import timezone
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("snapadmin.api.tasks")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@shared_task(bind=True, name="api.tasks.purge_expired_tokens")
|
|
16
|
+
def purge_expired_tokens(self):
|
|
17
|
+
from snapadmin.models import APIToken
|
|
18
|
+
|
|
19
|
+
cutoff = timezone.now()
|
|
20
|
+
deleted_qs = APIToken.objects.filter(
|
|
21
|
+
expiration_date__lt=cutoff,
|
|
22
|
+
expiration_date__isnull=False,
|
|
23
|
+
)
|
|
24
|
+
count, _ = deleted_qs.delete()
|
|
25
|
+
|
|
26
|
+
logger.info("expired_tokens_purged", count=count, cutoff=cutoff.isoformat())
|
|
27
|
+
return {"deleted": count, "cutoff": cutoff.isoformat()}
|