SnapAdmin
Automatic, beautiful, production-ready Django Admin — zero boilerplate. REST & GraphQL, Elasticsearch, offline mode and GDPR retention, all from your model definitions.
SNAPADMIN_ENCRYPTION), declarative database sharding and
read-replica routing (SNAPADMIN_SHARDING), and a new
manage.py snapadmin_age_keygen command join row-level
multi-tenancy (default-deny across every generated surface),
GDPR subject-access export/deletion, CSV/NDJSON
import mirroring the async export, a cache-backed
quota primitive and user-defined REST actions on top of the
existing admin/REST/GraphQL/Elasticsearch generation. Breaking changes to know about
before upgrading — see the migration guide:
SNAPADMIN_REST_API_ENABLED/SNAPADMIN_GRAPHQL_ENABLED now default to
False (pin either to True to keep serving them); DRF/drf-spectacular/
django-filter/graphene-django moved behind the new [api]/
[graphql] extras; the deprecated db_backup/
purge_expired_data/send_error_digest command aliases plus the
underscored snapadmin_info/snapadmin_license_check console scripts,
announced for removal since the beta series, are removed in this release; and
SNAPADMIN_PROFILE = "full"/"api" now really mount REST, GraphQL and
Swagger, per their documented meaning.
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.
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.
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",
# "django_celery_results",
# Your apps …
]
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 | Full-text search / ES_ONLY / DUAL models |
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) |
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.- 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
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. |
🩺 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 |
🔀 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"]
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"],
)
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 searchable/
filterable fields. 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 | ✅ | ✅ full parity — every Snap*Field flag is reachable from snap_field() |
| 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
— see #RFC1g in the project roadmap — 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.
🔌 @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.
? 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 |
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). 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.
🔌 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__ already
recorded its constructor arguments, so deconstruct() never reports them. 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")
APIToken admin is also registered automatically when register_all_admins() is called.
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_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:
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.
? 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"
⚛️ GraphQL API
SnapAdmin provides a dynamic GraphQL interface powered by Graphene-Django.
- Auto-Schema: All
SnapModelsubclasses are automatically added to the schema. - Secured by default: every resolver requires authentication (admin session or API token) and the model's Django
viewpermission — the same contract as REST — 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, and (per field) holders of a permission named inSNAPADMIN_MASKING_RULES, whose rules apply here too. - 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
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. 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.
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.
from snapadmin.models import SnapModel, EsStorageMode
from snapadmin import fields as snap_fields
# DB_ONLY (default — no extra config needed)
class Article(SnapModel):
title = snap_fields.SnapCharField(max_length=200, searchable=True)
body = snap_fields.SnapTextField()
# DUAL — save to PostgreSQL AND Elasticsearch, search via ES
class Product(SnapModel):
name = snap_fields.SnapCharField(max_length=200, searchable=True)
description = snap_fields.SnapTextField()
price = snap_fields.SnapDecimalField(max_digits=10, decimal_places=2)
es_index_enabled = True
es_storage_mode = EsStorageMode.DUAL
es_mapping = {
"name": {"type": "text", "analyzer": "standard"},
"description": {"type": "text"},
"price": {"type": "float"},
}
# ES_ONLY — no DB table (managed=False), data lives only in Elasticsearch
class SearchLog(SnapModel):
query = snap_fields.SnapCharField(max_length=255, searchable=True)
results_count = snap_fields.SnapIntegerField()
timestamp = models.DateTimeField(auto_now_add=True)
es_storage_mode = EsStorageMode.ES_ONLY
es_mapping = {
"query": {"type": "text"},
"results_count": {"type": "integer"},
"timestamp": {"type": "date"},
}
class Meta:
managed = False # required for ES_ONLY — no DB table is created
Custom analyzers & index settings — es_index_settings
Index-level settings (custom analyzers under analysis, number_of_shards,
number_of_replicas, …) are declared in es_index_settings and applied when the
index is first created. Existing indexes are never altered — to apply a change,
delete the index and run es_reindex_all():
class Product(SnapModel):
es_storage_mode = EsStorageMode.DUAL
es_mapping = {
"name": {"type": "text", "analyzer": "de_analyzer"},
"price": {"type": "float"},
}
es_index_settings = {
"analysis": {"analyzer": {"de_analyzer": {"type": "german"}}},
"number_of_shards": 1,
}
Automatic mapping — es_auto_mapping
Don't want to write mappings by hand? Set es_auto_mapping = True and the mapping is
derived from the model's fields: CharField/TextField → text with a
.raw keyword subfield (exact match + aggregations), Email/Slug/URL/UUID/IP/File →
keyword, integers & FK → long, Float → double,
Decimal → scaled_float, dates → date, JSONField →
object. Entries in es_mapping override the derived ones per field:
class SearchLog(SnapModel):
query = snap_fields.SnapCharField(max_length=255, searchable=True)
results_count = snap_fields.SnapIntegerField()
es_storage_mode = EsStorageMode.ES_ONLY
es_auto_mapping = True # derived: query → text + .raw, results_count → long
# es_mapping = {"query": {"type": "search_as_you_type"}} # optional override
es_search() and the routed REST ?search=) target only the
text-capable fields of es_mapping and run with lenient: true, so a mapping
that mixes numeric/date/boolean fields never breaks a search. ES failures that previously
disappeared silently are logged as structlog warning events
(es_ensure_index_failed, es_index_document_failed, es_search_failed, …).
Querying with es_search()
es_search(query_string=None, limit=20) is the single entry point for search. It runs a fuzzy multi_match over the index when ES is enabled, and gracefully falls back to an ORM query (using the model's searchable fields) when ES is off or unreachable — so the same call works in every environment. snap_search() is a public alias with identical behavior.
# Full-text search (fuzzy, typo-tolerant) — returns a queryset-like result
results = Product.es_search("wireles headphones") # note the typo — still matches
for product in results:
print(product.name, product.price)
# Cap the number of hits (default 20)
top5 = Product.es_search("laptop", limit=5)
# No query string → match-all (most-recent first), handy for "browse" views
everything = Product.es_search(limit=100)
# Public alias — identical behavior, nicer name for app code
hits = Product.snap_search("4k monitor")
# ES_ONLY models: es_search() is the ONLY way to read them (no DB table)
logs = SearchLog.es_search("error 404")
recent_logs = SearchLog.es_search(limit=50)
# DB_ONLY models still answer es_search() — it falls back to an ORM
# icontains query across the model's searchable fields:
articles = Article.es_search("django") # works even with ELASTICSEARCH_ENABLED=False
DB_ONLY/DUAL, es_search() returns a normal Django QuerySet (ES hits are mapped back to real DB rows, ES order preserved). For ES_ONLY it returns a lightweight EsQuerySet of model instances built straight from the index — iterable, sliceable, and countable, but not a DB queryset.
es_search() yourself for API consumers: a plain
GET /api/models/{app}/{Model}/?search=… on a DUAL model is routed to
Elasticsearch automatically. See Smart ES Query Routing for examples,
the routing matrix and the X-Snap-Query-Backend header.
Re-indexing (DUAL mode)
# Manually trigger a full re-index of every row into ES.
# Streams the table through the bulk API — one round-trip per 500 docs
# (tune with chunk_size=). 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
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_DIR = "/snapadmin"
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.
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.
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
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.
| 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.
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.
↩️ 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 |
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.
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). The two 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_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. |
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 range delete_by_query against the index on data_retention_field (which must be mapped as a date in es_mapping). 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.
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.
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_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.
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.
A deletion audit entry is written after a successful run — action delete, model
subject_access_deletion, the per-model row counts, 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 — Keys
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. The SnapEncrypted*Field family builds on this layer and is documented
with the release that ships it.
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.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. |
🏘️ 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".
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
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
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. Emails become
a***@domain.com, 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 (silently ignored, not a 400), 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).
# 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.
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. 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.
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, related objects) is stored as text. 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. 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):
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
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_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) |
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_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 |
SNAPADMIN_THROTTLE_USER | 600/min | Rate limit for authenticated callers, same enforcement; None disables it |
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_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_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) |
SNAPADMIN_BACKUP_LOCAL_DIR | ./backups | Copy 1: directory on the same server |
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_DIR | / | Target directory on the SFTP server |
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.
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.
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.
🩺 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
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. |
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. - 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.0b8
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.
⚖️ 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.
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_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 |
|---|---|
Legacy drofji-automatically-django-admin → SnapAdmin |
drofji-automatically-django-admin_to_django-snapadmin.md |
| 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–W018 and snapadmin.E001–E019 —
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.