Define your Django models once โ instantly get an admin panel, REST API, GraphQL and full-text search. Zero boilerplate.
Project description
๐ SnapAdmin โ Declarative Django Admin & API Package
SnapAdmin is a declarative Django package that eliminates admin and API boilerplate. Define your model fields once โ get a feature-rich Django admin (powered by Unfold), a full REST API with Swagger docs, a dynamic GraphQL API, and optional Elasticsearch full-text search. Every surface (REST, GraphQL, Swagger, ES) can be switched on or off with a single setting, and expensive ?search= API queries are automatically routed to Elasticsearch when a model's data is mirrored there โ plain listings stay on the database.
๐ Full Documentation โ Configuration guide, API reference, examples
โก The Core Idea โ 3 Steps, Full Stack
Define a model. Configure settings. Everything works.
# 1. Define a model
from snapadmin import fields as snap, models as snap_models
class Product(snap_models.SnapModel):
name = snap.SnapCharField(max_length=200, searchable=True, show_in_list=True)
price = snap.SnapDecimalField(max_digits=10, decimal_places=2, filterable=True)
available = snap.SnapBooleanField(default=True, filterable=True)
# 2. (Optional) Enable Elasticsearch + GDPR cleanup
# es_storage_mode = snap_models.EsStorageMode.DUAL # DB + ES in sync
# data_retention_days = 365 # Auto-delete records older than 1 year
That's it. You instantly get:
| What | How |
|---|---|
| Django Admin | Auto-registered, filtered, searchable โ no admin.py needed |
| REST API | Full CRUD at /api/product/ with Swagger docs |
| GraphQL | Dynamic schema at /api/graphql/ |
| ES Search | Optional โ enable with es_storage_mode = EsStorageMode.DUAL |
| GDPR Cleanup | Optional โ enable with data_retention_days on any model |
Elasticsearch Storage Modes
| Mode | Where data lives | When to use |
|---|---|---|
DB_ONLY (default) |
PostgreSQL only | Any model where search speed isn't critical |
DUAL |
PostgreSQL + Elasticsearch | Full-text search on large product/article catalogs |
ES_ONLY |
Elasticsearch only | High-frequency write logs, analytics events |
Searchable fields are declared in es_mapping (a {field: ES mapping} dict). Search
through es_search() โ a fuzzy multi_match when ES is on, with an automatic ORM
fallback when it's off, so the same call works everywhere (snap_search() is an alias):
# Fuzzy, typo-tolerant full-text search (default limit=20)
results = Product.es_search("wireles headphones") # typo still matches
top5 = Product.es_search("laptop", limit=5)
browse = Product.es_search(limit=100) # no query โ match-all, newest first
# ES_ONLY models are read *only* through es_search() (no DB table)
logs = SearchLog.es_search("error 404")
# DB_ONLY models answer too โ falls back to an ORM icontains query
Article.es_search("django") # works even with ELASTICSEARCH_ENABLED=False
For DB_ONLY/DUAL it returns a Django QuerySet; for ES_ONLY a lightweight
EsQuerySet of instances built from the index.
Full-text queries target only the text-capable fields of es_mapping (with
lenient: true), so mixed mappings with numeric/date fields never break a search.
Index-level settings โ custom analyzers, shards โ go into es_index_settings
(applied when the index is first created):
class Product(snap_models.SnapModel):
es_storage_mode = snap_models.EsStorageMode.DUAL
es_mapping = {
"name": {"type": "text", "analyzer": "de_analyzer"},
"price": {"type": "float"},
}
es_index_settings = {
"analysis": {"analyzer": {"de_analyzer": {"type": "german"}}},
"number_of_shards": 1,
}
Don't want to write mappings at all? Set es_auto_mapping = True and the mapping
is derived from your model fields โ CharField/TextField become text with a
.raw keyword subfield (exact match + aggregations), Email/Slug/URL/UUID/IP
โ keyword, integers/FK โ long, Decimal โ scaled_float, dates โ date,
JSONField โ object. Anything you declare in es_mapping overrides the derived
entry for that field:
class SearchLog(snap_models.SnapModel):
query = snap.SnapCharField(max_length=255, searchable=True)
results_count = snap.SnapIntegerField()
es_storage_mode = snap_models.EsStorageMode.ES_ONLY
es_auto_mapping = True # mapping derived from the fields above
# es_mapping = {"query": {"type": "search_as_you_type"}} # optional override
To change settings/mappings of an existing index: delete it, then run
Product.es_reindex_all()โ it recreates the index with the current definition and streams all rows through the bulk API (one round-trip per 500 docs, flat memory). Every swallowed ES failure (index creation, indexing, search fallback) is logged as a structlogwarning, so outages are visible instead of silent.
๐ What You'll See
After running docker compose up --build and visiting http://localhost:8000/admin/:
Django Admin (powered by Unfold)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SnapAdmin ๐ Search... admin โพโ
โโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ DEMO APP โ Products + Add โ
โ Categories โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Tags โ โ Name Price In Stock Category โโ
โ Products โ โ Premium Laptop $249 โ Active Audio โโ
โ Customers โ โ Ergonomic Mouse $89 โ Active Access. โโ
โ Orders โ โ USB-C Hub $49 โ Out Electr. โโ
โ Audit Logs โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Showcase โ Sidebar filters: Price range โ Available โ
โ SYSTEM โ Category โ โ
โ Dashboard โ โ
โโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
REST API Docs (Swagger UI) โ http://localhost:8000/api/docs/
GET /api/product/ List all products (filterable, paginated)
POST /api/product/ Create a product
GET /api/product/{id}/ Retrieve a product
PUT /api/product/{id}/ Update a product
DEL /api/product/{id}/ Delete a product
GET /api/customer/ โฆsame for every SnapModel
GraphQL Playground โ http://localhost:8000/api/graphql/
query {
allProducts(first: 10) {
edges { node { id name price available } }
}
}
System Dashboard โ http://localhost:8000/admin/snapadmin/dashboard/
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ System Dashboard v0.1.0a5 โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ Product โ โ Customer โ โ Order โ โ AuditLog โ โ
โ โ 50 rows โ โ 50 rows โ โ 50 rows โ โ 50 rows โ โ
โ โ [dual] โ โ [db_only]โ โ [db_only]โ โ 90d ret. โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ Cron Jobs โ
โ reindex_products_to_es daily at 02:00 โ
โ purge_expired_data daily at 03:00 โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
๐ฆ SnapAdmin Package Features
The core snapadmin package provides everything you need to bootstrap your project's admin and API:
| Feature | Description |
|---|---|
| Declarative Admin | Configure list_display, search_fields, list_filter directly in your models using SnapField. |
| Beautiful UI | Native integration with django-unfold for a modern, responsive admin experience. |
| Status Badges | Easily add color-coded HTML badges for choices and status fields. |
| Advanced Layout | Support for horizontal field rows and tabbed interfaces within the admin form. |
| Range Filters | Built-in date and numeric range filters for efficient data exploration. |
| Change Logging | Automatic tracking of field-level changes (old โ new) with a dedicated history view. |
| Automatic REST API | Instantly generated CRUD endpoints for every SnapModel with zero extra code. |
| Dynamic GraphQL API | Automatically generated GraphQL schema with support for complex data fetching. |
| Token Auth | Expirable API tokens with granular model-level access control. Keys are hashed at rest (SHA-256) and shown only once, at creation. |
| Configurable | Easily enable/disable REST API, GraphQL, Swagger docs, and search modes via settings. |
| Elasticsearch Ready | Multi-mode storage (DB_ONLY, DUAL, ES_ONLY) for blazing fast search. |
| Smart ES Query Routing | ?search= REST queries on DUAL models run on Elasticsearch automatically (fuzzy, relevance-ranked); plain listings stay on the DB. Toggle globally (SNAPADMIN_ES_QUERY_ROUTING) or per model (es_query_routing). |
| Auto ES Mapping | es_auto_mapping = True derives the index mapping from your model fields (text + .raw keyword subfields, dates, numerics); es_mapping entries override per field, es_index_settings adds analyzers/shards. |
| Secured GraphQL | Every resolver enforces authentication (session or API token) + per-model Django permissions โ the same contract as REST. search/first/offset arguments included. |
| API Field Privacy | api_exclude_fields hides sensitive columns from REST, GraphQL and schema introspection while the admin keeps showing them. |
| GDPR Data Retention | Per-model data_retention_days parameter with automatic Celery cleanup task. |
| Error Monitoring & Email Alerts | Optional middleware records every unhandled exception / 5xx as a browsable ErrorEvent; emails a spike alert when N errors hit within 15 minutes and a daily grouped digest (Celery Beat or cron) โ thresholds, window, recipients and send time all configurable. |
| 3-2-1 Database Backups | Scheduled DB dumps (SQLite copy / pg_dump, gzip) shipped to three configurable destinations โ a local directory, a network server (mounted share) and a remote offsite FTP/FTPS โ each with its own frequency and retention. |
| Offline Mode | Per-model offline_mode toggle: prefetches the last offline_cache_limit rows into IndexedDB, polls /api/health/ for real backend availability, shows dynamic toasts + a saved-objects panel, and syncs on reconnect. |
| Large-Dataset Tuning | Auto-derived list_select_related (no admin N+1), plus per-model list_per_page / show_full_result_count knobs for million-row tables. |
| Structured Logging | Integrated structlog for readable local logs and JSON logs in production. |
๐ Package Architecture
snapadmin/
โโโ api/ # REST & GraphQL API core: views, serializers, auth
โโโ management/ # Custom management commands
โโโ migrations/ # Core package migrations (e.g., APIToken)
โโโ static/ # UI assets (CSS, JS, SVG logos)
โโโ templates/ # Custom admin templates & dashboard
โโโ fields.py # SnapField definitions with admin introspection
โโโ models.py # SnapModel base, EsManager, and core logic
โโโ urls.py # Auto-configurable API and documentation routes
๐ Quickstart: Installation
From PyPI (Recommended)
pip install django-snapadmin
From GitHub (Latest/Development)
pip install git+https://github.com/drofji/django-snapadmin.git
๐ Usage & Configuration
1. Configure Settings
Add the required apps to INSTALLED_APPS in settings.py. Order matters โ unfold
and its contrib apps must come before django.contrib.admin, and django_ckeditor_5
must be present because SnapModel imports the CKEditor 5 widget at load time:
INSTALLED_APPS = [
# Theme โ must precede django.contrib.admin
"unfold",
"unfold.contrib.filters",
"unfold.contrib.forms",
"unfold.contrib.inlines",
# WYSIWYG (required โ imported by SnapModel)
"django_ckeditor_5",
# Django core
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# SnapAdmin stack (all required)
"rest_framework",
"drf_spectacular",
"django_filters",
"graphene_django",
"snapadmin",
# Optional โ only with the [celery] extra
# "django_celery_beat",
# "django_celery_results",
# Your apps โฆ
]
Installing
django-snapadminpulls indjango-unfold,django-ckeditor-5,djangorestframework,drf-spectacular,django-filterandgraphene-djangoautomatically โ you only need to list them inINSTALLED_APPS.
2. Define your Model
from snapadmin import fields as snap, models as snap_models
class Product(snap_models.SnapModel):
name = snap.SnapCharField(max_length=200, searchable=True, show_in_list=True)
# Group fields into a single horizontal row
price = snap.SnapDecimalField(max_digits=10, decimal_places=2, row="pricing")
available = snap.SnapBooleanField(default=True, row="pricing")
3. Register Admin
# admin.py
from snapadmin.models import SnapModel
SnapModel.register_all_admins()
Theming & Styles
SnapAdmin ships its admin styling as two layers so it never forces theme assumptions on installs that don't use Unfold:
| Stylesheet | Scope | When it loads |
|---|---|---|
snapadmin/css/admin.css |
Theme-agnostic core (field sizing, Select2, action bar, :root design tokens) |
Always, on every SnapModel admin page |
snapadmin/css/admin-unfold.css |
Unfold-specific overrides (.unfold-scoped rules, dark-mode borders, Add-button fix) |
Only when django-unfold is installed |
The Unfold layer is opt-in: it is appended automatically (after the core sheet,
so its rules win the cascade) only when Unfold is detected. A plain Django admin
install โ or one on another theme โ gets the core sheet alone and is never
styled with Unfold assumptions. The shared design tokens (--primary-color,
--radius, โฆ) live in the core sheet so both layers reference the same values.
Available Field Types
| Field | Django Equivalent | SnapAdmin Extras |
|---|---|---|
SnapCharField |
CharField |
searchable, filterable |
SnapTextField |
TextField |
- |
SnapRichTextField |
TextField |
wysiwyg=True preset - no extra arg needed |
SnapEmailField |
EmailField |
- |
SnapPhoneField |
CharField |
phone validation, max_length=20 preset |
SnapColorField |
CharField |
hex color validation (#RRGGBB), max_length=7 preset |
SnapSlugField |
SlugField |
max_length=50 preset |
SnapURLField |
URLField |
- |
SnapUUIDField |
UUIDField |
- |
SnapIntegerField |
IntegerField |
filterable |
SnapSmallIntegerField |
SmallIntegerField |
filterable |
SnapPositiveIntegerField |
PositiveIntegerField |
filterable |
SnapPositiveSmallIntegerField |
PositiveSmallIntegerField |
filterable |
SnapPositiveBigIntegerField |
PositiveBigIntegerField |
filterable |
SnapBigIntegerField |
BigIntegerField |
filterable |
SnapFloatField |
FloatField |
filterable |
SnapDecimalField |
DecimalField |
filterable |
SnapDateField |
DateField |
date range filter |
SnapDateTimeField |
DateTimeField |
date range filter |
SnapTimeField |
TimeField |
- |
SnapDurationField |
DurationField |
- |
SnapBooleanField |
BooleanField |
filterable |
SnapJSONField |
JSONField |
- |
SnapGenericIPAddressField |
GenericIPAddressField |
- |
SnapFileField |
FileField |
extension/size/encoding validation |
SnapImageField |
ImageField |
- |
SnapForeignKey |
ForeignKey |
autocomplete |
SnapOneToOneField |
OneToOneField |
autocomplete |
SnapManyToManyField |
ManyToManyField |
- |
SnapFunctionField |
- | computed display column |
SnapStatusBadgeField |
- | colored HTML badge column |
โ๏ธ Feature Toggles & Advanced Settings
Every SnapAdmin surface can be enabled or disabled independently via Django settings. Disabling a toggle removes the corresponding URL routes entirely (requests return 404):
# Feature toggles
SNAPADMIN_REST_API_ENABLED = True # REST CRUD endpoints (/api/models/โฆ, /api/tokens/โฆ)
SNAPADMIN_GRAPHQL_ENABLED = True # GraphQL endpoint (/api/graphql/)
SNAPADMIN_SWAGGER_ENABLED = True # Swagger UI + ReDoc (/api/docs/, /api/redoc/)
ELASTICSEARCH_ENABLED = False # Elasticsearch integration as a whole
# Smart ES query routing (see "REST API in Practice" below)
SNAPADMIN_ES_QUERY_ROUTING = True # Route ?search= on DUAL models to Elasticsearch
SNAPADMIN_ES_SEARCH_LIMIT = 1000 # Max hits fetched from ES per routed search
# Observability & GraphQL security
SNAPADMIN_QUERY_BACKEND_HEADER = True # X-Snap-Query-Backend header on list responses
SNAPADMIN_GRAPHQL_REQUIRE_AUTH = True # Auth + per-model perms on every resolver
SNAPADMIN_GRAPHIQL_ENABLED = DEBUG # GraphiQL playground โ keep out of production
Per-model opt-out from query routing (e.g. when the ES mirror of one model lags):
class Product(snap_models.SnapModel):
es_storage_mode = snap_models.EsStorageMode.DUAL
es_query_routing = False # this model's API searches always run on the DB
Hide sensitive columns from the whole API surface (REST, GraphQL, schema introspection) while keeping them in the admin:
class AuditLog(snap_models.SnapModel):
action = snap.SnapCharField(max_length=100, searchable=True)
user_email = snap.SnapEmailField() # PII
api_exclude_fields = ["user_email"] # never leaves the server via API
๐ REST API in Practice โ Query Examples
All examples assume an API token (create one in the admin under API Tokens, or via
POST /api/tokens/ โ the raw key is shown once):
TOKEN="your-40-char-token-key"
BASE="http://localhost:8000/api"
# Discover every available endpoint and its fields
curl -H "Authorization: Token $TOKEN" "$BASE/models/schema/"
# Plain listing โ paginated, served by the database
curl -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/?page=2"
# Field filters (auto-generated from field types, visible in Swagger)
curl -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/?available=true&price__gte=100"
# Full CRUD
curl -X POST -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"name": "Laptop Pro", "price": "1499.00", "available": true}' \
"$BASE/models/demo/Product/"
curl -X PATCH -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"available": false}' "$BASE/models/demo/Product/42/"
curl -X DELETE -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/42/"
Full-text search โ automatically routed to Elasticsearch
?search= runs against the model's searchable=True fields. For a DUAL-storage
model (data mirrored in ES) the very same request is executed on Elasticsearch โ
fuzzy, typo-tolerant, relevance-ranked โ with no change to the URL or your client
code. Filters and pagination still apply on top of the ES-ranked result:
# Product is DUAL โ this search runs on Elasticsearch (typo still matches)
curl -i -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/?search=laptp"
# HTTP/1.1 200 OK
# X-Snap-Query-Backend: elasticsearch โ the search ran on ES
# {"count": 3, "results": [{"id": 42, "name": "Laptop Pro", โฆ}, โฆ]}
# Combine ES search with DB filters and pagination โ still one request
curl -H "Authorization: Token $TOKEN" \
"$BASE/models/demo/Product/?search=laptop&available=true&page=1"
# The same URL on a DB_ONLY model transparently uses SQL icontains instead
curl -i -H "Authorization: Token $TOKEN" "$BASE/models/demo/Customer/?search=7"
# X-Snap-Query-Backend: database โ no ES mirror, DB handled it
Routing decision per request, in order:
| Model mode | ?search= present |
ES routing on | Executed on |
|---|---|---|---|
ES_ONLY |
any | โ | Elasticsearch (only source) |
DUAL |
yes | yes | Elasticsearch (fuzzy multi_match, relevance order) |
DUAL |
yes | no | Database (icontains over searchable fields) |
DUAL |
no | โ | Database (native pagination, no ES round-trip) |
DB_ONLY |
yes | โ | Database (icontains over searchable fields) |
Every list response carries the X-Snap-Query-Backend: elasticsearch | database
header, so you can always verify where a query ran โ including the case where ES
failed mid-request and the DB fallback silently answered (the header then says
database). Hide the header in production with SNAPADMIN_QUERY_BACKEND_HEADER = False.
GraphQL โ same tokens, same permissions
GraphQL enforces the same access contract as REST: every resolver requires an
authenticated caller (admin session or Authorization: Token) holding the model's
view permission; a token's allowed_models scope applies on top. List fields
accept search (ES-routed for DUAL/ES_ONLY models), first and offset:
curl -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"query": "{ allDemoProducts(search: \"laptop\", first: 10) { id name price } }"}' \
"http://localhost:8000/api/graphql/"
# Anonymous callers get {"errors": [{"message": "Authentication required."}]}
GraphiQL (the interactive playground) follows DEBUG by default โ override with
SNAPADMIN_GRAPHIQL_ENABLED. Auth enforcement can be disabled for private
deployments with SNAPADMIN_GRAPHQL_REQUIRE_AUTH = False (not recommended).
๐ API Token Security
API tokens authenticate REST/GraphQL requests via the Authorization: Token <key> header.
from snapadmin.models import APIToken
token = APIToken.create_for_user(
user=user,
token_name="CI Pipeline",
allowed_models=["myapp.Product", "myapp.Order"], # optional scope
expires_in_days=30,
)
print(token.token_key) # raw key โ available ONLY here, right after creation
- Hashed at rest โ only a SHA-256
token_digestand the non-secret 8-chartoken_prefixare stored. The rawtoken_keyis never persisted; it is returned exactly once (thePOST /api/tokens/response, or a one-time admin message). Afterwardstoken_keyisNoneand onlytoken_prefixidentifies the token. Authentication looks the presented key up by its digest. allowed_modelsโ empty โ unrestricted. An empty list means "any model the owning user already has Django permissions for"; the token scope is always AND-ed withuser.has_perm. A non-empty list narrows access to exactly those"app_label.ModelName"entries.
GDPR Data Retention
Add automatic record cleanup to any model with two class attributes:
class AuditLog(snap_models.SnapModel):
action = snap.SnapCharField(max_length=100)
created_at = snap.SnapDateTimeField(auto_now_add=True)
# Auto-delete records older than 90 days
data_retention_days = 90
data_retention_field = "created_at" # default; can point to any DateTimeField
Records are removed by the purge_expired_data Celery task (schedule it with Celery Beat) or manually:
python manage.py purge_expired_data # live run
python manage.py purge_expired_data --dry-run # preview only
Or programmatically, per model โ returns the number of records purged:
AuditLog.purge_expired() # delete expired rows now
AuditLog.purge_expired(dry_run=True) # count only, delete nothing
The purge is storage-aware, so personal data never lingers in a secondary store:
| Mode | What gets purged |
|---|---|
DB_ONLY |
Bulk delete from the database. |
DUAL |
Database rows and the mirrored Elasticsearch documents. |
ES_ONLY |
Range delete_by_query against the index on data_retention_field. |
A plain
QuerySet.delete()never calls each model'sdelete(), so a naรฏve bulk purge would leave the Elasticsearch copy behind.purge_expired()closes that gap forDUALandES_ONLYmodels (ES operations requireELASTICSEARCH_ENABLED=True).
๐จ Error Monitoring & Email Alerts
Optional, zero-dependency error notifications. One middleware records every unhandled
exception and 5xx response as an ErrorEvent (browsable in the admin under
Error Events), and two email channels keep you informed:
- Spike alert โ when
SNAPADMIN_ERROR_ALERT_THRESHOLDerrors occur withinSNAPADMIN_ERROR_ALERT_WINDOW_MINUTES(default: 20 errors / 15 min), one email goes out immediately. A cooldown guarantees at most one alert per window โ no inbox floods. - Daily digest โ a grouped 24-hour report (identical errors are merged by
exception class + endpoint, most frequent first). The digest is capped at
SNAPADMIN_ERROR_DIGEST_MAX_GROUPSgroups so it stays readable even on a bad day.
Prerequisite: working Django email settings (
EMAIL_BACKEND,EMAIL_HOST, โฆ โ i.e. a configured SMTP server) andDEFAULT_FROM_EMAIL. No emails are sent while the recipient lists are empty, so the feature is safely inert until you opt in.
1. Enable the middleware:
MIDDLEWARE = [
# ... Django middleware ...
"snapadmin.middleware.SnapErrorMonitorMiddleware",
]
2. Configure (all values shown are the defaults):
SNAPADMIN_ERROR_MONITOR_ENABLED = True # master kill-switch (no MIDDLEWARE edit needed)
# Spike alert
SNAPADMIN_ERROR_ALERT_ENABLED = True
SNAPADMIN_ERROR_ALERT_THRESHOLD = 20 # errors ...
SNAPADMIN_ERROR_ALERT_WINDOW_MINUTES = 15 # ... within this window โ email
SNAPADMIN_ERROR_ALERT_EMAILS = ["ops@example.com"]
# Daily digest
SNAPADMIN_ERROR_DIGEST_ENABLED = True
SNAPADMIN_ERROR_DIGEST_EMAILS = ["team@example.com"] # falls back to ALERT_EMAILS
SNAPADMIN_ERROR_DIGEST_MAX_GROUPS = 20 # cap on distinct error groups per email
# Housekeeping: ErrorEvents older than this are purged by the digest task
SNAPADMIN_ERROR_RETENTION_DAYS = 30
3. Schedule the digest โ pick the send time via Celery Beat:
CELERY_BEAT_SCHEDULE = {
"send-error-digest": {
"task": "api.tasks.send_error_digest",
"schedule": crontab(hour=8, minute=0), # your choice of send time
},
}
โฆor without Celery, from cron:
0 8 * * * python manage.py send_error_digest # last 24h, grouped
python manage.py send_error_digest --hours 12 # custom window
Monitoring is fail-safe by design: storage or SMTP failures are logged
(error_monitor_record_failed, error_monitor_alert_failed) and swallowed โ a broken
mail server never breaks your pages. Try it in the demo: hit http://localhost:8000/demo/error/
a few times (DEBUG only) and watch Error Events fill up; alert emails land in the
console with the default DEBUG email backend.
๐พ 3-2-1 Database Backups
Built-in scheduled backups following the classic 3-2-1 rule โ keep 3 copies of your data, on 2 different machines, 1 of them offsite:
| Copy | Destination | Where it lives | Default frequency |
|---|---|---|---|
| 1 | local |
A directory on the same server (SNAPADMIN_BACKUP_LOCAL_DIR) |
every 24 h |
| 2 | network |
A directory on another server on your network, reachable as a mounted NFS/SMB share (SNAPADMIN_BACKUP_NETWORK_DIR) |
every 24 h |
| 3 | remote |
An offsite server anywhere in the world via FTP/FTPS (SNAPADMIN_BACKUP_FTP_*) |
every 168 h (weekly) |
Dumps are gzip-compressed โ a file copy for SQLite, pg_dump for PostgreSQL โ and each
destination keeps the newest SNAPADMIN_BACKUP_KEEP dumps (oldest pruned automatically,
including on the FTP server).
Configuration (each destination has its own schedule โ tune how often the local, network and remote copies are refreshed independently):
SNAPADMIN_BACKUP_ENABLED = True # strictly opt-in (default: False)
SNAPADMIN_BACKUP_KEEP = 7 # dumps kept per destination
# Copy 1 โ same server
SNAPADMIN_BACKUP_LOCAL_DIR = "/var/backups/snapadmin"
SNAPADMIN_BACKUP_LOCAL_EVERY_HOURS = 24 # daily
# Copy 2 โ another server on the same network (mounted share); empty = off
SNAPADMIN_BACKUP_NETWORK_DIR = "/mnt/backup-server/snapadmin"
SNAPADMIN_BACKUP_NETWORK_EVERY_HOURS = 24 # daily
# Copy 3 โ offsite FTP/FTPS; empty host = off
SNAPADMIN_BACKUP_FTP_HOST = "backup.example.com"
SNAPADMIN_BACKUP_FTP_PORT = 21
SNAPADMIN_BACKUP_FTP_USER = "backup"
SNAPADMIN_BACKUP_FTP_PASSWORD = "secret"
SNAPADMIN_BACKUP_FTP_DIR = "/snapadmin"
SNAPADMIN_BACKUP_FTP_TLS = True # FTPS (recommended)
SNAPADMIN_BACKUP_REMOTE_EVERY_HOURS = 168 # weekly
Running โ the scheduler is a separate process from your web workers. Add the
api.tasks.run_db_backups task to Celery Beat (an hourly check; each destination only
fires when its own interval has elapsed โ last-run times persist across restarts):
CELERY_BEAT_SCHEDULE = {
"run-db-backups": {
"task": "api.tasks.run_db_backups",
"schedule": crontab(minute=30), # hourly due-check
},
}
โฆor without Celery, from cron:
30 * * * * python manage.py db_backup # ships only what is due
python manage.py db_backup --force # all configured destinations, now
python manage.py db_backup --destination remote # one destination, now
A failed destination (unreachable share, FTP down) is reported and logged but never cancels the other copies โ and it stays "due", so it is retried on the next pass.
๐ด Offline Mode
Make a model's admin list view survive a dropped connection with a single toggle:
class Customer(snap_models.SnapModel):
first_name = snap.SnapCharField(max_length=100, show_in_form=True)
last_name = snap.SnapCharField(max_length=100, show_in_form=True)
# Cache this model's list view client-side and enable offline support
offline_mode = True
# Prefetch only the 50 most-recent rows for offline view (default: 100)
offline_cache_limit = 50
When offline_mode = True, SnapAdmin injects snapadmin/js/offline.js into that
model's admin pages only. It then:
- Prefetches the most-recent
offline_cache_limitrows (default 100) fromGET /api/offline-data/<app>/<model>/and stores them in the browser's IndexedDB on every visit (the rendered list is kept as a fallback snapshot). - Repaints the list from cache when the backend becomes unreachable, and shows a saved-objects panel โ how many objects are cached (out of the limit), when they were cached, and how many changes are queued for sync.
- Queues mutations made while offline and replays them on reconnect, then refreshes the cache and shows a "synced N changes" toast.
Real backend health checks, not just navigator.onLine
Connectivity is decided by whether the Django backend actually answers, not by the
OS network flag โ a laptop can hold a Wi-Fi link while the server is down or the VPN
dropped. A lightweight connectivity.js loads on all SnapModel admin pages and:
- Polls
GET /api/health/(every 15s by default โ setwindow.SNAPADMIN_HEALTH_INTERVALto override) with a short timeout, and re-checks immediately on browseronline/offlineevents and tab refocus. The backend is "up" only when it responds. - Publishes one shared state as a
snapadmin:connectivityDOM event, so the connectivity layer and the per-model engine always agree. - Dynamic toasts, not static banners โ backend-lost / restored, "objects can't be shown right now" (non-cached pages), and "synced N changes" surface as auto-dismissing toasts.
On models that are not offline-capable, losing the backend shows a warning toast, blocks form submission, and disables the Save buttons until it returns (preventing silent data loss) while leaving the already-rendered page intact. Sidebar badges mark every model link so you can see which models sync offline: a green sync icon (spins while the backend is down) for offline-capable models, a muted no-offline icon for the rest.
The badge list and per-model cache limits are served by GET /api/offline-models/
(authenticated), with a localStorage fallback so badges still render while offline.
No settings, migrations, or extra dependencies are required โ it is pure client-side behavior gated per model. Models without the flag still get the connectivity warnings but ship no caching JS.
โก Large-Dataset Performance
SnapAdmin is built to stay responsive as tables grow. Most of the tuning is automatic, and the rest is a handful of per-model knobs.
Automatic โ no admin N+1
For every model, register_admin() inspects the columns shown in the list view and
auto-derives list_select_related from the ForeignKey columns among them. A list
view that renders a related column (or a __str__ that walks a relation) therefore
issues one joined query, not one query per row. Only the FKs actually displayed are
joined โ relations you don't show are never pulled.
The auto-generated REST API does the same on its querysets: select_related() for
ForeignKeys and prefetch_related() for many-to-many fields, with the field lists cached
per model to keep introspection out of the hot path.
Per-model knobs
Override these class attributes on any SnapModel to tune the admin list view:
class AuditLog(snap_models.SnapModel):
action = snap.SnapCharField(max_length=100, searchable=True)
list_per_page = 50 # rows per page (default 100)
list_max_show_all = 200 # cap on the "Show all" link
show_full_result_count = False # skip the unfiltered COUNT(*) on huge tables
| Attribute | Default | When to change it |
|---|---|---|
list_per_page |
100 |
Lower it for wide rows or heavy templates. |
list_max_show_all |
200 |
Guards against a "Show all" on a million-row table. |
show_full_result_count |
True |
Set False on very large tables โ the admin then skips the second, unfiltered COUNT(*) it runs to show the grand total, which is often the single most expensive query. |
REST pagination
The REST API paginates by default (PageNumberPagination, PAGE_SIZE = 25), so large
collections are never serialized in one response. Tune it via the REST_FRAMEWORK
setting.
Offloading search to Elasticsearch
For DUAL models the expensive part โ full-text ?search= โ is routed to
Elasticsearch automatically, while plain listings keep the database's native
pagination (no row cap, no extra round-trip). ES_ONLY models are always served
from ES, since no DB table exists. See REST API in Practice above for the
routing matrix and the X-Snap-Query-Backend response header.
Benchmarking at scale
Two demo management commands let you reproduce the numbers on your own hardware:
# Bulk-seed 100k customers + orders (batched bulk_create, flat memory)
python manage.py seed_large --count 100000
# Time the Order changelist queryset with vs without list_select_related
python manage.py benchmark_list_view --model order
benchmark_list_view iterates the changelist queryset and touches each row's
ForeignKey, so the unoptimized run pays the full N+1 cost while the optimized run
issues a single joined query. Representative output on a seeded table:
๐ Result
WITHOUT : 5,001 queries 584.5 ms
WITH : 1 queries 37.8 ms
Query reduction : 5,001 โ 1 (5001ร fewer)
Speedup : 15.5ร faster wall time
The query count for the unoptimized path scales linearly with row count (N + 1),
while the optimized path stays flat at 1 โ exactly the N+1 elimination
list_select_related is there to provide.
Going deeper
The section above covers SnapAdmin's knobs. For the broader data-access patterns
behind them โ select_related vs prefetch_related, only()/values(), keyset
vs offset pagination, indexing, the N+1 problem, the SQL/NoSQL trade-off, and
denormalization โ see the Optimizations Guide
in the full documentation.
๐ง Environment Variables Reference
Copy dist.env to .env and configure:
| Variable | Default | Description |
|---|---|---|
SECRET_KEY |
insecure placeholder | Django secret key - must be changed in production |
DEBUG |
True |
Enable Django debug mode - set False in production |
ALLOWED_HOSTS |
localhost,... |
Comma-separated allowed hostnames |
LOG_LEVEL |
INFO |
Log verbosity: DEBUG, INFO, WARNING, ERROR |
JSON_LOGS |
False |
Structured JSON log output for production log aggregation |
POSTGRES_DB |
snapadmin |
PostgreSQL database name |
POSTGRES_USER |
snapadmin |
PostgreSQL username |
POSTGRES_PASSWORD |
snapadmin |
PostgreSQL password |
POSTGRES_HOST |
db |
PostgreSQL host (Docker service name or IP) |
POSTGRES_PORT |
5432 |
PostgreSQL port |
REDIS_URL |
redis://redis:6379/0 |
Redis URL for Celery broker and result backend |
ELASTICSEARCH_URL |
http://elasticsearch:9200 |
Elasticsearch cluster URL |
ELASTICSEARCH_ENABLED |
False |
Enable ES integration; when False all models use DB_ONLY |
SNAPADMIN_REST_API_ENABLED |
True |
Serve the REST CRUD endpoints (False removes the routes) |
SNAPADMIN_SWAGGER_ENABLED |
True |
Serve Swagger UI + ReDoc |
SNAPADMIN_GRAPHQL_ENABLED |
True |
Serve the GraphQL endpoint |
SNAPADMIN_ES_QUERY_ROUTING |
True |
Route ?search= on DUAL models to Elasticsearch |
SNAPADMIN_ES_SEARCH_LIMIT |
1000 |
Max hits fetched from ES per routed search |
SNAPADMIN_QUERY_BACKEND_HEADER |
True |
Expose the X-Snap-Query-Backend header on list responses |
SNAPADMIN_GRAPHQL_REQUIRE_AUTH |
True |
Require auth + per-model perms on every GraphQL resolver |
SNAPADMIN_GRAPHIQL_ENABLED |
DEBUG |
GraphiQL playground โ keep out of production |
SNAPADMIN_THROTTLE_ANON |
60/min |
DRF rate limit for anonymous callers |
SNAPADMIN_THROTTLE_USER |
600/min |
DRF rate limit for authenticated clients |
SNAPADMIN_SEED_ADMIN_PASSWORD |
โ | Password for the seeded superuser; admin/admin default allowed only with DEBUG=True |
EMAIL_HOST / EMAIL_PORT |
localhost / 587 |
SMTP server for notification emails |
EMAIL_HOST_USER / EMAIL_HOST_PASSWORD |
โ | SMTP credentials |
EMAIL_USE_TLS |
True |
Use STARTTLS for SMTP |
DEFAULT_FROM_EMAIL |
snapadmin@localhost |
From address of alert/digest emails |
SNAPADMIN_ERROR_MONITOR_ENABLED |
True |
Record unhandled exceptions / 5xx as ErrorEvents |
SNAPADMIN_ERROR_ALERT_ENABLED |
True |
Enable the error spike alert email |
SNAPADMIN_ERROR_ALERT_THRESHOLD |
20 |
Errors within the window that trigger the alert |
SNAPADMIN_ERROR_ALERT_WINDOW_MINUTES |
15 |
Rolling window for the spike alert |
SNAPADMIN_ERROR_ALERT_EMAILS |
โ | Comma-separated alert recipients (empty = no alerts) |
SNAPADMIN_ERROR_DIGEST_ENABLED |
True |
Enable the daily grouped error digest |
SNAPADMIN_ERROR_DIGEST_EMAILS |
โ | Digest recipients; falls back to alert emails |
SNAPADMIN_ERROR_DIGEST_MAX_GROUPS |
20 |
Max distinct error groups per digest email |
SNAPADMIN_ERROR_DIGEST_HOUR / _MINUTE |
8 / 0 |
Daily send time of the digest (Celery Beat) |
SNAPADMIN_ERROR_RETENTION_DAYS |
30 |
Purge ErrorEvents older than this |
SNAPADMIN_BACKUP_ENABLED |
False |
Enable scheduled 3-2-1 database backups |
SNAPADMIN_BACKUP_KEEP |
7 |
Dumps kept per destination (oldest pruned) |
SNAPADMIN_BACKUP_LOCAL_DIR |
./backups |
Copy 1: directory on the same server |
SNAPADMIN_BACKUP_LOCAL_EVERY_HOURS |
24 |
How often the local copy refreshes |
SNAPADMIN_BACKUP_NETWORK_DIR |
โ | Copy 2: mounted share of a server on your network (empty = off) |
SNAPADMIN_BACKUP_NETWORK_EVERY_HOURS |
24 |
How often the network copy refreshes |
SNAPADMIN_BACKUP_FTP_HOST / _PORT |
โ / 21 |
Copy 3: offsite FTP/FTPS server (empty host = off) |
SNAPADMIN_BACKUP_FTP_USER / _PASSWORD |
โ | FTP credentials |
SNAPADMIN_BACKUP_FTP_DIR |
/ |
Target directory on the FTP server |
SNAPADMIN_BACKUP_FTP_TLS |
False |
Use FTPS (recommended for offsite) |
SNAPADMIN_BACKUP_REMOTE_EVERY_HOURS |
168 |
How often the offsite copy refreshes (weekly) |
SNAPADMIN_AUTO_SEED |
False |
Auto-run seed_demo on startup (demo only) |
TRAEFIK_DOMAIN |
yourdomain.com |
Production domain for docker-compose.traefik.prod.yml |
TRAEFIK_ACME_EMAIL |
โ | Email for Let's Encrypt certificate registration |
TRAEFIK_DASHBOARD_USER |
admin |
Reference username (see TRAEFIK_DASHBOARD_CREDENTIALS) |
TRAEFIK_DASHBOARD_PASSWORD |
changeme |
Reference password (see TRAEFIK_DASHBOARD_CREDENTIALS) |
TRAEFIK_DASHBOARD_CREDENTIALS |
admin:$$apr1$$... |
Dashboard BasicAuth in htpasswd format |
๐ Demo Application Features
The repository includes a demo/ app and a sandbox/ project to showcase SnapAdmin's power:
- Complete Project Setup: Ready-to-use Docker environment with PostgreSQL, Redis, and Elasticsearch.
- Example Domain Models: Product, Customer, and Order models showing complex relationships.
- Interactive Dashboard: A custom system dashboard with health checks and environment stats.
- Seeder Command:
python manage.py seed_demoto instantly populate your environment. - Celery Integration: Example background tasks for data indexing and stats generation.
๐ณ Running the Demo (Docker)
git clone https://github.com/drofji/django-snapadmin.git
cd django-snapadmin
cp dist.env .env
docker compose up --build
- Admin: http://localhost:8000/admin/ (admin / admin)
- REST API Docs: http://localhost:8000/api/docs/
- GraphQL API: http://localhost:8000/api/graphql/
With Elasticsearch (optional, adds ~512 MB RAM):
# 1. Enable in .env
echo "ELASTICSEARCH_ENABLED=True" >> .env
# 2. Start with ES profile
docker compose --profile es up --build
# 3. Also add Kibana for visualisation
docker compose --profile es --profile dev up --build
Building images with automatic retention
For the test/demo image, scripts/docker_build.sh builds, tags by build-day, and
self-prunes so old images never pile up:
scripts/docker_build.sh # image=snapadmin-test, keep 3 build-days
IMAGE=myimg scripts/docker_build.sh # custom image name
SNAPADMIN_IMAGE_KEEP_DAYS=5 scripts/docker_build.sh # widen the window
Retention policy โ one build per day, keep the last N build-days (N defaults to 3,
override via SNAPADMIN_IMAGE_KEEP_DAYS):
- Collapse within a day โ images are tagged
snapadmin-test:YYYY-MM-DDplus a moving:latest. Rebuilding the same calendar day re-points that day's tag at the new image; the superseded build becomes a dangling layer and is reclaimed. - Rolling N-day window โ the last build of each of the N most-recent build-days is kept; when an (N+1)-th distinct build-day appears, the oldest day's image is pruned.
- History gaps are irrelevant โ "N days" means the last N build-days, not calendar days. Idle days never consume a slot.
Example: builds a month ago, a week ago, yesterday, and today leave exactly three images after today's build โ one each for a week ago, yesterday, and today; the month-ago image and all superseded same-day builds are gone.
The pruner can also run standalone (e.g. in CI), with a dry-run mode:
python -m scripts.docker_retention prune --image snapadmin-test --dry-run
๐ Running with Traefik
Two Traefik overlay files are provided for routing requests through a reverse proxy.
Local development (HTTP)
Access the app at http://snapadmin.localhost/ without a port number:
docker compose -f docker-compose.yml -f docker-compose.traefik.local.yml up --build
| URL | Service |
|---|---|
http://snapadmin.localhost/admin/ |
Django admin |
http://traefik.localhost/ |
Traefik dashboard (BasicAuth) |
On Windows, add to C:\Windows\System32\drivers\etc\hosts:
127.0.0.1 snapadmin.localhost traefik.localhost
Production (HTTPS + Let's Encrypt)
For production with automatic TLS certificates, set these values in .env:
TRAEFIK_DOMAIN=admin.mycompany.com
TRAEFIK_ACME_EMAIL=your@email.com
TRAEFIK_DASHBOARD_CREDENTIALS=admin:$$apr1$$... # see dist.env for generation instructions
ALLOWED_HOSTS=admin.mycompany.com
DEBUG=False
Then start the production overlay:
docker compose -f docker-compose.yml -f docker-compose.traefik.prod.yml up -d
| URL | Service |
|---|---|
https://admin.mycompany.com/admin/ |
Django admin (auto-TLS) |
https://traefik.admin.mycompany.com/ |
Traefik dashboard (BasicAuth) |
All HTTP traffic is automatically redirected to HTTPS.
Dashboard credentials
The default credentials in dist.env are admin / changeme. To change them:
# Generate htpasswd string and escape $ for Docker Compose
echo $(htpasswd -nb newuser newpassword) | sed -e 's/\$/\$\$/g'
# Paste result into TRAEFIK_DASHBOARD_CREDENTIALS in .env
๐ป Local Development Setup
# Clone and setup environment
git clone https://github.com/drofji/django-snapadmin.git
cd django-snapadmin
python -m venv .venv
source .venv/bin/activate
# Install in editable mode
pip install -r requirements.txt
pip install -e .
# Initialize DB and run
python manage.py migrate
python manage.py seed_demo
python manage.py runserver
๐ Migrating from drofji-automatically-django-admin
The legacy package drofji-automatically-django-admin (import root drofji_autoadmin,
last tag v1.1.0) is being retired. SnapAdmin is its direct successor โ same declarative
admin, now with REST/GraphQL, the Unfold theme, Elasticsearch, GDPR retention and offline mode.
The underlying Django fields are unchanged, so this is a rename + settings swap, not a data
migration (the only new table is snapadmin_apitoken).
# 1. Swap the package
pip uninstall drofji-automatically-django-admin
pip install django-snapadmin # or: pip install git+https://github.com/drofji/django-snapadmin.git
# 2. Rename the import root, base class and fields (repo-wide)
grep -rl drofji_autoadmin . | xargs sed -i '' 's/drofji_autoadmin/snapadmin/g'
grep -rl AutoAdmin . | xargs sed -i '' 's/AutoAdmin/Snap/g'
# drofji_autoadmin โ snapadmin | AutoAdminModel โ SnapModel | AutoAdminCharField โ SnapCharField โฆ
What you must change by hand:
| Concern | Old (drofji_autoadmin) |
New (snapadmin) |
|---|---|---|
| Theme apps | admin_interface, colorfield |
Remove them; add unfold (+ unfold.contrib.*) and django_ckeditor_5 before django.contrib.admin |
| REST stack | โ | add rest_framework, drf_spectacular, django_filters, graphene_django, snapadmin |
rangefilter |
present | keep it |
| Admin registration | automatic (inheritance) | explicit โ add SnapModel.register_all_admins() to admin.py |
| Color fields | colorfield.ColorField |
SnapColorField |
| APIs | none | optional: path("", include("snapadmin.urls")) for /api/, /api/docs/, /graphql/ |
Then run python manage.py migrate (creates only snapadmin_apitoken) and
collectstatic if you serve static yourself.
โ ๏ธ Don't run both packages at once โ keeping
drofji_autoadmininINSTALLED_APPSalongside SnapAdmin makes both register the admin (AlreadyRegistered). Fully uninstall the old package and remove it fromINSTALLED_APPSfirst.
The full step-by-step is in docs/index.html under Migration Guide โ drofji_autoadmin โ SnapAdmin.
๐ License
MIT License โ see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_snapadmin-0.1.0a5.tar.gz.
File metadata
- Download URL: django_snapadmin-0.1.0a5.tar.gz
- Upload date:
- Size: 123.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
61e3fc1c485ee9041d03ad3507671f54918d6d17e9451f1be1e4b35a92d428a0
|
|
| MD5 |
5cafbea2ddeb6bc2c86b0b53e80b5fdc
|
|
| BLAKE2b-256 |
72a85a5ba694a56327c83037c2cba2824135c3b8b5c62a609d3d6be66208e27c
|
File details
Details for the file django_snapadmin-0.1.0a5-py3-none-any.whl.
File metadata
- Download URL: django_snapadmin-0.1.0a5-py3-none-any.whl
- Upload date:
- Size: 125.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8243de9319a8759ba3b2fd45a241687486fdee0e6bcc48e45e10e313b9832314
|
|
| MD5 |
f2d56c899ca43d83c07e4bc705ebc367
|
|
| BLAKE2b-256 |
737df72bd3f3f400f1c0a7ecff75e73f45075332886ff5985aa52b157735cd43
|