SnapAdmin
Automatic, beautiful, production-ready Django Admin — zero boilerplate. REST & GraphQL, Elasticsearch, offline mode and GDPR retention, all from your model definitions.
ForeignKey tenant column, admin inlines, operators
bound to every tenant, the importer, GDPR subject requests and reindexes — and stored files
leave storage with their rows (file cleanup). New and opt-in:
computed columns on the change form,
read-only admins, a hook for
cross-tenant jobs, project-defined raw-PII access,
masking ahead of an app's installation and
daily/weekly/monthly backup retention.
Breaking changes to know about before upgrading are listed in the release notes
under Breaking — among them the strict query parameters, snapadmin.E029
stopping manage.py check on an unusable setting, and a GDPR erasure that now reaches
tenant-scoped rows.
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.
django-snapadmin package — the code you ship in your own project. The
Demo section (example Product/Customer/Order models, the seeded Docker
stack, the dashboard) lives only in the repository to help you evaluate SnapAdmin; it is
not published to PyPI. Need to change generated behaviour? See
Extending & Overriding.
📦 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
Compatibility
SnapAdmin requires Python ≥ 3.10 and Django ≥ 5.2 (no upper bound pinned). SnapAdmin is currently a pre-1.0 beta (Development Status :: 4 - Beta); semantic versioning begins once a future 1.0 ships — see SECURITY.md for the current API-stability policy. Pin an exact version in production regardless.
| Versions | Status | |
|---|---|---|
| Python | 3.10 · 3.11 · 3.12 · 3.13 | Supported and tested in CI on every version (declared floor 3.10). |
| Django | 5.2 (LTS) · 6.0 | Supported and tested in CI on both (Django 6.0 requires Python ≥ 3.12). |
| Databases | SQLite · PostgreSQL · MySQL / MariaDB | Any Django-supported backend — SnapAdmin uses the ORM, adding no driver dependency. For MySQL, note the mysqlclient (GPL) vs PyMySQL (MIT) driver-licence trade-off before shipping a closed-source product. |
test.yml) and fails under 100% coverage of the snapadmin/ package,
so the compatibility claims here are enforced, not aspirational.
Async support
SnapModel.asave() / .adelete() / .arefresh_from_db() are
Django's own native async model methods (available since Django 5.2) — SnapAdmin adds no code for
them and needs none: each is a thin wrapper around the matching sync method
(self.save() / self.delete() / self.refresh_from_db()), and
Python resolves that through the instance's actual class, so they already reach
SnapModel's own overrides — the Elasticsearch mirror and a wysiwyg field's
sanitize-on-write run identically whether you call save() or
await obj.asave(). EsManager / EsQuerySet (the Elasticsearch
query layer for ES_ONLY models) gain aget() / afirst() /
alast() to match — a DB-backed model already got these for free from Django's own
QuerySet.
Out of scope, deliberately: async DRF ViewSets (DRF's own async support is
still partial), an async Elasticsearch client (the bundled client is sync-only and would need its
own wrapper), and bulk async operations (abulk_create / abulk_update).
GraphQL resolvers need nothing extra — Graphene already handles async resolvers transparently.
The smallest install that works
A bare pip install django-snapadmin is already enough for the generated admin,
because the REST API and GraphQL are off by default
(SNAPADMIN_REST_API_ENABLED / SNAPADMIN_GRAPHQL_ENABLED, both
False unless you set them):
INSTALLED_APPS = [
"django.contrib.admin", # SnapAdmin generates ModelAdmins into this site
"django.contrib.auth", # permissions gate both the admin and the API
"django.contrib.contenttypes", # required by auth; the audit trail keys off it
"django.contrib.sessions", # admin login
"django.contrib.messages", # admin "saved successfully" banners
"django.contrib.staticfiles", # serves SnapAdmin's CSS/JS
"snapadmin",
"myapp", # …your own apps
]
# urls.py
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("snapadmin.urls")), # the surfaces you turn on (REST adds /api/health/)
]
That is a working admin-only install. With the REST API off, snapadmin.urls mounts
nothing — so an admin-only container probes /admin/login/, as the
snapadmin-new --admin-only Dockerfile does, not /api/health/. For the REST API and GraphQL, install their extras
(pip install "django-snapadmin[api,graphql]"), add rest_framework,
drf_spectacular, django_filters and graphene_django to
INSTALLED_APPS, and switch the surfaces on — individually, or both at once with
SNAPADMIN_PROFILE = "api" (Swagger follows the REST setting unless set
explicitly). Installing the extras without the settings serves nothing;
the settings without the extras are caught at manage.py check
(snapadmin.E010) rather than at the first request.
Configuring INSTALLED_APPS
The Unfold theme is optional (pip install django-snapadmin[theme]) — with it you get the themed UI, without it SnapAdmin renders on Django's built-in admin. If you use Unfold, its contrib apps must come before django.contrib.admin. manage.py check surfaces the stock-admin fallback as one informational message (snapadmin.I001, never an error) so it is never silently mistaken for a bug. If 'unfold' is listed but cannot be imported — usually an import cycle, a custom user model that imports SnapAdmin while apps load — the fallback is logged (snapadmin.unfold_import_failed) and reported as the warning snapadmin.W032 with the error; every SnapAdmin admin module shares that one decision, so the admin is never half-themed.
INSTALLED_APPS = [
# Optional theme — only with the [theme] extra; must precede django.contrib.admin
"unfold",
"unfold.contrib.filters",
"unfold.contrib.forms",
"unfold.contrib.inlines",
# WYSIWYG — only with the [wysiwyg] extra (SnapRichTextField / wysiwyg=True)
# "django_ckeditor_5",
# Django core
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# REST API — [api] extra; needed only with SNAPADMIN_REST_API_ENABLED/
# SNAPADMIN_SWAGGER_ENABLED set to True (both default False)
"rest_framework",
"drf_spectacular",
"django_filters",
# GraphQL — [graphql] extra; needed only with SNAPADMIN_GRAPHQL_ENABLED = True
# (default False). Independent of [api].
"graphene_django",
"snapadmin",
# Optional — only with the [celery] extra
# "django_celery_beat", # edit the schedule from the admin
# "django_celery_results", # store task results in the database
# Optional — only with the [extra-settings] extra. SnapAdmin does not use it;
# add it for a runtime key/value Setting model in the admin (the demo shows it).
# "extra_settings",
# Optional — only with the [autocomplete-filter] extra (LGPL), for your own
# AutocompleteFilter admin filters; SnapAdmin core never imports it.
# "admin_auto_filters",
# Your apps …
]
Elasticsearch needs no app entry — install the [elasticsearch]
extra and set ELASTICSEARCH_ENABLED = True. The supported server is
Elasticsearch 8.x, and the extra pins the client to match
(>=8,<9). The same goes for [backup], [s3],
[age], [xlsx] and [encryption]: a dependency, not an
app.
A bare pip install django-snapadmin pulls only Django, structlog and nh3. djangorestframework, drf-spectacular and django-filter come via the [api] extra, and graphene-django via [graphql] — both features default to off (SNAPADMIN_REST_API_ENABLED / SNAPADMIN_GRAPHQL_ENABLED), so install pip install django-snapadmin[api,graphql] (or [all]) once you turn either on. Turning a feature on with its extra missing fails loudly, both at manage.py check (snapadmin.E010) and, if that step is skipped, the moment snapadmin.urls is imported. django-unfold is not installed by the base package — add the [theme] extra for the Unfold-themed admin; without it the admin renders on stock Django. django-ckeditor-5 is likewise not installed by default (it bundles CKEditor 5, a GPL/commercial editor) — add it via the [wysiwyg] extra only if you use rich-text fields.
? Optional extras, licensing notes, and the package layout
Optional extras
The base install is self-contained. Opt into extra integrations with pip extras:
| Extra | pip install | Pulls in | For |
|---|---|---|---|
api | django-snapadmin[api] | djangorestframework, drf-spectacular, django-filter | The REST API + OpenAPI schema/Swagger/ReDoc — off by default, needed once you set SNAPADMIN_REST_API_ENABLED / SNAPADMIN_SWAGGER_ENABLED to True |
graphql | django-snapadmin[graphql] | graphene-django | The generated GraphQL schema — off by default, needed once you set SNAPADMIN_GRAPHQL_ENABLED to True, independent of api |
theme | django-snapadmin[theme] | django-unfold | Unfold-themed admin UI (falls back to Django's built-in admin without it) |
elasticsearch | django-snapadmin[elasticsearch] | elasticsearch (>=8,<9) | Full-text search / ES_ONLY / DUAL models — against an Elasticsearch 8.x server |
celery | django-snapadmin[celery] | celery, django-celery-beat, django-celery-results | Background tasks (async export, GDPR purge, digests, backups) |
backup | django-snapadmin[backup] | paramiko | SFTP offsite database backups (LGPL) |
age | django-snapadmin[age] | pyrage | AGE-encrypted backups (MIT — SNAPADMIN_BACKUP_AGE_RECIPIENTS; or skip this extra and use the age CLI instead) |
s3 | django-snapadmin[s3] | boto3 | S3-compatible offsite backup transport (SNAPADMIN_BACKUP_S3_* — AWS, MinIO, Backblaze B2, Hetzner Object Storage, Wasabi) |
extra-settings | django-snapadmin[extra-settings] | django-extra-settings | An in-admin dynamic key/value Setting model (as the demo shows) |
wysiwyg | django-snapadmin[wysiwyg] | django-ckeditor-5 | Rich-text fields (SnapRichTextField / wysiwyg=True) — bundles CKEditor 5 (GPL-or-commercial) |
autocomplete-filter | django-snapadmin[autocomplete-filter] | django-admin-autocomplete-filter | AutocompleteFilter list filters in your own admin (LGPL) |
xlsx | django-snapadmin[xlsx] | openpyxl | XLSX output for the async export API (MIT — optional for size, not licence) |
encryption | django-snapadmin[encryption] | cryptography | AES-256-GCM for encrypted model fields (Apache-2.0 or BSD-3 — optional for weight, not licence) |
all | django-snapadmin[all] | everything above | — |
extra-settings gotchas
extra-settings is optional and not used by SnapAdmin's core (it was a required dependency before — now it isn't). Install the extra only if you want the dynamic Setting model. Two things to know:
EXTRA_SETTINGS_ADMIN_APPmust match anINSTALLED_APPSentry. If you register apps by theirAppConfigdotted path ("shop.apps.ShopConfig", or an app nested under a package like"myproject.apps.billing"), pass that dotted string — a bare label ("shop","billing") won't be found. If you list bare labels, use the bare label.django-extra-settings's own error message here names the label you passed, not the dotted path it actually expects, which makes this easy to misdiagnose. This is an upstream limitation ofdjango-extra-settings, not a SnapAdmin bug — but SnapAdmin'ssnapadmin.E025check answers the question that message dodges, resolving the value against the app registry and printing theINSTALLED_APPSentry you should have written. One caveat on when you get to see it: with Django's defaultAdminConfig, admin autodiscovery importsextra_settings.adminduringdjango.setup(), so the upstreamImproperlyConfiguredaborts startup before any system check runs.snapadmin.E025is what you get wherever that import is deferred instead —SimpleAdminConfigplus an explicitadmin.autodiscover(), a customAdminSite, or nodjango.contrib.adminat all — where the setting would otherwise be ignored silently or blow up on the first request rather than atmanage.py check.- The
Settingadmin is Unfold-styled automatically.django-extra-settingsregisters its own plainModelAdmin, which would render unstyled next to the rest of the themed site. SnapAdmin fixes this fromSnapAdminConfig.ready()by re-registering theSettingadmin (or its proxy, whenEXTRA_SETTINGS_ADMIN_APPre-homes it) with a class inheritingunfold.admin.ModelAdminon top of extra_settings' own configuration —list_display,search_fields, fieldsets and media are all preserved. This works regardless of whetherextra_settingsis listed before or aftersnapadmin; the only requirement is thatdjango.contrib.adminprecedesnapadmin(Django's project template already does this). Without Unfold installed, the styling step is a no-op.
Licensing notes for commercial use
wysiwyg bundles CKEditor 5 (GPL-or-commercial)
The rich-text editor (django-ckeditor-5) bundles CKEditor 5, which is dual-licensed GPL-2.0+ or commercial. It is kept out of the base install so the core package carries no GPL/commercial code — the base is permissive (MIT/BSD/Apache) and safe for commercial and proprietary use. Opt into [wysiwyg] only if you want rich-text fields, and for a commercial product obtain a CKEditor licence (they offer a free tier) or supply your own widget. Without the extra, using a wysiwyg=True field raises a clear ImproperlyConfigured telling you to install it.
DATABASES[...]['ENGINE'] = 'django.db.backends.mysql'), you must separately install mysqlclient (GPL-2.0-or-later). That is fine for internal or non-redistributed applications, but check your licence posture before shipping a closed-source product. Django also supports the pure-Python PyMySQL — install it and call pymysql.install_as_MySQLdb() in your project's __init__.py or early in settings.py before Django loads. PyMySQL is MIT-licensed but pure-Python and slower than the C-extension mysqlclient, so production deployments typically prefer the latter.
Neither note is legal advice — review dependency licences with counsel for commercial use. The full dependency/licence inventory lives in THIRD_PARTY_NOTICES.md.
Package layout
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
🏁 New Project — snapadmin-new
snapadmin-new (also python -m snapadmin.scaffold) generates a project you
keep — not a throwaway (snapadmin-demo) and not
a read-only report (snapadmin-init): manage.py, a
settings package, one app carrying a worked SnapModel example, SQLite and a
.env/dist.env. migrate then runserver work
immediately — no Docker, no manual edits.
pip install django-snapadmin
snapadmin-new myshop
cd myshop
pip install -r requirements.txt # the extras this project imports — [api,graphql] by default
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
Open http://127.0.0.1:8000/admin/ — the admin, the REST API
(/api/docs/) and the GraphQL endpoint (/api/graphql/) are already wired to
the worked Product model in catalog/models.py. Templates ship inside the
wheel under snapadmin/scaffold/templates/ and render with the standard library's
string.Template — no Jinja, no extra dependency.
snapadmin-new refuses to write into a non-empty target directory — a mistaken
re-run cannot silently clobber a project you already edited. The project/app name is validated
the same way django-admin startproject validates one: a valid Python identifier
that doesn't shadow an existing importable module.
Pass --full for the containerised stack — same project, plus a
Dockerfile, docker-compose.yml and the PostgreSQL / Redis / Elasticsearch
wiring (Postgres when POSTGRES_HOST is set, SQLite otherwise, so
manage.py check/migrate still work with no services running):
snapadmin-new myshop --full
cd myshop
docker compose up --build
Flags
| Flag | Effect |
|---|---|
--path DIR | Directory to create the project in (default: the current directory). |
--app-name NAME | Name of the app carrying the worked SnapModel example (default: catalog). |
--full | Also write a Dockerfile, docker-compose.yml and the Postgres/Redis/Elasticsearch wiring. |
--admin-only | Generate the admin alone: no REST API or GraphQL apps, both switches off, a base-install requirements.txt, and (with --full) a container health check on /admin/login/ instead of the API's /api/health/. The default project serves all three surfaces — token-authenticated and permission-gated, never anonymous — because showing them from one model is what it is for; --admin-only is the library's own 1.0 default, for a project that never wants an API. |
🩺 Integrate an existing project — snapadmin-init
Adding SnapAdmin to a project you already have means editing INSTALLED_APPS (with a
specific ordering), urls.py, a settings block and your requirements. The
snapadmin-init command (also python -m snapadmin.integrate) makes that
trivial — and safe.
pip install django-snapadmin
snapadmin-init # a per-item present/missing checklist with snippets
snapadmin-init --api --graphql # also check the REST / GraphQL configuration
snapadmin-init --json # machine-readable, for your own tooling
For each item it reports ✓ already present or ✗ add this — followed by
the block to paste:
- INSTALLED_APPS — the full list with
snapadminin place, and a note that theunfoldtheme is optional ([theme]extra) — if you use it, its apps must precededjango.contrib.admin. - URL routes — the
include("snapadmin.urls")line, honouring--url-prefix. - SnapAdmin settings — the
SNAPADMIN_*feature-toggle block. - REST / GraphQL config — with
--api/--graphql. - Install line —
pip install django-snapadmin[extras](--extras), and a warning if your requirements pin an incompatible Django. - Model conversion (advisory) — lists files still subclassing
models.Modeland shows how to convert one toSnapModel.
Flags
| Flag | Effect |
|---|---|
--path DIR | Project root (default: current directory). |
--settings PATH / --urls PATH | Point at a non-standard layout (auto-detected otherwise via manage.py / globbing). |
--url-prefix PREFIX | Prefix for the SnapAdmin routes in the URL snippet, e.g. api/. |
--extras a,b | Extras for the install line (e.g. elasticsearch,celery). |
--api / --graphql | Also check the REST / GraphQL configuration. |
--json | Emit the checklist as JSON. |
✅ Integration Checklist
Installed doesn't mean correctly integrated. Every row below names the command that proves it — run it, don't take the row's word for it. Stop at whichever group matches where your project actually is; you don't need "Optional / scale" to ship a first version.
snapadmin-init inspects your project's source files before anything is even
running — it now prints this exact checklist (see above), with
⚠️ "not checked" wherever a row genuinely needs a live database or server, rather than
guessing. snapadmin_info --health-check and snapadmin_info --section
features pick up from there once the project is running.
Must work
| Check | Why it matters | How to verify |
|---|---|---|
App boots with snapadmin installed | The most basic proof the install is wired correctly | python manage.py check |
| Models are registered | A model with no capabilities looks identical to a typo'd import — until you check | python manage.py snapadmin_info --section inventory — lists every registered model, its door and its gaps |
| Is each model getting the capabilities you think it is? | A model registered with @snap_model gets no Elasticsearch mirroring, no retention purge and no generated admin — silently, unless you check | python manage.py snapadmin_info --section inventory — each row shows door (subclass/decorator) and inactive_capabilities |
No snapadmin.E* errors | Errors block startup in production (DEBUG=False); catch them locally first | python manage.py check / snapadmin_info --section checks |
| Migrations applied | An unapplied migration is a production incident waiting to happen | python manage.py migrate --check — exits non-zero without applying anything |
First makemigrations after adopting SnapAdmin is AlterField-only | Converting fields to Snap*Field changes every one's deconstruct() path — expect a migration of nothing but AlterField (a no-op on PostgreSQL/MySQL, a table rebuild on SQLite), not a sign something else changed | python manage.py makemigrations --check --dry-run, then sqlmigrate the generated migration and confirm no column type/constraint actually changes — see the migration guide |
| The admin renders | The most-used surface — worth a manual look, not just a status code | Visit /admin/ and log in |
| REST / GraphQL respond, if enabled | Both are separate opt-in surfaces — confirm each you actually turned on | snapadmin_info --health-check — probes both and exits non-zero on failure |
| Static files served | A missing collectstatic step is invisible in DEBUG=True and breaks only in production | python manage.py collectstatic --dry-run |
Should be configured before production
| Check | Why it matters | How to verify |
|---|---|---|
| Authentication on the API | The default is SnapAdmin's own token auth — fine, but confirm it's the one you meant | snapadmin-init --api (checks SNAPADMIN_API_AUTHENTICATION_CLASSES) · see Integrating |
api_write_fields / api_read_only set where they matter | The default (unset) leaves every field mass-assignable — deliberate for a demo, risky for real data | snapadmin_info --section inventory — a "Write restricted" column, per model |
| PII masking configured for the fields that need it | Unmasked PII in the admin/API/exports/audit diff is a compliance and breach-surface problem | snapadmin_info --section features → pii_masking |
| HTML sanitization active | Reassurance, not a gap: nh3 fails closed — a missing sanitizer is a hard error the moment a wysiwyg field is written, never a silent pass-through | Nothing to configure; it can't silently be off |
| API throttling configured | Ships with a sane default — this row is about confirming that default is the one you want at your scale | snapadmin-init → throttling row (checks SNAPADMIN_THROTTLE_ANON/_USER) |
| API page size configured | Same idea, for SNAPADMIN_API_PAGE_SIZE — the default (25) is fine for most projects, but worth an explicit choice | snapadmin-init → pagination row |
| Structured logging wired | JSON logs in production are what makes an incident debuggable after the fact | Run any command and check the output shape (colourised dev / JSON prod) — see Structured Logging |
| Error monitoring / alert channels | The first sign of trouble should reach a human, not just a log file nobody reads | snapadmin_info --section features → health_alerts |
| Health probes wired to the container | Without this, an unhealthy container looks the same as a healthy one to your orchestrator | curl -f http://localhost:8000/api/health/ — see Container health check |
Data safety
| Check | Why it matters | How to verify |
|---|---|---|
| Backups enabled, at least two destinations (the 3-2-1 rule) | One destination is one point of failure away from zero backups | snapadmin_info --section features → backups · see 3-2-1 Backups |
Retention set (SNAPADMIN_BACKUP_KEEP) | Unbounded retention quietly fills a disk; no retention loses history | Check the setting is present — see 3-2-1 Backups |
| Backup encryption configured (strongly recommended) | Encryption is optional — you can run backups without it. We strongly recommend turning it on: an unencrypted dump on a rented offsite server is your whole database in someone else's hands. It costs one setting. | snapadmin_info --section features → backups shows encrypted (N recipient) · see Encrypting backups |
| Have you actually run a restore? | An untested backup is the most common form of not having a backup | python manage.py snapadmin_restore <bundle> --confirm against a recent dump and confirm the data matches — see Restoring a backup. snapadmin-init flags this row as "not checked" rather than pretending it verified anything. |
Optional / scale
| Check | Why it matters | How to verify |
|---|---|---|
| Elasticsearch | Only matters once a model actually opts into it | snapadmin_info --section features → elasticsearch / --section elasticsearch |
| Celery (background tasks) | Backups, digests and the retention purge all need a worker + a schedule entry — nothing runs by itself | snapadmin_info --section features → background_tasks · see Celery & Periodic Tasks |
| Offline mode | A per-model opt-in for a usable admin with no connection | Check offline_mode = True on the models that need it — see Offline Mode |
| Theme | Cosmetic — Unfold if installed, stock Django admin otherwise | Visit /admin/ |
| Async exports | Large exports stream on a Celery worker instead of blocking a request | See Async background export |
| Audit trail | On by default — this row is about confirming you haven't turned it off | snapadmin_info --section features → audit_trail |
🧭 Production Playbook
The checklist above proves an install is wired correctly. This one is about the decisions
around it — what to settle with whoever owns the project before writing integration code, the
order to build in, what the project's own test suite should cover, and which defaults stop being
the right ones under load. It is written for a developer starting a real deployment, and it is
the same material an AI assistant reads from
llms.txt, so the
two never give different advice.
1. Decisions to settle before writing integration code
Each row has a default that is safe but not necessarily right, and each one fails quietly rather than loudly if nobody chooses. Ordered by how expensive they are to change later — the first three are paid for once and then forever, the rest are settings.
| Decide | Why it can't be guessed | Where the answer lands |
|---|---|---|
| What is being built, and what load must it carry | Which models are hot, rows expected in a year, read/write ratio, peak concurrent users, whether the admin is an internal tool for ten staff or an operations console, how sensitive the data is, what the legal retention obligation is. Every row below is read against these answers. | Nothing — this is the conversation that decides which rows even apply |
AUTH_USER_MODEL — e-mail login or username? | Decided before the first migration and painful afterwards: swapping the user model later is a data migration across every foreign key pointing at it. SnapAdmin never assumes username — its user API builds its field list from UserModel.USERNAME_FIELD and adds email/first_name/last_name only when the model defines them — so both work, but only you can say which. Decide at the same time whether customers and staff share one table. | AUTH_USER_MODEL, USERNAME_FIELD, AUTH_PASSWORD_VALIDATORS |
| What guards the login door | SnapAdmin implements no SSO, no second factor and no login rate limiting of its own — the admin login is Django's. SNAPADMIN_SSO_PROVIDERS only renders buttons for backends you already wired (allauth, social-auth, mozilla-django-oidc) and drops any entry whose URL is protocol-relative or outside SNAPADMIN_SSO_ALLOWED_HOSTS. | manage.py check --deploy, session/CSRF cookie flags, brute-force protection (a proxy rule, django-axes, or a guard on snapadmin.limits), who gets is_staff vs is_superuser, SNAPADMIN_URL_PREFIX, SNAPADMIN_DASHBOARD_PUBLIC (False) |
| Which surfaces are exposed at all | REST and GraphQL are off by default and each needs its extra. Generating a writable endpoint per model is a decision about attack surface, not a convenience. | SNAPADMIN_PROFILE |
subject_path on every registered model | Required, no default — snapadmin.E011 fails manage.py check until it is answered. What the right path is, and which model is the subject, is a domain question. | Subject-access requests |
| Who may write which fields | Unset means every field on every registered model is mass-assignable through the generated API. Fine for a demo, rarely right for real data; snapadmin.W004 warns, but a warning is not a decision. | api_write_fields / api_exclude_fields / api_read_only |
| Which fields are personal data | Unset means the changelist, REST, GraphQL, exports and audit diffs all show raw values to any staff user. Decide alongside who holds snapadmin.view_raw_pii. | SNAPADMIN_MASKED_FIELDS + SNAPADMIN_MASKING_RULES |
| Should a field be invisible rather than starred | Orthogonal to masking: masking decides whether a visible field is raw, this decides whether it is there at all. Salary, cost price and internal notes usually want this one. | api_field_permissions |
| One tenant or many | Default-deny once on: no bound tenant means an empty read and a refused write. Two limits to state out loud — isolation is logical, and backups run below the ORM, so a bundle contains every tenant's rows. | tenant_scoped |
| How the application reaches the database, and as whom | Outside SnapAdmin's code, inside its blast radius: a least-privilege role that is not the schema owner and not a superuser, TLS, credentials from the environment rather than settings.py, CONN_MAX_AGE and a pooler matched to the worker model. Encrypted columns are decided while the tables are still small — adopting one later re-encrypts every row, and an encrypted column cannot be compared, ordered or range-filtered in the database. | SNAPADMIN_ANALYTICS_DB_ALIAS, SNAPADMIN_SHARDING, field encryption (snapadmin.E017: the key is never SECRET_KEY) |
| Backups: where, encrypted, and has a restore ever been run | Three separate questions. Settle two more while you are there: who holds the AGE identity file — a dump nobody can decrypt is not a backup — and how often the drill is repeated. | 3-2-1 backups, AGE encryption, restore drills |
| What deletes old data, and what runs it | SnapAdmin ships no daemon: without a Beat entry or a cron line, nothing purges and the tables grow forever. snapadmin.W012 catches configured-but-unscheduled. | data_retention_days, SNAPADMIN_AUDIT_RETENTION_DAYS (365), SNAPADMIN_EXPORT_RETENTION_DAYS (off) |
| Authentication and limits on the API, if it is on | The defaults are reasonable and none of them were chosen for this project: token auth, 60/min anonymous, 600/min authenticated, 25 rows per page capped at 500. GraphQL requires authentication by default; the user-management API is off. | SNAPADMIN_API_AUTHENTICATION_CLASSES, SNAPADMIN_THROTTLE_ANON/_USER, SNAPADMIN_API_PAGE_SIZE |
| What records that something changed — and what silently does not | The audit trail covers creates/updates/deletes made through a SnapAdmin-generated admin (plus token rename and rotation). It does not record REST or GraphQL writes, QuerySet.update(), bulk_update(), management commands or shell sessions. Telling a compliance team otherwise is the kind of mistake that surfaces during an audit. | Audit trail; for API writes call snapadmin.audit.record_audit(...) from a perform_create/perform_update override |
| Where a failure becomes visible | Error capture, alert channels and health alerting are all opt-in. An alert channel nobody reads is the same as no alerting — ask who is actually paged. | Error monitoring, alert channels, /api/health/, structured logging |
| Whether the licence posture matters | The base install is permissive only (MIT/BSD/Apache) and safe for commercial and proprietary use; [wysiwyg] bundles CKEditor 5 (GPL-or-commercial) and MySQL users choose between mysqlclient (GPL) and PyMySQL (MIT). | snapadmin-license-check --critical-only |
| What the first migration looks like | Converting existing fields to Snap*Field changes every deconstruct() path, so the first makemigrations is a wall of AlterField — expected, not a symptom. | sqlmigrate the result and confirm no column type or constraint changes |
| Whether there is infrastructure for the optional stacks | Elasticsearch and Celery are opt-in and need real services. Everything degrades to a clear "not available" rather than a false green, so the honest answer to "not yet" is to leave them off. | es_storage_mode, Celery |
2. Build order for a new project
The answers above become this order. Each step is verifiable before the next one starts —
skipping ahead is what produces a project that works under DEBUG and fails on its
first real day. snapadmin-new scaffolds steps 1–4 for a
greenfield project; snapadmin-init prints the same
steps as ready-to-paste snippets for an existing one, and never edits your source.
- Identity and the settings skeleton, before the first
migrate. A custom user model — even an emptyAbstractUsersubclass, because adding one later is the expensive migration —USERNAME_FIELD, password validators, and one settings module per environment with every secret read from the environment. - Install the surfaces the project actually wants. The extras the answers
require, Unfold's apps before
django.contrib.admin,snapadmin.urlsin the root URLconf, and oneSNAPADMIN_PROFILEline. - Model the domain as ordinary Django first, then add the Snap kwargs.
Relations, constraints, indexes and
Metaare Django decisions SnapAdmin does not change;searchable/filterable/show_in_listare presentation layered on top and add no migration. A model that is wrong as a Django model is not rescued by a generated admin. - Register, then look at it.
register_all_admins(), thenmanage.py check,snapadmin_info --section inventory— every model, the door it came in by, the capabilities inactive on it — and an actual visit to/admin/. - Permissions before data. Groups and permissions per role, write surfaces
on every model the API exposes, field-level guards where a
field must not be visible at all, and
SNAPADMIN_API_ACTION_PERMISSIONSfor every custom action — an unmapped action is refused for everyone, superusers included. Do this while the tables are empty and a mistake costs nothing. - Declare the data-protection layer.
subject_patheverywhere, masking for personal data, encrypted columns for anything that must be unreadable in a dump, retention windows where a legal one exists. - Wire the operational layer. Backups to at least two destinations with AGE
recipients, a Celery worker and a Beat schedule, the error-monitor middleware and one
alert channel that reaches a human, JSON logs, and
/api/health/as the container probe — never/admin/, which answers 302 with the database down. - Only now turn on what the load profile demands. Elasticsearch mirroring, a read replica or sharding, caching, remote storage. Each is an operational commitment; adding one because it might be needed is how a two-service project becomes a six-service one.
- Harden the deployment.
DEBUG = False,ALLOWED_HOSTS, a cleanmanage.py check --deploy,collectstaticin the image build, a least-privilege database role, andSNAPADMIN_URL_PREFIXif the admin should not sit at a guessable path. - Prove it with tests — the next subsection — and make
manage.py check,makemigrations --check --dry-runand the suite a CI gate rather than a habit. - Rehearse the recovery before you need it:
snapadmin_restore --database <alias>into a throwaway database, row counts compared, the result written down with a date, repeated on a schedule. - Pin the version and read the release notes on every upgrade. The package
is pre-1.0; pin an exact version in production, then re-run
manage.py checkandmakemigrations --checkimmediately after each bump.
3. Testing the project you build
SnapAdmin's own suite covers the library and nothing about your models,
your permissions or your data. Your project needs its own, in the same discipline: every
assertion states a concrete contract — a status code, a value, an exception type, a side effect,
a query count, never assert x is not None where a value is checkable — every test is
order-independent with no shared mutable state and no reliance on the wall clock, and
no test is ever weakened, skipped, xfailed, mocked into silence or given a
longer timeout to make it pass. A failing test is a diagnosis job first.
The base. pytest + pytest-django with a dedicated
test settings module; run the suite against the real database engine, because PostgreSQL differs
from SQLite on constraints, ordering and transactions; random order
(pytest-randomly) so ordering coupling fails loudly; factories rather than fixtures
full of literals; frozen time instead of datetime.now(); no outbound network in unit
tests. Measure coverage with --cov-branch and treat a high number as a floor for the
domain layer, never as the goal — it proves lines ran, not that a mutation would be caught.
Per app. Models: constraints, clean(), custom managers, computed
properties, __str__. Migrations: makemigrations --check --dry-run in
CI, plus a forward run on both an empty database and a production-shaped copy. Admin: that the
changelist renders for each role, that the columns and filters you declared are the ones that
appear, and that a save produces the audit row. API: one test per role per action, the
401/403/404 distinction, validation errors as 400 rather than 500, pagination, filtering and
every custom @snap_action. Background tasks: the task function called directly with
real arguments, .delay() mocked only at the boundary.
The SnapAdmin-specific traps, one test each. Every one of these fails silently in production if it regresses:
| Pin this | Because |
|---|---|
| Snap-only kwargs add no migration | makemigrations --check must keep saying "No changes detected" — required=True and the file-upload limits are the documented exceptions |
QuerySet.update() bypasses pre_save() | A wysiwyg or encrypted field written that way stores unsanitized markup or plaintext; bulk_create() is safe, because the insert compiler still calls pre_save() |
| An API action missing from the permission map is refused | Superusers included — a custom action added later and not mapped must fail closed, not inherit view |
| A tenant-scoped model with no bound tenant | Empty read, refused write, and a create naming a different tenant refused rather than silently rewritten |
| Masking degrades toward more masking | Every failure path must mask harder, and a hand-written admin needs PIIMaskingAdminMixin (snapadmin.W025) |
| Field-permission precedence | api_exclude_fields > api_field_permissions > api_write_fields > masking |
| A delete guard answers 403 | SNAPADMIN_API_DELETE_GUARD or api_can_delete must refuse, not delete |
| Elasticsearch down | The model still answers from the database when fallback is on — and deletes still propagate when it is up |
| Encrypted columns | The lookups the database cannot do raise FieldError rather than returning wrong rows |
GET /api/health/ | 503 when the database is unreachable, 200 when only Elasticsearch is |
| The retention purge | Deletes a row past its window only when the task runs, and keeps the rows a PROTECT key holds |
| Import conflicts | --on-conflict fail fails the row instead of overwriting it |
Production points that need a test rather than a hope. Pin the query count of
every list surface that matters (django_assert_num_queries) — an N+1 introduced by a
new __str__ or a template that walks a relation is invisible at 50 rows and fatal at
50,000. Assert that the pagination cap actually caps, that a client asking for a bigger page gets
the maximum and not the whole table, and that throttling answers 429. Keep
manage.py check and check --deploy (against production settings) in CI.
Test the permission matrix as a matrix, not one happy path. Assert the fail-closed defaults stay
fail-closed after every dependency upgrade. And make a restore drill part of the schedule,
asserted by row count rather than by the absence of an error message.
What SnapAdmin's own suite cannot do for you: it drives its generated admin in a real browser (browser E2E), but over the demo's models. A production project should keep browser E2E over its own critical admin flows — the same harness shape works, a live server plus Playwright with a check for JavaScript errors and missing assets on every page — and load tests against the API's real page sizes and filters, which a library has no traffic to run.
4. Production readiness and high load
Where the library helps, and where it deliberately stops. Every number below is the shipped default, and every one of them is a setting — the right value is the measured one.
| Area | What ships | What you tune at scale |
|---|---|---|
| Database | Nothing automatic — indexes, constraints and query shape stay yours | Index what you filter and order on; prefer keyset pagination over deep OFFSET; CONN_MAX_AGE and a pooler matched to the worker model — see the optimizations guide |
| Changelist counts | SNAPADMIN_ESTIMATED_COUNT on, threshold 100 000 rows: PostgreSQL's pg_class.reltuples replaces COUNT(*) on unfiltered listings only, with an exact count everywhere else | show_full_result_count = False on huge tables to drop the admin's second, unfiltered COUNT(*) — often the most expensive query on the page |
| Admin rendering | list_select_related derived automatically from the foreign keys actually shown, so the changelist joins instead of firing one query per row | list_per_page (100) and list_max_show_all (200) — see large-dataset performance |
| REST | Pagination always on: 25 per page, client-capped at 500, regardless of your own REST_FRAMEWORK defaults | SNAPADMIN_API_PAGE_SIZE, SNAPADMIN_API_MAX_PAGE_SIZE, and the JSON-filter scan cap (100 000 rows) / fetch-by key cap (10 000) |
| Rate limits | 60/min anonymous, 600/min authenticated | SNAPADMIN_THROTTLE_ANON/_USER, plus multi-window quotas for anything the API calls outward |
| Bulk data | Async exports stream on a Celery worker in chunks of 1 000; imports validate through full_clean() and resume after a crash | Keep large result sets out of request/response entirely — chunk sizes are settings |
| Search | For DUAL/ES_ONLY models the REST list endpoint serves from Elasticsearch, moving full-text search and deep pagination off the primary database; a search is capped at 1 000 hits | SNAPADMIN_ES_SEARCH_LIMIT, and SNAPADMIN_ES_DB_FALLBACK — a silent fallback keeps the site up and hides an outage, so choose deliberately |
| Read scaling | Nothing routed unless asked | SNAPADMIN_ANALYTICS_DB_ALIAS for one replica, SNAPADMIN_SHARDING for real shard/replica routing (AUTO_FAILOVER defaults to False — only enable it against a promotable replica) |
| Background work | Tasks, no daemon: backups, the retention purge, digests and async exports need a worker and a Beat entry | Every scheduled task but run_export returns status (ok/partial/noop/disabled) plus failed and raises when every unit failed — one monitoring rule covers all of them |
| Shared state | Quota counters are per-process unless told otherwise, and an evicted counter fails open | SNAPADMIN_LIMITS_CACHE_ALIAS pointed at a shared cache once there is more than one web process; the same deliberate choice for sessions |
| Delivery | /api/health/ answers 200 healthy/degraded and 503 when the database is down | collectstatic in the image build, remote S3-compatible storage for static/media/exports, JSON logs shipped somewhere searchable |
5. Development norms in a SnapAdmin project
- Read model configuration through the registry, never with a hand-rolled
getattr:is_registered(model)is the gate every surface asks, andget_model_meta(model, name, default)resolves decorator argument > class attribute >SNAPADMIN_<NAME>setting > default. - Prefer the change that needs no migration. Snap-only kwargs, settings and admin-layer overrides cost nothing to deploy; a column change costs a maintenance window on every install.
- Write through the ORM save path when a field carries behaviour.
Sanitization and encryption live in
pre_save(), sosave(), the admin, serializers andbulk_create()are covered — andQuerySet.update(), raw SQL and direct database access are not. - Extend, don't fork, the generated surfaces.
admin_overrideswins over anything the generator produces andget_admin_media()extends the base asset lists instead of snapshotting them; a copied-and-edited generated class silently stops inheriting fixes. - Keep new surfaces fail-closed. The library's own defaults refuse rather
than allow — an unmapped action, an unbound tenant, a masking rule that cannot resolve. Project
code that adds a bypass should make it explicit, audited and tested, the way
use_all_tenants()is. - Type the public functions, log through
structlog, read secrets from the environment. Every new setting gets a commented entry in the settings module and a line in the environment template, so nobody has to read the code to find it. - Upgrades are a task, not a bump. Pin the exact version, read the release
notes, run
manage.py checkandmakemigrations --check, then the suite. The audit trail's{field: {"old", "new"}}shape and everysnapadmin.*import path are stable across releases; behaviour behind a default can still change, which is what the notes are for.
🔀 Two Ways to Declare a Model
New, greenfield model? Subclass SnapModel below — that's the full
route. Existing models you can't or won't rewrite? @snap_model +
snap_field() opt them in from the outside, with no migration.
Both routes end in the same registry — snapadmin.registry.is_registered(model) is
the gate every surface asks — so switching later changes nothing else: apply
@snap_model to a SnapModel subclass and it overrides exactly the
keywords you pass, leaving the rest of the class-level configuration alone.
The same model, both ways
Subclass — the full route
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)
cost_price = snap.SnapDecimalField(max_digits=10, decimal_places=2)
api_write_fields = ["name", "price"]
api_exclude_fields = ["cost_price"]
subject_path = None # snapadmin.E011 asks every registered model
Decorator — opt an existing model in
from django.db import models
from snapadmin import snap_model
from snapadmin.fields import snap_field
@snap_model(
api_write_fields=["name", "price"],
api_exclude_fields=["cost_price"],
search_fields=["name"], # a plain model's ?search= reads this, not field flags
subject_path=None,
)
class Product(models.Model):
name = snap_field(models.CharField(max_length=200), searchable=True)
price = snap_field(models.DecimalField(max_digits=10, decimal_places=2), filterable=True)
cost_price = models.DecimalField(max_digits=10, decimal_places=2)
Both register the same model, with the same API write allowlist and the same REST search.
On the decorated side that search comes from search_fields: a plain model has no
generated admin, so its searchable/filterable flags reach no surface by
themselves (REST query filters are generated for every column either way). Neither needs a migration: the decorator adds no field or attribute, and
snap_field() sets its metadata after Django's Field.__init__ has already
recorded its constructor arguments.
The honest comparison
The two routes are not identical — a decorated plain model does not attach
SnapModel's runtime machinery, and the surfaces that need it skip a
decorated model rather than half-work. This table states plainly which capability lives where;
every ❌ links to the task tracking it, or is marked permanent with the reason.
| Capability | Subclassing SnapModel | @snap_model / snap_field() |
|---|---|---|
| REST + GraphQL + schema endpoint, API field/verb policy, filters, offline cache | ✅ | ✅ |
System checks + snapadmin_info inventory | ✅ | ✅ |
Field flags — searchable, filterable, layout, wysiwyg sanitize-on-write, required, file-upload validators | ✅ | ✅ every Snap*Field flag is settable through snap_field(); on a decorated plain model the admin-facing ones (searchable, filterable, layout) take effect only once a ModelAdmin or search_fields reads them |
| PII masking | ✅ | ✅ settings-driven, keyed by model — identical on both routes |
| Backups | ✅ | ✅ identical on both routes — the backup layer dumps the whole database with no model gate at all |
Elasticsearch (es_search(), mirroring on save, snapadmin_reindex) | ✅ | ❌ ES is not identical — no EsManager is attached; tracked as #RFC1g row 1, retrofittable with an opt-in EsMirrorMixin, not yet shipped |
GDPR retention purge (purge_expired()) | ✅ | ❌ the purge task and command skip it; tracked as #RFC1g row 2, not yet shipped |
Generated admin (register_all_admins()) | ✅ | ❌ register a ModelAdmin yourself; tracked as #RFC1g row 3, not yet shipped |
formatted_id, audit/PII save() hooks, admin_overrides | ✅ | ❌ depend on the generated-admin gap above |
? Why can't the decorator just attach these?
SnapModel gets ES mirroring, retention and the generated admin from its
base class — an attached manager, Meta hooks, save()
overrides. A decorator running after the class already exists has nothing to hook into the same
way; each row above needs its own retrofit (an opt-in mixin for ES, a shared classmethod for
retention, an admin-generation refactor), not a keyword. The design for all three is signed off
but none has shipped yet. That is also why the
decorator accepts no es_* or data_retention_* keywords: storing them
would promise machinery that never runs. Needing any ❌ row means subclassing
SnapModel — both routes end up in the same registry, so switching later changes
nothing else.
Links onward: Snap Fields (the flags themselves) ·
snap_field() (the field-level wrapper) ·
Mixing Snap & plain fields (you never have to convert a whole
model at once) · the decorator's full keyword table and
precedence rule.
🏗 SnapModel
Subclass SnapModel and you get admin, search, GDPR retention and Elasticsearch
mirroring for free — no extra code. Reach for it on a new model, when you want everything
SnapAdmin can do.
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)
? Already have a model you can't rewrite?
Subclassing is the full route, but not the only one.
@snap_model opts a plain models.Model in from the outside — no
rewrite, no migration. Most existing projects start there; see the two
ways compared.
? What does subclassing actually turn on?
A smart __str__, automatic admin registration, Elasticsearch mirroring on
save, and the GDPR retention purge. The decorator route
gets the first two but not the last two — see the comparison table.
objects manager and SnapModel — snapadmin.E027
SnapModel declares objects = EsManager(). Django resolves a manager
name by first match in MRO order, so in class Order(SnapModel, OwnerScopedMixin) the
mixin's own objects — typically a manager that filters rows by owner or tenant — is
silently replaced, and every consumer of Order.objects, the generated admin and the
API sees every owner's rows. manage.py check fails with
snapadmin.E027 when that happens. Declare the manager you mean on the model itself,
combining both when you want both:
class OrderManager(OwnerScopedManager, EsManager):
pass
class Order(SnapModel, OwnerScopedMixin):
objects = OrderManager()
Prefer that over listing the mixin before SnapModel: then the mixin's
manager wins and EsManager is gone — ES query routing, and on a
tenant_scoped model the tenant isolation, which lives in EsManager.
snapadmin.E027 reports that direction too, for a tenant-scoped model whose
objects is not an EsManager. If the plain EsManager really
is what you want on a model that is not tenant-scoped, add 'snapadmin.E027' to
SILENCED_SYSTEM_CHECKS.
🔌 @snap_model — Plain Django Models
There are two ways to declare a SnapAdmin model. Subclassing SnapModel (above) is
the full one. The @snap_model decorator is the other: it opts a plain
django.db.models.Model in from the outside, without touching its
field layer. Use it for a brownfield schema you cannot rewrite, a model whose base class belongs to
a third-party package, or fields that come from packages like django-money,
phonenumber_field or model-utils.
from django.db import models
from snapadmin import snap_model
@snap_model(
api_write_fields=["name", "price"], # mass-assignment allowlist
api_exclude_fields=["cost_price"], # never leaves the server
search_fields=["name"], # what ?search= matches on
)
class Product(models.Model):
name = models.CharField(max_length=200)
price = models.DecimalField(max_digits=10, decimal_places=2)
cost_price = models.DecimalField(max_digits=10, decimal_places=2)
The decorator adds no field and no attribute to the class, so it needs no
migration. From then on the model is a SnapAdmin model everywhere the question is asked:
the REST API mounts CRUD routes for it, the GraphQL schema gains a type, the offline endpoints and
the snapadmin.W00x system checks see it, and snapadmin_info inventories
it.
? Keywords — the full table
Each keyword mirrors the SnapModel class attribute of the same name. Only the
keywords you actually pass are recorded, so a value you leave out keeps its usual default.
| Keyword | Type | Effect |
|---|---|---|
api_exclude_fields | list[str] | Fields kept out of the REST serializer, the GraphQL type and the schema endpoint |
api_write_fields | list[str] | None | Mass-assignment allowlist; None leaves every non-excluded field writable |
api_read_only | bool | Serve the model over safe HTTP methods only (writes answer 405) |
api_http_method_names | list[str] | None | Explicit lowercase HTTP-verb allowlist; wins over api_read_only |
api_filter_lookups | dict[str, list[str]] | Per-field query-filter lookups, e.g. {"name": ["exact", "icontains"]} |
api_default_text_lookups | list[str] | None | Lookup set for every text field not named in api_filter_lookups |
api_json_filters | dict[str, list[str]] | Filterable key-paths inside JSON columns, e.g. {"payload": ["a.b"]} |
offline_mode | bool | Expose the model to the admin's offline cache |
offline_cache_limit | int | How many recent rows the offline cache prefetches |
search_fields | list[str] | Fields DRF's ?search= matches against — a plain model has no searchable=True Snap fields to derive them from |
What a decorated model does not get — registration and metadata only,
none of SnapModel's runtime machinery. See the full capability × door table, covering
both the model and field halves, on Two Ways to Declare a Model.
? Precedence — when three layers could all answer the same setting
snapadmin.registry.get_model_meta(model, name, default) is the one accessor every
SnapAdmin surface uses to read a model-level setting. It resolves a four-tier precedence rule,
returning the first tier that actually supplied a value:
| Tier | Source | Example |
|---|---|---|
| 1 — highest | The model's registry entry | what @snap_model(api_read_only=True) stored |
| 2 | The model's class attribute | class Product(SnapModel): api_read_only = True |
| 3 | A project-wide SNAPADMIN_<NAME> setting | SNAPADMIN_API_READ_ONLY, resolved through snapadmin.conf.get_setting so a SNAPADMIN_PROFILE preset can supply it too |
| 4 — lowest | The caller's built-in default | the default argument passed to get_model_meta |
Both declaration styles read identically, and a SnapModel subclass can still
override a single key through the decorator without losing the rest of its class-level
configuration. One caveat worth knowing: tier 3 is live for a decorated plain model and
effectively dead for a SnapModel subclass — the subclass already declares a
concrete class attribute for every name this is called with, so tier 2 answers first. The
settings tier is what lets the decorator route inherit a project-wide posture instead of always
falling to the hard-coded default — one more way the two doors converge.
The registry itself is public: snapadmin.registry.is_registered(model) is the gate
every surface asks. Applying @snap_model to a SnapModel subclass
overrides exactly the keywords you pass and leaves the rest of the class-level configuration
alone.
📋 Snap Fields
Every Snap field is a drop-in replacement for the corresponding Django field, with extra admin control attributes.
That includes Django's positional verbose_name: SnapCharField("Label", max_length=200)
works exactly like models.CharField("Label", max_length=200), so existing model code
survives the switch unchanged. Relation fields take to first, as in Django.
? The full flag table — searchable, filterable, layout, wysiwyg, …
| 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. The one flag also passed through to Django's own Field.editable, deliberately — that is what keeps the restriction true in a hand-written ModelForm or DRF serializer, not just the generated admin. It still adds no migration: deconstruct() drops it, because it affects no column |
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 |
safe_html | bool | False | Trust this field's HTML — stored and rendered verbatim, skipping sanitization entirely. Only for content you fully control |
auto_sanitize | bool | True | Sanitize a wysiwyg field's HTML when it is written to the database. Set False to store exactly what was submitted (rendering is still sanitized) |
autocomplete | bool | False | Searchable autocomplete widget (relation fields) |
* show_in_form's False default is project-wide, not
per-field: SNAPADMIN_SHOW_IN_FORM_DEFAULT raises it for every field that never sets
it explicitly — useful when adopting SnapAdmin onto models that predate it, where nothing setting
it anywhere used to mean every generated change form rendered empty
(snapadmin.W015 now catches that whatever the cause — but only for a model whose
admin SnapAdmin actually generated: register one yourself with @admin.register and
your own ModelAdmin, and the warning stays quiet, because the form you wrote owes
nothing to show_in_form). An explicit per-field
show_in_form= always wins over the project-wide setting.
Wysiwyg HTML is sanitized on write and on render. Rich-text fields
(wysiwyg=True / SnapRichTextField) hold HTML written by whoever can
write the field — an API token, a low-privileged staff account, a bulk import — so their value
goes through an HTML sanitizer (nh3)
that strips <script>, inline event handlers and unsafe URL schemes. Sanitizing
happens in the field's pre_save(), so it covers every ORM write path
— admin form, REST/GraphQL serializer, Model.save(), bulk_create() — and
what lands in the database is already clean. The changelist sanitizes again on render, which keeps
rows written before this behaviour existed safe to display.
Two opt-outs, and one gap worth knowing:
safe_html=True— you vouch for this field's HTML; it is stored and rendered verbatim.auto_sanitize=False— store exactly what was submitted (rendering is still sanitized). Useful when another layer owns the cleaning, or when you must preserve the original bytes.QuerySet.update()is not covered: Django never callspre_save()for a bulk update, so a value written that way reaches the column untouched. Sanitize it yourself, or write through the instance.
Point SNAPADMIN_HTML_SANITIZER at a dotted path to your own
Callable[[str], str] to replace the allowlist; it is used by both the write and the
render path.
|safe, a
frontend consuming the REST API, an export — received the raw payload. New writes are now
cleaned before they are stored, which is lossy: markup outside the sanitizer's allowlist
(embeds, <iframe>, custom attributes) is dropped instead of preserved. Existing
rows are never rewritten — nothing migrates your data. If a field legitimately needs richer
markup, set safe_html=True, use auto_sanitize=False, or supply a
sanitizer with a wider allowlist.
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.
Computed columns on the change form.
SnapFunctionField, @snap_property and SnapStatusBadgeField
are changelist columns by default (show_in_list=True). show_in_form=True
also shows the value read-only on the change form — escaped unless
safe_html=True, in its tab= and row=, in declaration order among
the model's fields. The add form never shows it: there is no saved row to compute from yet (and
"Save as new" takes the add path). show_in_form defaults to False on these
columns and SNAPADMIN_SHOW_IN_FORM_DEFAULT does not apply to them. The read-only row is
the admin method snap_form_<name>; write that name in a hand-written
fieldsets (admin_overrides). An argument the column cannot use — a typo, or
a flag only editable fields have, such as searchable= — is ignored and reported as
snapadmin.W033.
class OrderItem(SnapModel):
quantity = SnapPositiveIntegerField(show_in_form=True)
price = SnapDecimalField(max_digits=10, decimal_places=2, show_in_form=True)
@snap_property(verbose_name="Line total", show_in_form=True)
def line_total(self):
return f"{self.quantity * self.price:.2f}"
A boolean with a default is a checkbox. Snap fields derive
null=True from required=False, so SnapBooleanField(default=False) has
a nullable column — but its form is a checkbox (Unfold's switch on the themed admin), because a
default means the field is never "unknown". It stays a three-way Unknown / Yes / No select
only when it asked for that: null=True passed explicitly, or no default at all. The
column itself is unchanged, so no migration follows. The importer reads the usual spreadsheet
spellings — true/false, yes/no, on/off, 1/0, any
case — for every boolean column.
🔌 The flags on any field — snap_field()
A Snap*Field is sugar over two things: an ordinary Django field, plus the flags
above set as plain attributes. snap_field(field, **kwargs) sets those same
attributes on a field instance you already have, so a third-party field —
django-money's MoneyField, model-utils's
StatusField, phonenumber_field's PhoneNumberField, or
anything else SnapAdmin has never imported — gets the same admin/API behaviour without a
Snap*Field subclass to rewrite it into:
from django.db import models
from snapadmin.fields import snap_field
class Product(models.Model):
name = snap_field(models.CharField(max_length=255), searchable=True, filterable=True)
Every reader treats the result exactly like a Snap*Field — the searchable
filter, the admin's list/search/filter config, the wysiwyg widget, the tab/row layout — because
there is nothing new to read: snap_field() writes the identical attribute names a
Snap*Field stores on itself, not a second place to look. It returns the field, so
the call composes inline with the field declaration, and it adds no migration for every flag
above: the attributes are set after Django's Field.__init__, and SnapAdmin
invented every name they use, so deconstruct() has no reason to look at them.
editable is the exception that proves it — the one name shared with Django, whose
deconstruct() reports live attribute state rather than the arguments the
constructor was given, so a wrapper-set value would land in a migration. It is stripped, while an
editable= you passed to the Django field's own constructor stays exactly where you
put it. An unrecognised kwarg raises ValueError naming it, rather than doing
nothing.
snap_field() reaches full parity with every Snap*Field constructor
kwarg, including three that used to be refused:
wysiwyg=Truesanitizes on write, identically toSnapRichTextField. The stored value is cleaned on every ORM write path (Model.save(), the REST API,bulk_create) — the wrapper reuses the exact same sanitizer, not a second one, so a wrapped field and aSnap*Fieldgiven the same input store byte-identical output.safe_html=Trueandauto_sanitize=Falseopt out the same way on both routes;QuerySet.update()stays uncovered on both (Django never callspre_save()for it).required=Trueis accepted — the one flag that can produce a migration. It mutatesnull/blankdirectly toFalse, False, exactly what a hand-builtSnap*Field(required=True)produces; that is schema-affecting by design, unlike every other flag on this page.required=False(the default) is a deliberate no-op onnull/blank— it never loosens a field you explicitly built as non-nullable.allowed_extensions/allowed_encodings/max_size_bytesattach the same upload validatorSnapFileField/SnapImageFieldbuild, on aFileFieldorImageField(a non-file field raisesValueErrornaming the two field types, rather than failing later with a confusing error). No migration: the validator is stripped fromdeconstruct()by identity, the same technique those two field classes already use for their own auto-built validator.
# wysiwyg sanitize-on-write, required, and file validators — all reachable
description = snap_field(models.TextField(), wysiwyg=True)
sku = snap_field(models.CharField(max_length=32), required=True)
invoice = snap_field(models.FileField(), allowed_extensions=["pdf"], max_size_bytes=5_000_000)
🧵 You Don't Have to Snap-ify Everything
A SnapModel is a normal Django model; every Django field works on it, and Snap
behaviour is opt-in per field — not an all-or-nothing choice for the whole
model.
The demo's SearchLog model mixes all three shapes in one class body:
class SearchLog(snap_models.SnapModel):
# 1. Snap*Field — the sugar. Gets searchable/filterable/etc. built in.
query = snap_fields.SnapCharField(max_length=255, searchable=True)
results_count = snap_fields.SnapIntegerField()
# 2. snap_field() — a plain Django field plus only the Snap behaviour it needs.
timestamp = snap_fields.snap_field(
django_models.DateTimeField(auto_now_add=True),
filterable=True,
)
# 3. Plain on purpose — it needs no Snap behaviour at all.
user_agent = django_models.CharField(max_length=255, blank=True, default="")
| Shape | Stored & admin-editable | Search / filter / list behaviour | Notes |
|---|---|---|---|
Bare field (django_models.CharField(...)) | ✅ | None — absent from the search box and sidebar filters | Exactly what you'd get on any Django model with no SnapAdmin involved |
snap_field(field, **kwargs) | ✅ | Whichever Snap attributes you pass (searchable, filterable, …) | Same field instance, metadata attached in place — see snap_field() |
Snap*Field | ✅ | Same as snap_field(), same attributes | A shorter spelling — plus constructor kwargs that only exist on a Snap*Field.__init__ (required, the file-upload kwargs) predate #PAR1c's parity work and read more naturally at construction time |
A test proves the contrast rather than just asserting it in prose — see
TestGetAdminFields.test_searchlog_bare_field_absent_from_search_and_filter_surface:
user_agent stays out of both search_fields and list_filter,
while timestamp (wrapped with filterable=True) is in
list_filter right alongside fields declared with a Snap*Field.
The same freedom applies one level up, to a whole model:
@snap_model opts a plain models.Model in without touching its field
layer at all. See Two Ways to Declare a Model for the full picture.
? One place a bare field does get picked up: Elasticsearch auto-mapping
The admin's search box and sidebar filters are Snap-attribute-driven (searchable,
filterable), so a bare field genuinely sits out. Elasticsearch's
es_auto_mapping is different — it derives the mapping from every concrete field's
Django type, blind to whether it's a Snap*Field or not. On SearchLog
(ES_ONLY, es_auto_mapping = True), user_agent is a
CharField and auto-maps to ES text exactly like query does —
it is indexed and ES-searchable even though it never appears in the admin's search box. Not a
contradiction: two different surfaces, two different rules, both accurately documented rather than
one being quietly assumed from the other.
📐 Advanced Layout
Control the visual arrangement of your admin forms using row and tab attributes.
? Worked example
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.
? Worked example, plus how mistakes are caught
The source field's name and the choices may be written positionally or by keyword — both forms are identical:
from snapadmin import fields as snap
status_badge = snap.SnapStatusBadgeField(
"is_active",
[
snap.SnapStatusBadgeFieldChoice(True, "#065F46", "#D1FAE5", "#10B981"),
snap.SnapStatusBadgeFieldChoice(False, "#991B1B", "#FEE2E2", "#EF4444"),
],
)
# identical, and still supported
status_badge = snap.SnapStatusBadgeField(
field_name="is_active",
choices=[...],
)
Everything else stays keyword-only: verbose_name, style_arguments
(extra CSS properties merged into every badge) and the usual show_in_list /
show_in_form. A value with no matching choice renders unstyled, and the field is
display-only — no database column, no migration.
field_name, an empty choices list, or an entry that isn't a
SnapStatusBadgeFieldChoice (a bare string is the natural slip — the colours live on
the choice object) raises a ValueError naming the field and showing the call to
write. Model modules are imported at startup, so a typo surfaces there rather than as a blank
column the first time someone opens the changelist.
🔧 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")
No SnapModel of your own? The error log, the audit log and
the API-token admin come from register_builtin_admins(), which
register_all_admins() calls. A project that uses the error monitor, the audit trail or
token auth without declaring a SnapModel calls it itself — otherwise
SnapErrorMonitorMiddleware records errors no admin page shows, and
snapadmin.W031 says so. It is idempotent and keeps a project's own admin for any of
those models; pass site= for a custom AdminSite.
# admin.py
from snapadmin.admin import register_builtin_admins
register_builtin_admins()
register_all_admins() also registers the ErrorEvent and audit-log
admins, and the APIToken admin while an API that accepts tokens is on
(SNAPADMIN_REST_API_ENABLED or SNAPADMIN_GRAPHQL_ENABLED) — an install
with both off is not handed a screen for minting credentials to a surface that does not exist.
SNAPADMIN_TOKEN_ADMIN_ENABLED overrides that either way: True for a
project that authenticates its own views with APITokenAuthentication,
False to never offer it.
The primary-key column. The changelist shows the
primary key first — referenced by its real name, so a key not called id works — for
integer keys only; a UUID key is noise in a list and is left out. Set
admin_list_display_pk = True or False on the model to override that
either way. Search still covers the key.
A model the admin only shows. Rows the application writes —
a log, an import record — set admin_read_only = True: the generated admin offers no add,
change or delete, to anyone (a superuser included; a mixin in admin_mixins may grant one
back, e.g. has_delete_permission for a clean-up), and the view page shows every field
read-only when none carries show_in_form — no flags on fields nobody may edit.
snapadmin.W015 skips such a model. It is admin-only: the REST API's equivalent is
api_read_only; set both for a table nobody writes through either.
admin_sections is deprecated. It was
declared on SnapModel but never read by anything; a model that sets it gets
snapadmin.W026. Group form fields with the per-field tab and
row flags, or pass fieldsets through admin_overrides.
Extending the generated admin
register_admin() builds and registers a ModelAdmin from a model's Snap
field flags; two of its building blocks are public so a project can extend rather than replace them:
get_admin_fields()returns anAdminFieldSetsnamed tuple —(form_fields, list_display, search_fields, list_filter, autocomplete_fields), in that order. It is a plain 5-tuple by construction (positional unpacking, indexing andlen()all keep working), with named access added on top. A sixth member would still be a breaking change — the point of pinning the shape is that such a change is announced, not discovered as a silentValueErrorat admin autodiscover.get_admin_media()returns the base(js, css)asset lists — theme-sheet selection, theconnectivity.js/offline.jsgating and de-duplication withjs_admin_files/css_admin_filesall included. A project overridingregister_admin()can call it to extend the real lists instead of copying a snapshot that rots at the next release.
admin_overrides always wins. It is merged onto the generated
ModelAdmin last, so a project's own get_readonly_fields or
safe_html_<field> display method always takes precedence over the one SnapAdmin
generates — regardless of write order, because the generated versions never touch
admin_overrides in the first place.
🌐 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).
# Text fields: bare "?field=value" is an exact, index-usable match; substring
# search is explicit via "__icontains" (also: "__startswith", "__in").
curl -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/?available=true&price__gte=100"
curl -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/?name__icontains=laptop"
# JSON key-path filters (only for key-paths declared in api_json_filters)
curl -H "Authorization: Token $TOKEN" "$BASE/models/demo/Showcase/?json_field__a__b=value"
# 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/"
? Elasticsearch-powered query features — routing, filters, facets, deep scan
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_auto_mapping = True # without a mapping the mirror indexes ids only (snapadmin.E026)
es_query_routing = False # this model's API searches always run on the DB
Structured ES term filters — es_filter()
es_search() is fuzzy full-text; es_filter() is its
structured counterpart — exact term/terms constraints
run in Elasticsearch filter context (no relevance scoring, cacheable). It matters most
for fields a relational database can't index at all, such as a JSON column mapped in ES:
# Scalar → a `term` clause, list/tuple/set → a `terms` clause
Product.es_filter(available=True, price=[999, 1299])
# Compose with fuzzy full-text: query_string is added as a scored `must`
Product.es_filter(query_string="laptop", available=True)
# A `text` field auto-targets its keyword sub-field; a `__` path reaches into a
# JSON/object mapping the DB can't index — filtered here by nested key path:
Order.es_filter(payload__status="paid") # → ES field payload.status
Field names are resolved and validated against the model's effective ES mapping: exact types
(keyword/boolean/numeric/date/ip) filter directly, an analysed text field is
redirected to its keyword sub-field, and an unknown or analysed-text-only field raises
ValueError instead of silently matching nothing. Results mirror
es_search() — a primary-key-ordered QuerySet for DUAL
models, an EsQuerySet for ES_ONLY — and carry the same
X-Snap-Query-Backend marker. When Elasticsearch is disabled or a query errors, a
DUAL model falls back to the equivalent database filter (failing closed to an empty
result if a term field has no backing column); an ES_ONLY model returns empty. Like
es_search(), this is a model-level query method — if you surface its results through
your own view, apply your own permission and PII-masking checks (the built-in REST and GraphQL
layers already do).
ES facets / aggregations — es_aggregate()
Where es_filter() selects documents, es_aggregate() counts
them per value — one Elasticsearch terms aggregation per requested field,
returned as plain bucket dicts. It uses the same field resolution and optional filter context as
es_filter(), so it works on the same fields (including a keyword sub-field of a
text field or a JSON/object key path the database can't group by efficiently):
# One facet → {field: [{"key": …, "count": …}, …]}
Product.es_aggregate("available")
# {"available": [{"key": True, "count": 42}, {"key": False, "count": 3}]}
# Multiple facets, narrowed by a filter context, capped at N buckets each
Product.es_aggregate("category", "available", size=20, available=True)
# query_string= adds a scored full-text constraint before counting
Product.es_aggregate("category", query_string="laptop")
size caps the number of buckets per field (default 10). When Elasticsearch is
disabled or a query errors, a DUAL model recomputes each facet over the database with
values(field).annotate(Count) — failing closed to empty buckets for a field or filter
term that has no backing column — while an ES_ONLY model returns empty buckets for
every requested field. An unknown or analysed-text-only field raises ValueError, same
as es_filter().
True match count — es_count()
es_filter() returns at most SNAPADMIN_ES_SEARCH_LIMIT documents and can
never see past ES's index.max_result_window, so len(Model.es_filter(…))
under-reports the moment a query matches more rows than the limit. es_count() answers
the count question directly through the Elasticsearch _count API — no hits fetched, no
ceiling — using the exact same term resolution and filter context as es_filter():
# Exact number of matches, regardless of the search limit
Product.es_count(available=True) # → 4217
Product.es_count(price=[999, 1299]) # list → terms clause
Product.es_count(query_string="laptop") # scored full-text, still exact
Order.es_count(payload__status="paid") # → ES field payload.status
The rule: reach for es_count() whenever you need how many match rather than
which rows — a total for pagination, a dashboard tile, a guard before a bulk job — on a
result set that may exceed the search limit. When Elasticsearch is disabled or a query errors, a
DUAL model falls back to the equivalent database count() (failing closed to
0 for a term field with no backing column); an ES_ONLY model returns
0. An unknown or analysed-text-only field raises ValueError, same as
es_filter().
Deep scan past 10k — es_scan()
Elasticsearch refuses a from + size deeper than
index.max_result_window (10 000), so es_search() and
es_filter() can never return more than 10k hits. es_scan() walks the
entire result set instead — a lazy iterator that pages with
search_after over a stable id sort, one page_size batch per
round-trip, so memory stays bounded no matter how large the match:
# Stream every matching document — no 10k ceiling, memory-bounded
for product in Product.es_scan(available=True):
...
# Same filter context as es_filter(); tune the batch size per round-trip
for log in SearchLog.es_scan(query_string="error 404", page_size=5000):
...
Filtering is identical to es_filter() — scalar/list **terms in filter
context plus an optional scored query_string. DUAL models yield database
instances in cursor (id-ascending) order; ES_ONLY models yield objects
rebuilt from the index. It fails safe the same way: a DUAL model whose Elasticsearch
is disabled — or unreachable before any document is streamed — walks the equivalent
database filter with .iterator() (failing closed to nothing if a term has no backing
column); an ES_ONLY model yields nothing. If ES fails after streaming has
begun, the scan stops where it was rather than restarting on the database and double-emitting.
Stream the primary keys of N-million matches — source=False + limit
When you only need the pks of a huge match (to feed a bulk job, a queue, a downstream
pk__in query), hydrating a full model per hit is wasted work. Pass
source=False and es_scan() sends "_source": false — ES never
ships the document body — and reads each pk straight from the sort cursor, so a DUAL
model also skips its per-page in_bulk() database round-trip. Add limit=N to
stop after N pks; the ES request size is capped to what remains, so a limit below
page_size never over-fetches:
# Stream just the pks — no _source, no in_bulk() hydration
for pk in Product.es_scan(available=True, source=False):
enqueue(pk)
# Bound the walk to the first 10k pks
first = list(Product.es_scan(available=True, source=False, limit=10_000))
The rule: reach for source=False whenever the object body is dead
weight — you want ids, not rows. It skips the DB entirely, so a pk indexed in ES but missing from the
table is still yielded (the default full-hydration path would drop it). The default call
(source=None) keeps full object hydration, byte-identical to before. When ES is disabled
the DB fallback honours both flags — it streams pks via values_list("pk") and applies
limit. The scan keeps its unique id sort: it is already the cheapest
stable search_after order, since the primary key needs no separate tiebreak.
Fail loud instead of a silent DB scan — db_fallback=False
By default all four methods above degrade gracefully: when Elasticsearch is disabled or a query
errors, a DUAL model quietly re-runs the query on the database. That is exactly right for
a modest table — but on a large, DB-unindexable one it can be worse than a clear
failure: es_aggregate() falls back to a full-table GROUP BY on an unindexed
column, and es_scan() to an unbounded .iterator(). If you reached for
Elasticsearch because the database can't answer at that scale, a silent fallback just runs
the query you were avoiding.
Pass db_fallback=False to opt out — the method raises
SnapEsUnavailable instead of touching the database when ES can't answer:
from snapadmin.models import SnapEsUnavailable
# Default: graceful — falls back to the DB when ES is off
Product.es_count(available=True) # → DB count() if ES down
# Fail-fast: raise rather than run a query the DB can't scale
try:
total = Product.es_count(available=True, db_fallback=False)
except SnapEsUnavailable:
... # surface it, retry, alert — never a silent full-table scan
# Same flag on es_filter / es_aggregate / es_scan
for pk in Product.es_scan(available=True, db_fallback=False): # raises if ES down
...
The rule: reach for db_fallback=False whenever the database
cannot answer the query at your table's size (a JSON column, a multi-million-row facet) and a
loud failure is safer than a slow one; leave it at the default when the DB fallback is a legitimate,
affordable backup. Set the project-wide posture once with
SNAPADMIN_ES_DB_FALLBACK (default True); a per-call
db_fallback= always wins. ES_ONLY models are unaffected — they have no
database to fall back to — and a mid-stream es_scan() failure still stops rather
than raising, since its cursor is already gone.
Counting objects — with & without filters
Every list response is paginated and already reports the total matching count
in count — no need to page through everything. Request the smallest page to read
just the number:
# Total rows (page_size=1 → tiny payload, full count in "count")
curl -s -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/?page_size=1"
# {"count": 5231, "next": "...", "results": [ ...1 row... ]}
# Count of a filtered set — "count" reflects the filtered queryset
curl -s -H "Authorization: Token $TOKEN" \
"$BASE/models/demo/Product/?available=true&price__gte=100&page_size=1"
# Count of a full-text match set (DUAL → ES answers; count = ES hits)
curl -s -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/?search=laptop&page_size=1"
DB_ONLY/DUALwithout search —countis an exactSELECT COUNT(*)over the filtered SQL queryset.DUALwith?search=/ES_ONLY—countis the number of ES hits, capped bySNAPADMIN_ES_SEARCH_LIMIT(default 1000). For an exact large-set count, count on the DB (drop?search=) or raise the limit.
Exporting / bulk-reading optimally
Read large result sets page by page (bounded query + bounded response) rather
than with one huge page_size. Follow the next link the API returns:
URL="$BASE/models/demo/Product/?page_size=200&ordering=id"
while [ "$URL" != "null" ]; do
PAGE=$(curl -s -H "Authorization: Token $TOKEN" "$URL")
echo "$PAGE" | python -c "import sys,json;[print(r['id']) for r in json.load(sys.stdin)['results']]"
URL=$(echo "$PAGE" | python -c "import sys,json;print(json.load(sys.stdin)['next'] or 'null')")
done
| Mode | Fastest export path |
|---|---|
DB_ONLY / DUAL (no search) | Plain paginated listing — native SQL LIMIT/OFFSET, no ES round-trip, no row cap. Add ?ordering=id for a stable walk; FK columns are auto-select_related (no N+1). |
Full-text (DUAL) | Only route through ES (?search=) when you need fuzzy/relevance — it is capped by SNAPADMIN_ES_SEARCH_LIMIT. For a complete dump of matches, filter on the DB instead. |
ES_ONLY | Listings come from ES, bounded by SNAPADMIN_ES_SEARCH_LIMIT; raise it or narrow with filters/?search=. |
| Server-side job | Skip HTTP; stream the ORM: Model.objects.all().iterator(chunk_size=2000) keeps memory flat over millions of rows. |
The same three modes addressed directly from Python:
# DB_ONLY / DUAL — exact, index-backed count
Product.objects.filter(available=True, price__gte=100).count()
# DUAL / ES_ONLY — fuzzy full-text via Elasticsearch
Product.snap_search("laptp", limit=50) # DUAL: DB queryset in ES-relevance order
SearchLog.snap_search("checkout") # ES_ONLY: straight from the index
# Memory-flat export of any DB-backed model
for row in Product.objects.all().iterator(chunk_size=2000):
...
No-Celery bulk helpers — count + streaming export
Every dynamic model endpoint exposes two synchronous helpers that reuse the same filter, search and permission backends as the list view — no Celery required:
{"count": N}limit)count/ returns just the size of the filtered set — cheap paginator sizing without
pulling rows. export/ streams the entire filtered queryset as newline-delimited JSON
(application/x-ndjson), one serialized object per line, pulled lazily in chunks
(SNAPADMIN_EXPORT_CHUNK_SIZE, default 1000) so huge tables never materialise in memory.
Both require the model's view permission. This is the synchronous counterpart to the
Celery-backed /api/exports/ jobs above.
fetch-by — an explicit key set, in one call
{"field": "sku", "values": [...]} → stream every matching row as NDJSONexport streams a filtered result set; fetch-by answers a different
question — "give me exactly these records", for a key set large enough that a query string can't hold
it. It is a POST that reads, justified by that one constraint alone: there is no other
way to express a large explicit value list. field must be unique=True or
db_index=True on the target model — anything else answers 400 naming the
constraint, closing the unindexed-full-table-scan foot-gun a free-form field name would open. A hard
cap, SNAPADMIN_FETCH_BY_MAX_VALUES (default 10000), rejects an oversized
values list with 400 rather than silently truncating it — an unbounded list is
a denial-of-service vector, and the cap exists before this route does. Same NDJSON streaming,
permissions and masking as export (a masked field also can't be used as the lookup key);
not supported for ES_ONLY models, which have no DB column to index. Because it never
writes, it remains reachable via POST even on an api_read_only model — the read/write
policy that gates create on the same URL segment does not apply to it.
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
Restricting writes — api_write_fields
Field exposure and field writability are separate controls. By default every field not
listed in api_exclude_fields also accepts a client-supplied value on REST create/update
— set api_write_fields to an explicit list to change that: any field not named there is
forced read-only through the API (still returned in responses unless also excluded above). Use it
for status flags, ownership FKs and other columns that must only ever change server-side:
class Account(SnapModel):
owner = snap_fields.SnapForeignKey(User, on_delete=models.CASCADE)
is_locked = snap_fields.SnapBooleanField(default=False) # server-only status flag
balance = snap_fields.SnapDecimalField(max_digits=12, decimal_places=2) # computed
api_write_fields = ["owner"] # is_locked / balance never accept a client value
Leaving it unset (the default) keeps every non-excluded field writable, matching prior behaviour
— the snapadmin.W004 system check warns on any model that hasn't made the choice
explicitly, so the exposure is deliberate rather than an oversight. The warning is grouped:
one message names every unguarded model rather than repeating an identical block per model, and a
model served read-only (api_read_only, or an api_http_method_names allowlist
with no write verb) is skipped entirely — it has no mass-assignment surface to guard.
Read-only models & verb allowlists — api_read_only / api_http_method_names
api_write_fields controls which fields a write may touch;
api_read_only removes the write verbs entirely. Set it on an import-only or
reference table — one fed by an ETL job or another service, never by API clients — and the dynamic
REST API serves it read-only: list/retrieve/count/export
work, while POST/PUT/PATCH/DELETE answer
405 Method Not Allowed (no blank-row insert, no silent no-op update):
class ExchangeRate(SnapModel):
code = snap_fields.SnapCharField(max_length=3, unique=True)
rate = snap_fields.SnapDecimalField(max_digits=18, decimal_places=6)
api_read_only = True # rates come from the feed, not API clients
# or an explicit verb allowlist (HEAD/OPTIONS are always added):
class AuditEvent(SnapModel):
api_http_method_names = ["get", "post"] # append-only: read + create, no update/delete
The verb is rejected in dispatch before any handler runs, so a read-only model can
never insert a blank row, and the disallowed verbs are dropped from the OPTIONS
Allow header too. api_http_method_names takes precedence over
api_read_only when both are set; both default to full CRUD. A model that is
field-read-only (api_write_fields = []) yet still write-exposed is flagged by the
snapadmin.W007 check, nudging it toward api_read_only.
🎬 User-defined REST actions — @snap_action
CRUD covers create/read/update/delete; a real model usually also needs a handful of
operations — approve, refund, recalculate, archive. @snap_action turns a
model method into a callable REST endpoint, without hand-wiring a view. Declared on an abstract
base or a plain mixin, it reaches every model built on it — like a SnapFunctionField or
@snap_property column does; a concrete parent's actions (multi-table inheritance) stay
with the parent:
class Order(SnapModel):
total = snap_fields.SnapDecimalField(max_digits=10, decimal_places=2)
@snap_action()
def recalculate_total(self, request):
self.total = sum(item.quantity * item.price for item in self.items.all())
self.save(update_fields=["total"])
return {"total": str(self.total)}
reachable at POST /api/models/demo/Order/<pk>/recalculate_total/. Works
identically on a SnapModel subclass or a @snap_model-decorated plain
model — the decorator reads the method straight off the class, no field or registry machinery
involved.
| Keyword | Default | Meaning |
|---|---|---|
detail | True |
Per-object (needs a pk) or list-level (detail=False, receives
the model class as the first argument instead of an instance). One decorator call
expresses one scope, mirroring DRF's own @action(detail=...). |
methods | ("post",) |
Lowercase HTTP verbs this action accepts. |
permission | derived | An explicit "app_label.codename". Left unset, the permission is derived from
methods: view_<model> when every method is safe
(GET/HEAD/OPTIONS), change_<model> otherwise. |
An action can never widen a model's own policy. Every action URL is wired to
every HTTP verb, but Django REST Framework rejects a verb outside the view's
http_method_names — the same descriptor api_read_only/
api_http_method_names already drive — with 405 before a handler is even
selected. A POST-only action therefore structurally cannot reach a model configured
api_read_only = True; there is nothing to bypass. New check
snapadmin.E008 catches the same conflict at boot — an action whose
declared methods the model's own policy would always reject — before it ships as dead
configuration.
Return a plain dict (wrapped as a 200 response) or a
rest_framework.response.Response you build yourself, for a custom status or headers.
Raise snapadmin.api.views.SnapActionError("message", status=409) to answer with an
error — turned into the same {"detail": "message"} envelope every other error path in
the API already uses. Every model's registered actions (name, scope, methods, URL) are listed at
GET /api/models/schema/ — the concrete, per-model action set
drf-spectacular's static schema cannot enumerate for the one generic dispatcher operation it
documents instead.
GraphQL has no mutation counterpart, and none is planned. The GraphQL schema is
read-only by design (see GraphQL API) — @snap_action is a
REST-only surface, stated here explicitly rather than left to be discovered.
Strict query parameters — an unknown parameter is a 400, never ignored
Every query parameter on a model route is one of three things: a declared filter, a parameter the
API reads itself (?search=, ?ordering=, ?page=,
?page_size=, ?format=, and ?limit= on export/), or a
400 that names it. A dropped filter would run unfiltered and answer 200 with
every row — the worst possible answer for a client about to export or delete "the matching rows" —
so ?url__iendswith=.pdf on a field that does not offer that lookup is refused with the
lookups it does offer:
GET /api/models/demo/Customer/?email__iendswith=.com
HTTP 400
{"filters": ["Unknown or disallowed filter parameter(s): 'email__iendswith'. Allowed for 'email': email, email__icontains, email__in, email__startswith."]}
The same rule covers an unknown ?ordering= term (400 listing the valid
ones), ?search= on a model with no searchable field left for the caller, and any filter
on an ES_ONLY model (use ?search= there). It applies to list,
count/, export/, fetch-by/ and the detail routes; a
@snap_action owns its own query string and is not checked.
No oracle. A parameter on a field the caller may not see — masked PII, or an
api_field_permissions read guard they fail — is refused with exactly the message a
field that does not exist gets, whatever its value, and no hint ever lists that field's lookups. A
masked foreign key is hidden under its column name too (customer and
customer_id).
# settings.py
SNAPADMIN_API_STRICT_QUERY_PARAMS = True # default; False restores the pre-0.1.0b11 silence
SNAPADMIN_API_EXTRA_QUERY_PARAMS = ["_"] # accept a client's cache-buster on purpose
URL fields filter by their ending. A URLField adds
?field__endswith= and ?field__iendswith= to the library default lookup set
(a URL is asked about by its ending as often as by its start). Like icontains they cannot
use an index; a model- or project-wide lookup set (below) replaces the default as usual.
? Filter configuration in depth — lookups, JSON columns, swapping the backend
Auto-filter lookups — api_filter_lookups
Every text-type field (CharField/TextField/EmailField/
URLField/SlugField) gets an auto-generated filter. The bare
?field=value query parameter is always an exact, index-usable match;
substring, prefix and multi-value lookups are exposed as explicit suffixes —
?field__icontains=, ?field__startswith=, ?field__in=a,b,c.
Set api_filter_lookups to widen or narrow which of those suffixes exist for a
specific field on a specific model:
class Product(SnapModel):
sku = snap_fields.SnapCharField(max_length=32, unique=True) # exact only — no substring lookup at all
name = snap_fields.SnapCharField(max_length=200) # library default: exact + __icontains/__startswith/__in
# narrow "sku" to exact-only; every other text field on this model still
# gets the library default lookup set
api_filter_lookups = {"sku": ["exact"]}
Left unset (the default), every text field uses the library default lookup set shown above.
Breaking change from earlier releases: the bare key used to run
lookup_expr="icontains" — a leading-wildcard substring match that cannot use a
database index. Callers relying on that now need ?field__icontains=value instead of
the bare key. See the
0.1.0b3 release notes for the full upgrade note.
Change the default for a whole model or project — not column by column
api_filter_lookups is per-field, so making a table's filters index-friendly (dropping
the non-indexable icontains) would mean enumerating every column — and any column added
later silently re-enables icontains. Two broader knobs set the default once; the
first non-empty source wins: per-field api_filter_lookups →
per-model api_default_text_lookups → project-wide SNAPADMIN_API_TEXT_LOOKUPS
→ the library default.
# Per model — every text field on this model, index-friendly by default
class Product(SnapModel):
api_default_text_lookups = ["exact", "startswith", "in"] # no __icontains
api_filter_lookups = {"name": ["exact", "icontains"]} # …except name, which keeps it
# Project-wide (settings.py) — the safe posture for every model at once
SNAPADMIN_API_TEXT_LOOKUPS = ["exact", "startswith", "in"] # unset → library default
Reach for these on large tables where a leading-wildcard icontains scan is the query
you're trying to avoid; use Elasticsearch for substring/fuzzy search at
scale instead.
Null checks and membership lists — ?field__isnull=, ?field__in=
Beyond the text lookups above, the generated FilterSet exposes null-checks and comma-separated membership lists automatically — no per-field configuration:
- Numeric fields (
Integer/Float/Decimal…) gain?field__in=1,2,3and?field__isnull=true(alongside the existing?field__gte=/?field__lte=range). - Date / datetime fields gain
?field__isnull=true. They deliberately get no__in— an exact-timestamp membership list is rarely what you want, and ranges are already covered by__gte/__lte. - Foreign keys gain
?field_id__in=1,2and?field_id__isnull=true(find rows with — or without — a related object) next to the existing?field_id=exact match. - Text fields can opt into a null check by adding
"isnull"toapi_filter_lookups(or a model-/project-wide default); it is not in the library default set. It maps to a boolean, so?field__isnull=falsereturns a proper200— earlier releases built a text filter here that forwarded the raw string to Django'sisnulllookup and raisedHTTP 500.
# find products that were never categorised, or priced in a specific set
GET /api/models/demo/Product/?category_id__isnull=true
GET /api/models/demo/Product/?price__in=9.99,19.99&price__isnull=false
# opt a text field into a null check
class Product(SnapModel):
api_filter_lookups = {"sku": ["exact", "isnull"]} # exposes ?sku__isnull=true
The isnull value follows the usual boolean parsing — true/false
(also 1/0). All of these are additive: existing query parameters are unchanged.
Filtering JSON columns — api_json_filters
JSONField gets no auto-generated filter by default. Declare which key-paths within
which JSON field should be filterable and the dynamic API exposes each as a query parameter — a
dotted key-path becomes double-underscore-separated, mirroring Django's own lookup convention:
class Order(SnapModel):
payload = snap_fields.SnapJSONField(default=dict)
api_json_filters = {"payload": ["a.b", "a.c"]}
# exposes ?payload__a__b=value and ?payload__a__c=value
A single query parameter covers two cases, since the same key-path can hold either shape from
row to row: a scalar match (the JSON value at the path equals value
exactly) and a list-membership match (the JSON value at the path is itself a list
and value is one of its elements — __contains=[value], not a string
LIKE). The scalar case uses Django's JSON key-transform exact lookup, which every
backend supports natively, including SQLite. The list-membership case prefers the native
__contains JSON-containment lookup where the backend supports it, but SQLite
reports supports_json_field_contains = False and raises
NotSupportedError for that lookup — since SQLite is the default database for local
development and the test suite, api_json_filters detects this via
connection.features.supports_json_field_contains and falls back to a row-by-row Python
membership check instead, so list-membership filtering works out of the box on SQLite too, not just
PostgreSQL/MySQL.
Comma-separated OR. Like the __in filters, a comma in the value is an
OR: ?payload__a__b=x,y matches rows whose value at the path is x or
y (each side still tried as both a scalar and a list-membership match). A value that
legitimately contains a comma can't be expressed — the same trade-off __in makes.
Streaming at scale. On a backend with native JSON containment
(PostgreSQL/MySQL) the whole filter is one lazy queryset.filter(Q(…)) — it composes with
.iterator(), so the streaming export/ endpoint never pulls a PK list
into memory. The SQLite / no-native fallback must scan rows in Python for the list-membership half, so
it is capped at SNAPADMIN_API_JSON_FILTER_SCAN_CAP rows (default 100000);
past the cap it answers HTTP 400 rather than risk running the table out of memory,
pointing the caller at a native-JSON backend or Elasticsearch.
JSON columns carry no index, so any of these filters is always a full table scan
on every backend. For filtering JSON data at scale on large tables, use
SnapModel.es_search() (the Elasticsearch integration) instead of the DB-backed
auto-filters. Leaving api_json_filters unset (the default) exposes no JSON filters at
all — matching prior behaviour.
Swapping the filter backend — SNAPADMIN_API_FILTER_BACKEND
The dynamic model API's filter chain is
[SnapAdminFilterBackend, SearchFilter, OrderingFilter] — the auto-generated
django-filter FilterSet plus DRF's search and ordering backends. To plug in a custom
FilterSet or a bespoke backend (say, one that adds a full-text or geo filter),
set SNAPADMIN_API_FILTER_BACKEND to a dotted path — or a list of them — instead of
subclassing the view and monkeypatching its filter_backends:
# settings.py — replaces the whole chain, exactly like DRF's DEFAULT_FILTER_BACKENDS,
# so list every backend you still want alongside your custom one:
SNAPADMIN_API_FILTER_BACKEND = [
"myapp.filters.MyFilterBackend",
"rest_framework.filters.OrderingFilter",
]
# a single dotted path (or the class object) is accepted too:
SNAPADMIN_API_FILTER_BACKEND = "myapp.filters.MyFilterBackend"
Left unset (the default), the built-in chain is used unchanged. The value is resolved on each
request, so override_settings in tests and deploy-time config both take effect, and
drf-spectacular still introspects the resolved backends for the Swagger schema.
Vetoing deletes — api_can_delete + SNAPADMIN_API_DELETE_GUARD
Forbid deleting specific objects through the dynamic model API without
re-mounting routes. Two extension points are consulted before every DELETE and
both must allow it — returning False responds 403:
# 1) Per-model hook (default allows):
class Account(SnapModel):
is_system = snap_fields.SnapBooleanField(default=False)
def api_can_delete(self, request) -> bool:
return not self.is_system # system rows are undeletable
# 2) Project-wide guard — dotted path to Callable[[request, obj], bool]:
SNAPADMIN_API_DELETE_GUARD = "myproject.guards.protect_superusers"
Model validation errors answer 400, not 500
A django.core.exceptions.ValidationError is not part of DRF's exception hierarchy,
so anything raising Django's flavour on a write path — Model.clean(),
Model.full_clean(), a guard inside save(), a custom
pre_save receiver — used to escape the view and come back as an HTML 500
an API client cannot act on. Every SnapAdmin endpoint now translates it into a
400 that keeps the field names:
class Order(SnapModel):
status = snap_fields.SnapCharField(max_length=20)
invoice = snap_fields.SnapFileField(blank=True, null=True)
def clean(self):
if self.status == "completed" and not self.invoice:
raise ValidationError({"invoice": "A completed order needs an invoice."})
# POST /api/models/shop/Order/ → 400
# {"invoice": ["A completed order needs an invoice."]}
A message raised without a field lands under DRF's non_field_errors key, exactly
as a serializer-level error does. This needs no configuration and no
EXCEPTION_HANDLER of your own — but if you want the same translation on views
SnapAdmin never generated, SnapAdmin ships the handler as a drop-in:
# settings.py — optional, only for your own DRF views
REST_FRAMEWORK = {
"EXCEPTION_HANDLER": "snapadmin.api.exceptions.snap_exception_handler",
}
A project that already configures its own EXCEPTION_HANDLER keeps first refusal:
SnapAdmin offers the untouched exception to it first and only steps in where the alternative is
the 500 DRF was about to raise, so an existing handler behaves exactly as it did before.
Model rules on the API write path — api_full_clean
A Model.clean() rule is enforced wherever a ModelForm runs — the
admin — and nowhere else. The generated serializer validates each field, so the API
looks validated, but the cross-field rules are the ones a field validator cannot
state ("a completed record needs a file"), and those were silently skipped. The admin and the
API then disagreed about what a valid row is.
api_full_clean runs the model's own full_clean() —
clean_fields(), clean(), then the model's constraints — inside the
serializer's validate(), so the rule answers 400 naming the field:
class Order(SnapModel):
status = snap_fields.SnapCharField(max_length=20)
invoice = snap_fields.SnapFileField(blank=True, null=True)
api_full_clean = True # per model…
def clean(self):
if self.status == "completed" and not self.invoice:
raise ValidationError({"invoice": "A completed order needs an invoice."})
# settings.py — …or project-wide, for every registered model at once
SNAPADMIN_API_FULL_CLEAN = True # a model's own api_full_clean still wins
Off by default, on purpose. Switching it on starts rejecting writes the API
previously accepted. That is the correct answer — the admin was already refusing them — but it is
a behaviour change, and one that should be a deliberate line in your settings rather than a
surprise on upgrade. snapadmin-info reports it under
model_validation so an operator can see whether it is actually in use.
Two deliberate limits. Validation is scoped to the fields the serializer can
write: an auto_now_add column is empty on an unsaved row and validating it
would reject every create with an error no client could act on — the same exclusion a
ModelForm makes. And uniqueness is left to DRF's own
UniqueValidator/UniqueTogetherValidator, which are already on the
generated serializer; a second pass would report the same clash twice. On a
PATCH the rule sees the merged row — the stored values with the submitted
fields applied — not just the handful of fields the request carried.
⚛️ GraphQL API
SnapAdmin provides a dynamic GraphQL interface powered by Graphene-Django.
- Auto-Schema: All
SnapModelsubclasses are automatically added to the schema. A database-generated column (models.GeneratedField) appears as itsoutput_field's type, read-only; a field graphene cannot convert costs only that field — it is left out and logged asgraphql_field_skipped— never the whole model. - Secured by default: every resolver requires authentication (admin session or API token) and the model's Django
viewpermission — the same contract as REST — enforced on every relation the query traverses, not just top-level fields. ReadingA { relatedB { … } }needsviewon both models; a related object you may not view resolves as aPermission denied.error rather than leaking its data. Tokenallowed_modelsscopes apply on top. - PII masking: fields in
SNAPADMIN_MASKED_FIELDSare masked in GraphQL output exactly as in the REST API — raw only for superusers,snapadmin.view_raw_piiholders (or whoeverSNAPADMIN_PII_ACCESS_CHECKallows), and (per field) holders of a permission named inSNAPADMIN_MASKING_RULES, whose rules apply here too. - Search:
searchon a list field follows the REST API's?search=— the same fields, never one masked or read-guarded for the caller (a match would reveal it), Elasticsearch only where REST routes there, and an error when no field is left to search. - Unified Fetching: the
searchargument routes to Elasticsearch forDUAL/ES_ONLYmodels;first/offsetpaginate. - Endpoint:
/api/graphql/; GraphiQL followsDEBUG(override withSNAPADMIN_GRAPHIQL_ENABLED).
Field naming
The schema is generated, so field names follow a fixed scheme. For a model
Product in an app labelled demo, graphene lower-camel-cases the generated
names into demoProduct(id: ID!) (one object by primary key) and
allDemoProducts(search, first, offset) (a list). The general form is
<applabel><Model> and all<Applabel><Model>s; pluralisation
is naïve (a trailing s), so Category → allDemoCategorys. The
object type is <Applabel><Model>Type. List results are a plain list,
not a Relay connection — select fields directly, with no edges/node wrapper.
Fields in a model's api_exclude_fields are dropped from its GraphQL type as well.
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,
once more in the POST /api/tokens/{id}/rotate/ 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_name": "…"} only; any other field is a 400 naming it (scope and expiry changes mean a new token). Audited. No PUT.is_active off — the recommended revocation pathA regular user manages their own tokens — list, create, retrieve, rotate, deactivate, delete —
without needing to be a superuser; each lookup is scoped to token.user == request.user
(a superuser sees and manages every token).
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 or rotation |
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 on SnapAdmin's generated model API. 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 |
allowed_scopes | List of free-form strings your own views check with token_has_scope(token, "reports:read") — SnapAdmin only stores and matches them, the meaning is yours. Unlike allowed_models, an empty list denies every scope check (fail-closed): there is no Django-permission equivalent an opaque string could delegate to |
expiration_date | Optional expiry; leave blank for non-expiring tokens |
is_active | Deactivate without deleting the token — set via POST /api/tokens/{id}/deactivate/ or the admin |
Rotating a leaked key from Python:
token = APIToken.objects.get(token_prefix="ab12cd34")
new_raw_key = token.rotate() # old key stops authenticating immediately
# new_raw_key is shown once — store it now, it is not recoverable afterwards
🧮 Quotas & Rate Limits
SnapAnonRateThrottle / SnapUserRateThrottle (SNAPADMIN_THROTTLE_ANON /
SNAPADMIN_THROTTLE_USER, above) give one global rate for every anonymous or authenticated
caller. The rate is resolved in this order: the setting (None switches the throttle off; a
blank value — what an unset environment variable becomes — is no value and falls
through, with snapadmin.W029), then REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
under the scopes snapadmin_anon / snapadmin_user, then the default. The DRF
scopes anon/user belong to your own views; when they differ from what
SnapAdmin's endpoints enforce, snapadmin.W028 says so. A rate DRF cannot parse — or any
numeric SNAPADMIN_* setting int() cannot read — fails manage.py
check with snapadmin.E029 instead of answering 500 at the first
request, and a project still setting DEFAULT_FILE_STORAGE/STATICFILES_STORAGE
(removed in Django 5.1, ignored silently) gets snapadmin.W030, because SnapAdmin's file
features use STORAGES.
snapadmin.limits.reserve() is the narrower primitive projects keep rebuilding on top
of that: a per-tenant or per-token quota across several time windows at once, a
concurrency cap, and a cooldown after an upstream service answers
429 — including for calls your project makes outbound to a third party, which a
request-scoped DRF throttle has no way to express at all. It has no opinion about what the key means, so
it serves inbound and outbound alike:
from snapadmin.limits import reserve
with reserve(f"tenant:{tenant_id}", windows={60: 100, 3600: 1000}, concurrency=5) as slot:
if not slot.allowed:
return too_many_requests(retry_after=slot.retry_after) # slot.reason: "rate_limited" | "concurrency" | "cooldown"
call_the_upstream_api()
windows is {period_seconds: max_count} — every window must clear for the
reservation to succeed. concurrency, if given, additionally caps how many reservations for
the same key may be held at once; releasing the returned object (or using it as a context manager, as
above) frees the slot, and an unreleased slot expires on its own after
concurrency_timeout seconds (default 300) so a crashed holder can never leak it forever.
snapadmin.limits.cooldown(key, seconds) is a separate, explicit signal for the one thing
reserve() cannot see on its own: call it after an upstream actually answers 429,
and every reserve() for that key fails fast (before touching a window or the concurrency
counter) until the cooldown expires.
Two honest limitations, worth reading before trusting this in production:
- Counters are per-process unless
CACHESpoints at a shared backend. The defaultLocMemCacheis private to one process — four Gunicorn workers each enforce the configured limit independently, so the effective ceiling is four times whatwindowssays. PointSNAPADMIN_LIMITS_CACHE_ALIASat a shared cache (Redis, Memcached, a database cache) before relying on this across more than one worker. - A cache that evicts a counter under memory pressure fails open, not closed. An evicted counter looks identical to one that never existed, so the next call sees a fresh window and is allowed — the same trade-off any cache-backed rate limiter makes, including DRF's own throttles.
See it wired into an actual outbound call in the demo project's sync_exchange_rates
--rate-limit N management command (caps the "call to the external feed" to N runs per minute,
plus a concurrency=1 guard against two syncs racing the same feed).
🔍 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.
[elasticsearch] extra pins the client to >=8,<9 to match, and both
compose files SnapAdmin ships (the snapadmin-new --full template and
demo/docker-compose.yml) start an 8.x server. Pointing a 9.x client at an 8.x
cluster is not a soft degradation: every call comes back BadRequestError(400), so
there are no indices, no search results and a degraded /api/health/ — with nothing
but the logs to say why. A test reads the pin and the images from their own files and fails if
they drift apart.
Running with Docker
The default docker compose -f demo/docker-compose.yml up does not start Elasticsearch (saves ~512 MB RAM). To enable it:
# Enable ES in your demo/.env
ELASTICSEARCH_ENABLED=True
# Then start the full stack including ES
docker compose -f demo/docker-compose.yml --profile es up --build
# Add Kibana for visualisation (dev only)
docker compose -f demo/docker-compose.yml --profile es --profile dev up --build
External Elasticsearch (production / staging)
Point to any external ES cluster via env vars — no code change needed:
# demo/.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.
searchable=True is not one
The document SnapAdmin writes is {"id": pk} plus one key per entry in the effective
mapping, so es_storage_mode = DUAL with neither es_mapping nor
es_auto_mapping = True indexes the id and nothing else — while the index is created,
every save is mirrored and es_reindex_all() reports the full row count. Only the
searching fails, and it fails quietly. searchable=True is a field flag for the
admin search box and the REST ?search= filter; it never builds the mapping.
snapadmin.E026 refuses the combination at manage.py check so it cannot ship.
es_auto_mapping stays off by default on purpose: turning it on would start shipping
every concrete column of every mirrored model to a second datastore, including ones a project
deliberately kept out of it. If an id-only index really is what you want, add
'snapadmin.E026' to SILENCED_SYSTEM_CHECKS.
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
Deletes keep the mirror in step
A mirrored model's document leaves the index when its row leaves the database — including
through the deletes that never call Model.delete(): a bulk
QuerySet.delete() (one SQL DELETE, no per-row Python) and any row swept up
by an on_delete=CASCADE. SnapAdmin connects a post_delete receiver at
startup for exactly the registered models that mirror to Elasticsearch, so no call site has to
remember to clean up after itself:
Product.objects.filter(discontinued=True).delete() # rows gone — and their documents with them
Two consequences are worth knowing about:
- One Elasticsearch round trip per deleted row. For a large delete, clear the
mirror in bulk instead: collect the primary keys first, then hand them to
delete_pks_from_es(), which issues a singledelete_by_query. That is what the retention purge andstale_sync()do. - A mirrored model opts out of Django's fast-delete path — the collector has to
materialise the rows to send the signal at all. Only mirrored models are affected; a
DB_ONLYmodel has no receiver attached and keeps the fast path untouched.
from snapadmin.models import suppress_es_delete_receiver
pks = list(qs.values_list("pk", flat=True))
with suppress_es_delete_receiver(): # per-thread; the receiver stands down here
qs.delete()
Product.delete_pks_from_es(pks) # one bulk delete_by_query, not one call per row
An Elasticsearch outage never breaks the database delete: the receiver logs
es_delete_document_failed and the delete stands, the same fail-soft policy as every
other ES write path. delete_pks_from_es() is the exception on purpose — it
returns whether the mirror was cleared, which is how the retention purge can raise
SnapPurgeError instead of reporting a partial purge as a clean one.
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=). DB-backed models page a pk__gt keyset cursor
# (not QuerySet.iterator, which buffers the whole result on mysqlclient),
# so memory stays flat even on million-row tables — on every backend:
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) — needs a running worker:
from demo.apps.shop.tasks import reindex_products_to_elasticsearch
reindex_products_to_elasticsearch.delay()
📴 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 actually does, under the hood
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.
snapadmin/js/connectivity.js) only loads when
SNAPADMIN_CONNECTIVITY_ENABLED = True and at least one registered
model has offline_mode = True — an install with neither gets no health poll, no
toast, no badge and no save-blocking guard; the script is absent from the page entirely. This
changed in the same release that pinned SNAPADMIN_CONNECTIVITY_ENABLED's default to
False: a health poll running against a deployment with
SNAPADMIN_REST_API_ENABLED = False (a documented, supported combination —
SNAPADMIN_PROFILE = "admin" produces it) used to 404 forever and brick every Save
button. Set SNAPADMIN_CONNECTIVITY_ENABLED = True to restore the old always-on
behaviour.
With the layer on, a lightweight connectivity.js loads on every SnapModel admin page:
| 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. |
| 404 stands down | A 404 means "no health route on this deployment", not "the backend is down" — the script logs one console.debug line and stops polling for the rest of the page's lifetime, never a toast, never a guard. |
| Down requires proof | The backend is only declared "down" after ≥ 2 consecutive failed probes and at least one earlier successful probe. A probe that has never succeeded is "unknown", and unknown never blocks a save — a single slow first request is not proof the backend is down. |
| 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, a confirmed-down backend blocks form submission and disables the Save buttons until it returns, while leaving the already-rendered page intact. |
| Sidebar badges | Only offline-capable models get a badge — a green sync icon (spins while confirmed down). A model without offline_mode gets no badge at all. |
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 is purely client-side
and gated per model; SNAPADMIN_CONNECTIVITY_ENABLED is the one setting that gates
the shared health-poll layer all offline-capable models rely on.
⚡ 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 tuning knobs, ES offload, and benchmark numbers
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.
? The full guide — query optimization, caching, background jobs, database tuning
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).
⚙️ Celery & Periodic Tasks
SnapAdmin ships built-in Celery tasks for background export, indexing, token cleanup, error digests, backups and GDPR retention.
Nothing here runs on its own. SnapAdmin has no daemon and installs no schedule. Each task stays idle until you either point a scheduler at it (Celery Beat or system cron) or — for the event-triggered ones — keep a Celery worker running to pick the job up. Where this site calls a feature "automatic", it means automatic once wired; the table below is what wiring it means.
Task outcomes & monitoring
Every scheduled task below except run_export (which tracks its own status on the
SnapExportJob row) returns a summary dict carrying a status key with one
of exactly four values, plus a failed list ([] when nothing failed) —
every existing key on every task's summary keeps its name and shape, this is purely additive:
status | Meaning | Raises? | Log marker |
|---|---|---|---|
"ok" | ran, every unit succeeded | no | snapadmin_task_ok (info) |
"partial" | ran, some units failed (failed non-empty) | no | snapadmin_task_partial (error) |
"noop" | nothing was due / nothing to do | no | snapadmin_task_noop (info) |
"disabled" | switched off (or unusably misconfigured) by settings | no | snapadmin_task_disabled (info) |
| (total failure) | every unit failed, or the task could not start at all | yes | snapadmin_task_failed (error) |
A total failure raises (BackupError / SnapPurgeError /
AlertDeliveryError / ReindexError) instead of returning — a raised
exception is the only thing Celery itself records as a task failure, which is what a task-status
monitor watches. Retrying a partial failure would redo the units that already succeeded
(e.g. re-uploading a backup that already shipped to two of three destinations), so partial never
raises — it is loud instead: status="partial", a populated failed list,
and one error-level log line with a stable marker.
status != "ok",
page when the Celery task state is FAILURE.
This closes a reported incident: a disabled backup schedule ran "successfully" for weeks with no
backup ever taken (the task returned {"ran": False, "reason": "disabled"} and Celery
recorded a clean success every time), and a silently-failing offsite destination never surfaced
anywhere but a log line while the task still reported overall success.
? Every built-in task, the rename notice, and the Beat schedule
Built-in tasks and what triggers each
| Task | Trigger | Purpose |
|---|---|---|
snapadmin.purge_expired_data | You schedule it | GDPR — every registered model's data_retention_days (and data_retention_files), the audit log's SNAPADMIN_AUDIT_RETENTION_DAYS, and — if set — SNAPADMIN_EXPORT_RETENTION_DAYS job/file cleanup. See the full purge table |
snapadmin.purge_expired_tokens | You schedule it | Delete APIToken rows past their expiry (an expired token stops authenticating immediately either way — this only reclaims the rows) |
snapadmin.send_error_digest | You schedule it | Daily grouped digest of captured error events |
snapadmin.send_health_alert | You schedule it (e.g. every 5 min) | Probe subsystem health (DB / Elasticsearch / REST API / GraphQL, each skipped when its feature is off) and email an alert when one is down; a cooldown limits a persistent outage to one email |
snapadmin.run_db_backups | You schedule it (hourly due-check) | 3-2-1 database dumps to each destination that is due |
snapadmin.run_export | Event — enqueued when an async export job is created | Streams a large export in the background. No Beat entry; needs a running worker |
snapadmin.run_es_reindex | Event — enqueued by POST /api/es/reindex/ when SNAPADMIN_REINDEX_API_ASYNC=True | Bulk-reindex a model into Elasticsearch. Needs a running worker; schedule it as well if you want periodic reindexing |
snapadmin.tasks imports on a base install (no [celery] extra): the
decorator falls back to a stand-in that keeps every task name. Calling a task
runs its body synchronously in the current process; queueing it —
.delay(), .apply_async() — raises
ImproperlyConfigured telling you to install the extra. That split is on purpose: a
silent no-op would let a caller believe work was queued while nothing ever ran it. The API
endpoints that enqueue work (async export, async reindex) answer 503 with the
same advice rather than failing at import.
The demo project adds two of its own for illustration —
demo.tasks.reindex_products_to_elasticsearch and
demo.tasks.generate_daily_stats (defined in
demo/apps/shop/tasks.py; the dotted names are explicit task names, not import paths).
They are demo tasks, not part of the package.
Every management command is snapadmin_*-prefixed
Three commands used to ship without the prefix the rest use. Beyond the inconsistency, names
that generic can collide with a command of your own — Django resolves duplicates
silently by INSTALLED_APPS order, so whichever app wins, wins quietly. They were
renamed:
| Old name | Use instead | Removed in |
|---|---|---|
db_backup | snapadmin_db_backup | 1.0 |
purge_expired_data | snapadmin_purge_expired_data | 1.0 |
send_error_digest | snapadmin_send_error_digest | 1.0 |
A command name lives in crontabs, Dockerfiles and CI, so the old names still work
— same arguments, same behaviour, plus one rename notice on stderr (stdout stays clean, so a piped
cron job is unaffected). They will be removed in 1.0 — update your
schedules before then; the notice itself names the same window. Celery task names never
changed — snapadmin.purge_expired_data and friends were already prefixed, so
no Beat entry needs touching.
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 the scheduled tasks (Celery Beat)
This activates every "you schedule it" task from the table above. The two event-triggered ones
(run_export, run_es_reindex) are deliberately absent — they need a worker,
not a Beat entry.
from celery.schedules import crontab
CELERY_BROKER_URL = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND = "redis://localhost:6379/0"
CELERY_BEAT_SCHEDULE = {
"purge-expired-data": {
"task": "snapadmin.purge_expired_data",
"schedule": crontab(hour=1, minute=0), # daily 1am
"description": "GDPR — data_retention_days, the audit log, and job/file cleanup",
},
"purge-expired-tokens": {
"task": "snapadmin.purge_expired_tokens",
"schedule": crontab(hour=3, minute=0), # daily 3am
"description": "Remove expired API tokens",
},
"send-error-digest": {
"task": "snapadmin.send_error_digest",
"schedule": crontab(hour=8, minute=0), # daily 8am
"description": "Grouped digest of captured error events",
},
"send-health-alert": {
"task": "snapadmin.send_health_alert",
"schedule": crontab(minute="*/5"), # every 5 minutes
"description": "Email an alert when a subsystem (DB/ES) is down",
},
"run-db-backups": {
"task": "snapadmin.run_db_backups",
"schedule": crontab(minute=30), # hourly due-check
"description": "3-2-1 database backups (each destination when due)",
},
}
The run-db-backups entry above must fire at least as often as your shortest
SNAPADMIN_BACKUP_*_EVERY_HOURS — a destination's own interval only ever gets checked
when Beat wakes the task up, so scheduling it less often than that silently drops days. A new
check, snapadmin.W010, warns at manage.py check when the two disagree.
Separately, a run that completes even slightly later than its ideal wall-clock slot no longer
skips the following day: the due-time check applies a small (2% of the interval) tolerance for
exactly that jitter.
No Celery? Every scheduled task above can be driven from system cron instead —
see the per-feature sections (GDPR retention, error monitoring, backups) for the equivalent
cron lines.
CELERY_BEAT_SCHEDULE that includes a "description" key is automatically shown in the SnapAdmin Dashboard under Scheduled Cron Jobs. Note this reflects your Beat configuration — it is not proof that a Beat or worker process is actually running.
Running Celery in Docker
# demo/docker-compose.yml services:
worker:
command: celery -A demo.core worker -l INFO
beat:
command: celery -A demo.core beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler
💾 3-2-1 Database Backups
Built-in database 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, mysqldump for
MySQL) and shipped to up to five destinations, each on its own interval:
snapadmin.run_db_backups task actually runs. Wire it to Celery Beat or
system cron (see below); until then no dump is ever written.
Backups are the integration checklist's "Data safety" group in full: two destinations, retention, and — strongly recommended — encryption, covered next.
| Copy | Destination | Where it lives | Due every |
|---|---|---|---|
| 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). Plain FTP ships credentials in clear text — set SNAPADMIN_BACKUP_FTP_TLS = True (FTPS) or use sftp/s3 instead. | every 168 h (weekly) |
| 3 (alt) | sftp | Same offsite copy over SSH/SFTP (SNAPADMIN_BACKUP_SFTP_*) — encrypted transport, password or SSH-key auth. Needs pip install django-snapadmin[backup]. Use instead of, or alongside, remote. This is also the destination for Hetzner Storage Box. | every 168 h (weekly) |
| 3 (alt) | s3 | Any S3-compatible object store (SNAPADMIN_BACKUP_S3_*) — AWS S3, MinIO, Backblaze B2, Hetzner Object Storage or Wasabi, picked by SNAPADMIN_BACKUP_S3_ENDPOINT_URL. Needs pip install django-snapadmin[s3]. | every 168 h (weekly) |
Configuration
Backups are strictly opt-in, and forgetting the switch is
reported. With SNAPADMIN_BACKUP_ENABLED off, a scheduled
snapadmin.run_db_backups ends every run as disabled and stores nothing —
while the rest of the configuration looks complete. snapadmin.W023 warns when backups
are off and a Beat entry, a destination or AGE recipients are configured. Schedule the task
only while backups are on, or silence the check on an environment that keeps them off on purpose.
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
# Copy 3 (alternative) — offsite over SSH/SFTP; pip install django-snapadmin[backup]
SNAPADMIN_BACKUP_SFTP_HOST = "offsite.example.com"
SNAPADMIN_BACKUP_SFTP_PORT = 22
SNAPADMIN_BACKUP_SFTP_USER = "backup"
SNAPADMIN_BACKUP_SFTP_KEY_FILE = "/etc/snapadmin/id_ed25519" # key auth; or set _PASSWORD
SNAPADMIN_BACKUP_SFTP_KNOWN_HOSTS = "/etc/snapadmin/known_hosts" # unset = ~/.ssh/known_hosts
SNAPADMIN_BACKUP_SFTP_DIR = "snapadmin" # relative to the SSH login directory
SNAPADMIN_BACKUP_SFTP_EVERY_HOURS = 168 # weekly
# Copy 3 (alternative) — any S3-compatible object store; pip install django-snapadmin[s3]
SNAPADMIN_BACKUP_S3_BUCKET = "my-backups-bucket"
SNAPADMIN_BACKUP_S3_PREFIX = "snapadmin/" # optional key prefix inside the bucket
SNAPADMIN_BACKUP_S3_REGION = "eu-central-1"
# ENDPOINT_URL is the one setting that turns this into a transport for five
# providers — leave it unset for AWS, or point it at MinIO / Backblaze B2 /
# Hetzner Object Storage / Wasabi:
SNAPADMIN_BACKUP_S3_ENDPOINT_URL = "https://s3.eu-central-1.wasabisys.com"
# Leave both unset to use the ambient credential chain instead (env vars, a
# shared config file, or an IAM role / instance profile) — the right choice
# on AWS; a static key there would be a downgrade.
SNAPADMIN_BACKUP_S3_ACCESS_KEY_ID = "AKIA..."
SNAPADMIN_BACKUP_S3_SECRET_ACCESS_KEY = "..."
SNAPADMIN_BACKUP_S3_EVERY_HOURS = 168 # weekly
A configured-but-incomplete S3 destination is
snapadmin.W011 at manage.py check — advisory, like the AGE recipient
check above: an unparseable SNAPADMIN_BACKUP_S3_ENDPOINT_URL, or a bucket set with
neither an explicit access key pair nor a detected ambient AWS credential source (env vars, a
shared credentials file, an ECS/IRSA role — an EC2 instance profile can't be detected without a
network call, so that case is silently accepted).
SNAPADMIN_BACKUP_KEEP objects per part the same way it does for SFTP/FTP
(list, sort by the timestamped name, delete the rest) — but a real deployment is usually better
served by a bucket lifecycle rule (expire objects under the configured prefix
after N days), which runs server-side with no dependency on a backup task ever completing.
SNAPADMIN_BACKUP_SFTP_KNOWN_HOSTS — unset, paramiko reads
~/.ssh/known_hosts, expanded against the HOME of whoever runs the
process, and says nothing if the file is not there. In a container that is a trap: a
docker exec without a USER line runs as root with
HOME=/root, so a known_hosts correctly baked into the image at
/home/<svc>/.ssh/known_hosts is invisible — and the error you get,
Server '[host]:23' not found in known_hosts, sends you hunting for a host key that
is right there. Naming the file outright takes HOME out of the picture. An
explicitly configured file replaces the ~/.ssh lookup rather than
adding to it, the way OpenSSH's own UserKnownHostsFile does, and an unreadable one
fails loudly naming the setting instead of being silently skipped.
SNAPADMIN_BACKUP_SFTP_DIR is relative to the SSH login directory,
not to the filesystem root: it is handed to chdir() and the upload then targets the
working directory. An absolute value is snapadmin.W022 at
manage.py check (the default /, which means the login directory
itself, is not flagged). This matters on a jailed account — a storage sub-account, a chrooted
user — where the directory change appears to succeed and it is the upload that is
refused, with paramiko's bare Failure and no path at all. SnapAdmin now names the
full intended path in that failure, so a wrong directory no longer reads as a credentials
problem.
The opposite case: the login directory is the
absolute path. Several managed SFTP products log you straight into the directory you
would configure — pwd right after login prints it. There the absolute form is
correct, and the relative one resolves inside it: the directory does not exist, SnapAdmin
creates it, and every dump lands one level too deep. So keep the absolute value when
pwd already prints it, and accept the warning (or silence
snapadmin.W022). You do not have to guess which case you are in: the backup summary
and the db_backup_stored log line name the directory the server
resolved, not the configured string, and a directory created on the fly is logged as the
warning sftp_backup_dir_created.
Hetzner Storage Box — a worked SFTP recipe
sftp
destination above, not s3. Hetzner Object Storage is the
S3-compatible product; that one uses s3 with SNAPADMIN_BACKUP_S3_ENDPOINT_URL
pointed at your Hetzner region. Building a second transport for Storage Box would be redundant —
it is already exactly what sftp is for.
A Storage Box account works with the existing SFTP destination once four things are right:
SNAPADMIN_BACKUP_SFTP_HOST = "u123456.your-storagebox.de"
SNAPADMIN_BACKUP_SFTP_PORT = 23 # not 22 — Storage Box's external SSH port
SNAPADMIN_BACKUP_SFTP_USER = "u123456-sub1" # a sub-account, not the main account
SNAPADMIN_BACKUP_SFTP_KEY_FILE = "/etc/snapadmin/id_ed25519" # key-based auth
- Port 23, not the SSH default 22 — Storage Box's external SFTP/SCP endpoint.
- A sub-account (Hetzner Robot → Storage Box → Sub-accounts) scoped to its own subdirectory, so a leaked key can't reach the whole box.
- Key-based auth — upload the sub-account's public key in the Robot panel; set
SNAPADMIN_BACKUP_SFTP_KEY_FILEto the matching private key's path. - Pre-populate
known_hosts— SnapAdmin's SFTP transport rejects an unknown host key on purpose (no trust-on-first-use), so the host key has to already be there before the first backup runs:
Run this once as the same user the backup process runs as (e.g. during deployment), or make one manualssh-keyscan -p 23 -H u123456.your-storagebox.de >> ~/.ssh/known_hostssftp -P 23 u123456-sub1@u123456.your-storagebox.deconnection and accept the prompt. In a container, write the file to a fixed path and name it — the~above is resolved at run time, and adocker execwithout aUSERline is root, not your service user:ssh-keyscan -p 23 -H u123456.your-storagebox.de > /etc/snapadmin/known_hosts SNAPADMIN_BACKUP_SFTP_KNOWN_HOSTS = "/etc/snapadmin/known_hosts"
A Storage Box sub-account is scoped to its own subdirectory, which makes it exactly the case
snapadmin.W022 exists for: leave SNAPADMIN_BACKUP_SFTP_DIR at its default
/ (the sub-account's own root) or give it a relative path such as
"db". An absolute /backups is a path the sub-account cannot see, and the
upload — not the directory change — is what fails.
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": "snapadmin.run_db_backups",
"schedule": crontab(minute=30), # hourly due-check
}
# ...or plain cron, without Celery:
30 * * * * python manage.py snapadmin_db_backup # ships only what is due
python manage.py snapadmin_db_backup --force # all configured destinations, now
python manage.py snapadmin_db_backup --destination remote # one destination, now
Keeping the backup hour where you put it. By default a destination's window is
measured from its last actual run, so each run books the next one a full interval after it
finished. A daily backup that takes a few minutes therefore creeps forward a few minutes a day, and
over a month walks out of the quiet hours it was scheduled for and into working ones.
SNAPADMIN_BACKUP_ALIGN_TO_SCHEDULE = True measures the window from the planned
slot instead, pinning the schedule to the clock time of the first run however long any individual
run takes. Slots missed while the process was down collapse into a single catch-up run rather than
one run per missed slot. It is opt-in and the drift-tolerant default is unchanged; turning it on
needs no state-file surgery, and turning it back off is equally safe.
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/SFTP server, keeps only the newest
SNAPADMIN_BACKUP_KEEP dumps.
Encrypting backups (AGE)
Set SNAPADMIN_BACKUP_AGE_RECIPIENTS and every dump is encrypted with
age in-stream, before a single byte reaches
disk — pg_dump/mysqldump/SQLite → gzip → age → the .age-suffixed file. No plaintext
or plain-gzip artefact is ever written, not even transiently, and a mid-pipeline failure leaves
nothing behind rather than a partial or corrupt one. With the setting empty (the default), nothing
changes — this is exactly today's behaviour.
age's headline property is what makes this useful for a team rather than one person: encrypt once to any number of recipients, and any one of their private keys decrypts the file independently — no shared secret, no re-encryption to add a reader.
# One setting turns it on — a list, because multi-recipient is the default shape:
SNAPADMIN_BACKUP_AGE_RECIPIENTS = [
"age1scr8rpq5lxtaqqskkawrft82at865e4j3gvs30cjv79q5qq3gc7qwj8um3", # ops laptop
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBaU... deploy@ci", # an SSH public key works too
]
# Restore-only — a *path* to a private-key file, never the key material itself.
SNAPADMIN_BACKUP_AGE_IDENTITY_FILE = "/etc/snapadmin/age-identity.txt"
# "auto" (default) prefers the in-process pyrage library, falls back to the `age`
# CLI if pyrage isn't installed. Pin explicitly with "pyrage" or "binary".
SNAPADMIN_BACKUP_AGE_BACKEND = "auto"
An entry that doesn't look like a valid age
(age1…) or SSH (ssh-ed25519 … / ssh-rsa …) public key is
snapadmin.W008 at manage.py check — advisory, since the other recipients
still work; the malformed one only fails when a backup actually runs.
snapadmin.W021.
If any destination that leaves this machine is active — network,
remote, sftp or s3 — and
SNAPADMIN_BACKUP_AGE_RECIPIENTS is empty, manage.py check names the
offending destinations and the setting that fixes them. local is excluded: it is
the staging directory on the same host, so an unencrypted local-only setup is a choice rather
than an oversight.
It is a warning, not an error, unlike the
.env rule below. The transport
may already be encrypted (SFTP, FTPS, HTTPS to S3) and the destination may encrypt at rest
(SSE-KMS on a bucket, LUKS on the share) — both are legitimate answers, and neither is visible
from settings.py. If that is your setup, the warning is safe to ignore or to add to
SILENCED_SYSTEM_CHECKS.
| Backend | Needs | Best for |
|---|---|---|
pyrage | pip install django-snapadmin[age] (MIT, prebuilt wheels — no Rust toolchain to install) | Portability — identical on macOS/Windows dev machines and CI, no OS package |
binary | the age command-line tool on PATH (BSD-3-Clause) — apt install age on Debian 12+/Ubuntu 22.04+, brew install age on macOS | A production host that would rather manage encryption tooling through the OS package manager than add a Python wheel to the venv |
Both backends produce and consume the identical, standardised age file format — a bundle
encrypted with one restores fine with the other, or with the plain age command run by
hand on a jump host with no Django involved at all.
Don't have a keypair yet? Generate one from the library, through either backend:
python manage.py snapadmin_age_keygen
The private key is written once, to a new .age/ directory at the project root, and
is never printed or logged — only the public recipient is, ready to paste into
SNAPADMIN_BACKUP_AGE_RECIPIENTS above. Before writing anything, the command checks the
project's .gitignore for a rule that would already exclude .age/ —
recognising more than a literal .age/.age/ line (a leading-slash anchor, a
**/ prefix or /** suffix, and a blanket dotfile rule like .*)
— and appends one, with a visible message, if none is found. Every run closes with a reminder: move
the private key to a secure location (a password manager, a secrets vault, an encrypted volume) and
delete the local .age/ directory — it is a convenience for generation, never long-term
storage for a private key.
local/network directory) cannot read it without a matching private key.
It does not protect against a compromised application server — that server already
holds the live, unencrypted database.
Key rotation, stated plainly because it surprises people: adding a recipient only
affects backups made after the change — it does not retroactively unlock older bundles.
Removing one is the same in reverse: an already-encrypted bundle stays decryptable by every key it
was originally encrypted to. A private key never appears in a setting, in snapadmin_info
output, or in a log line — only the recipient (public key) list and the identity file path
are ever recorded; the identity is supplied only at restore time, from that file.
Media and .env in the bundle
SNAPADMIN_BACKUP_INCLUDE extends a run beyond the database — a subset of
db, media, env. The default is ["db"], so this is
entirely opt-in and today's behaviour is unchanged unless you add to it. Each part ships as its own
file sharing one run's timestamp, alongside one always-unencrypted manifest.json sidecar
— never a single combined archive, so --only db at restore time can fetch and decrypt
just the part you actually need.
SNAPADMIN_BACKUP_INCLUDE = ["db", "media", "env"] # default: ["db"]
# media: MEDIA_ROOT, tarred and streamed — never built in memory.
SNAPADMIN_BACKUP_MEDIA_EXCLUDE = ["cache/**", "*.tmp"] # glob patterns, relative to MEDIA_ROOT
SNAPADMIN_BACKUP_MEDIA_SIZE_WARNING_BYTES = 10 * 1024 ** 3 # log a warning past this size (10 GiB) — never aborts
# env: your project's .env file (or any file holding secrets).
SNAPADMIN_BACKUP_ENV_FILE = "/etc/snapadmin/project.env"
.env rule is fail-closed. Including env without
SNAPADMIN_BACKUP_AGE_RECIPIENTS configured is refused — a system check
(snapadmin.E007) catches it at manage.py check, and a matching runtime
guard catches it again if recipients are configured then cleared without a restart. A
.env file holds SECRET_KEY, database passwords and API keys; it is never
written to a backup destination unencrypted.
Naming env with no file behind it is reported too:
snapadmin.W024 warns when SNAPADMIN_BACKUP_ENV_FILE is unset or does not
point at a file, because the run would otherwise skip that part and still report success.
An unreadable media file (permission error, a broken symlink, one that vanished mid-run) is
skipped with a warning, not an aborted backup — a broken thumbnail must not cost you
the database. The manifest lists snapadmin/Django versions, the DB engine, every part actually
produced with its ciphertext checksum (so a truncated upload is caught before any decrypt attempt is
made), the full recipient list, and a ready-to-paste snapadmin_restore command — see
Restoring a backup.
Retention (SNAPADMIN_BACKUP_KEEP) applies per part: a run that
includes media keeps its own newest N media bundles independently of the database
dump's own N, so opting into media never starves the database backup's retention
headroom.
Daily, weekly and monthly retention
"Keep the newest N" alone either keeps too little history or too much recent noise. Three tiers
sit beside it — SNAPADMIN_BACKUP_KEEP_DAILY, _WEEKLY and
_MONTHLY — each keeping the newest backup of the N most recent days, ISO weeks or months
that have a backup (counted over the backups, never over the clock, the way borg and
restic count). A backup survives when any rule keeps it, and the newest run is always kept — a
KEEP of 0 used to delete every backup, the one just written included
(snapadmin.W036 now names it). Every destination — local, network, FTP, SFTP, S3 — prunes
through the one rule, per part, from the timestamp in the file name.
SNAPADMIN_BACKUP_KEEP = 3 # the last three runs, whatever their age
SNAPADMIN_BACKUP_KEEP_DAILY = 7 # + one a day for a week
SNAPADMIN_BACKUP_KEEP_WEEKLY = 4 # + one a week for a month
SNAPADMIN_BACKUP_KEEP_MONTHLY = 12 # + one a month for a year
A tier finer than a destination's own cadence keeps one backup per run there — a daily tier of 7
on a weekly offsite destination spans seven weeks — and snapadmin.W037 says so per
destination. All three default to 0, which leaves KEEP alone, exactly as before.
↩️ Restoring a Backup
manage.py snapadmin_restore restores a bundle snapadmin_db_backup
produced. Dry-run is the default — without --confirm it prints exactly
what would happen (which parts, which database, whether the .env file would be
overwritten) and changes nothing:
# See what backups are available (a local path, or from a configured destination):
python manage.py snapadmin_restore --list
python manage.py snapadmin_restore --list --destination sftp
# Plan only — prints what would happen, touches nothing:
python manage.py snapadmin_restore snapadmin-manifest-20260826-020000.json
# Pull straight from a destination and restore only the database:
python manage.py snapadmin_restore sftp:snapadmin-manifest-20260826-020000.json \
--only db --identity /etc/snapadmin/age-identity.txt --confirm
Before touching anything, the manifest's per-part checksum is verified against the fetched
ciphertext — a truncated or corrupted upload is refused rather than half-restored. An encrypted
bundle restored with no --identity prints exactly how many recipients it needs and
their fingerprints, instead of failing on an opaque parse error.
| Flag | Meaning |
|---|---|
--only db,media,env | Restore only these parts (comma-separated) |
--skip media | Restore everything selected except these parts |
--identity PATH | Path to the AGE identity (private key) file, for an encrypted bundle |
--confirm | Actually perform the restore (default: plan only) |
--no-snapshot | Skip the automatic pre-restore snapshot — prints a loud warning |
--database ALIAS | Restore the db part into another DATABASES alias — a restore drill — and print the row count per table (default: default) |
env is never restored by a bare --confirm. It overwrites
your project's secrets, so it must be named explicitly: --only env or
--only db,env. A restore that only names db/media never
touches your .env file.
Restoring db is not live-safe: existing connections are terminated
and, for PostgreSQL, the database is dropped and recreated before the dump loads. Run it in a
maintenance window. SQLite is a straight file replace after closing the connection.
Restore drills — --database
A periodic restore into a throwaway database is what proves a backup works. Declare the drill
database as an extra alias and restore into it; default is never touched:
# settings.py
DATABASES = {
"default": {...},
"restore_drill": {**DATABASES["default"], "NAME": "app_restore_drill", "USER": "drill"},
}
# The drill — plan first, then restore and verify:
python manage.py snapadmin_restore sftp:snapadmin-manifest-20260826-020000.json --database restore_drill
python manage.py snapadmin_restore sftp:snapadmin-manifest-20260826-020000.json --database restore_drill \
--identity /etc/snapadmin/age-identity.txt --confirm
- Only
dbgoes to another alias. Media and.envhave no per-alias target and would land on the live system, so a bare selection restoresdbalone and--only media/envis refused. - An alias that is
defaultin disguise is refused — the same SQLite file, or the same host, port and database name — so a typo cannot turn a drill into an overwrite of production. - No
CREATEDBneeded. On PostgreSQL the drill database is emptied by recreating itspublicschema instead ofdropdb/createdb; the drill role only has to own that database. - The result is verified. The command prints the row count of every table in
the drill database, and a restore that produced no tables fails instead of reporting success.
The pre-restore snapshot is skipped — it covers
default, which a drill never touches.
The pre-restore safety net: snapadmin_rollback
Before a --confirmed restore touches anything, it automatically snapshots the
current live state of every part it is about to overwrite — the same parts, encrypted
the same way a real backup would be — into SNAPADMIN_RESTORE_SNAPSHOT_DIR (default: a
rollback/ subdirectory of the local backup directory), and prints the snapshot id
prominently before proceeding. If the snapshot itself fails, the restore is aborted —
never on a best-effort basis.
# List available snapshots:
python manage.py snapadmin_rollback --list
# Roll back to the most recent one (dry-run by default, same --confirm):
python manage.py snapadmin_rollback --confirm
# Roll back to a specific one:
python manage.py snapadmin_rollback 20260826-020000 --identity /etc/snapadmin/age-identity.txt --confirm
Snapshots have their own retention, SNAPADMIN_RESTORE_SNAPSHOT_KEEP (default 3) —
separate from SNAPADMIN_BACKUP_KEEP, since these are short-lived safety nets, not
backups, and must not compete with the real retention policy for disk. --no-snapshot
exists for the operator who knows better and prints a loud warning when used.
🚨 Error Monitoring & Alerts
Optional 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 alerts keep the team informed (by email, by chat webhook, or
both — see Alert channels):
- 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. No mail server?
Configure a Slack/Discord/Teams/Telegram webhook instead — every alert below works with
chat delivery alone.
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": "snapadmin.send_error_digest",
"schedule": crontab(hour=8, minute=0),
}
# ...or plain cron, without Celery:
0 8 * * * python manage.py snapadmin_send_error_digest
python manage.py snapadmin_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.
Health alerts — email when a subsystem is down
A third, independent channel watches infrastructure rather than application errors. It runs
the same probes as snapadmin_info --health-check — database, Elasticsearch, the REST API and
GraphQL — and emails when one reports a failure, so an outage reaches an operator instead of only the
logs. Each probe honours its feature toggle (ELASTICSEARCH_ENABLED,
SNAPADMIN_REST_API_ENABLED, SNAPADMIN_GRAPHQL_ENABLED), so a subsystem you
turned off is never a false alarm. Run it on a schedule —
the snapadmin.send_health_alert Celery task, or the snapadmin_health_alert
management command from cron (it also exits non-zero while a probe is failing, so it
doubles as a monitoring gate). A cache-based cooldown limits a persistent outage to one email; a
recovery re-arms it so the next outage alerts immediately.
# settings.py — recipients fall back to SNAPADMIN_ERROR_ALERT_EMAILS
SNAPADMIN_HEALTH_ALERT_ENABLED = True
SNAPADMIN_HEALTH_ALERT_EMAILS = ["ops@example.com"]
SNAPADMIN_HEALTH_ALERT_COOLDOWN_MINUTES = 60
# Celery Beat — probe every few minutes:
"send-health-alert": {
"task": "snapadmin.send_health_alert",
"schedule": crontab(minute="*/5"),
}
# ...or plain cron, without Celery:
*/5 * * * * python manage.py snapadmin_health_alert
python manage.py snapadmin_health_alert --force # re-send within the cooldown
| Setting | Default | Description |
|---|---|---|
SNAPADMIN_HEALTH_ALERT_ENABLED | True | Enable the health-alert channel. |
SNAPADMIN_HEALTH_ALERT_EMAILS | [] | Recipients; falls back to SNAPADMIN_ERROR_ALERT_EMAILS. Empty (both) = no email. |
SNAPADMIN_HEALTH_ALERT_COOLDOWN_MINUTES | 60 | Minimum gap between two health-alert emails for an ongoing outage. |
docker-compose.yml pairs this with a
willfarrell/autoheal sidecar that restarts any container labelled autoheal=true
once its healthcheck goes unhealthy — covering the "hung but not exited" case that
restart: unless-stopped alone can't. See the demo Docker setup.
Alert channels — Slack, Discord, Teams, Telegram
Every alert above (spike, digest, health) is delivered by the same set of channels. Email is one of them; the others are chat webhooks, posted with the standard library — SnapAdmin adds no dependency for alerting. Thresholds, grouping and the cooldown are shared by all channels, so adding a webhook changes where an alert goes, never how often it fires.
? Configuring each channel, entry keys, and the fail-soft guarantees
# settings.py — read the URLs from the environment, never hard-code them
SNAPADMIN_ALERT_WEBHOOKS = [
{"type": "slack", "url": os.environ["SLACK_ALERT_WEBHOOK"]},
{"type": "discord", "url": os.environ["DISCORD_ALERT_WEBHOOK"]},
{"type": "teams", "url": os.environ["TEAMS_ALERT_WEBHOOK"]},
{"type": "telegram", "token": os.environ["TELEGRAM_BOT_TOKEN"],
"chat_id": os.environ["TELEGRAM_CHAT_ID"]},
# Only page the on-call channel for outages, not for the daily digest:
{"type": "json", "url": "https://ops.example.com/hooks/snapadmin",
"events": ["health", "error_spike"], "timeout": 3},
]
SNAPADMIN_ALERT_EMAIL_ENABLED = True # False → chat-only, no mail server needed
SNAPADMIN_ALERT_WEBHOOK_TIMEOUT = 5 # seconds per POST
| Entry key | Required | Description |
|---|---|---|
type | yes | slack, discord, teams, telegram, or json (a plain JSON POST for your own endpoint; webhook is an alias). |
url | yes (except telegram) | The incoming-webhook URL. Must be http(s)://. |
token + chat_id | telegram only | Bot token and target chat — posted to the Bot API sendMessage endpoint. |
events | no | Which alerts this channel wants: error_spike, error_digest, health. Omitted = all three. |
timeout | no | Per-channel POST timeout in seconds; defaults to SNAPADMIN_ALERT_WEBHOOK_TIMEOUT. |
snapadmin_info — failures are logged as
alert_channel_failed with the host only (https://hooks.slack.com/…).
snapadmin_health_alert command all carry on, and the other channels still receive the
alert. If every channel fails, the cooldown that was claimed for the send is released
again, so the next occurrence alerts instead of being swallowed for the rest of the window.
A malformed entry (unknown type, missing url) is logged and skipped
rather than raised.
SNAPADMIN_ALERT_WEBHOOK_TIMEOUT × number of channels — once per cooldown window, not
per error. Keep the timeout low (the 5-second default is deliberate), and if you run many channels,
subscribe the chatty ones to error_digest only: the digest and the health alert run in
a Celery task or a cron command, off the request path.
🪵 Structured Logging
SnapAdmin provides a structlog-based logging setup with colourised output for development and JSON output for production.
? Activating it and using the logger
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 gives you a one-line retention window on any SnapModel to help comply with
EU data retention laws (GDPR) — a model-wide age, a per-row deadline, or both. The attributes below declare what counts as expired; a
scheduled task you wire up is what actually deletes it. The same snapadmin.purge_expired_data
task also covers two things that are not SnapModels at all — the audit log and, if you opt
in, finished export/reindex job rows and their files — see
the full purge table below for everything the package can auto-delete.
data_retention_days deletes nothing
by itself. Point Celery Beat or system cron at
snapadmin.purge_expired_data (see Running the cleanup below) — until then
expired rows simply accumulate.
class AuditLog(SnapModel):
action = SnapCharField(max_length=100)
created_at = SnapDateTimeField(auto_now_add=True)
attachment = SnapFileField(upload_to="audit/", blank=True, null=True)
# Rows older than 90 days count as expired (deleted when the purge runs)
data_retention_days = 90
data_retention_field = "created_at" # default — any DateTimeField works
data_retention_files = ["attachment"] # delete the file with the row (#RET2c)
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. |
data_retention_date_field | str | None | None | Name of a Date/DateTimeField holding this row's own expiry instant — a per-row deadline rather than a model-wide age. See Per-row deletion dates. |
data_retention_files | list[str] | None | None | SnapFileField/SnapImageField names whose files are deleted along with an expiring row. None (the default) purges rows only, exactly as before this existed. |
Per-row deletion dates — data_retention_date_field
data_retention_days is one constant for the whole table, which can express
"delete 90 days after this row was created" and nothing else. When each record arrives with its
own agreed expiry — a delete_at an upstream supplier sets per row — name that column
instead:
class SuppliedRecord(SnapModel):
created_at = SnapDateTimeField(auto_now_add=True)
delete_at = SnapDateTimeField(null=True, blank=True) # set per row by the supplier
data_retention_date_field = "delete_at" # this row's own deadline
data_retention_days = 90 # the house rule for rows without one
The two combine, and the more specific rule wins:
| The row's date | What happens |
|---|---|
| Set, in the past | Purged — whatever data_retention_days says. |
| Set, in the future | Kept, even if it is older than data_retention_days. An expiry set per record overrides the house rule rather than racing it. |
NULL | Falls back to data_retention_days measured on data_retention_field — and is never purged when no window is configured, because "no expiry declared" has to mean "keep". |
Set it alone, with no data_retention_days at all, for a table where every row
carries its own deadline and there is no house rule. The scheduled purge, the management command
and snapadmin.W012 all treat a model configured this way as a model with retention —
a per-row deadline is a retention rule on its own. On an ES_ONLY model the deadline
column must be mapped as a date in the index, like data_retention_field.
Rows a PROTECT foreign key still holds
A tree whose retention is decided per row — parent = ForeignKey("self",
on_delete=PROTECT) — has due parents whose children are not due yet. Such a row is
kept, not allowed to abort the model's purge: everything else that is due goes, a
fully due subtree goes leaves first in the same run, and the kept rows are retried on the next run
once whatever protects them has expired too. A kept row keeps its
data_retention_files as well — files are deleted only for rows that are actually
deleted. The same applies to RESTRICT.
An ES_ONLY model has no other copy, so its purge query failing — or answering
with documents it could not delete — raises SnapPurgeError and the run reports the
model as failed, instead of returning 0 as though nothing had been due. The same
failures list in a delete_by_query answer makes
delete_pks_from_es() report False for a DUAL mirror.
It is reported, never swallowed: purge_expired() returns a
SnapPurgeResult — still an int, the purged count — whose
skipped_protected attribute counts the kept rows; the management command prints a
SKIPPED line per model, and the Celery task's summary carries them under
skipped_protected.
Running the cleanup
# Via Celery Beat (recommended — add to CELERY_BEAT_SCHEDULE):
"purge-expired-data": {
"task": "snapadmin.purge_expired_data",
"schedule": crontab(hour=1, minute=0),
}
# Manually via management command:
python manage.py snapadmin_purge_expired_data # live run — deletes records
python manage.py snapadmin_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 (plus data_retention_files, if declared). |
DUAL | Bulk delete from the database and the mirrored Elasticsearch documents (the pks are collected before the DB delete, then cleared from the index), plus data_retention_files. |
ES_ONLY | A delete_by_query against the index on data_retention_field (which must be mapped as a date in es_mapping), and on data_retention_date_field when one is declared — a document with no deadline is matched by the age range only, via must_not exists. No DB table means no field to read a file path from, so data_retention_files does not apply here. |
QuerySet.delete() never calls each model's delete(), so a naïve bulk purge would leave the Elasticsearch copy — or an uploaded file — behind. SnapAdmin's purge_expired() closes that gap for DUAL/ES_ONLY models and for data_retention_files alike. ES operations are best-effort and require ELASTICSEARCH_ENABLED=True.
File payloads: files before rows
Deleting the row but not the file it references is the wrong outcome for a GDPR retention
feature — the row is what remembers the file's name, so an orphaned file becomes unreachable
and undeletable without a separate storage sweep. data_retention_files closes
that gap, with two rules that make it safe to run unattended:
- Files are deleted before their row. If a file cannot be removed (a storage
error), the row — and therefore the file's name — is left intact and
purge_expired()raisesSnapPurgeErrorinstead of silently continuing, so the purge is retryable on the next run rather than orphaning the file permanently. A missing file (already gone) is treated as already-done, not an error. - A path another live row still references is never deleted. Two rows can legitimately point at the same storage path; before removing a file, SnapAdmin checks whether any other row outside this purge still needs it and skips the delete if so (one extra query per distinct path) — "we deleted a file another row still points at" is unrecoverable, "we left a file behind" is a storage sweep.
dry_run=True touches nothing — no file is deleted and no row is counted as
skipped, exactly as before data_retention_files existed.
Replaced and deleted files leave storage — delete_replaced / delete_on_delete
Django leaves a file in storage when its row's file is replaced or the row is deleted; with a dated
upload_to every replacement is another orphan, and storage keeps personal data the database
no longer points at. Two opt-in flags on SnapFileField / SnapImageField close
that:
class Contract(SnapModel):
scan = snap_fields.SnapFileField(
upload_to="contracts/%Y/%m/",
delete_replaced=True, # the old file goes once a replacement is saved
delete_on_delete=True, # the file goes with its row — bulk deletes included
)
A file is removed after the transaction commits (a rolled-back save or delete removes
nothing), and only when no other row of the model still names it — asked through the
base manager, so a row hidden from the current user or tenant still counts. Bulk deletes are covered
(QuerySet.delete() and the admin's "delete selected" fire the per-row signal once a field
opts in). A storage error is logged (snapadmin.files.delete_failed) and never raised into
the request. Both flags are Snap-only: no migration. A deferred field (.only()) has no
name to remove and is left alone. The retention purge's data_retention_files shares the
same "still named by another row" rule, and a GDPR subject-request delete removes
the files of every file field of the rows it erases, flags or not.
The audit log, and export/reindex job cleanup
Two more tables grow forever unless something purges them, and neither is a SnapModel
— so snapadmin.purge_expired_data covers each explicitly, alongside the per-model sweep
above, every time it runs:
- The audit log (
SnapadminAuditLog) — governed bySNAPADMIN_AUDIT_RETENTION_DAYS(default 365, i.e. on by default). Rows are append-only (save/deleteraise once persisted); the purge usesQuerySet.delete(), the one sanctioned bypass of that guard, so the trail itself stays tamper-evident right up until the retention window closes. This does not change whatmanage.py snapadmin_audit_export --purgedoes — that command keeps working exactly as before, reading the same setting independently. - Export/reindex jobs (
SnapExportJob/SnapReindexJob) — governed bySNAPADMIN_EXPORT_RETENTION_DAYS, unset (off) by default — unlike the two sweeps above, this one deletes files a project may deliberately want to keep (a downloaded report, an archival export), so it needs an explicit opt-in. Once set, finished job rows past the window are deleted along with their published files (files before rows, same ordering rule asdata_retention_files), and a second pass sweeps any export file left behind with no job row at all — the state a worker that died mid-export leaves. This assumes the export storage location (see Async Export) is used exclusively for SnapAdmin exports; do not point it at a bucket with unrelated files.
data_retention_days, the audit log's on-by-default 365 days, or
SNAPADMIN_EXPORT_RETENTION_DAYS) but no CELERY_BEAT_SCHEDULE entry runs
snapadmin.purge_expired_data — every report behind this warning turned out to be
retention configured but nothing ever scheduled to enforce it, so the table quietly grows forever
exactly as if it had never been set. Running
manage.py snapadmin_purge_expired_data from an external scheduler instead (cron, a
Kubernetes CronJob, a systemd timer) is invisible from settings — declare it with
SNAPADMIN_PURGE_EXTERNAL = True and the warning goes quiet with retention still on.
Every purge the package performs, in one place
Retention is real but was historically scattered across four unrelated tables — this table is the single answer to "what gets cleaned up, by what, and how often should I schedule it":
| Table | Setting | What removes it | Recommended schedule |
|---|---|---|---|
Any SnapModel's rows (+ data_retention_files) | data_retention_days / data_retention_field / data_retention_date_field / data_retention_files (per model) | snapadmin.purge_expired_data | Daily |
SnapadminAuditLog | SNAPADMIN_AUDIT_RETENTION_DAYS (default 365) | snapadmin.purge_expired_data (or snapadmin_audit_export --purge for a SIEM-export-then-prune pass) | Daily |
ErrorEvent | SNAPADMIN_ERROR_RETENTION_DAYS (default 30) | snapadmin.send_error_digest (purges as a side effect of sending the digest) | Daily (with the digest) |
SnapExportJob / SnapReindexJob + their files | SNAPADMIN_EXPORT_RETENTION_DAYS (default off — opt in) | snapadmin.purge_expired_data | Daily, once opted in |
Expired APIToken rows | APIToken.expiration_date (per token; an expired token stops authenticating immediately either way) | snapadmin.purge_expired_tokens | Daily |
All five rows above run from the beat schedule in
Celery & Periodic Tasks — the demo project's own
CELERY_BEAT_SCHEDULE schedules every one of them.
🧾 GDPR Subject-Access Requests
data_retention_days answers "how old is too old"; a subject-access request answers a
different question — "show (or delete) everything about this one person, right now".
manage.py snapadmin_subject_request answers it by walking every registered model's own
subject_path declaration, the same one snapadmin.E011/E012
require every registered model to state explicitly (declared, not guessed).
Declaring who a subject is
class Customer(SnapModel):
email = SnapEmailField(...)
is_data_subject = True # a valid --model entry point
subject_identifier = "email" # the field holding the raw identifier
subject_path = "email" # a subject's own path always equals its identifier
class Order(SnapModel):
customer = SnapForeignKey(Customer, on_delete=PROTECT)
subject_path = "customer__email" # one relation hop to the subject
class SomeOtherModel(SnapModel):
subject_path = None # explicit — this model carries nothing subject-scoped
subject_path is required on every registered model — None
is a valid, explicit answer, but silence is not: snapadmin.E011 fails
manage.py check for any registered model that never declares it at all, and
snapadmin.E012 catches a declared-but-malformed one (a subject model whose own path
doesn't match its identifier, a path over 3 relation hops, one that doesn't resolve via this model's
own forward relations, or a multi-hop path on an ES_ONLY model — see
the reference table below). The decorator route accepts the
same three keywords: @snap_model(subject_path=..., is_data_subject=..., subject_identifier=...).
Running a request
# Export everything reachable from this subject, unmasked, as one bundle:
python manage.py snapadmin_subject_request export \
--model demo.Customer --identifier alice@example.com --user dpo_operator
# ...optionally AGE-encrypted to a recipient (repeatable) — the plaintext is removed once encrypted:
python manage.py snapadmin_subject_request export \
--model demo.Customer --identifier alice@example.com --user dpo_operator \
--recipient age1qy...
# Preview a deletion — dry-run by default, deletes nothing:
python manage.py snapadmin_subject_request delete \
--model demo.Customer --identifier alice@example.com --user dpo_operator
# Actually delete, once the preview looks right:
python manage.py snapadmin_subject_request delete \
--model demo.Customer --identifier alice@example.com --user dpo_operator --confirm
--user must hold snapadmin.view_raw_pii (or pass
SNAPADMIN_PII_ACCESS_CHECK, when set).
A subject-access export is unmasked by design — it goes to the subject, so masking
it would defeat the point — which makes it a high-value artefact. The command is gated on the same
permission that already unlocks raw PII everywhere else in SnapAdmin, and every run is written to
the immutable audit trail against that operator.
Export: reuses the existing export machinery, unmasked
One SnapExportJob per matched model, run synchronously with
filters={subject_path: identifier} and requested_by set to the resolved
operator — the same masking bypass that already applies to a PII-privileged requester elsewhere
produces the unmasked export, so there is no second "skip masking" code path to get wrong. A
manifest.json lists every file, the row count per model, and who requested it. Pass one
or more --recipient age/SSH public keys to encrypt the whole bundle in place afterward
(the same AGE machinery backups use) — recommended, since an unmasked SAR
bundle is exactly the kind of thing that should not sit in plaintext once written.
Deletion: dry-run by default, refuses rather than routes around a block
Both dry-run and --confirm run the identical pre-flight — a Django
deletion Collector walk over every matched row, which discovers cascade spillover on its
own (a more complete picture than the subject_path declarations alone: deleting a
Customer also takes its CustomerProfile, since that relation is
on_delete=CASCADE) — so the dry run is a genuine preview of what --confirm
would do, not a separate, weaker check. If any matched row is protected
(on_delete=PROTECT — the demo's Order.customer uses it), the whole run
refuses up front and deletes nothing, rather than deleting in dependency order to
route around it:
$ python manage.py snapadmin_subject_request delete --model demo.Customer \
--identifier alice@example.com --user dpo_operator --confirm
REFUSED — deleting these rows is blocked by a protected relation: demo.Order
Nothing was deleted. Resolve the blocking rows manually (delete or reassign them) before retrying.
Stored files go with the rows. Every file field of every row the run erases —
whatever its cleanup flags — has its file removed from storage after the
rows are gone, unless another row still names the same file. The dry run prints the count
(DRY RUN files: 2), never the names: a file name is often personal data itself. A file
storage refuses to delete is reported (FAILED files: …) rather than raised — the rows are
already gone, and the operator must see what is left.
A deletion audit entry is written after a successful run — action delete, model
subject_access_deletion, the per-model row counts, the number of files removed, and the operator. This entry
cannot itself be swept away by a later deletion for the same subject:
SnapadminAuditLog is deliberately outside the general SnapAdmin registry (see
Unalterable Audit Trail), so it carries no subject_path at
all and is structurally invisible to this command's own matching sweep.
subject_path declaration — a model nobody registered is invisible before the question
is even asked.
Reference
| Attribute | Type | Default | Description |
|---|---|---|---|
subject_path | str | None | required, no default | Forward __-joined ORM path (≤3 relation hops) to the subject-identifying field, or None. |
is_data_subject | bool | False | Marks this model as a valid --model entry point. Requires subject_path == subject_identifier. |
subject_identifier | str | None | None | Field name on this model holding the raw identifier, required when is_data_subject=True. |
| Check | Severity | Fires when |
|---|---|---|
snapadmin.E011 | Error | A registered model never declares subject_path at all (not even None). |
snapadmin.E012 | Error | A declared subject_path is malformed: is_data_subject=True with no/mismatched subject_identifier, over 3 relation hops, unresolvable via this model's own forward relations, or a multi-hop path on an ES_ONLY model. |
🔐 Field Encryption
Encrypted model fields keep ciphertext in the database and hand your code the ordinary Python value. All of that rests on one thing being configured correctly: the keyset. This section is the keyset — where the key comes from, how it rotates, and the startup checks that stop a column from quietly staying plaintext.
SNAPADMIN_ENCRYPTION is never read, no dependency is imported, and no query
changes. Encryption needs the [encryption] extra
(pip install django-snapadmin[encryption], which pulls
cryptography — Apache-2.0 or BSD-3, so still proprietary-safe); a project that
encrypts nothing neither needs nor loads it.
Two halves, and they are read in this order: the field types — what you declare, what you get back and what you give up — and the keyset below, which everything else rests on.
Declaring an encrypted field
Each type is its plain counterpart plus encryption. Nothing above the field changes — a
SnapEncryptedDateField still renders a date picker, still validates a date and still
hands your code a datetime.date. Only the column changes, to text holding a
snap1. envelope.
from snapadmin import fields as snap, models as snap_models
class Patient(snap_models.SnapModel):
name = snap.SnapCharField(max_length=200, searchable=True)
ssn = snap.SnapEncryptedCharField(max_length=32, show_in_list=False)
email = snap.SnapEncryptedEmailField(blind_index=True) # still findable by value
notes = snap.SnapEncryptedTextField()
patient.ssn = "123-45-6789"
patient.save()
# the column now holds: snap1.2026-09.<nonce>.<ciphertext>
Patient.objects.get(pk=patient.pk).ssn # → "123-45-6789"
| Field | Python value you get back |
|---|---|
SnapEncryptedCharField | str. max_length is optional here and bounds the plaintext — the column is text. |
SnapEncryptedTextField | str, multi-line. |
SnapEncryptedEmailField | str, still validated as an address on the way in. |
SnapEncryptedJSONField | Whatever you stored. The document is encrypted whole, so key lookups and containment queries are impossible — keep the keys you query on in their own columns. |
SnapEncryptedIntegerField | int. No sums, no ordering, no range filters. |
SnapEncryptedDecimalField | Decimal, quantised to decimal_places exactly as the unencrypted field would be. |
SnapEncryptedDateField | datetime.date, stored ISO-8601. |
SnapEncryptedDateTimeField | datetime.datetime, stored ISO-8601 with its offset. |
None is written as SQL NULL and never encrypted, so
__isnull keeps working and "" stays distinguishable from "no value".
Re-saving a loaded row, a bulk_update(), a QuerySet.update() and a
fixture reload all refuse to encrypt an already-encrypted value a second time — double encryption
is the one failure here that is both silent and permanent.
What you can still query
icontains, gt, startswith, range, a
__year transform and ORDER BY are impossible, not merely
unsupported. Each raises a FieldError naming the field. That is deliberate: the
alternative is a query that succeeds and matches nothing, which turns encrypted data into
invisible data — a failure nothing announces and no test downstream catches.
| Lookup | Works? |
|---|---|
__isnull | ✅ Always — NULL is never encrypted. |
__exact / =, __in | ✅ only with blind_index=True. Refused otherwise. |
| Everything else | ❌ FieldError. Keep what you need to query on in a separate, unencrypted column. |
The blind index
blind_index=True adds a <field>_bi sibling column holding
HMAC-SHA256(index key, value), and rewrites __exact and
__in onto it — so the SQL that reaches the database carries no plaintext in any
parameter, query log or slow-query report. The index key is HKDF-derived from the
keyset with the field's own app.model.field as derivation info, never the
encryption key itself and never shared between columns. Adding unique=True moves
the constraint to the index, where it means what you meant — a unique constraint on ciphertext
is satisfied by every row, since each has its own nonce.
Lookups match under every key in the keyset, so prepending a key during a
rotation keeps existing rows findable with no rebuild and no downtime;
snapadmin_encrypt_fields --rotate converges the stored indexes afterwards.
Case is significant, matching Django's own exact; only Unicode NFC normalisation
is applied, so the same string typed composed or decomposed still matches.
bulk_update(), QuerySet.update(), and
save(update_fields=[...]) on a plain models.Model (a
SnapModel adds the sibling to update_fields for you). A row changed
that way keeps its old index and silently stops being findable by its new value. Either name the
<field>_bi column alongside the field, or repair the table afterwards with
manage.py snapadmin_encrypt_fields --reindex --apply.
The sibling column is treated as exactly as sensitive as the column it indexes — masked in the API, exports and the changelist, and never given a generated API filter. It is a deterministic function of the value, so serving it to a caller who is denied the field itself would hand them an offline equality oracle over it.
Where the plaintext does not go
The field decrypts transparently, which means every layer above the ORM receives an ordinary Python value and would happily emit it. Each surface has a tested default:
| Surface | Default |
|---|---|
| Elasticsearch | Excluded from the mapping and the document. A second datastore with a different threat model, which also keeps the value in its own inverted index. Naming one in an explicit es_mapping is snapadmin.E020. |
| Audit trail | Records that the field changed, never the before/after values — in the SnapAdmin trail and in Django's own admin history message. |
| REST, GraphQL, exports, changelist, imports | Masked, through the existing PII masking permission model. Grant a role the raw value with a per-field rule in SNAPADMIN_MASKING_RULES. |
dumpdata / fixtures | Emits the ciphertext, so a dump is no more sensitive than the database it came from. loaddata stores it back unchanged and needs no key. |
pg_dump / mysqldump / SnapAdmin backups | Ciphertext, necessarily. See the restore caveat below. |
Adopting an existing column
Switching a column that already holds data to an encrypted field is a schema change in
your project — the column type becomes text, plus the optional _bi sibling —
and the rows already in it stay plaintext. Every read of one raises until it is converted, which
is correct: the alternative is a column silently holding a mixture.
- Change the field,
makemigrations,migrate. This is the one place field-level encryption cannot honour SnapAdmin's "avoid migrations" norm. python manage.py snapadmin_encrypt_fields --adopt --models app.Model— reports what it would convert, and changes nothing.- Re-run it with
--apply.
python manage.py snapadmin_encrypt_fields --adopt --apply # encrypt what was already there
python manage.py snapadmin_encrypt_fields --rotate --apply # move rows onto the active key
python manage.py snapadmin_encrypt_fields --reindex --apply # rebuild blind-index columns
It writes nothing without --apply, walks by primary key in batches
(--batch-size, resume a killed run with --start-pk), and counts a row it
cannot convert instead of stopping on it — the run finishes, then exits with an error listing the
failures by primary key.
Three caveats worth reading before you ship
Renaming breaks decryption. Every ciphertext is authenticated against its own
app_label.model.field — that is what stops a value being lifted out of one column and
pasted into another. It also means renaming the app, the model or the field changes the binding,
and rows written under the old name stop decrypting until they are re-encrypted. Plan a rename
like a data migration.
A restore needs the keyset that wrote the dump. A database dump carries
ciphertext, so restoring it into an environment with a different keyset produces rows that cannot
be read — which looks exactly like data corruption and is not. Compare the keyset
fingerprint that manage.py snapadmin_info prints between the two
environments before concluding anything.
Dropping a key is irreversible. A key can only leave the keyset once no row
still names it. --rotate is what makes that true; there is no way back afterwards.
Keys and rotation
Generating a key
python manage.py snapadmin_encryption_key # the first key
python manage.py snapadmin_encryption_key --rotate # a key to prepend
The key is printed once, as the environment line to paste into your secret
store — the command writes it nowhere. --rotate prints the new key plus the ids
already in the keyset, never their material: rotating is prepending one line.
Where the key comes from
Four sources, most secure first. The first one configured wins — sources are never merged, so a stray environment variable can never silently half-override a secret store.
| # | Source | Use it when |
|---|---|---|
| 1 | SNAPADMIN_ENCRYPTION["KEY_PROVIDER"] — a dotted path to a callable returning the keyset | KMS, Vault, Secrets Manager. Nothing secret touches settings or the environment. Called once per process, so a network lookup is not a per-query cost. |
| 2 | SNAPADMIN_ENCRYPTION["KEY_FILE"], or the SNAPADMIN_ENCRYPTION_KEY_FILE environment variable | A Docker or Kubernetes secret mounted read-only. snapadmin.W016 warns if the file is readable by group or others. |
| 3 | The SNAPADMIN_ENCRYPTION_KEYS environment variable | The 12-factor / .env path — id:key entries separated by commas or newlines. |
| 4 | SNAPADMIN_ENCRYPTION["KEYS"] in the settings module | Tests and local development. snapadmin.W017 warns whenever DEBUG is off: a key in a settings module is a key in version control. |
SNAPADMIN_ENCRYPTION = {
# Ordered: the FIRST key encrypts, EVERY key decrypts.
"KEYS": [{"id": "2026-09", "key": "<32 bytes, base64url>"}],
# Better — keep the material out of settings entirely:
"KEY_PROVIDER": "myapp.secrets.load_snapadmin_keys",
"KEY_FILE": "/run/secrets/snapadmin_encryption",
"STRICT": True, # default
}
Rotation
The keyset is ordered: the first key encrypts everything written from now on, and every key in the list can decrypt. Rotating is therefore prepending, with no downtime and no migration:
manage.py snapadmin_encryption_key --rotateand prepend the printed line.- Deploy. New writes use the new key; existing rows keep opening with the old one, because every ciphertext records the id of the key it was written with.
- Re-encrypt the stored rows, then drop the old key from the list — not before. Dropping a key while rows still name it makes those rows permanently unreadable.
Two rules the package enforces for you
Never SECRET_KEY. Reusing Django's SECRET_KEY as
encryption key material is the classic mistake here: SECRET_KEY is rotated for
session and CSRF reasons, and the damage only becomes visible after a rotation, when
every encrypted column has already become unreadable. snapadmin.E017 fails
manage.py check if the two are the same value.
Key material is never rendered. No repr, no str, no
log line, no exception message and no snapadmin_info section emits key bytes — only
a key's id and its fingerprint, a short digest safe to print and to compare between
environments. A restore into an environment whose keyset fingerprint differs is the one failure
that looks like data corruption but is not, which is why the fingerprint is displayable at all.
Startup checks
| Check | Severity | Fires when |
|---|---|---|
snapadmin.E017 | Error | A configured key is Django's SECRET_KEY (verbatim, or base64-encoded). |
snapadmin.E018 | Error | A model declares an encrypted field and no keyset resolves. Fail-closed by default: a column that is supposed to hold ciphertext never quietly receives plaintext instead. |
snapadmin.E019 | Error | SNAPADMIN_ENCRYPTION is unresolvable — an unimportable KEY_PROVIDER, a missing KEY_FILE, a key that is not 32 base64url bytes, a duplicate key id. |
snapadmin.E020 | Error | An explicit es_mapping names an encrypted field. Indexing it would ship the plaintext to Elasticsearch — a second datastore with a different threat model, which also keeps the value in its own inverted index. SnapAdmin excludes it from the document regardless, so the entry only ever misleads. |
snapadmin.E021 | Error | searchable=True on an encrypted field with no blind_index=True. The admin search box and the REST ?search= filter build an icontains over ciphertext, which matches nothing — the search would report the row does not exist. |
snapadmin.E022 | Error | unique=True on an encrypted field with no blind_index=True. Every ciphertext carries its own random nonce, so the constraint is satisfied by every row and enforces nothing. With a blind index it moves to the <field>_bi column and means what you meant. |
snapadmin.E023 | Error | Meta.ordering sorts by an encrypted field. The database orders by the base64url envelope, which bears no relation to the value. Unlike a filter, ordering cannot be refused at query time — this check is the only thing that catches it. |
snapadmin.E024 | Error | filterable=True on an encrypted field. A sidebar filter reads the column's distinct values through the ORM, which decrypts them — so the admin would print every distinct plaintext into the sidebar for anyone who can open the changelist, then fail when the filter is applied. Unlike the others, a blind index cannot make this one safe: what the filter offers is exactly what the encryption is hiding. |
snapadmin.W016 | Warning | The mounted KEY_FILE is readable by group or others. |
snapadmin.W017 | Warning | Key material sits in the settings module (KEYS) with DEBUG off. |
snapadmin.W018 | Warning | An encrypted field has no keyset while STRICT is off. Startup continues — but every read and write of that field still fails: STRICT relaxes this check only, never the runtime guarantee. |
snapadmin.W019 | Warning | db_index=True on an encrypted field with no blind index. Not wrong, just dead weight: no lookup can use an index on a column every comparison is refused on. |
snapadmin.W020 | Warning | A SnapBlindIndexField whose source_field names no field on the model. Its value can no longer be derived, so it keeps whatever was written last while the field it tracks moves on. |
🏘️ Multi-Tenancy
Row-level tenant isolation, opt-in per model. A model declares itself tenant-scoped and adds a tenant column; every generated surface then requires a bound tenant to see or write any row of it — default-deny, not opt-out. Nothing about a model that never opts in changes.
from snapadmin import models as snap_models
from snapadmin.tenancy import tenant_field
class Order(snap_models.SnapModel):
customer = snap_models.SnapForeignKey(Customer, on_delete=models.PROTECT)
tenant_id = tenant_field() # a plain, nullable, indexed CharField
tenant_scoped = True # opts this model into isolation
tenant_field() returns a pre-configured CharField — nullable so the
migration it adds never breaks an existing row, indexed for the filter every scoped query runs.
Override any keyword (max_length=, …) to fit a project's own tenant-identifier shape;
declare a real ForeignKey by hand instead if the tenant is itself a project model.
tenant_field (the model attribute, distinct from the factory function above) renames the
column when set — otherwise it is "tenant_id".
class Invoice(snap_models.SnapModel):
organisation = models.ForeignKey(Organisation, null=True, on_delete=models.PROTECT)
tenant_scoped = True
tenant_field = "organisation" # the field name or its column, "organisation_id" — both work
With a ForeignKey the resolver returns the tenant row's primary key
(not the row): SnapAdmin writes it to the column (organisation_id) on every create path —
the admin, its inlines, the REST API and the importer — and compares a value a request body names
by that key, so tenant_field may spell either the field name or the column.
Resolving the current tenant
Add the middleware, then point it at a resolver:
# settings.py
MIDDLEWARE = [
# ... after AuthenticationMiddleware, so the resolver can read request.user
"snapadmin.tenancy.SnapTenantMiddleware",
]
SNAPADMIN_TENANT_RESOLVER = "myproject.tenancy.resolve_tenant" # request -> tenant value | None
SNAPADMIN_TENANT_USER_RESOLVER = "myproject.tenancy.resolve_for_user" # user -> tenant value | None
Both SnapAdmin middlewares — this one and SnapErrorMonitorMiddleware — run in sync
and async mode: under ASGI they keep the middleware chain async (a database-reading resolver
or the error recorder runs through sync_to_async) instead of making Django switch the whole
chain to sync mode with a thread hop per request.
SNAPADMIN_TENANT_RESOLVER runs once per request, resolving the caller's tenant from
whatever a project's own auth carries — a claim on the session/JWT, a subdomain, an SSO group.
Unset, every request resolves to no tenant — the correct fail-closed default until a
project configures one. SNAPADMIN_TENANT_USER_RESOLVER is the second half: an async
export or import job has no request when a Celery worker actually runs it, so the tenant is resolved
from the submitter once, at job-creation time, and stamped onto the job row for the worker to replay.
See
demo/core/tenancy.py for a complete (illustrative, email-domain-based) pair of
resolvers wired into the demo project.
What "default-deny" means concretely
| Surface | With no tenant bound | With a tenant bound |
|---|---|---|
| Admin changelist / change form | Empty list · 404 by pk | That tenant's rows only |
| Admin create | Refused (PermissionDenied) — never an orphaned row | Row stamped with the bound tenant |
| REST list / retrieve / count / export / fetch-by | Empty result · 404 | Scoped automatically (get_queryset()) |
| REST create | 403 | Row stamped server-side; a body naming a different tenant is a 400, never silently overwritten |
| REST update | n/a (row already unreachable) | A body naming a different tenant is a 400 |
| GraphQL query / node / relation traversal | Empty · not found | Scoped automatically |
Elasticsearch routing (es_search/es_filter/es_aggregate/es_count/es_scan) | A term that can never match a real document | The tenant term is forced into the query, overriding any caller-supplied value for the same field |
| Async export / import job | Job creation refused (403 / a clear CLI error) | Tenant captured on the job, replayed when the worker runs |
| Import column mapping | A column mapped to the tenant field is rejected by name — the tenant comes only from --tenant, never a file | |
| Offline cache payload | Empty payload | That tenant's rows only |
| Audit log read (changelist & timeline) | Rows naming a tenant-scoped model are hidden | Only rows whose target object is currently visible to that tenant |
snapadmin_purge_expired_data / snapadmin_reindex | Deliberately cross-tenant always — retention is time-based and the ES index must stay complete; see below | |
Model.objects without binding a
tenant, a third-party package that queries the table directly. It is not a substitute for a separate
schema or database where that level of isolation is required.
Backups and restores are not tenant-scoped at all — snapadmin.backup
dumps the whole configured database (pg_dump/mysqldump/a raw SQLite file
copy), below the ORM and therefore below this feature entirely. A backup bundle contains every
tenant's data, and restoring one is an all-tenants operation.
The one escape hatch: use_all_tenants()
Reserved for background code whose job is inherently cross-tenant — the retention purge (a row's age decides whether it is purged, not its tenant) and the Elasticsearch reindex (the index must stay complete across every tenant, or a verify pass would mismatch by construction against the whole index). Application code must never reach for it to work around a scoping failure:
from snapadmin.tenancy import use_all_tenants
with use_all_tenants():
Order.objects.all() # every tenant's rows, deliberately and audibly
Cross-tenant jobs under a project's own tenancy
The retention purge, the Elasticsearch reindex (snapadmin_reindex, the admin reindex
API, es_reindex_all()) and the GDPR subject request read every tenant's rows. Each enters
the model's snap_background_context(purpose) — purpose is
"retention", "reindex" or "subject_request" — and the default
returns use_all_tenants(), which is all SnapAdmin's own tenant_scoped needs.
When the rows sit behind a tenancy of the project's own — a manager that refuses to run without its
context, or PostgreSQL row-level security — return that context instead (keep
use_all_tenants() in it too if the model is also tenant_scoped):
from contextlib import ExitStack, contextmanager
from django.db import connection, transaction
class Invoice(SnapModel):
@classmethod
@contextmanager
def snap_background_context(cls, purpose):
with ExitStack() as stack:
stack.enter_context(super().snap_background_context(purpose)) # SnapAdmin's scoping
stack.enter_context(transaction.atomic())
with connection.cursor() as cursor: # RLS: this transaction may see every tenant
cursor.execute("SET LOCAL app.bypass_rls = 'on'")
yield
The reindex runs its whole pass inside the context, so a long reindex holds that transaction
open; a project that would rather not can bind a context variable of its own instead. A plain
@snap_model model may declare the same classmethod; without one it gets
use_all_tenants(). The alternative is to move the model onto tenant_scoped.
Creating rows as an operator bound to every tenant
A resolver may return snapadmin.tenancy.ALL_TENANTS for an operator who works across
tenants — every tenant's rows become readable. Creating a row then needs the tenant it belongs to:
the generated add form shows the tenant field, required, and the REST API takes the
tenant named in the request body (a foreign-key tenant by its key). With no tenant named, the create
is refused exactly as with no binding at all; under a single-tenant binding the field stays hidden and
the bound tenant is forced, as before. The change form never shows it — an operator does not move rows
between tenants. The importer refuses ALL_TENANTS as its tenant (it is a
reading binding, not a tenant), and a row it would create with no single tenant bound fails instead of
becoming an orphan. The audit entry of an operator's create records the tenant chosen.
A NULL tenant is unassigned data, not shared data
The column is nullable so the migration it adds never breaks an existing row — but a row with no tenant value matches no tenant's filter, by ordinary SQL equality semantics. It is simply invisible to every tenant until something assigns it, never a fallback any caller can reach.
Reference
| Attribute | Type | Default | Description |
|---|---|---|---|
tenant_scoped | bool | False | Opts a SnapModel subclass into row-level isolation. A @snap_model-decorated plain model cannot enforce this (see snapadmin.E009 below) — subclass SnapModel instead. |
tenant_field | str | None | None | Name of the tenant column when it is not "tenant_id". |
| Setting | Default | Description |
|---|---|---|
SNAPADMIN_TENANT_RESOLVER | unset | Dotted path to resolver(request) -> tenant value | None. Unset means every request resolves to no tenant. |
SNAPADMIN_TENANT_USER_RESOLVER | unset | Dotted path to resolver(user) -> tenant value | None, used to create an export/import job for a tenant-scoped model. |
| Check | Severity | Fires when |
|---|---|---|
snapadmin.E009 | Error | A model sets tenant_scoped = True but its resolved tenant column does not exist, or the model was registered via @snap_model rather than subclassing SnapModel (unenforceable either way — this check does not demand every registered model declare tenant scoping). |
🏢 Enterprise Config v0.1.0a8
Four settings-driven features that fit SnapAdmin's zero-boilerplate philosophy. Every one is inert on a stock single-database install — it does nothing until you configure it.
? Read-replica routing
Heavy API lists and analytical dashboards can lock up the primary write database. Point SnapAdmin
at a read replica and every auto-generated read-only list/retrieve is pinned to it
via .using(). Writes (POST/PUT/PATCH/DELETE) and the object lookups behind them always
stay on default, so replication lag can never stale or drop a mutation. An empty or
unknown alias is a safe no-op.
# settings.py
DATABASES = {"default": {...}, "read_replica": {...}}
SNAPADMIN_ANALYTICS_DB_ALIAS = "read_replica"
PII data masking
Declare sensitive fields once. They are obfuscated in the admin changelist, dropped from the
admin change form, and masked in both REST API and GraphQL responses for anyone who is not a
superuser or a holder of the snapadmin.view_raw_pii permission — or, when
SNAPADMIN_PII_ACCESS_CHECK is set, anyone that callable
allows. Emails become
a***@domain.com (just ***@domain.com when the local part is one or two
characters, so the first character is never the whole of it), other values +3********78. Masking is enforced on every
path that could otherwise leak the raw value: a masked field can't be targeted by
?field=, ?ordering=field or ?search= for an unprivileged
caller (it is answered exactly like a field that does not exist — a 400 under
strict query parameters, the default), the async export (POST /api/exports/) masks
rows unless the requester holds PII access, and the audit trail's changes diff is
masked in the admin and in snapadmin_audit_export (pass --reveal-pii
for the raw diff). An audit row's object label is str(instance) — often a name or an
email — so on a model with any masked field it reads Customer #12 instead, in the
audit changelist, its search, the change form, the timeline and the export, for anyone who may
not see every masked field of that model raw. Django's own history message stores a masked
field's values masked. A JSON field is masked value by value and keeps its keys; a rule's
constant replacement replaces each value, not the object.
# settings.py
SNAPADMIN_MASKED_FIELDS = {
"users.UserModel": ["email", "phone_number"],
"customers.Profile": ["passport_number", "billing_address"],
}
# Grant trusted staff raw access:
# assign the "snapadmin | api token | Can view unmasked PII data" permission.
A key that doesn't resolve to an installed model
(snapadmin.E001) or a field name that doesn't exist on it
(snapadmin.E002) is a startup error, not a silent no-op — masking a typo'd field would
otherwise fail open. So is a value in the wrong shape (snapadmin.E028): a bare string
instead of a list, a number, a list where a rule map belongs. Where the intent is readable it is
masked anyway — "email" masks the email field rather than the fields
e, m, a…, and a list under
SNAPADMIN_MASKING_RULES masks those fields with the built-in masker — and the check
still stops the deploy until it is written properly.
Who may see raw PII — your rule instead of the permission.
Every masking surface (admin, REST, GraphQL, exports, the audit diff, the subject request) asks one
function, snapadmin.masking.user_can_view_pii. By default it answers with the
snapadmin.view_raw_pii permission; set SNAPADMIN_PII_ACCESS_CHECK to a
callable (user) -> bool (or its dotted path) to decide by an SSO role, a membership
table — whatever the project's access model is. Only an answer that is True
unlocks; an async function is refused (its coroutine would be truthy for everyone). It replaces the permission step only: anonymous and
inactive users stay masked, superusers stay unmasked, and a per-field rule's
permission still unlocks its field. A callable that raises answers "masked" and logs
snapadmin.pii_access_check_failed; one that cannot be imported is
snapadmin.W035 at startup — fail closed, never open.
SNAPADMIN_PII_ACCESS_CHECK = "myproject.access.can_view_pii"
# myproject/access.py
def can_view_pii(user):
return "dpo" in getattr(user, "sso_roles", ())
Masking an app before it is installed. Rolling out
module by module, a project can write the masking of an app that is not installed yet: list its label
in SNAPADMIN_MASKING_PENDING_APPS = ["billing"]. A key of a listed app that is not
installed is reported as the informational snapadmin.I002 ("masked once installed")
instead of E001/E003; once the app is installed — under its label or its module
name — its keys are checked like any other, and snapadmin.W034 asks to remove it from the
list. A field a rule names that the model does not have is snapadmin.E004. Nothing is excused without
the list — a typo in an app label still errors, because it would otherwise unmask the app it meant.
Hand-written admins. The changelist masking
lives in snapadmin.models.PIIMaskingAdminMixin, which generated admins get
automatically. A model you register yourself — a custom user model derived from
AbstractUser is the usual case — needs it as the first base of its
ModelAdmin; without it the admin shows those fields unmasked, which
snapadmin.W025 reports at manage.py check (every AdminSite
is checked, not only the default one). A masked column keeps the field's own
verbose_name, translation included.
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from snapadmin.models import PIIMaskingAdminMixin
class UserAdmin(PIIMaskingAdminMixin, BaseUserAdmin):
...
Per-field rules — SNAPADMIN_MASKING_RULES
The setting above says which fields are sensitive; this one says how each is
obfuscated, and who may see it raw. A rule takes a pattern (a regex applied
with re.sub), a replacement (on its own, a flat redaction of the whole
value), and/or a permission that unlocks that one field for whoever
holds it — without granting the blanket snapadmin.view_raw_pii, which reveals every
masked field of every model.
# settings.py
SNAPADMIN_MASKING_RULES = {
"customers.Profile": {
# keep the last four digits, star the rest → ************1111
"card_number": {"pattern": r"\d(?=\d{4})", "replacement": "*"},
# first two and last two characters only → SE…03
"iban": {"pattern": r"^(\w{2}).*(\w{2})$", "replacement": r"\1…\2"},
# never shown below the field permission
"billing_address": {"replacement": "[redacted]",
"permission": "customers.view_profile_address"},
},
}
Naming a field here also declares it sensitive, so this setting works on its own —
SNAPADMIN_MASKED_FIELDS stays optional and unchanged, and a field listed only there
keeps the built-in masker. Rules apply on every surface that masks: the admin changelist, the REST
serializer, GraphQL, background exports and the audit-log diff. The matching check is
masking.user_can_view_pii(user, "app.Model.field") — the field argument is
additive, so existing calls are unaffected.
Patterns are compiled once and cached. One that
fails to compile, one whose replacement references a group it does not have, one shaped like a
catastrophic-backtracking bomb (a quantified group containing a quantifier, (a+)+), and
any value longer than 4096 characters all fall back to the built-in masker and log the reason —
every failure path degrades to more masking, never to raw data. So does a
pattern that matches nothing in a non-empty value — "keep the first three characters"
against a two-character value would otherwise hand the value back unchanged. A rule naming a model or
field that does not exist would instead fail open, so it is a startup error
(snapadmin.E003–E005) rather than a silent no-op.
🔐 Field-level permission guards — api_field_permissions
Masking says how an already-visible field is displayed; api_field_permissions
says whether it is visible or writable at all — a different, orthogonal question. Declare a
Django permission per field, per side:
class Employee(SnapModel):
salary = snap_fields.SnapDecimalField(max_digits=10, decimal_places=2)
api_field_permissions = {
"salary": {"read": "hr.view_salary", "write": "hr.change_salary"},
}
A caller lacking the named permission never sees the field's existence: absent
from a REST response (not null, not an error — the key itself is gone) or
nulled in GraphQL (the schema is built once at import time, so a per-request field
cannot be removed from the response shape the way REST's serializer can — a documented,
deliberate asymmetry, not an inconsistency). A denied write answers an explicit
400 naming the field, because a silently dropped write is a data-loss bug the caller
cannot detect — a required field a role can't write simply cannot be created by that role, which is
the correct, if blunt, outcome.
| Order | Guard | On denial |
|---|---|---|
| 1 | api_exclude_fields (absolute) |
Field does not exist on the serializer/type at all — nothing below this ever runs for it. |
| 2 | api_field_permissions (this feature) |
Read: absent/null (see above). Write: 400 naming the field. |
| 3 | api_write_fields allowlist |
Write only: forced read-only, client value silently ignored — its own, older, deliberately different contract; this feature does not change it. |
| 4 | SNAPADMIN_MASKING_RULES per-field permission |
Field present but starred/redacted unless the grant unlocks it. |
Rows 2 and 4, precisely: the permission gate decides whether the field appears at all; masking decides whether what appears is raw or starred. Gate first, mask second — a field can carry both a permission rule and a masking rule at once, and the two compose in that fixed order rather than racing.
Works on both doors: a SnapModel subclass sets it as a plain class attribute; a
@snap_model-decorated plain model sets it via
registry.register(Model, api_field_permissions={...}) (the decorator's own
api_field_permissions= keyword is a follow-up). Applied so far to the two surfaces an
external API consumer actually reaches — REST (the serializer, plus the
?field=/?ordering=/?search= oracle-prevention filters, same
precedent as PII masking above) and GraphQL; the admin form and background export
are a follow-up.
SSO / OAuth2 login buttons
SnapAdmin only renders the providers you already wired into
AUTHENTICATION_BACKENDS / URLconf (django-allauth, social-auth, mozilla-django-oidc) —
it adds no auth dependency. Configure once and get login-page buttons plus a public
GET /api/sso-providers/ endpoint for headless frontends.
# settings.py
SNAPADMIN_SSO_PROVIDERS = {
"azure": {"label": "Login with Microsoft Enterprise", "url": "/accounts/azure/login/"},
"keycloak": {"label": "Corporate Keycloak SSO", "url": "/api/v1/auth/keycloak/"},
}
# TEMPLATES → OPTIONS → context_processors: add "snapadmin.sso.sso_providers"
# In your admin/login.html override: {% include "snapadmin/sso_buttons.html" %}
A provider entry with no url, a protocol-relative url, or an absolute
url outside SNAPADMIN_SSO_ALLOWED_HOSTS (when that list is non-empty) is
dropped from the rendered buttons rather than shown broken — unset (the default) applies no host
restriction, since most deployments legitimately point providers at an external identity provider.
manage.py check catches all three at startup: a missing url is
snapadmin.W003; a protocol-relative or off-allowlist url is
snapadmin.W005.
? Admin-index nesting
Keep the admin index uncluttered: fold auto-generated sections under existing app groups, hide
groups, or rename headings — no custom AdminSite required.
# settings.py
SNAPADMIN_NESTED_APPS = {"snapadmin": "auth"} # move snapadmin models under "auth"
SNAPADMIN_HIDDEN_APPS = ["silk"] # hide these groups from the index
SNAPADMIN_APP_LABELS = {"auth": "Administration"} # rename a group heading
A SNAPADMIN_NESTED_APPS target that
names no installed app is snapadmin.W002 at manage.py check — advisory,
since the affected models simply stay under their own group until the target app exists.
SNAPADMIN_HIDDEN_APPS is cosmetic, not access control. It removes an
app group from the rendered admin index page only — the underlying ModelAdmin URLs
(/admin/<label>/<model>/add|change|delete/ etc.) stay registered and
reachable by any staff user who holds the model's Django permission. To actually restrict who can
reach a model, use Django's standard permission system (user_permissions / groups, or
a custom ModelAdmin.has_*_permission) — hiding a group here is purely a decluttering
convenience. These settings also only patch django.contrib.admin.site (the default
AdminSite); a project serving /admin/ from a different AdminSite
instance won't see them applied there — manage.py check warns
(snapadmin.W006) when that's detectable.
🧩 Module-level permissions — a recipe, not a subsystem
A project that grants staff rights per business module ("Helpdesk", with sub-packages an administrator assigns) does not need a permission model above Django's: Django's groups and custom permissions already are that layer, and every SnapAdmin surface already checks Django permissions. SnapAdmin deliberately does not ship a second, parallel system — two permission models that can disagree are a security bug waiting to happen. The recipe:
# helpdesk/models.py — one permission-only model per module: no table, just codenames
class HelpdeskModule(models.Model):
class Meta:
managed = False
default_permissions = () # no add/change/delete/view for a table that isn't there
permissions = [
("use_helpdesk", "Can use the Helpdesk module"),
("helpdesk_tickets", "Helpdesk: tickets"), # one per sub-package
("helpdesk_knowledge_base", "Helpdesk: knowledge base"),
]
# A data migration (or the admin) creates one Group per role and assigns the codenames:
# "Helpdesk agent" → use_helpdesk + helpdesk_tickets
# "Helpdesk lead" → everything above + helpdesk_knowledge_base
- Your own views:
@permission_required("helpdesk.helpdesk_tickets")orrequest.user.has_perm(...)— plain Django. - A generated REST action:
@snap_action(permission="helpdesk.helpdesk_tickets")gates it on the module permission instead of the model's own. - A field inside a model:
api_field_permissionsnames the module permission per field and side. - The admin: override
has_view_permission/has_change_permissionthroughadmin_overridesto require the module permission as well as the model's. - API tokens stay AND-ed with their owner's permissions, so a token can never reach a module its user is not in.
Unalterable audit trail DORA / ISO 27001
Django's built-in LogEntry is minimal and editable straight from the DB. SnapAdmin
records a richer, append-only SnapadminAuditLog for every admin create/update/delete —
who (actor + IP + User-Agent), what (target object + before/after
field diff) and when (tz-aware timestamp). Rows are immutable at the ORM level
(save/delete raise once persisted) and the admin is fully read-only.
# settings.py — on by default
SNAPADMIN_AUDIT_LOG_ENABLED = True
SNAPADMIN_AUDIT_RETENTION_DAYS = 365
# Export for a SIEM (newline-delimited JSON or CSV):
python manage.py snapadmin_audit_export --format json > audit.jsonl
python manage.py snapadmin_audit_export --since 2026-01-01 --action delete
python manage.py snapadmin_audit_export --purge # also prune rows past retention
For defence against direct database tampering, layer a DB trigger / append-only role on top — the ORM guard protects the admin and application paths.
Reading the trail — diffs and the per-object timeline
The diff is stored as {"field": {"old": …, "new": …}} and rendered in the admin as a
field-level table: one row per field, the value before, the value after. Values keep their
JSON-native type, so 42 stays distinguishable from "42"; anything without a
JSON representation (Decimal, dates, UUIDs) is stored as text, and a JSON field's list
or object is stored as JSON. A relation is stored by its key on both sides
({"old": 3, "new": 7}) and a many-to-many as the sorted list of primary keys
({"old": [2], "new": [2, 5]}) — so re-saving the same selection in another order records
nothing, and moving a row to a namesake still does. Keys, never the related object's label: a label
is whatever that model's __str__ prints, often a name or an email, and the trail's
masking only knows the audited model's own fields. The
old/new key names are part of the on-disk format and will not change.
Each entry's Object column links to that object's timeline — every change ever recorded for it, newest first, each as the same diff table:
/admin/snapadmin/snapadminauditlog/timeline/<app_label>/<model>/<object_id>/
Both views mask through SNAPADMIN_MASKED_FIELDS and
SNAPADMIN_MASKING_RULES, and both are gated on the audit
log's own view permission — the same one that already lists these rows — so reading the trail is
never a way around masking. That includes the entry's object label: on a model with a masked
field it is shown as <Model> #<pk> to a reader who may not see that
model's PII, and is not searchable for them. A long history is capped at the 100 most recent entries per page
(override timeline_max_entries on the model admin);
snapadmin_audit_export remains the way to read all of it.
Large-dataset performance
Every SnapAdmin admin auto-derives list_select_related from the FK columns it shows,
and the REST API auto-select_relateds FKs and prefetch_relateds M2M — no
N+1, zero config. On multi-million-row tables the changelist's SELECT COUNT(*) is the
costliest query; EstimatedCountPaginator swaps it for PostgreSQL's instant
reltuples estimate on unfiltered listings, staying exact for small or
filtered views and non-PostgreSQL databases.
# settings.py — on by default
SNAPADMIN_ESTIMATED_COUNT = True
SNAPADMIN_ESTIMATED_COUNT_THRESHOLD = 100000 # estimate only above this row count
# Per model: also skip the second, unfiltered COUNT(*) the admin runs for "X total"
class BigModel(snap_models.SnapModel):
show_full_result_count = False
Async background export Celery
Large synchronous exports time out. SnapAdmin offloads them to a Celery worker that streams a
model's rows to CSV, JSON or XLSX. The line-based formats (csv, json) are
written in resumable chunks, paged by a primary-key cursor
rather than OFFSET so a concurrent insert/delete elsewhere in the table can't shift the
window and skip or duplicate a row. Each chunk is written and fsync-ed to disk
before its checkpoint is persisted, so a worker crash between the two can only leave an
unconfirmed tail to discard on resume — never a duplicate. Jobs are also single-flight
(an atomic pending/failed → processing claim stops a redelivered
task or a manual re-trigger from running the same job on two workers at once) and
cancellable mid-run. A worker that crashes mid-processing leaves the job
stuck there until an operator resets it to pending to retry — there is no
heartbeat/TTL auto-recovery. Jobs are private to their requester; the caller needs the target
model's view permission. Finished files are published through Django's storage API
(SNAPADMIN_EXPORT_STORAGE, a dotted Storage class) rather than read off the
worker's local disk, so download/ works even when the web process and the worker don't
share a filesystem; left unset it defaults to local FileSystemStorage, unchanged from
before.
curl -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"app_label": "demo", "model": "Product", "export_format": "csv"}' \
http://localhost:8000/api/exports/
# → {"id": "…", "status": "pending", "progress_percent": 0, "eta_seconds": null, …}
# Same job as a spreadsheet — needs pip install django-snapadmin[xlsx]
curl -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"app_label": "demo", "model": "Product", "export_format": "xlsx"}' \
http://localhost:8000/api/exports/
XLSX — the container format extra
export_format="xlsx" writes a real workbook via openpyxl, installed with
the [xlsx] extra. Values keep their types, so price
arrives as a number Excel can sum and a DateTimeField as a date — converted to the
project's current timezone and stripped of its tzinfo, which Excel has no concept of.
Text that begins with = is stored as text, never as a formula: exported
row data must not become something a spreadsheet executes when the file is opened.
A workbook is a zip archive that only becomes readable once it is closed, so it cannot be appended to chunk by chunk the way a CSV can. Rows are streamed into a temporary spool (memory stays at one chunk, whatever the export's size) and the finished workbook is moved into place in one step. Two consequences worth knowing before you pick the format:
- No resume. A re-dispatched XLSX job re-exports from the first row instead of continuing from its checkpoint — progress and cancellation work as usual, resume does not.
- No partial download. A cancelled or failed XLSX job leaves no file at all, where a cancelled CSV leaves the rows it managed to write.
Requesting xlsx without the extra is rejected by POST /api/exports/ with a
400 naming it, rather than accepted as a job that could only fail in the worker.
Custom row sources — SNAPADMIN_EXPORT_SOURCES
By default an export streams model.objects.filter(**filters) as raw column rows. Three
shapes don't fit that: a result set defined by a structured Elasticsearch query (routing
it through filters would force the DB-fallback scan the ES query exists to avoid); an
explicit key list ("export exactly these records" — encoding it as a __in
filter re-evaluates a giant clause on every cursor page); or a custom document shape (not
raw values() rows). Register a source and the writer keeps everything else — the resumable
pk-cursor chunking, progress, cancellation and storage — while your source owns only what rows to
emit and how each looks:
# settings.py — {name: "dotted.path.to.factory"}, factory(job) -> source
SNAPADMIN_EXPORT_SOURCES = {
"product_catalog": "myapp.exports.product_catalog_source",
}
# myapp/exports.py
class ProductCatalogSource:
def __init__(self, job):
qs = Product.objects.all()
if job.filters:
qs = qs.filter(**job.filters)
self._qs = qs.order_by("pk")
def field_names(self): # CSV header / row-dict keys
return ["id", "catalog_line"]
def count(self): # drives progress / ETA
return self._qs.count()
def iter_batches(self, *, cursor, chunk_size):
# yield (rows, next_cursor); next_cursor is checkpointed and passed
# back on resume, so continue deterministically from it. Mask here.
while True:
page = self._qs.filter(pk__gt=cursor) if cursor is not None else self._qs
rows = list(page[:chunk_size].values("id", "name", "price"))
if not rows:
return
batch = [{"id": r["id"], "catalog_line": f'{r["name"]} (${r["price"]})'} for r in rows]
cursor = str(rows[-1]["id"])
yield batch, cursor
def product_catalog_source(job):
return ProductCatalogSource(job)
Create the job with source="product_catalog" (a field on SnapExportJob) and it
runs through your source instead of the ORM default; a blank source is byte-for-byte the
built-in export. The demo registers exactly this product_catalog source
(demo/apps/shop/export_sources.py). An unknown source name fails the job cleanly (never the
worker). Because your source owns PII masking on the rows it emits, apply masking there if the
document exposes protected fields — use snapadmin.masking.mask_field(app_label, model, field,
value, user), the same choke point the built-in source goes through, so
configured rules apply to your rows too.
🗄️ Database Sharding & Replica Routing
A separate, more general mechanism from the single-alias read-replica routing above — for a
project partitioning data across multiple physical databases, not just steering
reads to one replica. Inert unless configured: with SNAPADMIN_SHARDING unset or
ENABLED: False, nothing changes — no new DATABASES entry, no extra
router, no query overhead.
Two configuration shapes resolve to the same thing. The simple path — a flat list of DSNs the module slices into shards/replicas automatically:
# settings.py
SNAPADMIN_SHARDING = {
"ENABLED": True,
"SHARDING_ENABLED": True, # partition data across shards
"MIRRORING_ENABLED": True, # give each shard read replicas
"REPLICAS_PER_SHARD": 1, # 1 primary + 1 replica per shard, sliced in order
"DATABASES": [
"postgres://user:pass@s1-primary:5432/db",
"postgres://user:pass@s1-replica:5432/db",
"postgres://user:pass@s2-primary:5432/db",
"postgres://user:pass@s2-replica:5432/db",
],
}
Or full manual control — an explicit mapping naming exactly which DSN is which shard's primary/replica:
SNAPADMIN_SHARDING = {
"ENABLED": True,
"STRATEGY": "range", # modulo | hash | range | custom
"SHARDS": {
"shard_1": {
"PRIMARY": "postgres://user:pass@s1-primary:5432/db",
"REPLICAS": ["postgres://user:pass@s1-replica:5432/db"],
"RANGE": (0, 1000000), # only read for STRATEGY == "range"
},
"shard_2": {
"PRIMARY": "postgres://user:pass@s2-primary:5432/db",
"RANGE": (1000001, 2000000),
},
},
}
A model opts in with shard_key — the field to shard by, or True for the
project-wide default (SNAPADMIN_SHARDING['SHARD_KEY'], default "id") —
mirroring how tenant_scoped opts a model into
multi-tenancy. No model, including Django's own auth/sessions/
admin tables, is ever routed across shards without explicitly asking:
class Order(SnapModel):
shard_key = "customer_id"
...
? Forcing routing for one block of code
snap_master_only() forces every read and write in scope onto each shard's
primary, bypassing replica selection and failover — for code that must see its own just-written
data immediately. snap_target(shard="shard_2", replica=True) forces routing onto one
named shard regardless of the row's own shard key. Both work as a context manager or as a decorator,
on a plain function or an async def one alike:
from snapadmin.sharding.decorators import snap_master_only, snap_target
with snap_master_only():
Order.objects.filter(id=order_id).update(status="paid")
assert Order.objects.get(id=order_id).status == "paid" # no replication lag
@snap_target(shard="shard_2")
def read_from_shard_2():
return list(Order.objects.all())
manage.py snap_migrate runs migrations against every shard's primary
only — sequentially, or all at once with --parallel — and never touches a replica,
since replication already propagates schema at the database layer.
manage.py snapadmin_db_backup does the same for backups: with sharding enabled, the
"db" bundle part becomes one independently-checksummed
"db.<shard_name>" part per shard's primary.
| Setting | Default | Description |
|---|---|---|
SNAPADMIN_SHARDING['ENABLED'] | False | Master switch — everything below is inert until this is True. |
STRATEGY | "modulo" | modulo / hash (CRC32) / range / custom (a dotted CUSTOM_ROUTER_FUNC). |
SHARD_KEY | "id" | Project-wide default field name for a model whose own shard_key is True. |
REPLICA_SELECTION | "round_robin" | random / round_robin / first_available. |
HA_SETTINGS.AUTO_FAILOVER | False | Promote a live replica to serve writes when the primary is unreachable. Only point this at a replica that can actually be promoted. A read-only standby rejects the write anyway (PostgreSQL reports cannot execute INSERT in a read-only transaction, which reads as an application bug rather than "the primary is down"); a replica that does accept writes — MySQL without super_read_only — diverges from the primary and loses those rows when replication resumes. Left False, a downed primary surfaces as a plain connection error. |
HA_SETTINGS.HEALTH_CHECK_TIMEOUT | 1.0 | Seconds before a TCP reachability probe gives up on a host. |
HA_SETTINGS.FALLBACK_TO_PRIMARY | True | Read from the primary when every replica is down; False raises instead. |
| Check | Severity | Fires when |
|---|---|---|
snapadmin.E013 | Error | SNAPADMIN_SHARDING is enabled but a DSN or the SHARDS/DATABASES shape cannot be resolved. |
snapadmin.E014 | Error | An unrecognised STRATEGY, or 'custom' with no importable CUSTOM_ROUTER_FUNC. |
snapadmin.E015 | Error | An unrecognised REPLICA_SELECTION. |
snapadmin.E016 | Error | STRATEGY == "range" with a shard missing its RANGE, or two overlapping ranges. |
📥 Bulk Import
The write-side counterpart to async export, mirroring its architecture (chunking, progress,
resumability) for the opposite direction: reading rows from a CSV or NDJSON file into a model, via
manage.py snapadmin_import and a SnapImportJob (see
snapadmin.importing for the full contract).
python manage.py snapadmin_import --model demo.Product --file products.csv
python manage.py snapadmin_import --model demo.Product --file p.json --format json
python manage.py snapadmin_import --model demo.Product --file p.csv \
--map '{"Product Name": "name"}' --natural-key name --on-conflict update
python manage.py snapadmin_import --model demo.Product --file products.csv --resume
- Column mapping is header-name matching by default (case/whitespace/underscore-
insensitive, against both the field name and its
verbose_name), plus an optional explicit--mapthat wins wherever a header is named in it. An unmapped column is reported in the run's summary and skipped — never guessed at, never a hard failure. - The duplicate key (
--natural-key) is a field name or a comma-separated list. Left unset it defaults to the model's firstunique=Truefield, or the primary key if the file actually carries a mapped column for it; with neither, every row is a create (there is nothing to detect a duplicate with). --on-conflictisfail(the default),skiporupdate. The default isfail— an import that silently overwrites production rows because nobody passed a flag is the same class of bug this project's write-surface hardening exists to close. A "fail" duplicate is reported as a failed row, never a failed run — one bad or duplicate row must never abort a run processing a million others.- Validation runs through the model's own field validators —
full_clean()on the constructed (or updated) instance, so everySnap*Field's extension/size/format rule applies for free. No parallel validation layer. - The report is one NDJSON line per row —
{"row": N, "action": "created"|"updated"|"skipped"|"failed", "pk": …, "errors": {...}}— plus one{"summary": {...}}line at the end, written through the same storage seam the export API downloads from. Its path is printed when the command finishes. - Crash-safe, chunk-checkpointed resume (
--resume): every row's write, the job's own progress counters and the report's confirmed byte length commit together as one transaction per chunk — a crash loses at most one chunk's progress, and a resumed run can never re-create a row an earlier attempt already committed. - A write surface from the first line, not a follow-up. A column targeting a field
outside
api_write_fields, insideapi_exclude_fields, or a masked/PII field with no--requested-byuser holding PII access, fails the whole run up front, naming the field — never a silent write. A model that isapi_read_only(or whoseapi_http_method_namesexcludes every write verb) refuses the import entirely.
🧩 Integrating with Your Project
SnapAdmin drops into a real project without bridge code — authentication, the user model and the Elasticsearch client are all pluggable, and imports have a first-class helper.
Pluggable API authentication (JWT / session / custom)
By default the API accepts SnapAdmin's own token auth. Point
SNAPADMIN_API_AUTHENTICATION_CLASSES at any DRF authenticators — model CRUD, schema
and token endpoints all honour it. With non-token auth, model permissions fall back to plain
Django model permissions (a token additionally applies its allowed_models scope):
Every viewset action is mapped to one permission, and an
unmapped action is refused. List/retrieve/count/export/fetch_by
and OPTIONS need view; create needs add; update needs
change; destroy needs delete. An action the map does not know is denied
for everyone, superusers included — it used to fall back to view, which would open any
new write action to read-only users the moment someone forgot to map it. A project that adds
its own action to a subclassed DynamicModelViewSet maps it explicitly; the built-in
entries always win, and only view/add/change/delete
are accepted (@snap_action actions carry their own permission and need no entry):
SNAPADMIN_API_ACTION_PERMISSIONS = {"recalculate": "change", "preview": "view"}
SNAPADMIN_API_AUTHENTICATION_CLASSES = [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
"snapadmin.api.authentication.APITokenAuthentication", # keep tokens working too
]
JWT in three steps (djangorestframework-simplejwt):
# 1. settings.py — add the authenticator above + the app
INSTALLED_APPS += ["rest_framework_simplejwt"]
# 2. urls.py — obtain / refresh endpoints
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
urlpatterns += [
path("api/token/", TokenObtainPairView.as_view()),
path("api/token/refresh/", TokenRefreshView.as_view()),
]
# 3. authenticate, then call any SnapAdmin endpoint with the Bearer token
# curl -H "Authorization: Bearer $ACCESS" .../api/models/demo/Product/
SNAPADMIN_GRAPHQL_REQUIRE_AUTH) and already accepts SnapAdmin tokens + sessions.
? Custom user model, the ES client, and the bulk reindex command
Custom user model
APIToken and everything built on it target settings.AUTH_USER_MODEL,
so a project with a custom user model works out of the box (usernames read via
get_username(), so USERNAME_FIELD = "email" is fine).
Configurable Elasticsearch client
ELASTICSEARCH_KWARGS = { # merged into Elasticsearch(...)
"api_key": "…", "ca_certs": "/etc/ssl/es.pem",
"request_timeout": 30, "max_retries": 3, "retry_on_timeout": True,
}
# …or full control (cloud_id, sniffing, custom transport):
SNAPADMIN_ES_CLIENT_FACTORY = "myproject.es.make_client" # zero-arg callable → client
One client per process. The client built from
ELASTICSEARCH_URL/ELASTICSEARCH_KWARGS is created once per worker process
(a forked worker builds its own) and reused by every save, delete and search; changing one of those
settings builds a new one. A factory you configure is called as written — it owns its own caching.
An index's existence and mapping are ensured once per process before the first
document, not before every save (that was two extra round-trips per write, one of them a
cluster-state update); a failed indexing call forgets it, so the next save ensures again. Deleted an
index by hand? Run snapadmin_reindex — otherwise Elasticsearch may auto-create it with a
dynamic mapping on the next write.
Bulk reindex command
python manage.py snapadmin_reindex # all ES-enabled SnapModels
python manage.py snapadmin_reindex --model demo.Product # one model
python manage.py snapadmin_reindex --chunk-size 1000 # tune bulk batch size
python manage.py snapadmin_reindex --limit 1000 # probe run — first 1000 rows only
python manage.py snapadmin_reindex --tune # relax refresh/replicas for the load
python manage.py snapadmin_reindex --no-tune # force tuning off (overrides the setting)
python manage.py snapadmin_reindex --parallel 4 # fan out with helpers.parallel_bulk
python manage.py snapadmin_reindex --resume # continue a crashed run from its checkpoint
python manage.py snapadmin_reindex --verify # count the index against the source afterwards
python manage.py snapadmin_reindex --progress-interval 30 # at most one progress line per 30s
Every run is tracked on a SnapReindexJob row (the async-export job pattern), so a large
reindex is observable, resumable, and cancellable: the command prints
processed/total (percent%) ETA Ns per chunk; DB-backed models are paged by a
pk__gt cursor checkpointed after each chunk, so --resume continues a crashed
run from where it stopped instead of restarting the table (idempotent — each document is written under
_id = pk, so a resumed or restarted run only overwrites, never duplicates);
--tune disables the index refresh and drops replicas to 0 for the load and restores both in
a finally; --parallel N indexes each chunk with helpers.parallel_bulk;
and setting the job's status to cancelled stops the run between chunks. ES_ONLY models have
no DB pk to cursor over, so they reindex in a single pass (no resume).
Two efficiency/ergonomics touches for wide tables and mass loads:
- Fetches only the ES-mapped columns. Each chunk is loaded with
.only(*mapped, pk)— a document is built from just the primary key plus the mapped fields, so a table's large unmappedTEXTbodies are never dragged through the reindex. This is automatic and needs no flag; it is skipped only when a mapping key isn't a plain concrete column (a property or relation-spanning key), where deferring columns could cause per-row queries — then the run falls back to fetching every column (today's behaviour). --limit Nreindexes only the firstNrows — a probe/canary run to sanity-check a mapping change or throughput before committing to a full load. The ES request stays bounded and progress is measured against the limit.- Configurable tuning default.
--tunedefaults to theSNAPADMIN_REINDEX_TUNE_DEFAULTsetting (defaultFalse), so a project that always wants a mass load to relax the index can set it once;--no-tuneforces it off for a single run. --progress-interval SECONDS(default5) throttles the per-chunk progress line to at most one per interval, so a multi-hour run in a detached container doesn't fill the log with tens of thousands of lines. The very first line and the line reporting a model's completion, cancellation or failure always print, whatever the interval — a run's outcome is never swallowed by the throttle.--verifyasks Elasticsearch for the index's actual document count once a model finishes and compares it against the source row count the run itself recorded (with--limit, that's the limited count). Documents Elasticsearch rejected are subtracted from the expected count first — they were never going to be there, and reporting them as "missing" would make the check cry wolf.ES_ONLYmodels have no independent source to compare against and are reported as not applicable rather than a false mismatch. A mismatch exits non-zero (CommandError, same as any other per-model failure) — the whole point is that a short index must stop looking like a clean run.
Reindex over HTTP (admin only, opt-in)
The same bulk reindex, for ops without shell access — off by default, staff-only
(IsAdminUser). Enable it and optionally offload to Celery:
SNAPADMIN_REINDEX_API_ENABLED = True # the endpoint 404s until you set this
SNAPADMIN_REINDEX_API_ASYNC = False # True → snapadmin.run_es_reindex Celery task (202 + task_id)
{"chunk_size": N}Returns 200 with a per-model summary (sync) or 202 + a task_id
(async). Async without Celery installed responds 503; non-staff callers get 403.
Generic ETL — external source → SnapModel upsert
Import from any external system with a streamed bulk upsert — no per-row saves, no per-row ES
writes; one bulk reindex at the end. Idempotent via
bulk_create(update_conflicts=True):
from snapadmin.etl import upsert_from_source
def rows_from_remote(): # any iterable of dicts (e.g. a streamed cursor)
for r in remote_cursor:
yield {"code": r.code, "base": "EUR", "rate": r.rate}
summary = upsert_from_source(
ExchangeRate, rows_from_remote(),
unique_fields=["code"], # conflict target (needs a unique constraint)
batch_size=1000,
)
# {"processed": 5231, "batches": 6, "reindex": {"indexed": 5231}}
It bypasses Model.save(), so full_clean() is not run — validate
upstream for speed. Demo: python manage.py sync_exchange_rates.
Works on PostgreSQL, SQLite and MySQL/MariaDB: unique_fields is always the
documented conflict target, but on MySQL/MariaDB (which upsert through
ON DUPLICATE KEY UPDATE) it is inferred from the matching unique index rather than
passed explicitly — SnapAdmin branches on the backend so the same call runs everywhere.
Pruning rows the source dropped — stale_sync()
The upsert only writes rows the source reports. A recurring full-table sync usually
also needs to delete the rows it stopped reporting. stale_sync() is that
delete half — with a guard against the footgun where a truncated or half-downloaded feed wipes
almost the whole table:
from snapadmin.etl import upsert_from_source, stale_sync
seen = {r.code for r in remote_cursor} # natural keys present in THIS sync
upsert_from_source(ExchangeRate, rows_from_remote(), unique_fields=["code"])
stale_sync(
ExchangeRate, seen,
key_field="code", # the same unique natural key
max_fraction=0.1, # refuse if >10% would be deleted
)
# {"total": 5231, "stale": 4, "deleted": 4, "fraction": 0.0008, "dry_run": False}
If the stale rows exceed max_fraction of the candidate rows, nothing is
deleted and StaleSyncAbort is raised (its .stale/.total/
.fraction let you alert, retry the fetch, or deliberately override). Pass
max_fraction=1.0 to disable the guard entirely (allow an unbounded delete — the explicit
no-guard override). Pass dry_run=True to preview the counts, or queryset= to
scope the sync to one source's slice of a shared table so rows owned by others are never treated as
stale. For a DUAL/ES-mirrored model the deleted rows are cleared from Elasticsearch in the
same bulk call (no two-phase commit — the database delete lands first, mirroring
purge_expired()). Demo:
python manage.py sync_exchange_rates --only 7 --prune.
Non-raising skip mode — on_exceed="skip"
An unattended job often wants to log-and-continue rather than crash when the guard trips. Pass
on_exceed="skip" and stale_sync() returns the summary with
deleted=0 and aborted=True instead of raising — you decide whether to alert
on it. The default stays on_exceed="raise".
result = stale_sync(ExchangeRate, seen, key_field="code", on_exceed="skip")
if result["aborted"]:
logger.warning("rates prune skipped: %.0f%% stale", result["fraction"] * 100)
Scaling past an in-memory key set — strategy="last_seen"
The default strategy="keyset" diffs the local natural keys against
seen_keys in Python — simple and exact, but it holds the whole key column and the
passed seen_keys in memory. On a table too large to diff that way, switch to the DB-side
watermark strategy: stamp every row the sync still reports with the run's start time in
a timestamp column, then delete the rows left below it — no natural-key set is ever
materialised.
run_started = timezone.now()
# the upsert stamps last_seen = run_started on every reported row …
upsert_from_source(ExchangeRate, rows_with_last_seen(run_started), unique_fields=["code"])
# … then prune everything the sync left behind, entirely DB-side:
stale_sync(
ExchangeRate,
strategy="last_seen",
last_seen_field="last_seen",
run_started=run_started,
max_fraction=0.1,
)
Rows whose last_seen is NULL (never synced) are treated as not
stale, so populate the column on every sync for the guard to be reliable. The max_fraction,
on_exceed, queryset, dry_run and the ES-mirror clear all work the
same as the keyset strategy. Demo:
python manage.py sync_exchange_rates --only 8 --prune --strategy last_seen.
Optional user-management API (admin-only)
Set SNAPADMIN_USER_API_ENABLED = True for admin-only user/permission endpoints —
useful when building a frontend admin panel. Every endpoint requires a staff user:
🎛️ SNAPADMIN_PROFILE Presets
A new project has roughly 90 SNAPADMIN_* settings to decide before it has a
sensible install. SNAPADMIN_PROFILE collapses that to one line by picking sane
defaults for the handful of settings that actually differ depending on how you use the
package — everything else keeps its built-in default regardless of profile.
# settings.py
SNAPADMIN_PROFILE = "admin" # "admin" | "api" | "full" (default: unset — no profile applied)
admin— admin UI only. Turns REST, GraphQL, Swagger and GraphiQL off, for a project that only wants the generated Django admin and nothing else exposed over HTTP.api— REST + GraphQL on, admin used minimally. Turns REST, GraphQL and Swagger on explicitly; GraphiQL followsDEBUG. Settings-identical tofulltoday — the two differ as declarations of intent, and as the places future api- or admin-specific settings will attach to.full— every generated surface on: REST, GraphQL and Swagger, with GraphiQL followingDEBUG.
Precedence: an explicit setting always wins. A profile only fills in a
setting nothing else configured — get_setting(name, default) checks, in order: an
explicit Django setting, then the active profile's preset, then the built-in default. Leaving
SNAPADMIN_PROFILE unset skips the profile step entirely, so every existing install
resolves exactly as it did before this feature existed — that is the upgrade guarantee, and it
is pinned by a test that walks every setting the package reads.
Since 0.1.0b8, full and "no profile" are no longer the same thing.
They used to be: every preset value equalled the built-in default, so full was a
documented no-op. Then SNAPADMIN_REST_API_ENABLED and
SNAPADMIN_GRAPHQL_ENABLED flipped to False (see
the 1.0 migration guide), and a profile that merely mirrored the
defaults would have inverted with them — api, whose whole purpose is "REST + GraphQL
on", would have turned them off. Each profile now states its values outright, so a profile means
what its name says no matter what the defaults do. Leaving SNAPADMIN_PROFILE unset
is still exactly the pre-profile behaviour.
| Setting | admin | api | full | no profile (built-in default) |
|---|---|---|---|---|
SNAPADMIN_REST_API_ENABLED | False | True | True | False |
SNAPADMIN_GRAPHQL_ENABLED | False | True | True | False |
SNAPADMIN_SWAGGER_ENABLED | False | True | True | False |
SNAPADMIN_GRAPHIQL_ENABLED | False | follows DEBUG | follows DEBUG | follows DEBUG |
every other SNAPADMIN_* setting | unchanged — same built-in default in every profile | |||
admin isn't a setting
Elasticsearch is enabled per model (es_storage_mode on the model, not a global
SNAPADMIN_* toggle — see Elasticsearch Storage Modes),
so there is no global setting for a profile to flip. Running in the admin profile
means simply not opting any model into es_storage_mode; it is a usage choice, not
an enforced gate.
A misconfigured profile is caught at manage.py check time rather than failing
silently: an unrecognised SNAPADMIN_PROFILE value is snapadmin.E006,
and an explicit setting that quietly overrides what the active profile would otherwise set is
snapadmin.W009 — both advisory-friendly, since overriding a profile on purpose is a
normal, supported thing to do. See Environment Variables Reference for
every check id.
🔧 Environment Variables Reference
Every SNAPADMIN_* knob is a plain Django setting — you can set it directly in settings.py. The demo project additionally reads them from an env file: copy demo/dist.env to demo/.env and edit it there.
SNAPADMIN_* rows apply to any project that installs the package. The infrastructure rows (POSTGRES_*, REDIS_URL, TRAEFIK_*, SNAPADMIN_AUTO_SEED) belong to the bundled demo's Docker stack, not to the installable package.
? Every setting, grouped, with its default
Django & infrastructure (demo stack)
| 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 the Celery broker and result backend |
SNAPADMIN_AUTO_SEED | False | Auto-run seed_demo on startup (demo only) |
SNAPADMIN_SEED_ADMIN_PASSWORD | — | Password for the seeded superuser; the admin/admin default is allowed only with DEBUG=True |
Feature toggles
| Variable | Default | Description |
|---|---|---|
SNAPADMIN_REST_API_ENABLED | False | Serve the REST CRUD endpoints |
SNAPADMIN_SWAGGER_ENABLED | SNAPADMIN_REST_API_ENABLED | Serve Swagger UI + ReDoc — follows the REST setting unless set explicitly |
SNAPADMIN_GRAPHQL_ENABLED | False | Serve the GraphQL endpoint |
SNAPADMIN_API_ACTION_PERMISSIONS | {} | Map a project's own viewset action to the model permission it needs (view/add/change/delete); an unmapped action is refused (see Integrating) |
SNAPADMIN_TOKEN_ADMIN_ENABLED | None (auto) | Whether register_all_admins() offers the APIToken admin. Unset, it follows the APIs that accept tokens (REST or GraphQL on); True/False force it (see Admin Registration) |
SNAPADMIN_GRAPHIQL_ENABLED | DEBUG | GraphiQL playground — keep it out of production |
SNAPADMIN_GRAPHQL_REQUIRE_AUTH | True | Require auth + per-model permissions on every GraphQL resolver |
SNAPADMIN_URL_PREFIX | "" | Extra path segment prepended to every SnapAdmin route (relocates the whole API/GraphQL/Swagger surface) |
SNAPADMIN_DASHBOARD_PUBLIC | False | Serve the system dashboard without the default staff gate |
SNAPADMIN_USER_API_ENABLED | False | Serve the admin-only user-management API (/api/users/, /api/permissions/) |
SNAPADMIN_THEME_AUTH_ADMIN | True | With the optional Unfold theme installed, re-register Django's stock User/Group admins with Unfold's theme and forms (see Themed auth admin) |
SNAPADMIN_SHOW_IN_FORM_DEFAULT | False | Project-wide default for every Snap*Field's show_in_form — raise it when adopting SnapAdmin onto models that never set the flag per field; an explicit per-field value still wins (snapadmin.W015 flags a model whose form would render empty regardless of the cause) |
SNAPADMIN_CONNECTIVITY_ENABLED | False | Load the admin-wide health-poll/save-guard/sidebar-badge layer (snapadmin/js/connectivity.js) — only takes effect when at least one registered model also has offline_mode = True (see Offline Mode) |
SNAPADMIN_LIMITS_CACHE_ALIAS | "default" | Which CACHES entry snapadmin.limits.reserve() stores its window/concurrency/cooldown counters in — point it at a shared backend before relying on quotas across more than one worker process (see Quotas & Rate Limits) |
SNAPADMIN_STARTUP_REPORT | "auto" | Print what SnapAdmin has switched on when the development server starts — "auto" (under DEBUG, dev server only), True (every process but machine-read commands), False (never); see Startup report |
SNAPADMIN_STARTUP_REPORT_PROBES | False | Add the health probes to the startup report (a connection each, at boot) |
API behaviour, auth & limits
| Variable | Default | Description |
|---|---|---|
SNAPADMIN_API_AUTHENTICATION_CLASSES | token auth | API authenticator dotted paths (add session / JWT) — see Integrating |
SNAPADMIN_API_PAGE_SIZE | 25 | Default list page size on DynamicModelViewSet |
SNAPADMIN_API_MAX_PAGE_SIZE | 500 | Hard ceiling on client-requested ?page_size= |
SNAPADMIN_API_STRICT_QUERY_PARAMS | True | Answer an unknown query parameter, an unknown ?ordering= term or a filter on a masked/read-gated field with 400 naming it, instead of dropping it (see Strict query parameters); False restores the pre-0.1.0b11 silence |
SNAPADMIN_API_TOKEN_TOUCH_INTERVAL | 60 | Seconds between writes of an API token's last_used_at — at most once per interval, so a busy client does not add an UPDATE to every request; 0 writes on every request. Override APITokenAuthentication.touch_token() to record use differently |
SNAPADMIN_API_EXTRA_QUERY_PARAMS | [] | Query parameters strict mode accepts on purpose, e.g. a client's cache-buster _ |
SNAPADMIN_THROTTLE_ANON | 60/min | Rate limit for anonymous callers, enforced by the viewset itself regardless of the host project's REST_FRAMEWORK config; None disables it. A blank value keeps the default (see Quotas & Rate Limits) |
SNAPADMIN_THROTTLE_USER | 600/min | Rate limit for authenticated callers, same enforcement; None disables it, a blank value keeps the default |
SNAPADMIN_API_DELETE_GUARD | — | Dotted path to a Callable[[request, obj], bool] vetoing API deletes (403); AND-ed with each model's api_can_delete hook |
SNAPADMIN_QUERY_BACKEND_HEADER | True | Expose the X-Snap-Query-Backend header on list responses |
SNAPADMIN_ANALYTICS_DB_ALIAS | — | DATABASES alias for read-only list/retrieve routing; empty = no routing |
Elasticsearch
| Variable | Default | Description |
|---|---|---|
ELASTICSEARCH_ENABLED | False | Enable ES integration; when False all models fall back to DB_ONLY |
ELASTICSEARCH_URL | http://elasticsearch:9200 | Elasticsearch cluster URL |
ELASTICSEARCH_KWARGS | {request_timeout: 5} | Extra kwargs merged into the Elasticsearch(...) client |
SNAPADMIN_ES_CLIENT_FACTORY | — | Dotted path to a zero-arg callable returning a custom ES client |
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_REINDEX_API_ENABLED | False | Serve the admin-only bulk ES reindex endpoint (POST /api/es/reindex/) |
SNAPADMIN_REINDEX_API_ASYNC | False | Offload the reindex endpoint to the snapadmin.run_es_reindex Celery task |
Export
| Variable | Default | Description |
|---|---|---|
SNAPADMIN_EXPORT_ENABLED | True | Enable the async background export API (/api/exports/) |
SNAPADMIN_EXPORT_CHUNK_SIZE | 1000 | Rows per export chunk (progress + resume granularity) |
SNAPADMIN_EXPORT_DIR | BASE_DIR/exports | Directory the local export working files are written to (also the default storage's root) |
SNAPADMIN_EXPORT_STORAGE | — | Dotted path to a Storage subclass export files are published to and downloaded from; unset uses local FileSystemStorage rooted at SNAPADMIN_EXPORT_DIR |
SNAPADMIN_EXPORT_MAX_ROWS | 0 | Row ceiling for the synchronous .../export/ when no valid ?limit= is passed; 0 = unlimited. Exceeding it responds 413 and points the caller at POST /api/exports/ |
SNAPADMIN_EXPORT_LIMIT_MAX | 0 | Hard cap an explicit ?limit= on .../export/ is clamped down to; 0 = no clamp |
SNAPADMIN_IMPORT_CHUNK_SIZE | 1000 | Rows per import chunk — the checkpoint/resume granularity for manage.py snapadmin_import (see Bulk Import) |
SNAPADMIN_FETCH_BY_MAX_VALUES | 10000 | Hard cap on the values list .../fetch-by/ accepts; over the cap is 400, never a truncation. snapadmin.W013 warns if raised past a sane ceiling |
Audit trail, GDPR & large tables
| Variable | Default | Description |
|---|---|---|
SNAPADMIN_MASKING_RULES | {} | Per-field masking rules (regex / redaction) and per-field PII permissions |
SNAPADMIN_PII_ACCESS_CHECK | unset | A callable (user) -> bool (or dotted path) deciding who sees raw PII instead of snapadmin.view_raw_pii; fails closed (details, snapadmin.W035) |
SNAPADMIN_MASKING_PENDING_APPS | [] | App labels whose masking keys are written before the app is installed — reported as snapadmin.I002 instead of E001/E003 until it is (details) |
SNAPADMIN_ENCRYPTION | {} | Field-encryption keyset and where it is read from — KEY_PROVIDER / KEY_FILE / KEYS / STRICT (see Field Encryption) |
SNAPADMIN_ENCRYPTION_KEYS | — | id:key entries (comma- or newline-separated) configuring the same keyset from the environment |
SNAPADMIN_ENCRYPTION_KEY_FILE | — | Path to a mounted secret holding the same content |
SNAPADMIN_AUDIT_LOG_ENABLED | True | Record admin create/update/delete as an immutable audit trail |
SNAPADMIN_AUDIT_RETENTION_DAYS | 365 | Retention window for snapadmin_audit_export --purge |
SNAPADMIN_PURGE_EXTERNAL | False | Declare that an external scheduler (cron, a Kubernetes CronJob) runs snapadmin_purge_expired_data, so snapadmin.W012 stops asking for a Celery Beat entry (see GDPR Data Retention) |
SNAPADMIN_ESTIMATED_COUNT | True | Use PostgreSQL's fast row estimate for huge, unfiltered changelists |
SNAPADMIN_ESTIMATED_COUNT_THRESHOLD | 100000 | Only estimate the count above this many rows |
Email, error monitoring & alerts
| Variable | Default | Description |
|---|---|---|
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 the 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 |
3-2-1 database backups
| Variable | Default | Description |
|---|---|---|
SNAPADMIN_BACKUP_ENABLED | False | Enable scheduled 3-2-1 database backups |
SNAPADMIN_BACKUP_KEEP | 7 | Dumps kept per destination (oldest pruned; the newest is always kept) |
SNAPADMIN_BACKUP_KEEP_DAILY / _WEEKLY / _MONTHLY | 0 | Also keep the newest backup of each of the N most recent days / ISO weeks / months that have one (details) |
SNAPADMIN_BACKUP_LOCAL_DIR | ./backups | Copy 1: directory on the same server |
SNAPADMIN_BACKUP_ALIGN_TO_SCHEDULE | False | Measure each destination's window from its planned slot instead of its last actual run. Off, every run books the next one a full interval after it finished, so the backup hour creeps forward by whatever each run costs and eventually lands in working hours. On, the schedule is pinned to the clock time of the first run, and slots missed during downtime collapse into a single catch-up run rather than a burst |
SNAPADMIN_BACKUP_LOCAL_EVERY_HOURS | 24 | How often the local copy becomes due |
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 becomes due |
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 becomes due (weekly) |
SNAPADMIN_BACKUP_SFTP_HOST / _PORT | — / 22 | Copy 3 (alt): offsite SFTP server (empty host = off) — port 23 for Hetzner Storage Box |
SNAPADMIN_BACKUP_SFTP_USER / _PASSWORD / _KEY_FILE | — | SFTP credentials — key file wins over password when both are set |
SNAPADMIN_BACKUP_SFTP_KNOWN_HOSTS | — | Host-key file to trust. Empty = ~/.ssh/known_hosts of whoever runs the process — which in a container is usually root. Set outright, it replaces that lookup rather than adding to it |
SNAPADMIN_BACKUP_SFTP_DIR | / | Target directory, relative to the SSH login directory — an absolute value is snapadmin.W022 |
SNAPADMIN_BACKUP_SFTP_EVERY_HOURS | 168 | How often the SFTP copy becomes due (weekly) |
SNAPADMIN_BACKUP_S3_BUCKET | — | Copy 3 (alt): S3-compatible bucket name (empty = off) |
SNAPADMIN_BACKUP_S3_PREFIX | — | Optional key prefix inside the bucket |
SNAPADMIN_BACKUP_S3_ENDPOINT_URL | — | AWS's own endpoint if unset; set to target MinIO/Backblaze B2/Hetzner Object Storage/Wasabi |
SNAPADMIN_BACKUP_S3_REGION | — | AWS region (or the provider's equivalent) |
SNAPADMIN_BACKUP_S3_ACCESS_KEY_ID / _SECRET_ACCESS_KEY | — | Explicit credentials; leave both unset to use boto3's ambient credential chain (env vars, shared config, an IAM role) |
SNAPADMIN_BACKUP_S3_EVERY_HOURS | 168 | How often the S3 copy becomes due (weekly) |
SNAPADMIN_BACKUP_AGE_RECIPIENTS | [] | List of age/SSH public keys — encrypts every dump in-stream when non-empty (see Encrypting backups) |
SNAPADMIN_BACKUP_AGE_IDENTITY_FILE | — | Restore-only: path to a private-key file (never the key material itself) |
SNAPADMIN_BACKUP_AGE_BACKEND | "auto" | "auto" / "pyrage" / "binary" — which AGE implementation to use |
SNAPADMIN_BACKUP_AGE_BINARY_PATH | — | Override the age executable path for the binary backend (default: PATH lookup) |
SNAPADMIN_BACKUP_INCLUDE | ["db"] | Subset of db/media/env a run bundles (see Media and .env in the bundle) |
SNAPADMIN_BACKUP_MEDIA_EXCLUDE | [] | Glob patterns, relative to MEDIA_ROOT, excluded from the media bundle |
SNAPADMIN_BACKUP_ENV_FILE | — | Path to the .env file backed up when env is in SNAPADMIN_BACKUP_INCLUDE |
SNAPADMIN_BACKUP_MEDIA_SIZE_WARNING_BYTES | 10 GiB | Past this size, the media backup logs a warning — never aborts |
SNAPADMIN_RESTORE_SNAPSHOT_DIR | <local dir>/rollback | Where snapadmin_restore --confirm's automatic pre-restore snapshots are stored (see The pre-restore safety net) |
SNAPADMIN_RESTORE_SNAPSHOT_KEEP | 3 | How many pre-restore snapshots to keep (oldest pruned first) — separate from SNAPADMIN_BACKUP_KEEP |
snapadmin.run_db_backups. See Celery & Periodic Tasks.
Traefik (demo deployment)
| Variable | Default | Description |
|---|---|---|
TRAEFIK_DOMAIN | yourdomain.com | Production domain for demo/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 |
🧩 Ecosystem Compatibility
How SnapAdmin coexists with popular third-party Django packages. For the Python and Django versions themselves — and for how the whole suite is built and what it does and does not verify — see Testing & Quality Engineering.
SnapAdmin and admin themes (Unfold, Jazzmin, Grappelli)
Those are themes: they restyle the admin you already wrote — you still write
the ModelAdmin, the list_display, the search_fields.
SnapAdmin generates that admin from your field declarations, and the REST API,
the GraphQL schema and the search mapping from the same ones. It is not a competing theme — it
sits a layer above, and uses Unfold as its optional theme (the
[theme] extra).
| Themes | SnapAdmin | |
|---|---|---|
| Admin look | ✅ their whole point | Unfold's, via the [theme] extra |
Who writes the ModelAdmin | you | generated |
| REST API + Swagger | — | generated |
| GraphQL | — | generated |
| Elasticsearch | — | generated |
| Audit, GDPR, backups, health | — | built in |
If you only want a better-looking admin, use a theme — it is less machinery. SnapAdmin earns its place when the same models must also be an API, a search index and an auditable system of record.
The two rules that make SnapAdmin safe by default
- SnapAdmin only auto-registers
SnapModelsubclasses. Every other model in your project — including third-party ones (taggit.Tag, guardian's permission models,reversion.Version,django_celery_beat, …) — is left entirely alone. There is no global admin takeover. - Auto-registration never clobbers an existing admin. If a model is already
registered (by you or a package) when SnapAdmin runs, it skips it (
AlreadyRegistered). Your custom admin wins.
Two escape hatches let a package take over a SnapModel's admin when you want it to:
| Goal | How |
|---|---|
| Add a package's admin behaviour on top of SnapAdmin's auto-config | admin_mixins = [ThePackageAdminMixin] on the SnapModel |
| Let a package fully own the admin for a model | admin_enabled = False on the SnapModel, then register the package admin yourself |
admin_mixins classes are placed first in the MRO, so their
get_queryset / changelist_view / actions wrap SnapAdmin's, which in turn
wraps Django/Unfold's ModelAdmin. The methods the generator builds —
get_readonly_fields, get_fieldsets, formfield_for_dbfield and the
generated display columns — live on a generated base behind the mixins, so a mixin extends
them through super():
class AuditFieldsMixin:
def get_readonly_fields(self, request, obj=None):
return [*super().get_readonly_fields(request, obj), "reviewed_by"]
class Invoice(SnapModel):
admin_mixins = [AuditFieldsMixin]
# Precedence, first match wins:
# admin_overrides > admin_mixins > generated > PIIMaskingAdminMixin > SnapSaveMixin > ModelAdmin
Generated attributes (list_display, ordering, Media, …)
are set on the generated class itself — change them with admin_overrides.
Beyond mixins, three more SnapModel attributes tune the generated
ModelAdmin without an admin.py: admin_overrides — a dict of
attributes/methods merged on last so they win over the defaults (e.g.
{"list_per_page": 25}); and css_admin_files / js_admin_files
(a static path or list) appended to the generated Media.css / Media.js.
These are the supported alternative to hand-writing class Media or your own
admin.site.register — SnapAdmin owns registration via register_admin() (one
model) and register_all_admins() (a whole app label). The zero-padded id helper
snapadmin.models.formatted_id is public for use in overrides. SnapAdmin serves its own
admin assets under the snapadmin/ static namespace (snapadmin/js/admin.js,
snapadmin/css/admin.css, …); when migrating from the predecessor package, update any
hardcoded drofji_autoadmin/… (or bare admin.js) static path to it.
select2 is opt-in, not automatic, on a plain <select>. The
shipped admin.js only initialises select2 on an element carrying a
snapadmin-select2 class (or a data-snapadmin-select2 attribute) — add that
class to a form field's widget attrs to opt it in. This is deliberately narrow: an
earlier, broader selector reached the changelist's own action dropdown, and select2 taking over an
Alpine-bound <select> (as the Unfold theme renders it) silently broke bulk
actions.
Package matrix
| Package | Works with SnapAdmin? | Notes / recommended integration |
|---|---|---|
| django-mptt (tree structures) | ✅ | Multiple-inherit MPTTModel alongside SnapModel. For the tree UI in the admin, add admin_mixins = [MPTTModelAdmin]. Model fields still auto-generate. |
| django-guardian (object-level perms) | ✅ | Guardian's models are untouched (not SnapModels). For per-object permissions in the admin, admin_mixins = [GuardedModelAdmin]. SnapAdmin's REST API honours standard Django has_perm, which guardian backends extend. |
| django-reversion (versioning) | ✅ | admin_mixins = [reversion.admin.VersionAdmin] to get version history on the auto-generated admin. Reversion's own models are separate. |
| django-debug-toolbar | ✅ | Purely middleware/URLs; no interaction with model or admin generation. Add its middleware as usual. |
| django-import-export | ✅ | admin_mixins = [ImportExportModelAdmin] layers import/export buttons onto the auto-generated changelist. (SnapAdmin also ships its own async export API.) |
| django-simple-history (history) | ✅ | Add HistoricalRecords() to the model and admin_mixins = [SimpleHistoryAdmin]. The historical model is a plain model SnapAdmin ignores. |
| django-filter (filtering) | ✅ | The SnapAdmin REST API uses standard DRF; add DjangoFilterBackend to DEFAULT_FILTER_BACKENDS (or per-view) and it composes with SnapAdmin's search/ordering backends. Admin filters come from the filterable=True field flag. |
| django-taggit (tags) | ✅ | Taggit's TaggableManager is a normal field; it appears in the auto-generated form/API. Taggit's own Tag/TaggedItem models are not SnapModels, so they are not auto-registered. |
Legend: ✅ compatible — no SnapAdmin change needed beyond the documented hook.
Worked example — import/export + versioning on one model
from import_export.admin import ImportExportModelAdmin
from reversion.admin import VersionAdmin
from snapadmin import models as snap_models, fields as snap_fields
class Invoice(snap_models.SnapModel):
number = snap_fields.SnapCharField(max_length=32, searchable=True, show_in_list=True, show_in_form=True)
# Compose ecosystem admin behaviour with SnapAdmin's auto-config:
admin_mixins = [ImportExportModelAdmin, VersionAdmin]
The generated admin is Invoice → ImportExportModelAdmin → VersionAdmin → (SnapAdmin mixins) →
ModelAdmin: import/export buttons, version history, and SnapAdmin's
field-driven list_display / search / filters / PII masking / audit logging, all at once.
AdminSite), set admin_enabled = False on that SnapModel and
register the package admin yourself. SnapAdmin will not touch it, and the model's REST API /
GraphQL / search continue to work independently of the admin.
🧪 Testing & Quality Engineering
This page exists so that a team evaluating SnapAdmin can judge the test suite without reading it. Everything below is what the repository does today; the final table is what it deliberately does not do yet, and nothing on this page is claimed before it actually runs in CI.
The method behind the suite — mistake-proofing guards, the four-step bug-fix protocol (regression test, same-class sweep, class guard, escape analysis), mutation testing and the tests that check the tests — is written down in the testing methodology.
Every count here is a floor, read off a real
pytest --collect-only -q run rather than maintained by arithmetic. Measured
2026-09-15 on CPython 3.12 / Django 6.0:
| Measurement | Value | Command that produced it |
|---|---|---|
| Tests collected | 5,364 tests across 160 files | pytest --collect-only -q |
| The everyday run | 5,357 passed, 6 skipped, 12 deselected, ~45 s | pytest |
Line coverage of snapadmin/ | 100% — 11,702 statements, 0 missing | pytest --cov=snapadmin --cov-branch --cov-fail-under=100 |
Branch coverage of snapadmin/ | 100% — 3,464 branches, 0 partial (gated) | pytest --cov=snapadmin --cov-branch --cov-fail-under=100 |
| CI jobs per push | 10 — a 6-way Python × Django matrix, one real-services job, one static-analysis job, one browser-E2E job and one lowest-dependencies job | .github/workflows/test.yml |
tomllib gates below Python 3.11 and
backend-capability gates; the 12 deselected tests need a live Elasticsearch.
The suite ships with the source distribution. The wheel a
project installs carries only snapadmin/; the sdist also carries tests/,
the demo project it runs against, the docs it checks for truth and pytest.ini. Unpack
it and run python -m pytest inside to hold an installed version to the same checks
CI runs — without cloning the repository.
Where the tests go. Beyond the contract and docs
suites, the heaviest areas are the ones with the most ways to go wrong — each figure a
floor, checked against a collection run by
tests/test_testing_docs_truth.py: field behaviour and encrypted fields (250+), the
REST surface (190+), field-level encryption end to end (400+ across the cipher, the keyset, the
blind index, every leak surface and the conversion command), export (130+), backups (170+) and
restore (90+ including the pre-restore snapshot), PII masking (120+), settings resolution (90+),
API tokens (100+ across issuing, hashing and validation), internationalisation (110+ across the
package and the demo), data retention (80+), bulk import (70+), alert channels (70+), the audit
trail (60+), multi-tenancy (60+ across the model, admin, audit and Elasticsearch layers) and
offline mode (60+). Accessibility (WCAG 2.1 AA) and GraphQL permission enforcement have their own
suites. The operator tooling is held to the same bar: the diagnostics report behind
snapadmin-info (220+ across eleven files, 80+ of them in the feature-adoption audit, where
every capability probe runs switched both on and off), the startup system checks (200+) and the
licence audit and its command (60+).
How the tests are written
The rules below are the project's own engineering standard, applied to every change:
- Test-first. A behaviour change or a bug fix starts with a test that fails for the right reason; the production change comes second. Every bug fix ships a regression test pinned to the exact input that broke, and then asks which other inputs hit the same class of bug.
- An assertion states a contract, not a pulse.
assert result is not Noneis treated as a defect in the test. The suite asserts the value, the status code, the exception type and its message, the row that was written, the number of queries. Where a whole set is checkable — the system-check ids a misconfiguration emits, say — it asserts the set (_ids(found) == ["snapadmin.E026"]), never mere containment, because containment stays green when a spurious second error appears. - Never weaken a check to get a green build. No loosened assertion, no
skip, noxfail, no extra mock, no edited expected value, no raised timeout, no# pragma: no cover. When the code and a test disagree, which one is right is answered before either is touched. - Arrange-Act-Assert, with names a reader understands a year later —
expected_status,admin_user,invalid_payload; neverobj1,data,result2. - Ambiguous business logic is a question, not a guess. If the code, the tests and the documentation disagree, or several behaviours are defensible, the rule is to stop and ask rather than pin a guess into a test where it becomes the specification by accident.
The layers, and what each one protects
Not one pyramid but several overlapping ones, because a library fails in more ways than an
application does. Every layer below runs on every push; two of them — the live datastore and the
browser — need a service or a browser, so a plain local pytest deselects them by marker.
| Layer | What it protects | Named examples |
|---|---|---|
| Unit | one function or class in isolation, with Elasticsearch and sockets stubbed | tests/test_fields.py, tests/test_conf.py, tests/test_registry.py |
| Integration | the Django stack wired together — ORM, generated admin, HTTP, Celery run in-process | tests/test_model_api.py, tests/test_admin_site.py, tests/test_backup.py |
| Security | authentication, authorisation, tenancy, PII masking, field encryption, stored XSS, token storage, the audit log | tests/test_pii_masking.py, tests/test_encryption_leak_surfaces.py, tests/test_tenancy.py |
| Contract | the public import surface: names, signatures, defaults — plus an AST backstop read from the source rather than a hand-kept list | tests/test_public_contract.py, tests/test_public_surface_snapshot.py, tests/test_api_surface_defaults.py |
| Documentation-truth | that this page, the README, llms.txt and the in-package module map describe the code that really ships | tests/test_docs_completeness.py, tests/test_ai_entry_points.py, tests/test_docs_site.py |
| Regression | one named past bug each, pinned to the exact input that broke | tests/test_widget_security.py, tests/test_es_delete_sync.py, tests/test_api_validation_errors.py |
| Adversarial | malformed input, hostile settings, conflicting kwargs, wrong permissions — asserting the right refusal, not merely the absence of a crash | tests/test_wysiwyg_sanitize.py, tests/test_scaffold_validate.py, tests/test_sharding_registration.py |
| Compatibility (optional deps) | the core still imports with DRF, Graphene, Celery or Unfold absent | tests/test_api_optional.py, tests/test_celery_optional.py, tests/test_unfold_optional.py |
| Diagnostics | every snapadmin_info collector renders, and reports honestly both on and off | tests/test_diagnostics_features.py and ten more |
| i18n | ten catalogs compile, strings are wrapped, switching language works | tests/test_i18n.py, tests/test_demo_i18n.py |
| Accessibility | WCAG 2.1 AA assertions on the dashboard and the SSO partial | tests/test_accessibility.py |
| Property-based | laws that must hold for every generated input, not a handful of examples — deconstruct() round-trips, the encryption envelope and the blind index, DSN parsing, masking rules, snap_field(), export serialization | tests/test_properties_fields.py, tests/test_properties_encryption.py, tests/test_properties_exporting.py |
| Fuzz | generated hostile input against the request-facing surfaces — REST query strings, export filters, masking settings, key configuration, rich-text HTML — asserting the right refusal and the invariant: no 5xx, no widened result, no raw PII, no key material in an error | tests/test_fuzz_api.py, tests/test_fuzz_encryption_config.py, tests/test_fuzz_sanitize.py |
| Performance / query count | no N+1 on the changelist with relations, the REST list endpoint, the audit timeline and masked output — each surface is counted at two row counts (the numbers must match) and pinned exactly, so a new query on a hot path is a decision rather than an accident; plus the list-view knobs and estimated-count pagination | tests/test_query_counts.py, tests/test_performance.py, tests/test_pagination.py |
| Asset-truth | every shipped CSS/JS/SVG reference resolves to a file inside the package | tests/test_static_asset_references.py, tests/test_admin_js_asset.py |
| Process-level E2E | snapadmin-new generates a project and that project really boots — a real subprocess, a real check and migrate | tests/test_scaffold_e2e.py |
| End-to-end smoke | the seam between the layers: admin form POST → database row → audit entry → REST read, in one walk | tests/test_critical_path_smoke.py |
| Browser E2E | the generated admin in a real browser (Playwright, Chromium), under both admin themes — its JavaScript, stylesheets, autocompletes, inlines, the dashboard chart and the outage guard; every page also fails its test on an uncaught JavaScript error, a missing asset or a 5xx. Opt-in locally: pytest -m e2e | tests/e2e/test_admin_flows.py, tests/e2e/test_admin_forms.py, tests/e2e/test_connectivity.py |
| Live datastore | the Elasticsearch query DSL against a real cluster, where a mock cannot judge it | tests/test_elasticsearch_live.py |
| Security regression | every shipped security fix, each mapped to the tests that fail if it returns; the release notes are checked against the map (below) | tests/security_regressions.py, tests/test_security_regressions.py |
| Dependency compatibility | the declared minimum of every dependency, installed and run; the classifiers equal the CI matrix; CI installs every declared dependency at the declared range (below) | tests/test_dependency_compat.py, tests/test_gates_script.py |
The checks, and how each one actually works
Naming a tool is not documenting it. Each check below is described so that a team could reproduce the same setup in their own project from this page alone.
The 100% line-coverage gate
What runs: pytest --cov=snapadmin --cov-report=term-missing
--cov-fail-under=100, in each of the six matrix jobs.
What it measures: whether every executable statement in the shipped
snapadmin/ package was reached by at least one test — 11,702 statements, 0 missing.
demo/ and tests/ are excluded, since coverage of the test suite measures
nothing.
What fails the build: a pull request that adds a line no test reaches.
What a contributor does about it: write the test. Reaching for
# pragma: no cover to silence the gate is not an option — a pragma is treated as
a hidden gap rather than an exemption.
The honest exception: seventeen lines across twelve modules do carry one, each
with a written reason, and they fall into three groups — abstract methods whose body is only
raise NotImplementedError; if TYPE_CHECKING: blocks that never execute;
and the import-time branch taken when an optional dependency is absent (which the suite does cover,
by re-importing the module with that dependency hidden, but not in a way the coverage counter can
attribute). That list is frozen by tests/test_testing_docs_truth.py, so it cannot grow
without a deliberate change and a matching edit here. It used to be eighteen: six guards labelled
"defensive, unreachable" were revisited, all six were reachable — one had silently switched PII
masking off on a hand-written serializer — and each is now covered by a test instead.
What it does not prove: that the line is correct, or that any assertion
would notice if it changed. Coverage is a helper metric here, not the goal.
Branch coverage — 100%, gated
What runs: the same command with --cov-branch, in the same CI step
as the line gate: pytest --cov=snapadmin --cov-branch --cov-fail-under=100.
What it measures: whether each conditional was taken both ways. A
if x: … that is only ever entered with x true is 100% line-covered and
50% branch-covered.
Where it stands: 100% — 3,464 branches, 0 partial.
How it got there: 59 partial branches were closed one by one, each with a test for
the case it represented — never with # pragma: no branch. Doing it found real defects,
not only missing tests: an ES search result whose delete() deleted nothing for a
DUAL model while reporting every row as deleted; an ES_ONLY delete that
counted a failed Elasticsearch call as success (which a GDPR erasure then reported as done);
a restore that logged an unknown part as applied; two tasks that could return no
status at all. Branches that could never be taken were removed rather than tested.
What fails the build: a pull request that adds a conditional one of whose sides
no test takes.
Random test order, on every single run
What runs: pytest-randomly is a dev dependency and needs no flag —
it reshuffles the collected tests on every invocation, locally and in CI, and prints the seed it
used.
What it catches: order dependence. A test that passes only because an earlier one
created a row, left a setting overridden, populated a cache or seeded the random module will
eventually run first and fail.
What a contributor does about a failure: reproduce it with
pytest -p randomly --randomly-seed=<printed seed>, then find the shared state and
remove it. Pinning the order with -p no:randomly to make the failure go away is
prohibited: a seed-dependent failure is a real defect in the tests, and it is the one class of
defect this check exists to surface.
The empty-assertion guard
What runs: tests/test_assertions_can_fail.py, an ordinary test in
the suite that parses every other test file with the ast module.
What it checks: three shapes of assertion that no outcome could falsify —
assert True; assert <anything> or True, where the or
makes the left-hand side irrelevant; and a test function containing no assert, no
pytest.raises, no mock assert_* call and no
assertNumQueries, whose entire claim is therefore "it did not raise".
Why it exists: a test that cannot fail is worse than no test. It takes the name of
a guarantee, counts toward the total, keeps the line covered and protects nothing — and it is
invisible to every other metric here, the coverage gate included. An audit of this suite found four
such assertions that had been shipping.
What a contributor does about it: assert the behaviour the test is named after. The
guard carries a frozen list of pre-existing assertion-free tests which may only ever
shrink; adding to it requires a stated reason.
The contract suites
What runs: three files, 349 tests between them, whose only purpose is to fail
when the public surface changes shape.
tests/test_public_contract.py is the hand-curated half — every import path, signature
and default that a downstream project depends on.
tests/test_public_surface_snapshot.py is the generated backstop: it walks every
top-level snapadmin/*.py module with ast, collects the functions and
classes actually defined there, and diffs that against a committed snapshot, so an addition,
removal or rename shows up even when nobody remembered to update the hand-curated list.
tests/test_api_surface_defaults.py sweeps for a default value spelled inline at a read
site instead of going through its single named constant.
What a contributor does when one fails: first work out which case it is. If the
failure is in the surface this change deliberately alters, update the pin in the
same commit, following the deprecation policy when something is being removed. If the
failure is in a surface the change never meant to touch, the change just broke a published API —
fix the code, not the test. Loosening or deleting the assertion is never the answer.
The compatibility matrix
What runs: six jobs per push — Python 3.10, 3.11, 3.12 and 3.13 × Django 5.2 and
6.0, minus 3.10/3.11 on Django 6.0, which requires 3.12.
Why six and not one: anything a test parses or imports can differ between
interpreters, and only CI can see it. Two cases have already cost this project a red build after a
green local run: tomllib, which is stdlib only from 3.11, and
datetime.fromisoformat, which cannot parse a trailing Z before 3.11 —
and a trailing Z is exactly how DRF renders timestamps. A green local run
proves one interpreter and nothing more.
Release gate: the tag-triggered publish workflow calls this same workflow, so a
release cannot reach PyPI on a red matrix.
Static analysis — Ruff and mypy
What runs: the static-analysis job — ruff check snapadmin
(pyflakes, pycodestyle, bugbear, complexity, and bandit's security rules over the whole package,
backups, crypto, encryption, the API and sharding included), ruff format --check snapadmin,
and mypy over snapadmin/ with the Django plugin.
What fails the build: any Ruff finding, any unformatted file, and any type error —
all three are clean and all three are blocking. mypy was advisory while a backlog of 136 findings in
the dynamic admin and field layers was worked down; that backlog is gone, so a new type error now
fails the job instead of scrolling past in a report nobody reads. The modules where a wrong type
costs most — encryption/, crypto.py, sharding/,
backup.py, validators.py, logging_config.py and
quickstart/ — additionally run under raised strictness: every function annotated and
every untyped body checked.
How the backlog was cleared, since the shape repeats: most of it came from mixins.
A mixin that calls super().handle_exception() or reads self._meta has no
base class saying those exist, so every such call was unchecked. Each mixin now declares what it is
mixed into in an if TYPE_CHECKING: block — the DRF view or serializer it extends, or
the model attributes a SnapModel supplies — which costs nothing at runtime and makes
those calls checkable. What is left is a handful of local suppressions, each naming its rule and its
reason on the line.
What a contributor does about a finding: fix it. A suppression is local, names its
rule and says why (# noqa: S603 - argv list, no shell); a bare or blanket one is not
accepted. The first pass paid for itself: it found an unescaped primary key rendered as markup in the
changelist, a database name interpolated into SQL, and a webhook transport that would have followed
file: URLs.
Locally: ruff check snapadmin && ruff format --check snapadmin && PYTHONPATH=. mypy.
Property-based and fuzz tests — hypothesis
What runs: every tests/test_properties_*.py and
tests/test_fuzz_*.py file, inside the ordinary pytest run — no marker, no
separate command. Each test states a law instead of an example:
"decrypt(encrypt(x)) is x", "a field rebuilt from its own
deconstruct() deconstructs identically", "no text cell of a CSV export opens with a
formula trigger", "a query parameter on a masked field changes nothing in the response".
hypothesis then generates the inputs —
empty values, None, wrong types, astral Unicode, control characters, 10 KB strings,
duplicated keys, hostile HTML, backwards key entries — and checks the law against each one.
How much it searches: 100 examples per test locally, 300 in CI
(--hypothesis-profile=ci; both profiles are defined in tests/conftest.py).
No per-example time limit: a wall-clock deadline is a timing check, and a slow CI runner is not a bug.
What a failure looks like: hypothesis shrinks the failing input to the
smallest one that still fails and prints it, together with a
@reproduce_failure(...) line — paste that line onto the test and the exact case replays
locally, whatever seed CI used. What a contributor does about one: first decide
whether the law or the code is wrong. A law can be too broad ('ß'.upper()
is 'SS', so "case-insensitive" was the wrong promise for a function that only calls
lower()) — then the law is narrowed, and the comment says why. Otherwise it is a
defect: it gets a named regression test in the example suite and a fix, never a filter that steers
the generator around it.
What the first pass found: a sharding DSN whose password contained a raw
/, # or ? leaked that password into the configuration error,
and a digits-only one parsed into the wrong host; CSV exports wrote =HYPERLINK(…) cells
raw (spreadsheet formula injection); POST /api/exports/ accepted a JSON list as
filters and answered 500 to a list of numbers; a masking setting written as
a string or a number masked nothing and crashed manage.py check; a provider returning key
material as bytes crashed configuration loading; and snap_field() on an
auto_now field could make makemigrations crash.
Query-count pins
What runs: tests/test_query_counts.py drives the N+1-prone surfaces —
a changelist with foreign keys and a many-to-many, the REST list endpoint, the audit timeline, and
the masked changelist, list and timeline — through real HTTP requests and counts their SQL twice, at
3 rows and at 12. What fails: the two counts differing (a query per row, somewhere)
or the larger one moving off its pinned number. A new query on a hot path should be a decision; when
it is one, the pin changes in the same commit with the reason in its message. The one
backend-specific query — PostgreSQL's planner estimate, which an unfiltered changelist asks for
before an exact COUNT(*) — is part of the pin on PostgreSQL and absent elsewhere.
Mutation testing — mutmut, advisory
What it asks: the question coverage cannot. 100% line and branch coverage says
every statement ran; it says nothing about whether a test would notice the statement being
wrong. A mutation tool makes one small, deliberately wrong change to a function — inverts a boolean,
turns > into >=, replaces a return value, drops a call — and runs the
tests against that mutant. Tests fail → the mutant is killed, and the suite
demonstrably detects that class of error. Tests still pass → the mutant survived:
the line was covered, but nothing asserted anything that depended on it. A survivor is a gap in the
assertions, not in the coverage.
What gets mutated: function and method bodies in snapadmin/
(migrations and the scaffold templates excluded). mutmut copies the package into
mutants/, rewrites each function into a dispatcher that can select one mutated
version at a time, and runs the suite against it — which is why the handful of tests that read the
repository's documents or the package's own source text as data (docs truth, the
AST surface snapshot, version sync, the sdist manifest) sit out mutation runs: they assert nothing a
mutant could change and cannot run against rewritten source. The property-based tests run under a
dedicated mutation hypothesis profile — 25 examples, derandomised — so a mutant
is killed by a law, never by a newly generated example.
The two cadences (.github/workflows/mutation.yml, one command each,
both also runnable locally):
- Per push — only what the push changed.
python scripts/mutation.py diff --base origin/mainreads the changed line numbers out ofgit diff, maps each to the function that encloses it, and runs only those functions' mutants. A typical commit is a few dozen mutants: minutes, not hours. A change that touches no function body (an import, a constant) runs nothing and says so; a change wider than 40 functions is truncated and left to the weekly run, which the report states. - Weekly — the dangerous modules, whole.
python scripts/mutation.py modulesmutatesencryption/(cipher, keys, blind index),crypto.py,sharding/router.py,masking.py,api/filters.py,backup.py,restore.pyandtasks.py— everywhere a silent wrong answer means leaked data, a lost backup or a row nobody can find again. This is what covers code nobody is touching.
What it does not do: block. Both jobs are advisory and the script exits
0 either way, because a surviving mutant is not automatically a bug — an
equivalent mutant (a changed log string, a guard that cannot be reached) is unkillable by
construction, and a 100% mutation score is explicitly not the target.
What a contributor does about a survivor: read it with
mutmut show <name>, then either strengthen the test until the mutant dies — that is
the point of the exercise — or record in writing why that mutation is not worth catching.
The report does one round of that triage itself: a survivor whose only change is
the wording of a string is counted but not listed, because a test that asserts an error's
type, its key and a phrase in it legitimately does not notice a reworded sentence — pinning prose
word for word is not a bar this project sets. (It decides that by comparing the mutant's source
with the untouched copy beside it, every string literal blanked, so a mutation that changes a
string into something else — None, a different expression — still counts as
behaviour.) What is left is the list worth reading.
Measured on the first real run (the change that introduced these tests): 749
mutants, 547 killed, 202 survived — 123 of them message wording. Of the rest, the recurring shape
was a system check's hint= disappearing unnoticed; the hints of the checks that change
ship pinned by tests now, and the remaining ones are accepted for the moment rather than pretended
away — each would need its own deliberately-broken configuration to reach.
Real PostgreSQL and a live Elasticsearch
What runs: a seventh job, one combination only (Python 3.12 · Django 5.2), with
postgres:16 and
docker.elastic.co/elasticsearch/elasticsearch:8.13.0 as service containers. It runs the
identical suite against PostgreSQL, then pytest -m real_es against the cluster.
Why one job and not the whole matrix: a difference between database backends does
not depend on the interpreter version, so spreading it over six jobs would cost six times the
minutes to learn the same thing.
Why it exists: the matrix varies Python and Django but every one of its six jobs
runs on in-memory SQLite, so a backend difference cannot fail it — while the package advertises
PostgreSQL support and carries backend-specific code. And for Elasticsearch the gap is sharper
still: a MagicMock accepts any call with any arguments, so the query DSL these methods
exist to build is precisely what a mocked test cannot check. A malformed terms clause
or a wrongly-shaped search_after cursor passes the mock and answers
400 Bad Request from a cluster.
What it found on its first run: three tests had silently assumed SQLite — one of
them pinning behaviour that exists only on the backend without native JSON containment,
which meant the PostgreSQL path real users get had no test at all. The live cluster then corrected
an assumption no mock would ever have challenged: a boolean field aggregates to the
integer keys 1/0, not True/False.
Version drift: the image is the same one demo/docker-compose.yml
ships, and tests/test_version_sync.py fails if the two ever diverge, or if either
leaves the range the [elasticsearch] extra supports.
docker run --rm -d -p 9200:9200 -e discovery.type=single-node \
-e xpack.security.enabled=false \
docker.elastic.co/elasticsearch/elasticsearch:8.13.0
SNAPADMIN_TEST_ES_URL=http://localhost:9200 pytest -m real_es
Each test owns its index and deletes it afterwards, so a failed run leaves nothing behind. Point
SNAPADMIN_TEST_POSTGRES (plus _PORT, _DB, _USER,
_PASSWORD) at a PostgreSQL server to run the whole suite against it instead of SQLite.
Browser E2E — Playwright, under both admin themes
What runs: a separate job, one combination (Python 3.12 · Django 5.2), with
Playwright driving Chromium against the demo/ project, which pytest-django's live
server serves over real HTTP. The job runs the suite twice — once on Unfold, once with
SNAPADMIN_TEST_ADMIN_THEME=stock on Django's own admin — because SnapAdmin picks the
admin classes it builds on at import time, so one process can only ever render one of the two, and
the package supports both. A release is gated on this job like on every other.
What it walks: signing in (a wrong password refused, ?next= honoured),
the index to a changelist, search, a filter, pagination, a row to its change form, a bulk delete
behind its confirmation page, an order saved with a select2 autocomplete customer and an inline line
item, a model rule refusing a save, view-only and permission-less staff refused
(tests/e2e/test_admin_flows.py); a many-to-many and a one-to-one saved from the form,
the rich-text editor fitting the window, every select showing its value
(tests/e2e/test_admin_forms.py); the dashboard chart drawn with the live counts
(tests/e2e/test_dashboard.py); the outage guard — no toast on a healthy load, Save
disabled while /api/health/ fails and given back when it answers
(tests/e2e/test_connectivity.py); exactly one theme stylesheet per page
(tests/e2e/test_themes.py).
What every page is also held to, whatever the scenario asserts: an uncaught
JavaScript exception, a script, stylesheet, image or icon of the site answered with an error, or any
response of the site answered with a 5xx fails the test at teardown. Those are exactly the defects a
test-client suite is blind to, and they would otherwise pass every scenario that did not happen to
look at the broken widget.
How it stays isolated: every test gets a fresh browser and a flushed database, and
Playwright starts and stops inside the test. Its synchronous API keeps an event loop marked as running
for as long as it is started, and Django refuses ORM calls while one is — so the escape hatch that
allows them is switched on for exactly one test, never left behind for the next. Text assertions run
under a pinned English locale, UTC and a fixed window size.
What it found on its first run — defects every test-client test had passed, each
now fixed and also pinned by a fast test in the ordinary run: the dashboard chart had not been drawn
since 0.1.0b6 (a two-line {# #} comment rendered as page text containing a literal
<script>); the outage guard did nothing at all on Unfold, whose Save buttons sit
outside the <form>; the guard announced "Back online" on every healthy page load;
a SnapOneToOneField or SnapManyToManyField declared
show_in_form=True was missing from the generated form since the first release, while a
reverse relation was listed as an autocomplete field; the rich-text editor pushed the page sideways
on Unfold and clipped its toolbar on the stock admin; every single-choice select on the stock admin
showed no value; and, in the demo, /admin/ answered 500 without Unfold and two
configured static files did not exist.
Flaky is a diagnosis: no rerun plugin, no sleeps, no raised timeouts. Timing is
Playwright's auto-waiting expect; a failed scenario uploads a trace (DOM, network and
a screenshot per step) as a job artifact for seven days.
pip install "playwright>=1.45" && playwright install chromium
pytest -m e2e # the Unfold admin
SNAPADMIN_TEST_ADMIN_THEME=stock pytest -m e2e # Django's stock admin
SNAPADMIN_E2E_BROWSER=firefox (or webkit, after
playwright install <engine>) swaps the engine and SNAPADMIN_E2E_HEADED=1
shows the window. A failed test leaves test-results/e2e/<test>.zip;
playwright show-trace replays it step by step.
One command for every gate — scripts/gates.py
What runs: python scripts/gates.py runs, in order, the gates CI
runs: suite (the full suite with the 100% line + branch gate and the CI hypothesis
profile), static (Ruff lint, Ruff format, mypy), deps
(pip check), e2e (the browser suite, once per admin theme) and
services (the suite on PostgreSQL, then the live-Elasticsearch tests).
security (the security regression suite on its own line), lowest-deps
and dist (build the sdist and wheel, twine check --strict) run by name
or under --release.
What it never does: report a gate it could not run as passed. Playwright missing,
no SNAPADMIN_TEST_POSTGRES/SNAPADMIN_TEST_ES_URL, no
pg_dump/psql (the PostgreSQL run executes a real backup round trip), no
uv — the gate is listed as not run with what it needs. An ordinary run still exits 0 then, so a
contributor without a browser can check everything else; a gate asked for by name fails (CI's
lowest-deps job asks by name, so a missing uv there is red, not green),
and --release is the release gate and fails on any. The six-job Python × Django matrix is the one thing no single machine
reproduces, and the summary says so.
How it stays equal to CI: each command is spelled exactly as
.github/workflows/test.yml spells it, and tests/test_gates_script.py
reads the workflow: a CI step that is neither runner setup nor a gate's command fails the suite,
and so does a gate whose command its CI job stopped running. The service variables reach the
services gate only — CI gives the databases to that one job, so exporting them for it
never moves the suite or the browser run onto PostgreSQL.
python scripts/gates.py --list prints every gate's commands and job.
The security regression suite
What runs: pytest -m security_regression — the tests
tests/security_regressions.py names for each security fix the package has shipped,
30+ of them since 0.1.0b1: the dashboard's staff gate, rich-text sanitizing, the dynamic API's
model allowlist, GraphQL relation permissions, the mass-assignment guard, masking on every
surface, the SSO open redirect, export-filter traversal, the SFTP host key, the encryption leak
surfaces, the audit trail's labels, CSV formula injection, DSN password redaction and the rest.
tests/conftest.py applies the marker from the registry, so nobody decorates a test by
hand. What fails the build: an entry in any release note's Security
section with no registry entry (the notes are read as data, so a new note is checked without anyone
remembering a list); a named test that no longer exists or carries skip,
skipif or xfail; a registry smaller than its recorded floor.
What a contributor does: a security fix gets its regression test, its note under
Security and a registry entry naming the test, in the same change.
Dependency compatibility — the declared minimums, run
The gap it closes: every other job installs demo/requirements.txt,
whose >= specifiers resolve to the newest release of everything — so the lower
bounds pyproject.toml publishes were never run by anything, while an adopter pinning
one of them runs exactly that. What runs: the lowest-deps job
(Python 3.10, the lowest supported) executes python scripts/gates.py lowest-deps: a
fresh environment, every declared dependency resolved to its lowest allowed version with
uv pip install --resolution lowest-direct, pip check, then the suite. A
declared minimum that cannot be installed with the rest is a failure naming the version that did
install — the bound was a promise nobody could keep, and the fix is to raise it.
Also checked on every run (tests/test_dependency_compat.py): the
Python and Django classifiers are exactly the CI matrix, and the lowest of each is the declared
lower bound; every declared dependency is installed by CI at the declared range (one written
exception, boto3, whose client the S3 tests stub); pip check runs in the
static-analysis job; makemigrations --check runs as a test in every job, so a model
change without its migration fails CI.
Documentation-truth tests
What runs: 145 tests across four files that read the documentation as data.
tests/test_docs_completeness.py asserts that every SNAPADMIN_* setting the
code reads appears both in the documentation and in the demo project, that every registered
system-check id is explained somewhere, and that every optional extra is listed consistently across
the README, this page, THIRD_PARTY_NOTICES.md and the licence inventory.
tests/test_ai_entry_points.py pins the two machine-readable layers: every module named
in the in-package module map really imports, every docs anchor llms.txt links to really
resolves, and the two copies of llms.txt are byte-identical.
tests/test_docs_site.py checks this page's own structure — one sidebar link per section,
no link to a section that does not exist.
tests/test_version_sync.py pins the version literal across every place the repository
publishes it.
Why: documentation rot is silent. Nothing breaks at import time when a section
disappears or a setting is never written down; it just teaches the next reader something false.
The end-to-end tripwire
What runs: tests/test_critical_path_smoke.py, three tests.
What it does: posts the real generated admin add form over HTTP, then asserts that
the row which comes out is the same one the REST API serves and the same one the audit trail
recorded, with the acting user and a per-field diff.
Why a separate test when every layer already has a deep suite: because each of those
suites can keep passing while the seam between the layers quietly breaks. It is deliberately
small — a tripwire for "the pipeline stopped connecting", not a second copy of the deep suites.
What is not in place yet
A quality page that lists only what exists is marketing. These are the layers the project's own engineering standard asks for and that this repository does not have today. They are described here as they will be built, so that the description is not mistaken for the check:
| Check | How it will work, and what it will verify | Status |
|---|---|---|
| Load testing | Throughput and latency under many concurrent users — the REST API at its real page sizes, filters and throttles, a changelist on a table of millions of rows. A library has no production traffic of its own to measure, so this belongs in a project built on it (the playbook says how); what a library can own is the query-count pins above, which keep a hot path from growing a query per row. | Not in this repository. No Locust, no k6 |
| Firefox and WebKit in CI | The browser suite runs on Chromium in CI. Playwright drives Firefox and WebKit from the
same tests (SNAPADMIN_E2E_BROWSER=firefox), which works locally, but no job runs
them yet. |
Local only |
snapadmin-info tells an
operator what is switched off, not only what is on, and a capability cannot ship without a probe
for both states. A quality page that implied checks it does not run would be the same failure in
the documentation layer. When one of these lands it moves into the table above it, with the
command that runs it.
🩺 Diagnostics — snapadmin_info
One command reports SnapAdmin's whole configuration and the health of everything it connects to — useful when deploying, debugging a staging box, or feeding a monitoring dashboard. It reads configuration and probes services only; it changes nothing, and secrets (database passwords, broker credentials, token values) are never printed.
snapadmin-info # full text report
snapadmin-info --json # machine-readable (monitoring / CI)
snapadmin-info --section version # one section (repeatable)
snapadmin-info --brief # top-level values only
snapadmin-info --health-check # probes only; non-zero exit on failure
snapadmin-info --startup # the short block runserver prints at startup
manage.py command, but the package also installs a shell shim so that
snapadmin-info and python manage.py snapadmin_info are the same thing —
the shim walks up from the current directory to find your manage.py, forwards the
arguments and returns the exit code. Run it anywhere inside your project. With no project in
sight it says so and points at snapadmin-demo, instead of failing as a missing
binary. Same for snapadmin-license-check.
The underscored console-script spelling (snapadmin_info,
snapadmin_license_check) was a duplicate of the dashed one and was removed in
1.0 — the dashed form and the python manage.py snapadmin_info
form both stay.
Flags
| Flag | Effect |
|---|---|
--json | Emit the raw report as JSON — one object per section — for a monitoring endpoint or CI step. |
--section NAME | Limit to one section (repeatable). Names: api, celery, checks, database, elasticsearch, features, graphql, inventory, version. |
--brief | Show only the top-level scalar values of each section, hiding nested detail. |
--verbose | Include extra per-section detail — the online Celery workers, per-capability adoption counts, and the full text of every system-check message. |
--health-check | Run only the health probes (system checks, database, Elasticsearch, REST API, GraphQL — each skipped when its feature toggle is off) and exit non-zero if any fails — usable as a readiness/liveness check. |
--startup | Print the startup report — the same block runserver shows when it starts, byte for byte. Configuration only, unless SNAPADMIN_STARTUP_REPORT_PROBES is on. It takes no other flag: combined with --json, --section, --brief, --verbose or --health-check it stops with an error rather than ignore it. |
Sections
- System checks — a per-severity count of Django's system checks, not their text. Django normally prints every message in full before any management command runs, which buried this report behind a screen of advisory text, so
snapadmin_infoopts out of that pass and summarises here instead:--verboseprints the messages, and errors are always listed because they block a working deployment (they also make--health-checkfail). Runmanage.py checkfor the full text with hints. - Version & Status — SnapAdmin version, the Django/Python runtime, and every
SNAPADMIN_*feature toggle at its effective value. Run from inside a tree extracted bysnapadmin-demo, it also reports which release that tree came from, and says so plainly when the tree is older (or newer) than the installed package — a mismatch that otherwise shows up only as behaviour you thought was already fixed. - Feature adoption — a commerce-readiness
✓/✗checklist of which business-important capabilities are actually on or in use in this project: backups, retention-based deletion, audit trail, PII masking, the REST/GraphQL APIs, API tokens, Elasticsearch, background tasks, health/error alerting, rate limiting, the read-only / write-allowlist / delete guards, plain models opted in with@snap_model(these are the ones the reindex and the retention purge skip) and SSO. Where a capability is adopted per-model or per-field (retention, masking, read-only models, …),--verboseadds a one-line count. Unlike the toggle list above, the signal here is adoption — a model actually declaring retention, a masked field actually configured — so you can see at a glance what is protected and what is left open. A capability switched on whose optional package is missing (SNAPADMIN_GRAPHQL_ENABLED = Truewithout the[graphql]extra, say) is listed again underneeds_extrawith thepip installline — it reads as on, but its first request would fail. - Database — engine, name, host, port and user (never the password), reachability, table count and — where the backend exposes it — on-disk size.
- Elasticsearch — collapses to
disabledwhen off; otherwise cluster reachability, status and index count, plus theDB_ONLY/DUAL/ES_ONLYtally of your models. - Celery & Broker — broker and result-backend URLs with the password redacted, the number of online workers, and the configured Beat schedule (collapses to
disabledwhen Celery isn't installed). - Models & Security — every registered
SnapModelwith its storage mode, retention window, API write-allowlist and PII-masking flags, plus API-token counts (total / active / expired — counts only, never the token value).
Title: unavailable — ExceptionType: message (in --json:
"collector_error") and every other section still runs. The message is one line, never
a traceback, and credentials inside it are redacted — a driver's error text routinely quotes the
whole connection string. A crashed section that is a health probe counts as a
failed probe, so --health-check still exits non-zero: isolation never turns a broken
subsystem green.
Sample output
The report is written to be read at a glance: a run of booleans collapses into one
on line and one off line, and a uniform list of records — the model
inventory — becomes an aligned table instead of repeating every key name once per row.
🩺 System checks
Warnings: 2
Ok: ✓
Detail: run `manage.py check` for the full text
📦 Version & Status
Version: 0.1.0b11
Status: pre-release
Django: 6.0.6
Python: 3.12.4
🧩 Feature adoption
✓ on REST API · GraphQL · Audit trail · Retention purge · PII masking · API tokens ·
Read only models · Write allowlist
✗ off Backups · Elasticsearch · Health alerts · Delete guard · SSO
🗄 Database
Engine: postgresql
Name: snapadmin_db
Host: localhost
Ok: ✓
Tables: 24
🔍 Elasticsearch
Enabled: ✓
Ok: ✓
Cluster status: green
Indices: 3
⚙ Celery & Broker
Enabled: ✓
Broker: redis://***@localhost:6379/0
Workers online: 1
📊 Models & Security
Models:
Total: 3
Items:
Model Es mode Retention days Write restricted Masked
─────────────── ─────── ────────────── ──────────────── ──────
shop.AuditLog DB_ONLY 90 ✓ ✗
shop.Customer DB_ONLY — ✓ ✓
shop.Product DUAL — ✓ ✗
Masked fields: 2
snapadmin/diagnostics/. A section that
depends on an optional package (Elasticsearch, Celery) imports it lazily and simply reports
disabled when it is absent — the command never fails because a service isn't installed.
snapadmin_info --json | curl -d @- https://monitoring.example.com/health pushes a
structured snapshot to a dashboard; a cron running snapadmin_info --health-check turns
the non-zero exit into an alert when the database or an enabled Elasticsearch cluster goes down.
Startup report — what is switched on, said when the server starts
A capability that is installed but switched off is invisible: nobody runs a diagnostics command for a feature they have forgotten exists. So when the development server starts, SnapAdmin prints one short block to stderr — what is on, what is off, what is switched on without its optional package, whether the installed dependencies allow commercial use, and how to see more or silence it:
SnapAdmin 0.1.0b11 · Django 5.2.7 · Python 3.12.4
✓ on REST API · Audit trail · Error monitoring · Retention purge · PII masking ·
Field encryption · Background tasks · Rate limiting · Write allowlist · …
✗ off Backups · Elasticsearch · Health alerts · Delete guard · SSO
⚠ misconfigured Sharding — run manage.py check
⚠ needs extra GraphQL is switched on but not installed — pip install 'django-snapadmin[graphql]'
? not checked API tokens (needs the database)
Licences: every installed dependency allows commercial use
Configuration only — health probes skipped (SNAPADMIN_STARTUP_REPORT_PROBES = True runs them).
More: manage.py snapadmin_info · silence: SNAPADMIN_STARTUP_REPORT = False
It is the Feature adoption section in its configuration-only mode —
one implementation, so the boot block and snapadmin_info --startup cannot disagree.
It runs inside django.setup(), so it issues no query, opens no connection and
never resolves an encryption keyset (a KEY_PROVIDER may call a KMS): API-token
use needs the database and is marked ?, and field encryption is judged from the
settings and the encrypted fields declared, not from keys that load. Whether the keys actually
resolve is what snapadmin_info and snapadmin.E019 answer.
| Setting | Default | Effect |
|---|---|---|
SNAPADMIN_STARTUP_REPORT | "auto" | "auto": under DEBUG, for a development server only (runserver, runserver_plus, runsslserver) — not a production worker, not every migrate of a dev box. True: every process (a staging box whose operator wants the facts in the logs), except commands whose output a program reads or that report the same facts (snapadmin_info, dumpdata, shell, dbshell, diffsettings, the sql* commands, inspectdb, test, help, anything with --json, shell completion). False: never. Any other value is read as "auto" and reported as snapadmin.W027. |
SNAPADMIN_STARTUP_REPORT_PROBES | False | Also run the health probes (database, Elasticsearch, REST API, GraphQL) and add one Health: line — a connection each, at every boot. Off, the block says the probes were skipped, so it is never mistaken for a health check. |
- Once per process.
runserver's autoreloader parent only watches files; the child that serves requests prints. Never under pytest, so a test suite's output stays clean. - Text or one JSON line. Text when
DEBUGis on or stderr is a terminal; otherwise (gunicorn, a container's captured stderr) one JSON line on stderr,{"event": "snapadmin.startup", "level": "info", "timestamp": …}, carrying the same facts as fields. It is written to the stream directly rather than through logging, because SnapAdmin's logging writes to stdout — where a piped command's output goes. - Misconfigured is not off. A capability switched on whose configuration
cannot resolve (a
SNAPADMIN_SHARDINGthe router rejects, aSNAPADMIN_ENCRYPTIONthat is not a dict) gets its own⚠ misconfiguredline pointing atmanage.py check. - Nothing secret. Booleans, capability names, extra names and package names only — never a key, a key id, a DSN, a host, a URL, an e-mail address or a token.
- Never fatal. A report that cannot be built logs
snapadmin.startup.report_failed(credentials stripped from the message) and the boot carries on.
⚖️ Licence Audit — snapadmin_license_check
The runtime counterpart of THIRD_PARTY_NOTICES.md:
it audits the licences of the SnapAdmin dependencies actually installed in your environment
and tells you whether your install is safe for commercial/proprietary use. A curated map of every
declared dependency (SPDX licence, tier, core-vs-extra, bundled-licence caveats) is overlaid on what
pip resolved.
pip-audit for a CVE scan and never claims "no known vulnerabilities".
snapadmin-license-check # full report
snapadmin-license-check --json # machine-readable (CI)
snapadmin-license-check --critical-only # only 🟡/🔴 licences
snapadmin-license-check --compatible-with MIT # per-package compatibility
snapadmin-license-check --verbose # + uncurated deps + notes
As with snapadmin-info, the dashed shim, the
underscored snapadmin_license_check and
python manage.py snapadmin_license_check are the same command.
Tiers
| Tier | Meaning |
|---|---|
| 🟢 permissive | MIT / BSD / Apache-2.0 / ISC — use freely, including closed-source and commercial products. |
| 🟡 weak copyleft | LGPL / MPL — fine for proprietary use as an unmodified, dynamically-imported dependency. |
| 🔴 copyleft / commercial | GPL / AGPL / SSPL / "GPL-or-commercial" — distribution obligations; kept out of the base install, opt-in only. |
Flags
| Flag | Effect |
|---|---|
--json | Emit the report as JSON — packages, the commercial-compatibility verdict and the vulnerability-scan note — for a CI gate. |
--critical-only | Show only the non-permissive (🟡 / 🔴) licences — the ones worth a second look. |
--compatible-with SPDX | Advisory per-package compatibility with a project licensed SPDX (e.g. MIT): ✓ yes, ✗ no, ? review. |
--verbose | Also list any dependency SnapAdmin declares that isn't in the curated map, classified best-effort from its own metadata. |
Sample output
📋 SnapAdmin licence audit
Core dependencies
🟢 Django BSD-3-Clause installed 6.0
🟢 djangorestframework BSD-3-Clause installed 3.16
…
Optional extras
🟢 [elasticsearch] elasticsearch Apache-2.0 installed 8.19
🟡 [backup] paramiko LGPL-2.1 installed 3.5
Weak copyleft — fine for proprietary use as an unmodified, dynamically-imported dependency.
🔴 [wysiwyg] django-ckeditor-5 GPL-2.0-or-later OR Commercial not installed
bundles: The BSD Python wrapper ships CKEditor 5, which is GPL/commercial.
Commercial compatibility: ✓ OK — installed licences are proprietary-safe
Vulnerability scan: no vulnerability scanner installed — `pip install pip-audit` then run `pip-audit`.
Curated data last reviewed: 2026-09-03 (0 days ago)
pip install django-snapadmin pulls only MIT/BSD/Apache-2.0 code. The one
dependency to watch for commercial use is the [wysiwyg] extra (CKEditor 5,
GPL-or-commercial); the LGPL helpers ([backup], [autocomplete-filter])
are weak copyleft and opt-in. See Optional extras.
pyproject.toml
and each package's own licence metadata. Past 180 days unreviewed, the command adds a loud
⚠ Curated licence data is over N days old line so a fork or a long-idle install
doesn't trust a table nobody has looked at in months — verify the affected packages yourself
(or send a PR) rather than relying on a stale 🟢/🟡/🔴.
🌍 Internationalization (i18n)
SnapAdmin's UI strings are wrapped in gettext and ship compiled translation catalogs for 10 locales — English, Russian, German, Swiss German (de_CH, ß→ss), French, Swiss French (fr_CH), Spanish, Italian, Polish, Dutch. A missing string falls back to English automatically.
Wire up Django's locale machinery in your project (the demo project already does this):
# settings.py
MIDDLEWARE = [..., "django.middleware.locale.LocaleMiddleware", ...] # after SessionMiddleware
LANGUAGES = [("en", "English"), ("ru", "Russian"), ("de", "German"), ("de-ch", "Swiss German"), ...]
# urls.py — backs the language switcher
path("i18n/", include("django.conf.urls.i18n")),
Drop the accessible language selector into any page (for example an admin login or base template override):
{% include "snapadmin/language_switcher.html" %}
It posts to Django's set_language view and renders nothing when only one language is configured. The switcher builds its option list from Django's own get_available_languages, so it follows your LANGUAGES setting rather than a catalog shipped by SnapAdmin.
Native-reviewed: en (the source language) and ru.
Machine-assisted, awaiting a native speaker's review: de, de_CH,
es, fr, fr_CH, it, nl,
pl. Those read correctly but may not read naturally; a correction is a small
pull request: the edited snapadmin/locale/<locale>/LC_MESSAGES/django.po and
the django.mo that compilemessages rebuilds from it. Native speakers
are warmly invited to review a whole catalog.
What a machine can decide is checked on every run, in every one of the twenty catalogs
(tests/test_translation_lint.py and tests/test_i18n.py, the class of
defects msgfmt --check reports): no untranslated or fuzzy entry, every
placeholder of the source kept with its conversion — a misspelt %(name)s is a
KeyError when the string renders, %(n)d for %(n)s a
TypeError — the same leading and trailing newlines and HTML tags, a header naming
its own locale in UTF-8 and declaring the language's plural rule, and a compiled
django.mo that holds exactly what its django.po says.
verbose_names and field labels are yours to translate in your project's catalogs.
The theme's strings are translated too
django-unfold ships no translation catalogs at all, so on a themed
admin its own chrome — “All applications”, “Apply Filters”, “No results found”, “Select action”,
the command-palette hints — used to stay English around a page whose labels were translated. That
is the mixed-language admin people report.
Django resolves a string against the catalogs of every installed app, so SnapAdmin
simply answers for them: snapadmin/theme_i18n.py declares those msgids and the ten
shipped catalogs translate them. Nothing to enable, and Unfold is not patched — remove the theme
and the extra entries are just unused.
LOCALE_PATHS, your wording wins: project catalogs are consulted
before any app's.
django-unfold has no locale/ directory
today — a translation submitted upstream was declined pending an extraction workflow
(unfoldadmin/django-unfold#1704,
plus the earlier #1115).
If upstream lands its own catalogs, theme_i18n.py and the 70 msgids it feeds
become redundant, not broken — Django merges every installed app's catalog for
a given msgid, so nothing here has to change first, and this module can simply be deleted once
Unfold's own translation for a string is available.
🎨 Theming & Styles
SnapAdmin ships its admin styling as three layers: one shared sheet plus exactly one theme layer, so an install that doesn't use Unfold gets a modern form layout, and an install that does is never fought by one.
| Stylesheet | Scope | When it loads |
|---|---|---|
snapadmin/css/admin.css |
Theme-agnostic core — the shared :root design tokens, SnapAdmin's own widgets (Select2 legibility, the CKEditor shell, the formatted_id badge) and changelist cosmetics |
Always, on every SnapModel admin page |
snapadmin/css/admin-stock.css |
The form-layout rewrite for Django's built-in admin — labels above full-width fields, consistent borders and padding, a padded filter_horizontal picker, spaced date/time shortcuts, a styled action bar |
Only when django-unfold is absent |
snapadmin/css/admin-unfold.css |
The few gaps Unfold leaves — Add-button visibility, the CKEditor shell inside Unfold's field column, and a guarantee that a themed <select> shows exactly one arrow |
Only when django-unfold is installed |
The two theme layers are mutually exclusive, and that is the scoping mechanism — neither carries a theme prefix, so exactly one of them ever reaches the page. Shared design tokens (--primary-color, --radius, …) are defined once in the core sheet and referenced by both.
.unfold ancestor class — which current Unfold does not put on the page — so the scoped copies were dead while unscoped ones overrode Unfold's two-column rows, its field widths and the gutter its select chevron sits in. If you add your own admin CSS via css_admin_files, keep layout rules out of anything a theme loads.
INSTALLED_APPS and the layers swap over automatically.
Django's built-in auth admin under the theme
Unfold ships template overrides for django.contrib.auth but leaves the matching admin classes and forms to the project. Left unwired, the built-in User screen renders Unfold's templates against Django's forms — and the mismatch is not cosmetic: the password row comes out empty with no way to change the password, because Unfold's default password-hash template is written for Django < 5.1 and the "Reset password" button lives in a newer variant that only Unfold's own UserChangeForm selects.
SnapAdmin wires it for you from SnapAdminConfig.ready(): User and Group are re-registered as (DjangoAdmin, unfold.admin.ModelAdmin) with Unfold's UserChangeForm, UserCreationForm and AdminPasswordChangeForm. It is deliberately conservative — only a registration whose admin class is exactly Django's stock one is replaced, so if you have subclassed UserAdmin yourself, your class is left completely alone (wire Unfold in yourself in that case). Without the theme installed the step is a no-op.
# settings.py — opt out and keep Django's stock auth admin as-is
SNAPADMIN_THEME_AUTH_ADMIN = False
🧬 Extending & Overriding
SnapAdmin is built to extend without forking. When a generated surface isn't enough, reach for the closest hook below — each snippet drops into your project.
1. Add your own field type
Mix SnapField into any Django field, then run incoming kwargs through
_initializeSnapLogic() (consumes the Snap-only options like searchable,
filterable, show_in_list) and handleDjangoKwargs() (returns
the Django-safe kwargs):
# yourapp/fields.py
from django.db import models
from snapadmin.fields import SnapField
class SnapMoneyField(models.DecimalField, SnapField):
def __init__(self, **kwargs):
kwargs.setdefault("max_digits", 12)
kwargs.setdefault("decimal_places", 2)
kwargs.setdefault("filterable", True) # Snap option
kwargs = self._initializeSnapLogic(**kwargs) # consume Snap options
cleaned = self.handleDjangoKwargs(**kwargs) # Django-only kwargs
super().__init__(**cleaned)
2. Reuse the built-in validators
The validators behind SnapPhoneField / SnapColorField /
SnapFileField are public and work on any plain Django field:
from snapadmin.validators import SnapPhoneValidator, SnapFileValidator
phone = models.CharField(max_length=20, validators=[SnapPhoneValidator()])
contract = models.FileField(validators=[
SnapFileValidator(allowed_extensions=["pdf"], max_size_bytes=10 * 1024 * 1024),
])
3. Extend a SnapModel
SnapModel is a normal abstract model — add methods, a custom manager, override
save(), or tune the ES hooks. The admin/API surfaces are driven off your declared
fields, so extra methods never break them:
class Product(snap_models.SnapModel):
name = snap.SnapCharField(max_length=200, searchable=True)
price = SnapMoneyField()
es_storage_mode = snap_models.EsStorageMode.DUAL
es_auto_mapping = True # searchable=True does not map anything
es_index_settings = {"number_of_shards": 2} # override index creation
def save(self, *args, **kwargs):
self.name = self.name.strip()
super().save(*args, **kwargs) # keeps ES mirroring intact
4. Add custom REST endpoints (or override CRUD for one model)
The REST layer is one generic DynamicModelViewSet routed by
app_label/model_name. To customise a single model, register
your own DRF viewset in your urls.py, before SnapAdmin's
routes so it wins for that path — reuse Snap's serializer via
get_serializer_for_model():
from rest_framework import viewsets, decorators
from rest_framework.routers import DefaultRouter
from snapadmin.api.serializers import get_serializer_for_model
from yourapp.models import Product
class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.all()
serializer_class = get_serializer_for_model("yourapp", "product")
@decorators.action(detail=False)
def on_sale(self, request):
page = self.paginate_queryset(Product.objects.filter(price__lt=100))
return self.get_paginated_response(self.get_serializer(page, many=True).data)
router = DefaultRouter()
router.register(r"api/product", ProductViewSet, basename="product")
urlpatterns = [*router.urls, path("", include("snapadmin.urls"))]
/api/? SnapAdmin's routes live wherever you
include("snapadmin.urls"), so the simplest fix is to mount them under an unused path
(path("snapadmin/", include("snapadmin.urls"))). If you can't change the mount point
(SnapAdmin is included at the site root, or an intermediate URLconf pins it under /api/),
set SNAPADMIN_URL_PREFIX = "snapadmin/" to relocate the whole surface — REST,
Swagger and GraphQL — under that extra segment. Route names are unchanged, so
reverse()/{% url %} keep working; empty (the default) keeps the historical
layout.
Prefer to keep the generic viewset but change how it behaves globally? Subclass
DynamicModelViewSet and point your own route at it.
5. Swap auth, permissions & the ES client (config, no code)
| To change… | Set |
|---|---|
| Who can call the API (JWT / session / custom) | SNAPADMIN_API_AUTHENTICATION_CLASSES — see Integrating |
| Which model a token may touch | APIToken.allowed_models (AND-ed with Django perms) |
| Whether a token may call your own endpoint | APIToken.allowed_scopes + token_has_scope() — see Token Management |
| How the Elasticsearch client is built | ELASTICSEARCH_KWARGS / SNAPADMIN_ES_CLIENT_FACTORY |
| GraphQL auth / GraphiQL exposure | SNAPADMIN_GRAPHQL_REQUIRE_AUTH / SNAPADMIN_GRAPHIQL_ENABLED |
| Hiding fields from every API surface | api_exclude_fields on the model |
| Restricting which fields REST create/update can write | api_write_fields on the model |
| Filtering key-paths inside a JSON column | api_json_filters on the model |
| Widening/narrowing a text field's auto-filter lookups | api_filter_lookups on the model |
SnapModels and enforces
the same per-model permissions and api_exclude_fields as REST — extend it by
adding/removing SnapModels and tuning those settings, not by editing the schema.
6. Override admin templates & the dashboard
SnapAdmin's templates live under the snapadmin/ template namespace, so Django's
normal template resolution lets you shadow any of them. Put your project's templates/
dir ahead of the app in TEMPLATES['DIRS'], then drop in a same-named file:
yourproject/templates/snapadmin/dashboard.html # replace the system dashboard
yourproject/templates/snapadmin/email/error_alert.html # rebrand the spike-alert email
yourproject/templates/snapadmin/email/error_digest.html # rebrand the daily digest
7. Package layout — the module map
Everything is importable from its own module (from snapadmin.backup import run_backup);
the most common names are also re-exported from the top level
(from snapadmin import SnapModel, SnapCharField). The re-exports are lazy, so importing
snapadmin — or a console script that runs before Django is configured — never eagerly
loads the Django-backed modules.
| Module | Owns |
|---|---|
models | SnapModel, the ES query layer (EsManager/EsQuerySet, es_filter/es_aggregate/es_count/es_scan), APIToken, the admin-registration helpers, and the EsStorageMode enum / SnapPurgeError/SnapEsUnavailable exceptions. |
fields | Every Snap*Field type — the declarative field layer with its snap-only kwargs (stripped from deconstruct() so they add no migration). |
validators | SnapPhoneValidator / SnapColorValidator / SnapFileValidator (deconstructible). |
admin, widgets, views | The admin base classes (Unfold-optional), form widgets, and the staff-gated dashboard view. |
api/ | The REST + GraphQL surface: views, serializers, filters, authentication, exports, graphql, health, users. |
exporting, reindexing, etl | The async export writer (+ pluggable row sources), the resumable bulk ES reindex, and the ETL upsert/prune helpers. |
monitoring, health, backup, audit, masking | Error monitoring & digests, subsystem health alerts, 3-2-1 backups, the audit trail, and PII masking. |
diagnostics/ | The auto-discovered collectors behind snapadmin_info (version, features, database, elasticsearch, celery, inventory, …). |
licensing | The curated dependency-licence map behind snapadmin_license_check. |
quickstart/, integrate/ | The stdlib-only console scripts snapadmin-demo and snapadmin-init — they never import Django at import time. |
checks, apps, urls, logging_config | System checks, the AppConfig, URL routes, and structlog setup. |
No module ever moves or is renamed — a deep import path is a permanent contract. Structure improves additively only: new modules, blessed top-level re-exports, docstrings.
🔄 Migration Guides
Hard migrations — upgrades that need manual steps beyond reading the
release notes —
get a dedicated guide in
docs/migrations/.
Most releases don't need one; SnapAdmin aims for backward compatibility on every change.
| Migration | Guide |
|---|---|
| v0.0.x → v0.1.x (significant rewrite) | 0.0.x_to_0.1.x.md |
| 0.1.0a10 → 0.1.0a11 (migration history reset) | 0.1.0a10_to_0.1.0a11.md |
| 0.1.0a11 → 0.1.0b1 (Celery task rename, dashboard staff gate, deps moved to optional extras) | 0.1.0a11_to_0.1.0b1.md |
0.1.0b7 → 0.1.0b8 (REST/GraphQL moved to [api]/[graphql] extras, both default off, deprecated aliases removed) |
0.1.0b7_to_0.1.0b8.md |
🧭 AI Assistants — llms.txt & the module map
If you build with an AI coding assistant, SnapAdmin ships two entry points written for machines rather than browsers. Both are kept in sync with the code by the test suite, so an assistant reading them will not be taught something that has since been renamed or removed.
llms.txt — a map of this documentation
llms.txt follows the
llmstxt.org convention: plain Markdown, no navigation chrome,
no JavaScript. It states what SnapAdmin is, carries the three-step quickstart, lists the facts an
assistant should not have to infer — snap-only field kwargs add no migration, import paths are the
public contract, the Unfold theme is optional, misconfiguration surfaces as
snapadmin.W001–W037, snapadmin.E001–E029 and
snapadmin.I001–I002 —
and then links every section of this page with a
line describing what it covers.
Point your assistant at the URL, or paste the file into its context. A byte-identical copy sits at the repository root and travels inside the source distribution, so it is available offline too.
The module map — available from any install
The snapadmin package docstring is a quickstart plus a map of the package: what each
module and management command is for, the SNAPADMIN_* setting families, and the
optional extras. Unlike llms.txt this needs no network and no repository access — it
is what an assistant finds when it reads the installed package, and what you get from:
python -c "import snapadmin; help(snapadmin)"
tests/test_ai_entry_points.py asserts that every module the map names actually
imports, that every documentation anchor llms.txt links to still exists on this page,
that the two copies of llms.txt match, and that the documented extras match
pyproject.toml. A rename that breaks the map fails CI instead of shipping.
🚀 Quick Start — snapadmin-demo
The fastest way to see SnapAdmin running, with no clone and no existing project.
pip install django-snapadmin puts a snapadmin-demo command on your PATH
(also python -m snapadmin.quickstart):
pip install django-snapadmin
snapadmin-demo
It downloads the demo/ directory from the GitHub source tarball of the matching
release tag (the wheel doesn't ship it), caches and checksums it under
~/.cache/snapadmin-demo/ so re-runs are instant and work offline, extracts only
demo/ into the current directory, then installs its requirements, applies migrations,
seeds example data and starts the server at http://localhost:8000.
--skip-install) pip installs the demo requirements into the
current environment — run it in a fresh virtualenv. Tar members are sanitised
(no path traversal, symlinks skipped) and you are asked before any existing file is overwritten
or, on a refresh, deleted. It is stdlib-only and never imports Django in-process — it drives
manage.py as a subprocess.
Refreshing an existing demo tree
pip install -U django-snapadmin upgrades the package. The
demo/ directory it extracted earlier is an ordinary directory and is
not upgraded with it — it keeps serving the models, templates and settings of the
release it came from, which is a reliable way to chase problems the installed release already
fixed. Re-run snapadmin-demo to bring the tree up to date:
pip install -U django-snapadmin
snapadmin-demo --skip-install --no-serve # refresh the tree in place
Each extraction leaves a small .snapadmin-demo.json stamp at the root of the tree —
the release it came from and the list of files that extraction wrote. On the next run that stamp
gives you:
- a line naming both versions before anything is touched (“Refreshing the existing demo tree at … : v0.1.0b5 → v0.1.0b6”);
- deletion of files the new release dropped. Extraction overlays a tree, so
without this a template removed upstream would linger and keep rendering. Only files listed in
the previous stamp are candidates — anything you added yourself (a
.env, your own app, an edited settings file that still exists upstream) is never in that list and is never deleted; - a drift line in
manage.py snapadmin_infowhen the tree and the installed package disagree, so the report says so instead of leaving you to guess.
Flags
| Flag | Effect |
|---|---|
--version X.Y.Z | Which release to fetch (default: the installed django-snapadmin version). |
--path DIR | Where to extract demo/ (default: current directory). |
--skip-install | Reuse the current environment — don't pip install the demo requirements. |
--no-serve | Prepare everything but don't start the server. |
--clear-cache | Delete cached downloads under ~/.cache/snapadmin-demo/ first. |
-y, --yes | Replace existing demo files without asking. |
Configuration
--interactive runs a short wizard — run mode (runserver / Docker), database
(SQLite / PostgreSQL, with connection details), Elasticsearch on/off, admin password, secret-key
generation and debug — and writes a .env the demo reads. Capture and share a setup so a
whole team gets the same environment:
snapadmin-demo --interactive --save-config team.ini # answer once, save it
snapadmin-demo --load-config team.ini # a colleague replays it
The same choices are available as non-interactive flags for CI —
--mode, --database, --db-host, --db-password,
--elasticsearch / --no-elasticsearch, --admin-password,
--debug. (The demo runs on SQLite or PostgreSQL; MySQL is not one of its backends.)
🌟 Running the Demo
The repository includes a complete demo project to explore all features.
Via Docker (Recommended)
git clone https://github.com/drofji/django-snapadmin.git
cd django-snapadmin
cp demo/dist.env demo/.env
docker compose -f demo/docker-compose.yml up --build
With Traefik — Local Development (HTTP)
Access the app at http://snapadmin.localhost/ with a BasicAuth-protected dashboard:
docker compose -f demo/docker-compose.yml -f demo/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 demo/.env:
TRAEFIK_DOMAIN=admin.mycompany.com
TRAEFIK_ACME_EMAIL=your@email.com
TRAEFIK_DASHBOARD_CREDENTIALS=admin:$$apr1$$... # see demo/dist.env for generation
ALLOWED_HOSTS=admin.mycompany.com
DEBUG=False
Then start:
docker compose -f demo/docker-compose.yml -f demo/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 demo/.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 demo/requirements.txt
pip install -e .
python demo/manage.py migrate
python demo/manage.py seed_demo
python demo/manage.py runserver
Building images with automatic retention
For the test/demo image, demo/scripts/docker_build.sh builds, tags by build-day, and self-prunes so old images never pile up:
demo/scripts/docker_build.sh # image=snapadmin-test, keep 3 build-days
IMAGE=myimg demo/scripts/docker_build.sh # custom image name
SNAPADMIN_IMAGE_KEEP_DAYS=5 demo/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 demo.scripts.docker_retention prune --image snapadmin-test --dry-run
Container health check — Docker, Coolify, Dokploy, Kubernetes
SnapAdmin serves an unauthenticated probe at GET /api/health/. Point your platform's
health check at it — it is the one endpoint that actually reports whether this instance can serve:
| Overall status | HTTP | Meaning |
|---|---|---|
healthy | 200 | Database reachable; the instance can serve. |
degraded | 200 | An optional subsystem (Elasticsearch) is down. Still serving — taking the instance out of rotation would make the outage worse. Alert on the body, don't restart on it. |
unhealthy | 503 | The database is unreachable. Restart or replace this instance. |
/admin/.
It answers 302 (redirect to login) even with the database down, so a broken instance
reports as healthy. Probing the login page only tells you a web server is listening.
An anonymous caller receives only {"status": "healthy"} — the
per-service breakdown is reserved for an authenticated session or token — so the endpoint is safe
to expose to a load balancer or an external uptime monitor.
Dockerfile (the demo image ships exactly this, and bundles curl for it):
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -fsS http://localhost:8000/api/health/ || exit 1
Docker Compose — use this when the image has no HEALTHCHECK of its own,
or to override it per environment:
services:
app:
healthcheck:
test: [ "CMD", "curl", "-fsS", "http://localhost:8000/api/health/" ]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s # covers migrate + collectstatic on first boot
Coolify / Dokploy / Caprover — these expose the same Docker fields in their UI. The values to enter:
| Field | Value | Why |
|---|---|---|
| Path | /api/health/ | The trailing slash matters — Django's APPEND_SLASH would otherwise answer 301 first. |
| Port | 8000 | Whatever Gunicorn binds inside the container, not the published port. |
| Method / expected status | GET / 200 | Anything else means the database is unreachable. |
| Interval | 30s | Every probe opens a database cursor; much below this is pointless load. |
| Timeout | 5s | The check does no heavy work; a slow answer is itself a symptom. |
| Retries | 3 | Survives a single blip without flapping the container. |
| Start period / grace | 60s | Migrations, collectstatic and any seeding run before the first request is served. Raise it if your start-up work is longer — a too-short grace period restart-loops a container that was simply still booting. |
Kubernetes — the same endpoint works for both probes; give the liveness probe a longer failure threshold so a brief database blip restarts nothing:
readinessProbe:
httpGet: { path: /api/health/, port: 8000 }
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet: { path: /api/health/, port: 8000 }
initialDelaySeconds: 60
periodSeconds: 30
failureThreshold: 6
Static, media and backups on remote storage
Local disk is fine for one container. The moment there are two, media must move
off the local filesystem: a file uploaded through instance A is a 404 on instance B, and a
container restart loses it. The demo settings switch Django 5+ STORAGES from a single
environment variable, and the same block is what you would copy into your own project.
| What you have | Protocol | How to use it |
|---|---|---|
| AWS S3 | S3 | SNAPADMIN_STORAGE_BACKEND=s3, leave AWS_S3_ENDPOINT_URL empty — boto3 derives it from the region. |
| Hetzner Object Storage | S3-compatible | Same, with AWS_S3_ENDPOINT_URL=https://fsn1.your-objectstorage.com and AWS_S3_REGION_NAME=fsn1 (or hel1/nbg1 — use the bucket's own location). |
| MinIO / self-hosted | S3-compatible | Same, plus AWS_S3_ADDRESSING_STYLE=path when you have no wildcard DNS. |
| Backblaze B2 | S3-compatible | Same, endpoint https://s3.<region>.backblazeb2.com. |
| Hetzner Storage Box | SFTP / CIFS / WebDAV — not S3 | Mount it on the host and keep STORAGE_BACKEND=local, pointing the media volume at the mount. For backups only you need no mount at all — see below. |
# .env — any S3-compatible provider, same variables
SNAPADMIN_STORAGE_BACKEND=s3
AWS_ACCESS_KEY_ID=…
AWS_SECRET_ACCESS_KEY=…
AWS_STORAGE_BUCKET_NAME=my-bucket
AWS_S3_ENDPOINT_URL=https://fsn1.your-objectstorage.com # empty for real AWS
AWS_S3_REGION_NAME=fsn1
AWS_QUERYSTRING_AUTH=True # signed, time-limited URLs — correct for a private bucket
AWS_S3_FILE_OVERWRITE=False # never let one upload silently replace another
AWS_DEFAULT_ACL= # modern buckets disable ACLs; sending one fails the PUT
SNAPADMIN_STATIC_ON_S3=False # static is public+immutable; WhiteNoise is usually enough
It needs pip install "django-storages[s3]" (BSD-3-Clause). Nothing is imported
while STORAGE_BACKEND=local — STORAGES holds a dotted path Django resolves
lazily — so the dependency stays optional.
SNAPADMIN_EXPORT_STORAGE=storages.backends.s3.S3Storage at the same bucket.
Database backups are a separate mechanism — SnapAdmin writes dumps to its own destinations rather than through Django's storage layer, so a Storage Box works directly:
| Destination | Setting | Fits |
|---|---|---|
| Offsite over SSH | SNAPADMIN_BACKUP_SFTP_* | Hetzner Storage Box, any SSH host. No mount needed; needs the [backup] extra. |
| Offsite over FTP/FTPS | SNAPADMIN_BACKUP_FTP_* | Storage Box also speaks FTPS. |
| Another machine on the network | SNAPADMIN_BACKUP_NETWORK_DIR | Any mounted share — NFS, or a Storage Box mounted over CIFS. |
# Mount a Storage Box over CIFS, then point the network destination at it
//u123456.your-storagebox.de/backup /mnt/box cifs credentials=/etc/box.cred,uid=1000 0 0
SNAPADMIN_BACKUP_NETWORK_DIR=/mnt/box/snapadmin
celery -A yourproject inspect ping -t 10 — it succeeds only
while the worker is actually consuming, so a hung worker fails it. Beat exposes nothing meaningful
to probe; rely on restart: unless-stopped plus the
health-alert email, which is the practical signal that it stopped
firing. The demo's compose file wires both.
📦 Demo App — Model Overview
The demo/ app contains pre-built models that showcase every SnapAdmin feature. Most differ by Elasticsearch storage mode; the last differs by how it was declared at all.
| 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, snap_field() on a plain DateTimeField |
AuditLog |
DB_ONLY | GDPR retention: data_retention_days=90 |
Showcase |
DB_ONLY | All 30 field types in one model, tabs, rows, WYSIWYG, phone, color |
LegacyStockLevel |
— | The other door: a plain django.db.models.Model opted in
with @snap_model instead of subclassing, with the consequences visible — no ES, no
retention purge, and a hand-written ModelAdmin because nothing is generated for it.
Also carries the demo's api_field_permissions
guard (reorder_cost is readable only with demo.view_stock_cost) |
🌱 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.