SnapAdmin
Automatic, beautiful, production-ready Django Admin — zero boilerplate. REST & GraphQL, Elasticsearch, offline mode and GDPR retention, all from your model definitions.
ErrorEvent, emails a spike
alert when the 15-minute error threshold is crossed and a daily grouped
digest at a configurable time (Celery Beat or manage.py send_error_digest).
Thresholds, window, recipients and retention are all settings; no emails are sent until you
add recipients. Plus 3-2-1 database backups: scheduled gzip dumps shipped to
a local directory, a network share and an offsite FTP/FTPS server — each destination with its
own frequency and retention. Also: a backward-compatibility contract test suite now pins the
public API surface for future releases. See Error Monitoring
and 3-2-1 Backups.
Declarative Admin
Embed admin config in model fields. No ModelAdmin classes needed.
Auto REST API
Full CRUD API generated for every SnapModel automatically.
Dynamic GraphQL
Unified GraphQL schema generation for all managed models.
Token Auth
Named tokens with expiry, model restrictions, and Django perms.
Swagger UI
Interactive OpenAPI 3 documentation via drf-spectacular.
Elasticsearch
Integrated full-text search with automatic DB fallback.
Structured Logs
Colourised structlog for dev, JSON for production.
Docker Ready
One-command stack: Django + PostgreSQL + Redis + ES.
📦 Installation
SnapAdmin can be installed via PyPI or directly from source.
From PyPI
pip install django-snapadmin
From GitHub
pip install git+https://github.com/drofji/django-snapadmin.git
🌟 Running the Demo
The repository includes a complete sandbox project to explore all features.
Via Docker (Recommended)
git clone https://github.com/drofji/django-snapadmin.git
cd django-snapadmin
cp dist.env .env
docker compose up --build
With Traefik — Local Development (HTTP)
Access the app at http://snapadmin.localhost/ with a BasicAuth-protected dashboard:
docker compose -f docker-compose.yml -f docker-compose.traefik.local.yml up --build
# App: http://snapadmin.localhost/admin/
# Traefik dashboard: http://traefik.localhost/ (admin / changeme)
On Windows, add to C:\Windows\System32\drivers\etc\hosts:
127.0.0.1 snapadmin.localhost traefik.localhost
With Traefik — Production (HTTPS + Let's Encrypt)
Automatic TLS certificates for your custom domain. Set in .env:
TRAEFIK_DOMAIN=admin.mycompany.com
TRAEFIK_ACME_EMAIL=your@email.com
TRAEFIK_DASHBOARD_CREDENTIALS=admin:$$apr1$$... # see dist.env for generation
ALLOWED_HOSTS=admin.mycompany.com
DEBUG=False
Then start:
docker compose -f docker-compose.yml -f docker-compose.traefik.prod.yml up -d
# App: https://admin.mycompany.com/admin/
# Traefik dashboard: https://traefik.admin.mycompany.com/ (BasicAuth)
# HTTP → HTTPS redirect is automatic for all routes
Generating Dashboard Credentials
# Requires apache2-utils (apt) or httpd-tools (yum)
echo $(htpasswd -nb admin yourpassword) | sed -e 's/\$/\$\$/g'
# Paste result into TRAEFIK_DASHBOARD_CREDENTIALS in .env
Manual / Local Setup
git clone https://github.com/drofji/django-snapadmin.git
cd django-snapadmin
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pip install -e .
python manage.py migrate
python manage.py seed_demo
python manage.py runserver
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.
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
🏗 SnapModel
Inherit from SnapModel to get automatic admin registration and a smart __str__.
from snapadmin import models as snap_models, fields as snap
class Product(snap_models.SnapModel):
name = snap.SnapCharField(max_length=200, searchable=True)
price = snap.SnapDecimalField(max_digits=10, decimal_places=2, filterable=True)
available = snap.SnapBooleanField(default=True)
📋 Snap Fields
Every Snap field is a drop-in replacement for the corresponding Django field, with extra admin control attributes.
| Flag | Type | Default | Effect |
|---|---|---|---|
show_in_list | bool | True | Adds to list_display |
show_in_form | bool | False | Shows field in change form |
searchable | bool | False | Adds to search_fields |
filterable | bool | False | Smart sidebar filter (type-aware) |
editable | bool | True | Always read-only when False |
updatable | bool | True | Read-only after first save when False |
row | str | None | Group fields into a horizontal row |
tab | str | None | Place field into a specific Unfold tab |
wysiwyg | bool | False | Enable CKEditor 5 for TextFields |
autocomplete | bool | False | Searchable autocomplete widget (relation fields) |
Available field types
Every field type below maps 1:1 to a Django field (except the computed ones) and accepts all the flags above.
| Category | Snap field types |
|---|---|
| Text | SnapCharField, SnapTextField, SnapRichTextField, SnapSlugField, SnapEmailField, SnapURLField, SnapUUIDField, SnapGenericIPAddressField, SnapPhoneField, SnapColorField |
| Numbers | SnapIntegerField, SnapPositiveIntegerField, SnapSmallIntegerField, SnapPositiveSmallIntegerField, SnapBigIntegerField, SnapPositiveBigIntegerField, SnapFloatField, SnapDecimalField |
| Date & time | SnapDateField, SnapDateTimeField, SnapTimeField, SnapDurationField |
| Boolean & JSON | SnapBooleanField, SnapJSONField |
| Files | SnapFileField, SnapImageField |
| Relations | SnapForeignKey, SnapOneToOneField, SnapManyToManyField |
| Computed (no DB column) | SnapFunctionField (render from a callable), SnapStatusBadgeField (coloured pill badge) |
Showcase model exercises every field type across tabbed sections, and SnapFunctionField/SnapOneToOneField appear on Showcase and CustomerProfile respectively.
📐 Advanced Layout
Control the visual arrangement of your admin forms using row and tab attributes.
class Customer(snap_models.SnapModel):
# These two will appear on the same line
first_name = snap.SnapCharField(max_length=100, row="name")
last_name = snap.SnapCharField(max_length=100, row="name")
# This field will appear in the "Contact" tab
email = snap.SnapEmailField(tab="Contact")
🏷 Status Badges
Render coloured pill badges for any choice/status field directly in the admin list view.
from snapadmin import fields as snap
status_badge = snap.SnapStatusBadgeField(
field_name="is_active",
choices=[
snap.SnapStatusBadgeFieldChoice(True, "#065F46", "#D1FAE5", "#10B981"),
snap.SnapStatusBadgeFieldChoice(False, "#991B1B", "#FEE2E2", "#EF4444"),
]
)
🔧 Admin Registration
Call register_all_admins() once in your admin.py to register every SnapModel subclass with Django's admin site.
# admin.py
from snapadmin.models import SnapModel
SnapModel.register_all_admins()
To restrict registration to a specific app, pass the app_label argument:
SnapModel.register_all_admins(app_label="myapp")
APIToken admin is also registered automatically when register_all_admins() is called.
🌐 REST API
CRUD endpoints are generated automatically for all managed models.
Request Examples
All requests authenticate with an API token (see Token Management):
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"
# Auto-generated field filters (full per-model list visible in Swagger)
curl -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/?available=true&price__gte=100"
# Create, update, delete
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/"
Smart ES Query Routing — ?search=
?search= runs a full-text search over the model's searchable=True fields.
For a DUAL-storage model — whose data is already mirrored in
Elasticsearch — the very same request is executed on ES: fuzzy, typo-tolerant and
relevance-ranked, with no change to the URL or your client code. Field 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", …}, …]}
# ES search combined 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 shape on a DB_ONLY model transparently uses SQL icontains
curl -i -H "Authorization: Token $TOKEN" "$BASE/models/demo/Customer/?search=7"
# X-Snap-Query-Backend: database ← no ES mirror, the DB handled it
Routing decision per request:
| Model mode | ?search= present | Routing enabled | 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 internal DB fallback answered (the header then honestly says
database). Configuration:
# settings.py
SNAPADMIN_ES_QUERY_ROUTING = True # global switch (default True)
SNAPADMIN_ES_SEARCH_LIMIT = 1000 # max hits fetched from ES per routed search
SNAPADMIN_QUERY_BACKEND_HEADER = True # set False to hide the header in production
# models.py — per-model opt-out (e.g. when one model's ES mirror lags)
class Product(SnapModel):
es_storage_mode = EsStorageMode.DUAL
es_query_routing = False # this model's API searches always run on the DB
Hiding fields from the API — api_exclude_fields
Columns listed in api_exclude_fields are removed from the REST serializer
(responses and writes), the GraphQL object type and /api/models/schema/ —
while the admin keeps showing them. Use it for PII and internal columns:
class AuditLog(SnapModel):
action = snap_fields.SnapCharField(max_length=100, searchable=True)
user_email = snap_fields.SnapEmailField() # PII
api_exclude_fields = ["user_email"] # never leaves the server via API
⚛️ GraphQL API
SnapAdmin provides a dynamic GraphQL interface powered by Graphene-Django.
- Auto-Schema: All
SnapModelsubclasses are automatically added to the schema. - Secured by default: every resolver requires authentication (admin session or API token) and the model's Django
viewpermission — the same contract as REST. Tokenallowed_modelsscopes apply on top. - Unified Fetching: the
searchargument routes to Elasticsearch forDUAL/ES_ONLYmodels;first/offsetpaginate. - Endpoint:
/api/graphql/; GraphiQL followsDEBUG(override withSNAPADMIN_GRAPHIQL_ENABLED).
Request Examples
# Same API tokens as REST — anonymous callers get "Authentication required."
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/"
# Single object by id
curl -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"query": "{ demoProduct(id: 42) { name price available } }"}' \
"http://localhost:8000/api/graphql/"
# settings.py
SNAPADMIN_GRAPHQL_REQUIRE_AUTH = True # default — never disable in production
SNAPADMIN_GRAPHIQL_ENABLED = DEBUG # playground only during development
Authorization: Token … header the REST API uses (or hold an admin session).
🔑 Token Management
SnapAdmin uses named API tokens for REST API authentication. Tokens are created and managed in the Django admin under API Tokens, or programmatically:
from snapadmin.models import APIToken
# Create a token for a user, valid for 30 days
token = APIToken.create_for_user(
user=user,
token_name="CI Pipeline",
allowed_models=["myapp.Product", "myapp.Order"],
expires_in_days=30,
)
Include the token in every API request as an HTTP header:
Authorization: Token <token_key>
token_prefix are stored — the raw key is
never persisted. It is returned exactly once: in the POST /api/tokens/ response
(and shown once in the admin when you create a token). Copy it then; it cannot be recovered later.
On every subsequent read token_key is null and only token_prefix
identifies the token.
Token REST Endpoints
Token Fields
| Field | Description |
|---|---|
token_name | Human-readable label (e.g. "Read-only dashboard") |
token_key | 40-character secret key — treat like a password. Hashed at rest; returned only once, at creation |
token_prefix | First 8 characters of the key (not secret) — identifies a stored token in lists and the admin |
allowed_models | List of "app_label.ModelName" strings the token may target. Empty ≠ unrestricted: it means "any model the owning user already has Django permissions for". Token scope is always AND-ed with user.has_perm, so a non-empty list narrows access further |
expiration_date | Optional expiry; leave blank for non-expiring tokens |
is_active | Deactivate without deleting the token |
🔍 Elasticsearch Integration
SnapAdmin supports three Elasticsearch storage modes via EsStorageMode. Choose the mode per model based on your use case.
Enable Elasticsearch
Add to your settings.py (or set via env vars):
ELASTICSEARCH_ENABLED = True # False by default — ES is completely skipped when False
ELASTICSEARCH_URL = "http://localhost:9200" # local dev or external cluster
ELASTICSEARCH_ENABLED=False. All models fall back to DB_ONLY automatically — no ES process required.
Running with Docker
The default docker compose up does not start Elasticsearch (saves ~512 MB RAM). To enable it:
# Enable ES in your .env
ELASTICSEARCH_ENABLED=True
# Then start the full stack including ES
docker compose --profile es up --build
# Add Kibana for visualisation (dev only)
docker compose --profile es --profile dev up --build
External Elasticsearch (production / staging)
Point to any external ES cluster via env vars — no code change needed:
# .env (production)
ELASTICSEARCH_ENABLED=True
ELASTICSEARCH_URL=https://my-cluster.example.com:9200
Storage Modes
| Mode | Storage | When to use |
|---|---|---|
DB_ONLY |
PostgreSQL only | Default. Standard Django ORM behavior. No ES dependency. |
DUAL |
PostgreSQL + Elasticsearch | Write to both DB and ES. Use es_search() for fast full-text search, DB for transactions. |
ES_ONLY |
Elasticsearch only | No DB table. Ideal for logs, events, or analytics data. |
Setting the Mode on a Model
The fields you want searchable in Elasticsearch are declared in es_mapping — a dict of {field_name: ES mapping}. (There is no es_index_fields attribute.) For DUAL you may also set es_index_enabled = True.
from snapadmin.models import SnapModel, EsStorageMode
from snapadmin import fields as snap_fields
# DB_ONLY (default — no extra config needed)
class Article(SnapModel):
title = snap_fields.SnapCharField(max_length=200, searchable=True)
body = snap_fields.SnapTextField()
# DUAL — save to PostgreSQL AND Elasticsearch, search via ES
class Product(SnapModel):
name = snap_fields.SnapCharField(max_length=200, searchable=True)
description = snap_fields.SnapTextField()
price = snap_fields.SnapDecimalField(max_digits=10, decimal_places=2)
es_index_enabled = True
es_storage_mode = EsStorageMode.DUAL
es_mapping = {
"name": {"type": "text", "analyzer": "standard"},
"description": {"type": "text"},
"price": {"type": "float"},
}
# ES_ONLY — no DB table (managed=False), data lives only in Elasticsearch
class SearchLog(SnapModel):
query = snap_fields.SnapCharField(max_length=255, searchable=True)
results_count = snap_fields.SnapIntegerField()
timestamp = models.DateTimeField(auto_now_add=True)
es_storage_mode = EsStorageMode.ES_ONLY
es_mapping = {
"query": {"type": "text"},
"results_count": {"type": "integer"},
"timestamp": {"type": "date"},
}
class Meta:
managed = False # required for ES_ONLY — no DB table is created
Custom analyzers & index settings — es_index_settings
Index-level settings (custom analyzers under analysis, number_of_shards,
number_of_replicas, …) are declared in es_index_settings and applied when the
index is first created. Existing indexes are never altered — to apply a change,
delete the index and run es_reindex_all():
class Product(SnapModel):
es_storage_mode = 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,
}
Automatic mapping — es_auto_mapping
Don't want to write mappings by hand? Set es_auto_mapping = True and the mapping is
derived from the model's fields: CharField/TextField → text with a
.raw keyword subfield (exact match + aggregations), Email/Slug/URL/UUID/IP/File →
keyword, integers & FK → long, Float → double,
Decimal → scaled_float, dates → date, JSONField →
object. Entries in es_mapping override the derived ones per field:
class SearchLog(SnapModel):
query = snap_fields.SnapCharField(max_length=255, searchable=True)
results_count = snap_fields.SnapIntegerField()
es_storage_mode = EsStorageMode.ES_ONLY
es_auto_mapping = True # derived: query → text + .raw, results_count → long
# es_mapping = {"query": {"type": "search_as_you_type"}} # optional override
es_search() and the routed REST ?search=) target only the
text-capable fields of es_mapping and run with lenient: true, so a mapping
that mixes numeric/date/boolean fields never breaks a search. ES failures that previously
disappeared silently are logged as structlog warning events
(es_ensure_index_failed, es_index_document_failed, es_search_failed, …).
Querying with es_search()
es_search(query_string=None, limit=20) is the single entry point for search. It runs a fuzzy multi_match over the index when ES is enabled, and gracefully falls back to an ORM query (using the model's searchable fields) when ES is off or unreachable — so the same call works in every environment. snap_search() is a public alias with identical behavior.
# Full-text search (fuzzy, typo-tolerant) — returns a queryset-like result
results = Product.es_search("wireles headphones") # note the typo — still matches
for product in results:
print(product.name, product.price)
# Cap the number of hits (default 20)
top5 = Product.es_search("laptop", limit=5)
# No query string → match-all (most-recent first), handy for "browse" views
everything = Product.es_search(limit=100)
# Public alias — identical behavior, nicer name for app code
hits = Product.snap_search("4k monitor")
# ES_ONLY models: es_search() is the ONLY way to read them (no DB table)
logs = SearchLog.es_search("error 404")
recent_logs = SearchLog.es_search(limit=50)
# DB_ONLY models still answer es_search() — it falls back to an ORM
# icontains query across the model's searchable fields:
articles = Article.es_search("django") # works even with ELASTICSEARCH_ENABLED=False
DB_ONLY/DUAL, es_search() returns a normal Django QuerySet (ES hits are mapped back to real DB rows, ES order preserved). For ES_ONLY it returns a lightweight EsQuerySet of model instances built straight from the index — iterable, sliceable, and countable, but not a DB queryset.
es_search() yourself for API consumers: a plain
GET /api/models/{app}/{Model}/?search=… on a DUAL model is routed to
Elasticsearch automatically. See Smart ES Query Routing for examples,
the routing matrix and the X-Snap-Query-Backend header.
Re-indexing (DUAL mode)
# Manually trigger a full re-index of every row into ES.
# Streams the table through the bulk API — one round-trip per 500 docs
# (tune with chunk_size=), flat memory even on million-row tables:
Product.es_reindex_all() # {"indexed": N} — or {"skipped": True} if ES is off
# Per-instance indexing happens automatically on save() in DUAL/ES_ONLY mode.
# To force a single object back into the index:
product.index_in_es()
product.delete_from_es() # remove just this object's ES document
# Via Celery task (recommended for large datasets):
from demo.tasks import reindex_products_to_elasticsearch
reindex_products_to_elasticsearch.delay()
⚙️ Celery & Periodic Tasks
SnapAdmin ships built-in Celery tasks for background indexing, token cleanup, and GDPR data retention. Wire them up with Celery Beat to get a fully automated backend in minutes.
Built-in Tasks
| Task | Default Schedule | Description |
|---|---|---|
api.tasks.purge_expired_tokens | Daily 3am | Delete all APIToken records past their expiration date |
api.tasks.purge_expired_data | Daily 1am | GDPR — auto-delete records older than data_retention_days on each SnapModel |
demo.tasks.reindex_products_to_elasticsearch | Daily midnight | Re-index all products to Elasticsearch (DUAL mode demo) |
demo.tasks.generate_daily_stats | Daily 2am | Compute and log daily business metrics (demo app) |
Celery Setup
# myproject/celery.py
from celery import Celery
app = Celery("myproject")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
Schedule All Tasks (Celery Beat)
Add the following to settings.py to activate all background tasks. The dashboard will display them automatically.
from celery.schedules import crontab
CELERY_BROKER_URL = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND = "redis://localhost:6379/0"
CELERY_BEAT_SCHEDULE = {
"reindex-products-to-es": {
"task": "demo.tasks.reindex_products_to_elasticsearch",
"schedule": crontab(hour=0, minute=0), # daily midnight
"description": "Sync Product records (DUAL mode) to Elasticsearch",
},
"purge-expired-data": {
"task": "api.tasks.purge_expired_data",
"schedule": crontab(hour=1, minute=0), # daily 1am
"description": "GDPR — delete records older than data_retention_days",
},
"generate-daily-stats": {
"task": "demo.tasks.generate_daily_stats",
"schedule": crontab(hour=2, minute=0), # daily 2am
"description": "Daily business stats",
},
"purge-expired-tokens": {
"task": "api.tasks.purge_expired_tokens",
"schedule": crontab(hour=3, minute=0), # daily 3am
"description": "Remove expired API tokens",
},
}
CELERY_BEAT_SCHEDULE that includes a "description" key is automatically shown in the SnapAdmin Dashboard under Scheduled Cron Jobs.
Running Celery in Docker
# docker-compose.yml services:
worker:
command: celery -A sandbox worker -l INFO
beat:
command: celery -A sandbox beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler
🪵 Structured Logging
SnapAdmin provides a structlog-based logging setup with colourised output for development and JSON output for production.
Activate in settings.py
from snapadmin.logging_config import configure_logging
# Human-readable coloured output (development)
configure_logging(log_level="INFO", json_logs=False)
# JSON lines (production / Docker)
configure_logging(log_level="WARNING", json_logs=True)
Using the logger in your code
from snapadmin.logging_config import get_logger
logger = get_logger(__name__)
logger.info("order_created", order_id=42, total=199.90)
logger.warning("es_unavailable", model="Product")
logger.error("payment_failed", reason="card_declined")
log_level: DEBUG, INFO, WARNING, ERROR, CRITICAL.
Noisy third-party loggers (django.db.backends, elasticsearch, urllib3) are silenced to WARNING automatically.
🔒 GDPR Data Retention
SnapAdmin provides one-line automatic record cleanup to help comply with EU data retention laws (GDPR). Add two class attributes to any SnapModel:
class AuditLog(SnapModel):
action = SnapCharField(max_length=100)
created_at = SnapDateTimeField(auto_now_add=True)
# Auto-delete records older than 90 days
data_retention_days = 90
data_retention_field = "created_at" # default — any DateTimeField works
How it works
| Attribute | Type | Default | Description |
|---|---|---|---|
data_retention_days | int | None | None | Max age of records in days. Set to a positive integer to enable auto-deletion. |
data_retention_field | str | "created_at" | Name of the DateTimeField to measure record age against. |
Running the cleanup
# Via Celery Beat (recommended — add to CELERY_BEAT_SCHEDULE):
"purge-expired-data": {
"task": "api.tasks.purge_expired_data",
"schedule": crontab(hour=1, minute=0),
}
# Manually via management command:
python manage.py purge_expired_data # live run — deletes records
python manage.py purge_expired_data --dry-run # preview only — no deletes
# Programmatically, per model (returns the number purged):
AuditLog.purge_expired() # delete now
AuditLog.purge_expired(dry_run=True) # count what *would* be deleted, delete nothing
Purging across every storage layer
The purge is storage-aware — it removes expired records from wherever the model actually keeps them, so personal data never lingers in a secondary store:
| Mode | What gets purged |
|---|---|
DB_ONLY | Bulk delete from the database. |
DUAL | Bulk delete from the database and the mirrored Elasticsearch documents (the pks are collected before the DB delete, then cleared from the index). |
ES_ONLY | A range delete_by_query against the index on data_retention_field (which must be mapped as a date in es_mapping). |
QuerySet.delete() never calls each model's delete(), so a naïve bulk purge would leave the Elasticsearch copy behind. SnapAdmin's purge_expired() closes that gap for both DUAL and ES_ONLY models. ES operations are best-effort and require ELASTICSEARCH_ENABLED=True.
🚨 Error Monitoring & Email Alerts
Optional email notifications about server errors, built on one middleware. Every unhandled
exception and 5xx response is stored as an ErrorEvent — browsable in the admin under
Error Events — and two email channels keep the team informed:
- Spike alert — when
SNAPADMIN_ERROR_ALERT_THRESHOLDerrors occur withinSNAPADMIN_ERROR_ALERT_WINDOW_MINUTES(default 20 errors / 15 min), one email is sent immediately. A cooldown guarantees at most one alert per window. - Daily digest — a grouped 24-hour report: identical errors are merged by
exception class + endpoint, ordered by frequency, and capped at
SNAPADMIN_ERROR_DIGEST_MAX_GROUPSgroups so the email stays readable.
EMAIL_BACKEND,
EMAIL_HOST, credentials and DEFAULT_FROM_EMAIL. While the recipient
lists are empty no email is ever sent, so the feature is inert until you opt in.
Setup
# settings.py
MIDDLEWARE = [
# ... Django middleware ...
"snapadmin.middleware.SnapErrorMonitorMiddleware",
]
# Spike alert (defaults shown)
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_EMAILS = ["team@example.com"] # falls back to ALERT_EMAILS
SNAPADMIN_ERROR_DIGEST_MAX_GROUPS = 20
SNAPADMIN_ERROR_RETENTION_DAYS = 30 # ErrorEvents older than this are purged
Scheduling the digest
# Celery Beat — the send time is entirely yours:
"send-error-digest": {
"task": "api.tasks.send_error_digest",
"schedule": crontab(hour=8, minute=0),
}
# ...or plain cron, without Celery:
0 8 * * * python manage.py send_error_digest
python manage.py send_error_digest --hours 12 # custom window
Settings reference
| Setting | Default | Description |
|---|---|---|
SNAPADMIN_ERROR_MONITOR_ENABLED | True | Master kill-switch — disable recording without touching MIDDLEWARE. |
SNAPADMIN_ERROR_ALERT_ENABLED | True | Enable the spike alert channel. |
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_COOLDOWN_MINUTES | = window | Minimum gap between two alert emails. |
SNAPADMIN_ERROR_ALERT_EMAILS | [] | Alert recipients. Empty = channel off. |
SNAPADMIN_ERROR_DIGEST_ENABLED | True | Enable the daily digest channel. |
SNAPADMIN_ERROR_DIGEST_EMAILS | [] | Digest recipients; falls back to the alert list. |
SNAPADMIN_ERROR_DIGEST_MAX_GROUPS | 20 | Cap on distinct error groups per digest email. |
SNAPADMIN_ERROR_RETENTION_DAYS | 30 | ErrorEvent rows older than this are purged by the digest task. |
error_monitor_record_failed,
error_monitor_alert_failed) and swallowed — a broken mail server never breaks a page.
Try it in the demo: hit /demo/error/ a few times (DEBUG only) and watch
Error Events fill up; with the default DEBUG console email backend the alert
lands in your terminal.
💾 3-2-1 Database Backups
Built-in scheduled backups following the classic 3-2-1 rule — 3 copies
of your data, on 2 different machines, 1 of them offsite. Dumps are
gzip-compressed (file copy for SQLite, pg_dump for PostgreSQL) and shipped to up to three
destinations, each on its own schedule:
| Copy | Destination | Where it lives | Default frequency |
|---|---|---|---|
| 1 | local | Directory on the same server (SNAPADMIN_BACKUP_LOCAL_DIR) | every 24 h |
| 2 | network | Directory on another server on your network — a mounted NFS/SMB share (SNAPADMIN_BACKUP_NETWORK_DIR; empty = off) | every 24 h |
| 3 | remote | Offsite server anywhere in the world, via FTP/FTPS (SNAPADMIN_BACKUP_FTP_*; empty host = off) | every 168 h (weekly) |
Configuration
SNAPADMIN_BACKUP_ENABLED = True # strictly opt-in (default: False)
SNAPADMIN_BACKUP_KEEP = 7 # dumps kept per destination (oldest pruned)
# Copy 1 — same server
SNAPADMIN_BACKUP_LOCAL_DIR = "/var/backups/snapadmin"
SNAPADMIN_BACKUP_LOCAL_EVERY_HOURS = 24 # daily
# Copy 2 — server on the same network (mounted share)
SNAPADMIN_BACKUP_NETWORK_DIR = "/mnt/backup-server/snapadmin"
SNAPADMIN_BACKUP_NETWORK_EVERY_HOURS = 24 # daily
# Copy 3 — offsite FTP/FTPS
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 offsite
SNAPADMIN_BACKUP_REMOTE_EVERY_HOURS = 168 # weekly
Running the backups
Backups run as a separate process from your web workers — via Celery Beat or cron.
The hourly Beat task is only a due-check: each destination actually fires when its own
*_EVERY_HOURS interval has elapsed (last-run times persist in a state file, surviving restarts):
# Celery Beat:
"run-db-backups": {
"task": "api.tasks.run_db_backups",
"schedule": crontab(minute=30), # hourly due-check
}
# ...or plain cron, without Celery:
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
db_backup_store_failed)
and reported, but never cancels the other copies — and it stays due, so it is retried on the next
pass. Every destination, including the FTP server, keeps only the newest
SNAPADMIN_BACKUP_KEEP dumps.
📴 Offline Mode
Flip one class attribute to make a model's admin list view survive a dropped connection. SnapAdmin injects snapadmin/js/offline.js into that model's admin pages only — models without the flag ship no extra JavaScript.
class Customer(SnapModel):
first_name = SnapCharField(max_length=100, show_in_form=True)
last_name = 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
What it does (offline-capable models)
| Behavior | Detail |
|---|---|
| Prefetch & cache | Pulls the most-recent offline_cache_limit rows (default 100) from GET /api/offline-data/<app>/<model>/ into the browser's IndexedDB on every visit. The rendered list is kept as a fallback snapshot. |
| Saved-objects panel | When the backend is unreachable, repaints the list from cache and shows a panel: how many objects are cached (out of the limit), when they were cached, and how many changes are queued. |
| Reconnect sync | Queues mutations made while offline and replays them when the backend returns, then refreshes the cache and shows a "synced N changes" toast. |
Real backend health checks
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:
| Behavior | Detail |
|---|---|
| Health polling | Polls GET /api/health/ every 15s by default (override via window.SNAPADMIN_HEALTH_INTERVAL) with a short timeout, and re-checks immediately on online/offline events and tab refocus. The backend is "up" only when it responds. |
| Shared state | Publishes one resolved state as a snapadmin:connectivity DOM event, so the connectivity layer and the per-model engine always agree. |
| Dynamic toasts | Backend-lost / restored, "objects can't be shown right now" (non-cached pages), and "synced N changes" surface as auto-dismissing toasts — no static banners. |
| Save guard | On a non-offline model, an unreachable backend blocks form submission and disables the Save buttons until it returns, while leaving the already-rendered page intact. |
| Sidebar badges | Each model link gets a green sync icon (spins while the backend is down) if it is offline-capable, or a muted no-offline icon otherwise — so you can see at a glance which records survive a disconnect. |
The badge list and per-model cache limits come from GET /api/offline-models/ (authenticated), with a localStorage fallback so badges still render while offline.
offline_mode flag is purely client-side and gated per model; non-offline models still get the connectivity warnings but ship no caching JS.
🎨 Theming & Styles
SnapAdmin ships its admin styling as two layers, so an install that doesn't use Unfold is never forced to adopt Unfold's assumptions.
| Stylesheet | Scope | When it loads |
|---|---|---|
snapadmin/css/admin.css |
Theme-agnostic core — field sizing, Select2 widgets, action bar, and the shared :root design tokens |
Always, on every SnapModel admin page |
snapadmin/css/admin-unfold.css |
Unfold-specific overrides — all .unfold-scoped rules, dark-mode borders, and the Add-button visibility fix |
Only when django-unfold is installed |
The Unfold layer is opt-in. SnapAdmin appends it automatically — after the core sheet, so its .unfold rules win the cascade — only when Unfold is detected. A plain Django admin install gets the core sheet alone. The shared design tokens (--primary-color, --radius, …) are defined once in the core sheet and referenced by both layers.
INSTALLED_APPS and the Unfold overrides simply stop loading.
⚡ Large-Dataset Performance
SnapAdmin keeps the admin and API responsive as tables grow. Most tuning is automatic; the rest is a few per-model knobs.
Automatic — no admin N+1
When a model is registered, SnapAdmin 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 — issues one joined query instead of one query per row. Only the FKs you actually display are joined; relations you don't show are never fetched.
The auto-generated REST API applies the same treatment to 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 request 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 list templates. |
list_max_show_all |
200 |
Guards against a "Show all" rendering 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 display the grand total, which is often the single most expensive query on the page. |
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 and ES_ONLY models the REST list endpoint serves results directly from Elasticsearch (es_search) rather than the database, moving full-text search and large-result pagination off the primary database. See Elasticsearch Storage Modes.
Benchmarking at scale
Two demo management commands let you reproduce these 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 one joined query. Representative output on a seeded table (5,000 orders, SQLite):
📊 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 unoptimized query count scales linearly with row count (N + 1); the optimized path stays flat at 1 — exactly the N+1 elimination list_select_related provides. See the broader Optimizations guide for the underlying data-access patterns.
🚀 Optimizations Guide
The Large-Dataset Performance section above is the reference for SnapAdmin's specific knobs. This guide is the broader picture: the data-access patterns every developer working with large tables should understand, whether or not they use SnapAdmin. Most of it is plain Django/SQL; the SnapAdmin-specific automation is called out where it applies.
1. Query optimization for large tables
The cheapest query is the one you never run, and the cheapest row is the one you never fetch. A few tools cover most cases:
| Tool | Use it when | What it does |
|---|---|---|
select_related(...) |
Following a ForeignKey / OneToOne (to-one) relation. | Pulls the related row in the same query via a SQL JOIN. One query total. |
prefetch_related(...) |
Following a ManyToMany or reverse FK (to-many) relation. | A second query for the related set, joined in Python. Two queries total, not N+1. |
only(...) / defer(...) |
Rows are wide (big text/JSON columns) but you render few columns. | Fetches/skips specific columns. Caution: touching a deferred column triggers a fresh per-row query — an N+1 in disguise. |
values() / values_list() |
You need raw data (export, aggregation), not model instances. | Returns dicts/tuples, skipping model construction overhead entirely. |
exists() |
You only need to know whether rows match. | Emits SELECT 1 ... LIMIT 1 — far cheaper than count() or truthiness on the queryset. |
count() |
You need the number, not the rows. | A single COUNT(*) — but on huge tables even this is expensive (see below). |
Indexing. Any column you filter or sort on at scale should have a database index (db_index=True, or Meta.indexes for composite/partial indexes). Without one, the database scans the whole table for every query. The flip side: indexes cost write time and storage, so index the columns you actually query, not every column.
Pagination. Offset pagination (LIMIT 50 OFFSET 1000000) makes the database walk and discard every skipped row — page 20,000 is slow even with an index. Keyset (cursor) pagination — WHERE id > :last_seen_id ORDER BY id LIMIT 50 — stays constant-time at any depth because it seeks straight to the next page via the index. Prefer it for deep, infinite-scroll, or API pagination over very large tables.
COUNT(*) just to show "X total" — frequently the most expensive query on the page. Set show_full_result_count = False on a SnapModel to drop it on huge tables.
2. The N+1 problem
The classic performance trap: you fetch a list of N objects in 1 query, then access a related object on each one, firing N more queries — N + 1 total. At 50 rows it's invisible in dev; at 50,000 rows it melts the database.
# N+1: one query for orders, then one per order for the customer
for order in Order.objects.all(): # 1 query
print(order.customer.first_name) # +1 query EACH iteration
# Fixed: a single JOIN pulls customers alongside orders
for order in Order.objects.select_related("customer"): # 1 query, total
print(order.customer.first_name)
How to spot it: count queries. In tests, django.test.utils.CaptureQueriesContext (or assertNumQueries); in development, django-debug-toolbar; in code, len(connection.queries) with DEBUG=True.
register_admin() inspects the FK columns shown in the list view and auto-derives list_select_related, so changelist pages issue one joined query instead of one per row. The benchmark above shows it: 5,001 queries → 1. Run python manage.py benchmark_list_view to see it on your own data.
3. SQL vs NoSQL — when to offload to Elasticsearch
A relational table is the right home for most data: it gives you transactions, joins, foreign-key integrity, and ad-hoc queries. Reach for a search engine like Elasticsearch when the access pattern outgrows what SQL does cheaply:
| Keep it in PostgreSQL when… | Offload to Elasticsearch when… |
|---|---|
| You need ACID transactions and referential integrity. | You need fast full-text / fuzzy / relevance-ranked search across large text. |
| Queries are structured (filter/sort on indexed columns, joins). | Faceted search, aggregations, and autocomplete over millions of rows. |
| The table is the system of record. | Read volume and query complexity would overwhelm the primary DB. |
SnapAdmin makes this a per-model setting via es_storage_mode — see Elasticsearch Storage Modes:
DB_ONLY— PostgreSQL only (the default). Right for the vast majority of models.DUAL— write to both; Postgres is the source of truth, ES is a denormalized read/search copy.ES_ONLY— high-volume, write-heavy logs/events that never need relational integrity (managed=False, no DB table).
4. Denormalization & data duplication
Normalization removes redundancy so each fact lives in exactly one place — great for write integrity, but it pushes joins onto every read. Denormalization deliberately duplicates data to make reads cheap, trading storage and write complexity for read speed.
DUAL mode is the worked example: PostgreSQL stays the normalized source of truth, while a denormalized, query-optimized copy lives in Elasticsearch for fast search. The cost is consistency — the two stores can drift, so the copy must be kept in sync (SnapAdmin re-indexes on save and via a nightly Celery task; see Celery & Periodic Tasks).
Related patterns:
- Computed/materialized columns — store an expensive aggregate (e.g.
order_counton a customer) instead of recomputing it on every read; update it on write or on a schedule. - Caching — a read-through cache is denormalization with a TTL; same consistency trade-off, bounded by expiry.
5. Practical checklist & anti-patterns
Before shipping a view over a large table, check:
- ✅ Every
WHERE/ORDER BYcolumn at scale is indexed. - ✅ To-one relations you display use
select_related; to-many useprefetch_related. - ✅ Deep/API pagination uses keyset, not offset.
- ✅ Existence checks use
exists(), notcount()orlen(). - ✅ The unfiltered grand-total
COUNT(*)is disabled on huge admin tables (show_full_result_count = False).
Common anti-patterns to avoid:
- ❌ Fetching whole rows just to render one column (use
only()/values()). - ❌ Sorting or filtering on an unindexed column on a large table.
- ❌ Leaving
Show allenabled on a million-row admin list (cap it withlist_max_show_all). - ❌ Accessing a relation inside a loop without
select_related/prefetch_related(the N+1). - ❌ Touching a
defer()red column in a loop — it re-queries per row.
Related reading: Large-Dataset Performance (SnapAdmin's knobs) and Elasticsearch Storage Modes (the SQL/NoSQL split).
📦 Demo App — Model Overview
The demo/ app contains pre-built models that showcase every SnapAdmin feature. Each model uses a different Elasticsearch storage mode.
| Model | ES Mode | Demonstrates |
|---|---|---|
Category, Tag |
DB_ONLY | Simple lookup tables, ForeignKey and M2M relations |
Product |
DUAL | Nightly Celery re-index, full-text ES search with DB fallback, status badges |
Customer, Order |
DB_ONLY | Relational data, ForeignKey with autocomplete, range filters |
SearchLog |
ES_ONLY | ES-only model (managed=False), high-frequency write pattern |
AuditLog |
DB_ONLY | GDPR retention: data_retention_days=90 |
Showcase |
DB_ONLY | All 30 field types in one model, tabs, rows, WYSIWYG, phone, color |
🌱 Seed Command
The seed_demo management command populates the database with realistic demo data in seconds.
python manage.py seed_demo # default: 20 products, 10 customers, 5 orders
python manage.py seed_demo --no-index # skip ES indexing (no ES cluster needed)
The command is idempotent — running it multiple times adds more data without duplicating existing records.
🔄 Migration Guide: drofji-automatically-django-admin → SnapAdmin
The legacy package drofji-automatically-django-admin (import root
drofji_autoadmin, last tag v1.1.0) is being retired and its
repository removed. SnapAdmin is its direct successor — same declarative,
field-driven admin, now renamed and extended with a REST API, GraphQL, the Unfold theme,
Elasticsearch, GDPR retention and offline mode. The underlying Django field types are
unchanged, so this is a rename + settings swap, not a data migration.
snapadmin_apitoken (token auth). Your data stays exactly where it is.
1. Swap the package
pip uninstall drofji-automatically-django-admin
pip install drofji-snapadmin
# or pin the repo:
pip install git+https://github.com/drofji/django-snapadmin.git
If you pinned the old GitHub URL in requirements.txt, replace that line with
drofji-snapadmin.
2. Rename the import root (drofji_autoadmin → snapadmin)
# Before
from drofji_autoadmin import models as drofji_models, fields as drofji_fields
from drofji_autoadmin import validators
# After
from snapadmin import models as snap_models, fields as snap_fields
from snapadmin import validators
One-shot, repo-wide:
grep -rl drofji_autoadmin . | xargs sed -i '' 's/drofji_autoadmin/snapadmin/g'
3. Rename the base class & fields (AutoAdmin* → Snap*)
# Before
class Product(drofji_models.AutoAdminModel):
name = drofji_fields.AutoAdminCharField(max_length=200, searchable=True)
# After
class Product(snap_models.SnapModel):
name = snap_fields.SnapCharField(max_length=200, searchable=True)
All field flags (show_in_list, searchable, filterable,
row, tab, …) and the per-model admin_overrides dict keep
the same names and behaviour. Mechanical replace:
grep -rl AutoAdmin . | xargs sed -i '' 's/AutoAdmin/Snap/g'
# AutoAdminModel→SnapModel, AutoAdminCharField→SnapCharField, AutoAdminFunctionField→SnapFunctionField, …
4. Swap the theme in INSTALLED_APPS
The old package themed the admin with admin_interface + colorfield.
SnapAdmin uses Unfold. Remove the old theme apps (leaving them
alongside Unfold causes conflicting admin overrides) and add the new stack — order matters,
Unfold and its contrib apps must precede django.contrib.admin:
INSTALLED_APPS = [
- "admin_interface",
- "colorfield",
+ "unfold",
+ "unfold.contrib.filters",
+ "unfold.contrib.forms",
+ "unfold.contrib.inlines",
+ "django_ckeditor_5",
"django.contrib.admin",
"django.contrib.auth",
# … other django.contrib.* …
"rangefilter", # keep — SnapAdmin still uses it
- "drofji_autoadmin",
+ "rest_framework",
+ "drf_spectacular",
+ "django_filters",
+ "graphene_django",
+ "snapadmin",
# your apps …
]
5. Register the admin explicitly (new requirement)
The old package auto-registered models purely by inheritance. SnapAdmin needs one explicit
call — add it to your app's admin.py, or your models won't appear in the admin:
# admin.py
from snapadmin.models import SnapModel
SnapModel.register_all_admins()
6. Replace color fields
If you used ColorField from django-colorfield, switch to
snap_fields.SnapColorField (validates #RRGGBB / #RGB).
7. (Optional) Wire up the new APIs
SnapAdmin ships a REST API, GraphQL endpoint and Swagger UI — features the old package did not have. Include the routes only if you want them:
# urls.py
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("snapadmin.urls")), # /api/, /api/docs/, /graphql/
]
# settings.py — all default True
SNAPADMIN_REST_API_ENABLED = True
SNAPADMIN_GRAPHQL_ENABLED = True
SNAPADMIN_SWAGGER_ENABLED = True
ELASTICSEARCH_ENABLED = False # opt-in; needs the [elasticsearch] extra
SNAPADMIN_ES_QUERY_ROUTING = True # route ?search= on DUAL models to ES
SNAPADMIN_ES_SEARCH_LIMIT = 1000 # max ES hits per routed search
SNAPADMIN_GRAPHQL_REQUIRE_AUTH = True # auth + perms on every GraphQL resolver
SNAPADMIN_GRAPHIQL_ENABLED = DEBUG # GraphiQL playground — dev only
8. Migrate & collect static
python manage.py migrate # creates only snapadmin_apitoken
python manage.py collectstatic # if you serve static yourself
drofji_autoadmin installed and in INSTALLED_APPS alongside
SnapAdmin makes both try to register the admin → AlreadyRegistered. Fully
uninstall the old package and remove it from INSTALLED_APPS before enabling
SnapAdmin.
🔄 Migration Guide: v0.0.x → v0.1.x
Version 0.1.0 is a significant rewrite. If you are upgrading from an older version, follow these steps.
1. Update the package
pip install --upgrade django-snapadmin
2. Replace model field imports
# Before (v0.0.x)
from snapadmin.fields import CharField, IntegerField # bare re-exports
# After (v0.1.x)
from snapadmin import fields as snap
name = snap.SnapCharField(max_length=200, searchable=True, show_in_list=True)
3. Update SnapModel usage
# Before (v0.0.x)
from snapadmin.models import BaseModel
class Product(BaseModel): ...
# After (v0.1.x)
from snapadmin.models import SnapModel
class Product(SnapModel): ...
4. Admin registration
# After (v0.1.x) — add to your app's admin.py:
from snapadmin.models import SnapModel
SnapModel.register_all_admins() # registers all SnapModel subclasses
5. New settings
# settings.py additions for v0.1.x:
SNAPADMIN_REST_API_ENABLED = True # default True
SNAPADMIN_GRAPHQL_ENABLED = True # default True
SNAPADMIN_SWAGGER_ENABLED = True # default True
ELASTICSEARCH_ENABLED = False # default False — enable only with ES cluster
SNAPADMIN_ES_QUERY_ROUTING = True # default True — ?search= on DUAL models → ES
SNAPADMIN_ES_SEARCH_LIMIT = 1000 # default 1000 — max ES hits per routed search
snapadmin_apitoken (for the token auth feature). Run python manage.py migrate after upgrading.