⚡ Django Admin, supercharged

SnapAdmin

Automatic, beautiful, production-ready Django Admin — zero boilerplate. REST & GraphQL, Elasticsearch, offline mode and GDPR retention, all from your model definitions.

What's New in v0.1.0b8 — eighth beta Field-encryption key management (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.

📦 The package vs. 🌟 the demo. Everything under Start Here, Declare Your Models, Use the Data, Run It in Production and Reference in the sidebar is the installable 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.

VersionsStatus
Python3.10 · 3.11 · 3.12 · 3.13Supported and tested in CI on every version (declared floor 3.10).
Django5.2 (LTS) · 6.0Supported and tested in CI on both (Django 6.0 requires Python ≥ 3.12).
DatabasesSQLite · PostgreSQL · MySQL / MariaDBAny 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.
CI-enforced matrix. Every push and pull request runs the full suite across the Python × Django grid above (GitHub Actions, 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:

Extrapip installPulls inFor
apidjango-snapadmin[api]djangorestframework, drf-spectacular, django-filterThe REST API + OpenAPI schema/Swagger/ReDoc — off by default, needed once you set SNAPADMIN_REST_API_ENABLED / SNAPADMIN_SWAGGER_ENABLED to True
graphqldjango-snapadmin[graphql]graphene-djangoThe generated GraphQL schema — off by default, needed once you set SNAPADMIN_GRAPHQL_ENABLED to True, independent of api
themedjango-snapadmin[theme]django-unfoldUnfold-themed admin UI (falls back to Django's built-in admin without it)
elasticsearchdjango-snapadmin[elasticsearch]elasticsearchFull-text search / ES_ONLY / DUAL models
celerydjango-snapadmin[celery]celery, django-celery-beat, django-celery-resultsBackground tasks (async export, GDPR purge, digests, backups)
backupdjango-snapadmin[backup]paramikoSFTP offsite database backups (LGPL)
agedjango-snapadmin[age]pyrageAGE-encrypted backups (MIT — SNAPADMIN_BACKUP_AGE_RECIPIENTS; or skip this extra and use the age CLI instead)
s3django-snapadmin[s3]boto3S3-compatible offsite backup transport (SNAPADMIN_BACKUP_S3_* — AWS, MinIO, Backblaze B2, Hetzner Object Storage, Wasabi)
extra-settingsdjango-snapadmin[extra-settings]django-extra-settingsAn in-admin dynamic key/value Setting model (as the demo shows)
wysiwygdjango-snapadmin[wysiwyg]django-ckeditor-5Rich-text fields (SnapRichTextField / wysiwyg=True) — bundles CKEditor 5 (GPL-or-commercial)
autocomplete-filterdjango-snapadmin[autocomplete-filter]django-admin-autocomplete-filterAutocompleteFilter list filters in your own admin (LGPL)
xlsxdjango-snapadmin[xlsx]openpyxlXLSX output for the async export API (MIT — optional for size, not licence)
alldjango-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:

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.
MySQL drivers SnapAdmin itself carries no MySQL driver dependency. If you configure Django to use MySQL (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.

Never overwrites. 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

FlagEffect
--path DIRDirectory to create the project in (default: the current directory).
--app-name NAMEName of the app carrying the worked SnapModel example (default: catalog).
--fullAlso 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.

Read-only by design. It inspects your project and prints the exact snippet to paste for each missing piece — it never edits your files. Pasting a reviewed snippet is safer and more trustworthy than an automatic edit that might misread a non-standard project, so there is no backup gate to worry about.
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:

Flags

FlagEffect
--path DIRProject root (default: current directory).
--settings PATH / --urls PATHPoint at a non-standard layout (auto-detected otherwise via manage.py / globbing).
--url-prefix PREFIXPrefix for the SnapAdmin routes in the URL snippet, e.g. api/.
--extras a,bExtras for the install line (e.g. elasticsearch,celery).
--api / --graphqlAlso check the REST / GraphQL configuration.
--jsonEmit 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.

Two tools do the checking 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

CheckWhy it mattersHow to verify
App boots with snapadmin installedThe most basic proof the install is wired correctlypython manage.py check
Models are registeredA model with no capabilities looks identical to a typo'd import — until you checkpython 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 checkpython manage.py snapadmin_info --section inventory — each row shows door (subclass/decorator) and inactive_capabilities
No snapadmin.E* errorsErrors block startup in production (DEBUG=False); catch them locally firstpython manage.py check / snapadmin_info --section checks
Migrations appliedAn unapplied migration is a production incident waiting to happenpython manage.py migrate --check — exits non-zero without applying anything
First makemigrations after adopting SnapAdmin is AlterField-onlyConverting 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 changedpython 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 rendersThe most-used surface — worth a manual look, not just a status codeVisit /admin/ and log in
REST / GraphQL respond, if enabledBoth are separate opt-in surfaces — confirm each you actually turned onsnapadmin_info --health-check — probes both and exits non-zero on failure
Static files servedA missing collectstatic step is invisible in DEBUG=True and breaks only in productionpython manage.py collectstatic --dry-run

Should be configured before production

CheckWhy it mattersHow to verify
Authentication on the APIThe default is SnapAdmin's own token auth — fine, but confirm it's the one you meantsnapadmin-init --api (checks SNAPADMIN_API_AUTHENTICATION_CLASSES) · see Integrating
api_write_fields / api_read_only set where they matterThe default (unset) leaves every field mass-assignable — deliberate for a demo, risky for real datasnapadmin_info --section inventory — a "Write restricted" column, per model
PII masking configured for the fields that need itUnmasked PII in the admin/API/exports/audit diff is a compliance and breach-surface problemsnapadmin_info --section featurespii_masking
HTML sanitization activeReassurance, not a gap: nh3 fails closed — a missing sanitizer is a hard error the moment a wysiwyg field is written, never a silent pass-throughNothing to configure; it can't silently be off
API throttling configuredShips with a sane default — this row is about confirming that default is the one you want at your scalesnapadmin-initthrottling row (checks SNAPADMIN_THROTTLE_ANON/_USER)
API page size configuredSame idea, for SNAPADMIN_API_PAGE_SIZE — the default (25) is fine for most projects, but worth an explicit choicesnapadmin-initpagination row
Structured logging wiredJSON logs in production are what makes an incident debuggable after the factRun any command and check the output shape (colourised dev / JSON prod) — see Structured Logging
Error monitoring / alert channelsThe first sign of trouble should reach a human, not just a log file nobody readssnapadmin_info --section featureshealth_alerts
Health probes wired to the containerWithout this, an unhealthy container looks the same as a healthy one to your orchestratorcurl -f http://localhost:8000/api/health/ — see Container health check

Data safety

CheckWhy it mattersHow to verify
Backups enabled, at least two destinations (the 3-2-1 rule)One destination is one point of failure away from zero backupssnapadmin_info --section featuresbackups · see 3-2-1 Backups
Retention set (SNAPADMIN_BACKUP_KEEP)Unbounded retention quietly fills a disk; no retention loses historyCheck 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 featuresbackups 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 backuppython 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

CheckWhy it mattersHow to verify
ElasticsearchOnly matters once a model actually opts into itsnapadmin_info --section featureselasticsearch / --section elasticsearch
Celery (background tasks)Backups, digests and the retention purge all need a worker + a schedule entry — nothing runs by itselfsnapadmin_info --section featuresbackground_tasks · see Celery & Periodic Tasks
Offline modeA per-model opt-in for a usable admin with no connectionCheck offline_mode = True on the models that need it — see Offline Mode
ThemeCosmetic — Unfold if installed, stock Django admin otherwiseVisit /admin/
Async exportsLarge exports stream on a Celery worker instead of blocking a requestSee Async background export
Audit trailOn by default — this row is about confirming you haven't turned it offsnapadmin_info --section featuresaudit_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.

CapabilitySubclassing 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
Backupsidentical 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.

KeywordTypeEffect
api_exclude_fieldslist[str]Fields kept out of the REST serializer, the GraphQL type and the schema endpoint
api_write_fieldslist[str] | NoneMass-assignment allowlist; None leaves every non-excluded field writable
api_read_onlyboolServe the model over safe HTTP methods only (writes answer 405)
api_http_method_nameslist[str] | NoneExplicit lowercase HTTP-verb allowlist; wins over api_read_only
api_filter_lookupsdict[str, list[str]]Per-field query-filter lookups, e.g. {"name": ["exact", "icontains"]}
api_default_text_lookupslist[str] | NoneLookup set for every text field not named in api_filter_lookups
api_json_filtersdict[str, list[str]]Filterable key-paths inside JSON columns, e.g. {"payload": ["a.b"]}
offline_modeboolExpose the model to the admin's offline cache
offline_cache_limitintHow many recent rows the offline cache prefetches
search_fieldslist[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:

TierSourceExample
1 — highestThe model's registry entrywhat @snap_model(api_read_only=True) stored
2The model's class attributeclass Product(SnapModel): api_read_only = True
3A project-wide SNAPADMIN_<NAME> settingSNAPADMIN_API_READ_ONLY, resolved through snapadmin.conf.get_setting so a SNAPADMIN_PROFILE preset can supply it too
4 — lowestThe caller's built-in defaultthe 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, …
FlagTypeDefaultEffect
show_in_listboolTrueAdds to list_display
show_in_formboolFalse*Shows field in change form
searchableboolFalseAdds to search_fields
filterableboolFalseSmart sidebar filter (type-aware)
editableboolTrueAlways read-only when False
updatableboolTrueRead-only after first save when False
rowstrNoneGroup fields into a horizontal row
tabstrNonePlace field into a specific Unfold tab
wysiwygboolFalseEnable CKEditor 5 for TextFields
safe_htmlboolFalseTrust this field's HTML — stored and rendered verbatim, skipping sanitization entirely. Only for content you fully control
auto_sanitizeboolTrueSanitize a wysiwyg field's HTML when it is written to the database. Set False to store exactly what was submitted (rendering is still sanitized)
autocompleteboolFalseSearchable 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:

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.

Upgrading: this changes what gets stored Before, rich-text columns kept whatever was submitted and only the changelist cleaned it on the way out; anything else reading the column — your own templates with |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.

CategorySnap field types
TextSnapCharField, SnapTextField, SnapRichTextField, SnapSlugField, SnapEmailField, SnapURLField, SnapUUIDField, SnapGenericIPAddressField, SnapPhoneField, SnapColorField
NumbersSnapIntegerField, SnapPositiveIntegerField, SnapSmallIntegerField, SnapPositiveSmallIntegerField, SnapBigIntegerField, SnapPositiveBigIntegerField, SnapFloatField, SnapDecimalField
Date & timeSnapDateField, SnapDateTimeField, SnapTimeField, SnapDurationField
Boolean & JSONSnapBooleanField, SnapJSONField
FilesSnapFileField, SnapImageField
RelationsSnapForeignKey, SnapOneToOneField, SnapManyToManyField
Computed (no DB column)SnapFunctionField (render from a callable), SnapStatusBadgeField (coloured pill badge)
See them all live The demo app's 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 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="")
ShapeStored & admin-editableSearch / filter / list behaviourNotes
Bare field (django_models.CharField(...))None — absent from the search box and sidebar filtersExactly 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*FieldSame as snap_field(), same attributesA 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.

Mistakes are caught where you write them A missing 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")
Note The 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:

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.

GET/api/models/schema/List all available endpoints
GET/api/models/{app}/{Model}/List objects (filters, search, pagination)
POST/api/models/{app}/{Model}/Create object
GET/api/models/{app}/{Model}/{id}/Retrieve object
PATCH/api/models/{app}/{Model}/{id}/Update object (PUT for full update)
DELETE/api/models/{app}/{Model}/{id}/Delete object

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= presentRouting enabledExecuted on
ES_ONLYanyElasticsearch (only source)
DUALyesyesElasticsearch (fuzzy multi_match, relevance order)
DUALyesnoDatabase (icontains over searchable fields)
DUALnoDatabase (native pagination, no ES round-trip)
DB_ONLYyesDatabase (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"

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
ModeFastest 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_ONLYListings come from ES, bounded by SNAPADMIN_ES_SEARCH_LIMIT; raise it or narrow with filters/?search=.
Server-side jobSkip 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:

GET/api/models/<app>/<Model>/count/?<filters>Match count for the filtered queryset → {"count": N}
GET/api/models/<app>/<Model>/export/?<filters>&limit=NStream ALL matching rows as NDJSON (no pagination; optional 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

POST/api/models/<app>/<Model>/fetch-by/Body {"field": "sku", "values": [...]} → stream every matching row as NDJSON

export 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.

KeywordDefaultMeaning
detailTrue 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.
permissionderived 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:

# 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.

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 CategoryallDemoCategorys. 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
Upgrading from ≤ 0.1.0a3 GraphQL used to be open to anonymous callers. Since v0.1.0a4 every resolver enforces authentication and per-model permissions; clients must send the same 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>
Tokens are hashed at rest Only a SHA-256 digest and the 8-character 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

GET/api/tokens/List tokens owned by the current user (every token for a superuser)
POST/api/tokens/Create a new token
GET/api/tokens/{id}/Retrieve a token
POST/api/tokens/{id}/rotate/Replace the secret in place — same row, id, scopes and history; the new raw key is returned once
POST/api/tokens/{id}/deactivate/Flip is_active off — the recommended revocation path
DELETE/api/tokens/{id}/Delete a token outright (administrators; prefer deactivate for routine revocation)

A 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

FieldDescription
token_nameHuman-readable label (e.g. "Read-only dashboard")
token_key40-character secret key — treat like a password. Hashed at rest; returned only once, at creation or rotation
token_prefixFirst 8 characters of the key (not secret) — identifies a stored token in lists and the admin
allowed_modelsList 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_scopesList 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_dateOptional expiry; leave blank for non-expiring tokens
is_activeDeactivate 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:

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
Local dev without Docker Leave 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

ModeStorageWhen 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/TextFieldtext with a .raw keyword subfield (exact match + aggregations), Email/Slug/URL/UUID/IP/Filekeyword, integers & FK → long, Floatdouble, Decimalscaled_float, dates → date, JSONFieldobject. 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
Searches are mapping-aware Full-text queries (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, …).

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
Return types For 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.
The REST API routes to ES automatically You rarely need to call 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)

BehaviorDetail
Prefetch & cachePulls 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 panelWhen 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 syncQueues 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.

Opt-in, off by default The connectivity layer (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:

BehaviorDetail
Health pollingPolls 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 downA 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 proofThe 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 statePublishes one resolved state as a snapadmin:connectivity DOM event, so the connectivity layer and the per-model engine always agree.
Dynamic toastsBackend-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 guardOn 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 badgesOnly 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.

Zero configuration for offline_mode itself No migrations or extra dependencies either way. 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
AttributeDefaultWhen 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.
REST pagination is on by default The REST API paginates with 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:

ToolUse it whenWhat 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) paginationWHERE 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.

Skip the grand-total COUNT. Django's admin runs a second, unfiltered 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.

SnapAdmin eliminates the admin N+1 automatically. 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:

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:

The rule of thumb: normalize until reads hurt, then denormalize the specific hot path — and own the cache-invalidation/consistency cost you just took on. Don't denormalize speculatively.

5. Practical checklist & anti-patterns

Before shipping a view over a large table, check:

Common anti-patterns to avoid:

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:

statusMeaningRaises?Log marker
"ok"ran, every unit succeedednosnapadmin_task_ok (info)
"partial"ran, some units failed (failed non-empty)nosnapadmin_task_partial (error)
"noop"nothing was due / nothing to donosnapadmin_task_noop (info)
"disabled"switched off (or unusably misconfigured) by settingsnosnapadmin_task_disabled (info)
(total failure)every unit failed, or the task could not start at allyessnapadmin_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.

One monitoring rule covers all six tasks: alert when 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

TaskTriggerPurpose
snapadmin.purge_expired_dataYou schedule itGDPR — 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_tokensYou schedule itDelete APIToken rows past their expiry (an expired token stops authenticating immediately either way — this only reclaims the rows)
snapadmin.send_error_digestYou schedule itDaily grouped digest of captured error events
snapadmin.send_health_alertYou 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_backupsYou schedule it (hourly due-check)3-2-1 database dumps to each destination that is due
snapadmin.run_exportEvent — enqueued when an async export job is createdStreams a large export in the background. No Beat entry; needs a running worker
snapadmin.run_es_reindexEvent — enqueued by POST /api/es/reindex/ when SNAPADMIN_REINDEX_API_ASYNC=TrueBulk-reindex a model into Elasticsearch. Needs a running worker; schedule it as well if you want periodic reindexing
Without Celery installed, importing the tasks still works 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 nameUse insteadRemoved in
db_backupsnapadmin_db_backup1.0
purge_expired_datasnapadmin_purge_expired_data1.0
send_error_digestsnapadmin_send_error_digest1.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 changedsnapadmin.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.

Dashboard integration Every entry in 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 rule3 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:

These do not run until you schedule them. The intervals below are how often each destination becomes due — nothing becomes due unless the 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.

CopyDestinationWhere it livesDue every
1localDirectory on the same server (SNAPADMIN_BACKUP_LOCAL_DIR)every 24 h
2networkDirectory on another server on your network — a mounted NFS/SMB share (SNAPADMIN_BACKUP_NETWORK_DIR; empty = off)every 24 h
3remoteOffsite 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)sftpSame 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)s3Any 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).

Retention on S3 is a fallback, not the whole story. SnapAdmin prunes to the newest 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

Storage Box is not S3. It speaks SFTP/SCP/WebDAV — use the 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
  1. Port 23, not the SSH default 22 — Storage Box's external SFTP/SCP endpoint.
  2. A sub-account (Hetzner Robot → Storage Box → Sub-accounts) scoped to its own subdirectory, so a leaked key can't reach the whole box.
  3. Key-based auth — upload the sub-account's public key in the Robot panel; set SNAPADMIN_BACKUP_SFTP_KEY_FILE to the matching private key's path.
  4. 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:
    ssh-keyscan -p 23 -H u123456.your-storagebox.de >> ~/.ssh/known_hosts
    Run this once as the same user the backup process runs as (e.g. during deployment), or make one manual sftp -P 23 u123456-sub1@u123456.your-storagebox.de connection 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
Failure isolation & retention A failed destination (unreachable share, FTP/SFTP down) is logged (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)

Encryption is optional — you can run backups without it. We strongly recommend turning it on. An unencrypted dump on a rented offsite server, an FTP host, or a Storage Box is your whole database in someone else's hands. It costs one setting.

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.

BackendNeedsBest for
pyragepip install django-snapadmin[age] (MIT, prebuilt wheels — no Rust toolchain to install)Portability — identical on macOS/Windows dev machines and CI, no OS package
binarythe age command-line tool on PATH (BSD-3-Clause) — apt install age on Debian 12+/Ubuntu 22.04+, brew install age on macOSA 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.

What this protects against — and what it does not. Encryption protects the backup once it has left the server that produced it: a compromised or merely readable destination (an FTP host, a Storage Box, anyone who can list your 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"
The .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

Backups nobody has restored are not backups. Test a restore before you need one — the integration checklist's "have you run a restore?" row exists for exactly this reason.

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.

FlagMeaning
--only db,media,envRestore only these parts (comma-separated)
--skip mediaRestore everything selected except these parts
--identity PATHPath to the AGE identity (private key) file, for an encrypted bundle
--confirmActually perform the restore (default: plan only)
--no-snapshotSkip 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.

A snapshot is only as good as the disk it lives on. It protects against a bad restore, not against losing the server entirely — it is not a substitute for the encrypted offsite copy a real backup destination provides.

🚨 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):

Prerequisite: working SMTP — or a webhook Email delivery uses Django's standard email machinery — configure 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

SettingDefaultDescription
SNAPADMIN_ERROR_MONITOR_ENABLEDTrueMaster kill-switch — disable recording without touching MIDDLEWARE.
SNAPADMIN_ERROR_ALERT_ENABLEDTrueEnable the spike alert channel.
SNAPADMIN_ERROR_ALERT_THRESHOLD20Errors within the window that trigger the alert.
SNAPADMIN_ERROR_ALERT_WINDOW_MINUTES15Rolling window for the spike alert.
SNAPADMIN_ERROR_ALERT_COOLDOWN_MINUTES= windowMinimum gap between two alert emails.
SNAPADMIN_ERROR_ALERT_EMAILS[]Alert recipients. Empty = channel off.
SNAPADMIN_ERROR_DIGEST_ENABLEDTrueEnable the daily digest channel.
SNAPADMIN_ERROR_DIGEST_EMAILS[]Digest recipients; falls back to the alert list.
SNAPADMIN_ERROR_DIGEST_MAX_GROUPS20Cap on distinct error groups per digest email.
SNAPADMIN_ERROR_RETENTION_DAYS30ErrorEvent rows older than this are purged by the digest task.
Fail-safe by design Storage or SMTP failures are logged (error_monitor_record_failed, error_monitor_alert_failed) and swallowed — a broken mail server never breaks 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
SettingDefaultDescription
SNAPADMIN_HEALTH_ALERT_ENABLEDTrueEnable the health-alert channel.
SNAPADMIN_HEALTH_ALERT_EMAILS[]Recipients; falls back to SNAPADMIN_ERROR_ALERT_EMAILS. Empty (both) = no email.
SNAPADMIN_HEALTH_ALERT_COOLDOWN_MINUTES60Minimum gap between two health-alert emails for an ongoing outage.
Restart the container too (demo). Emailing tells you a subsystem is down; restarting it is a separate concern. The demo 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 keyRequiredDescription
typeyesslack, discord, teams, telegram, or json (a plain JSON POST for your own endpoint; webhook is an alias).
urlyes (except telegram)The incoming-webhook URL. Must be http(s)://.
token + chat_idtelegram onlyBot token and target chat — posted to the Bot API sendMessage endpoint.
eventsnoWhich alerts this channel wants: error_spike, error_digest, health. Omitted = all three.
timeoutnoPer-channel POST timeout in seconds; defaults to SNAPADMIN_ALERT_WEBHOOK_TIMEOUT.
A webhook URL is a credential. Anyone holding a Slack URL or a Telegram bot token can post to your channel, so keep them in the environment, not in source control. SnapAdmin treats them as secrets: they are never written to a log line, never included in an alert body, and never reported by snapadmin_info — failures are logged as alert_channel_failed with the host only (https://hooks.slack.com/…).
Fail-soft, and never silently silenced. A channel that times out is logged and skipped — the request that recorded the error, the digest task and the 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.
The spike alert posts inside the request that crossed the threshold. Channels are tried one after another, so the worst case a user waits is roughly 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 levels Supported values for 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.

Nothing purges on its own. SnapAdmin runs no background daemon, so setting 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

AttributeTypeDefaultDescription
data_retention_daysint | NoneNoneMax age of records in days. Set to a positive integer to enable auto-deletion.
data_retention_fieldstr"created_at"Name of the DateTimeField to measure record age against.
data_retention_fileslist[str] | NoneNoneSnapFileField/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:

ModeWhat gets purged
DB_ONLYBulk delete from the database (plus data_retention_files, if declared).
DUALBulk 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_ONLYA 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.
Why this matters for GDPR A plain 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:

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:

snapadmin.W012 — retention is configured somewhere (a model's 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":

TableSettingWhat removes itRecommended schedule
Any SnapModel's rows (+ data_retention_files)data_retention_days / data_retention_field / data_retention_files (per model)snapadmin.purge_expired_dataDaily
SnapadminAuditLogSNAPADMIN_AUDIT_RETENTION_DAYS (default 365)snapadmin.purge_expired_data (or snapadmin_audit_export --purge for a SIEM-export-then-prune pass)Daily
ErrorEventSNAPADMIN_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 filesSNAPADMIN_EXPORT_RETENTION_DAYS (default off — opt in)snapadmin.purge_expired_dataDaily, once opted in
Expired APIToken rowsAPIToken.expiration_date (per token; an expired token stops authenticating immediately either way)snapadmin.purge_expired_tokensDaily

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.

Honest limits — stated here, not left implied. This command reaches only the SnapAdmin registry, and prints that limit on every run. It cannot see or touch a backup bundle (a SAR deletion does not retroactively purge an already-taken backup — see 3-2-1 Backups's own retention), an Elasticsearch copy the matched model does not itself mirror, or any third-party store a project integrates outside SnapAdmin. It is only as complete as the registry and every model's own subject_path declaration — a model nobody registered is invisible before the question is even asked.

Reference

AttributeTypeDefaultDescription
subject_pathstr | Nonerequired, no defaultForward __-joined ORM path (≤3 relation hops) to the subject-identifying field, or None.
is_data_subjectboolFalseMarks this model as a valid --model entry point. Requires subject_path == subject_identifier.
subject_identifierstr | NoneNoneField name on this model holding the raw identifier, required when is_data_subject=True.
CheckSeverityFires when
snapadmin.E011ErrorA registered model never declares subject_path at all (not even None).
snapadmin.E012ErrorA 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.

Nothing runs until a model asks for it. With no encrypted field declared, 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.

#SourceUse it when
1SNAPADMIN_ENCRYPTION["KEY_PROVIDER"] — a dotted path to a callable returning the keysetKMS, Vault, Secrets Manager. Nothing secret touches settings or the environment. Called once per process, so a network lookup is not a per-query cost.
2SNAPADMIN_ENCRYPTION["KEY_FILE"], or the SNAPADMIN_ENCRYPTION_KEY_FILE environment variableA Docker or Kubernetes secret mounted read-only. snapadmin.W016 warns if the file is readable by group or others.
3The SNAPADMIN_ENCRYPTION_KEYS environment variableThe 12-factor / .env path — id:key entries separated by commas or newlines.
4SNAPADMIN_ENCRYPTION["KEYS"] in the settings moduleTests 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:

  1. manage.py snapadmin_encryption_key --rotate and prepend the printed line.
  2. 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.
  3. 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

CheckSeverityFires when
snapadmin.E017ErrorA configured key is Django's SECRET_KEY (verbatim, or base64-encoded).
snapadmin.E018ErrorA 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.E019ErrorSNAPADMIN_ENCRYPTION is unresolvable — an unimportable KEY_PROVIDER, a missing KEY_FILE, a key that is not 32 base64url bytes, a duplicate key id.
snapadmin.W016WarningThe mounted KEY_FILE is readable by group or others.
snapadmin.W017WarningKey material sits in the settings module (KEYS) with DEBUG off.
snapadmin.W018WarningAn 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

SurfaceWith no tenant boundWith a tenant bound
Admin changelist / change formEmpty list · 404 by pkThat tenant's rows only
Admin createRefused (PermissionDenied) — never an orphaned rowRow stamped with the bound tenant
REST list / retrieve / count / export / fetch-byEmpty result · 404Scoped automatically (get_queryset())
REST create403Row stamped server-side; a body naming a different tenant is a 400, never silently overwritten
REST updaten/a (row already unreachable)A body naming a different tenant is a 400
GraphQL query / node / relation traversalEmpty · not foundScoped automatically
Elasticsearch routing (es_search/es_filter/es_aggregate/es_count/es_scan)A term that can never match a real documentThe tenant term is forced into the query, overriding any caller-supplied value for the same field
Async export / import jobJob creation refused (403 / a clear CLI error)Tenant captured on the job, replayed when the worker runs
Import column mappingA column mapped to the tenant field is rejected by name — the tenant comes only from --tenant, never a file
Offline cache payloadEmpty payloadThat tenant's rows only
Audit log read (changelist & timeline)Rows naming a tenant-scoped model are hiddenOnly rows whose target object is currently visible to that tenant
snapadmin_purge_expired_data / snapadmin_reindexDeliberately cross-tenant always — retention is time-based and the ES index must stay complete; see below
Honest limits — stated here, not left implied. This is logical isolation, not physical: every guarantee above holds because every surface reads through the same scoped manager, and one query path that bypasses it still leaks — a raw SQL query, a custom management command calling 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 allsnapadmin.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

AttributeTypeDefaultDescription
tenant_scopedboolFalseOpts a SnapModel subclass into row-level isolation. A @snap_model-decorated plain model cannot enforce this (see snapadmin.E009 below) — subclass SnapModel instead.
tenant_fieldstr | NoneNoneName of the tenant column when it is not "tenant_id".
SettingDefaultDescription
SNAPADMIN_TENANT_RESOLVERunsetDotted path to resolver(request) -> tenant value | None. Unset means every request resolves to no tenant.
SNAPADMIN_TENANT_USER_RESOLVERunsetDotted path to resolver(user) -> tenant value | None, used to create an export/import job for a tenant-scoped model.
CheckSeverityFires when
snapadmin.E009ErrorA 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.E003E005) 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.

OrderGuardOn denial
1api_exclude_fields (absolute) Field does not exist on the serializer/type at all — nothing below this ever runs for it.
2api_field_permissions (this feature) Read: absent/null (see above). Write: 400 naming the field.
3api_write_fields allowlist Write only: forced read-only, client value silently ignored — its own, older, deliberately different contract; this feature does not change it.
4SNAPADMIN_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" %}
GET/api/sso-providers/Public list of SSO buttons (labels + login URLs)

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/failedprocessing 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.

POST/api/exports/Start an export → job id + status
GET/api/exports/<id>/Poll status, rows processed/total, %, ETA
POST/api/exports/<id>/cancel/Cancel between chunks
GET/api/exports/<id>/download/Download the finished file
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:

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.

SettingDefaultDescription
SNAPADMIN_SHARDING['ENABLED']FalseMaster 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_FAILOVERFalsePromote 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_TIMEOUT1.0Seconds before a TCP reachability probe gives up on a host.
HA_SETTINGS.FALLBACK_TO_PRIMARYTrueRead from the primary when every replica is down; False raises instead.
CheckSeverityFires when
snapadmin.E013ErrorSNAPADMIN_SHARDING is enabled but a DSN or the SHARDS/DATABASES shape cannot be resolved.
snapadmin.E014ErrorAn unrecognised STRATEGY, or 'custom' with no importable CUSTOM_ROUTER_FUNC.
snapadmin.E015ErrorAn unrecognised REPLICA_SELECTION.
snapadmin.E016ErrorSTRATEGY == "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

🧩 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/
Scope — the setting applies to the REST API. GraphQL keeps its own contract (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:

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)
POST/api/es/reindex/Reindex every ES-enabled SnapModel; optional {"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:

GET/POST/api/users/List / create users
PATCH/DELETE/api/users/{id}/Manage one user
POST/api/users/{id}/set-password/{"password": "…"}
POST/api/users/{id}/permissions/{"permissions": ["demo.view_product", …]}
GET/api/permissions/All assignable permissions (for pickers)

🎛️ 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)

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.

Settingadminapifullno profile (built-in default)
SNAPADMIN_REST_API_ENABLEDFalseTrueTrueFalse
SNAPADMIN_GRAPHQL_ENABLEDFalseTrueTrueFalse
SNAPADMIN_SWAGGER_ENABLEDFalseTrueTrueFalse
SNAPADMIN_GRAPHIQL_ENABLEDFalsefollows DEBUGfollows DEBUGfollows DEBUG
every other SNAPADMIN_* settingunchanged — same built-in default in every profile
Why "ES off" in 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.

Package vs demo The 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)

VariableDefaultDescription
SECRET_KEYinsecure placeholderDjango secret key — must be changed in production
DEBUGTrueEnable Django debug mode — set False in production
ALLOWED_HOSTSlocalhost,…Comma-separated allowed hostnames
LOG_LEVELINFOLog verbosity: DEBUG, INFO, WARNING, ERROR
JSON_LOGSFalseStructured JSON log output for production log aggregation
POSTGRES_DBsnapadminPostgreSQL database name
POSTGRES_USERsnapadminPostgreSQL username
POSTGRES_PASSWORDsnapadminPostgreSQL password
POSTGRES_HOSTdbPostgreSQL host (Docker service name or IP)
POSTGRES_PORT5432PostgreSQL port
REDIS_URLredis://redis:6379/0Redis URL for the Celery broker and result backend
SNAPADMIN_AUTO_SEEDFalseAuto-run seed_demo on startup (demo only)
SNAPADMIN_SEED_ADMIN_PASSWORDPassword for the seeded superuser; the admin/admin default is allowed only with DEBUG=True

Feature toggles

VariableDefaultDescription
SNAPADMIN_REST_API_ENABLEDFalseServe the REST CRUD endpoints
SNAPADMIN_SWAGGER_ENABLEDSNAPADMIN_REST_API_ENABLEDServe Swagger UI + ReDoc — follows the REST setting unless set explicitly
SNAPADMIN_GRAPHQL_ENABLEDFalseServe the GraphQL endpoint
SNAPADMIN_GRAPHIQL_ENABLEDDEBUGGraphiQL playground — keep it out of production
SNAPADMIN_GRAPHQL_REQUIRE_AUTHTrueRequire 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_PUBLICFalseServe the system dashboard without the default staff gate
SNAPADMIN_USER_API_ENABLEDFalseServe the admin-only user-management API (/api/users/, /api/permissions/)
SNAPADMIN_THEME_AUTH_ADMINTrueWith 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_DEFAULTFalseProject-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_ENABLEDFalseLoad 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

VariableDefaultDescription
SNAPADMIN_API_AUTHENTICATION_CLASSEStoken authAPI authenticator dotted paths (add session / JWT) — see Integrating
SNAPADMIN_API_PAGE_SIZE25Default list page size on DynamicModelViewSet
SNAPADMIN_API_MAX_PAGE_SIZE500Hard ceiling on client-requested ?page_size=
SNAPADMIN_THROTTLE_ANON60/minRate limit for anonymous callers, enforced by the viewset itself regardless of the host project's REST_FRAMEWORK config; None disables it
SNAPADMIN_THROTTLE_USER600/minRate limit for authenticated callers, same enforcement; None disables it
SNAPADMIN_API_DELETE_GUARDDotted path to a Callable[[request, obj], bool] vetoing API deletes (403); AND-ed with each model's api_can_delete hook
SNAPADMIN_QUERY_BACKEND_HEADERTrueExpose the X-Snap-Query-Backend header on list responses
SNAPADMIN_ANALYTICS_DB_ALIASDATABASES alias for read-only list/retrieve routing; empty = no routing

Elasticsearch

VariableDefaultDescription
ELASTICSEARCH_ENABLEDFalseEnable ES integration; when False all models fall back to DB_ONLY
ELASTICSEARCH_URLhttp://elasticsearch:9200Elasticsearch cluster URL
ELASTICSEARCH_KWARGS{request_timeout: 5}Extra kwargs merged into the Elasticsearch(...) client
SNAPADMIN_ES_CLIENT_FACTORYDotted path to a zero-arg callable returning a custom ES client
SNAPADMIN_ES_QUERY_ROUTINGTrueRoute ?search= on DUAL models to Elasticsearch
SNAPADMIN_ES_SEARCH_LIMIT1000Max hits fetched from ES per routed search
SNAPADMIN_REINDEX_API_ENABLEDFalseServe the admin-only bulk ES reindex endpoint (POST /api/es/reindex/)
SNAPADMIN_REINDEX_API_ASYNCFalseOffload the reindex endpoint to the snapadmin.run_es_reindex Celery task

Export

VariableDefaultDescription
SNAPADMIN_EXPORT_ENABLEDTrueEnable the async background export API (/api/exports/)
SNAPADMIN_EXPORT_CHUNK_SIZE1000Rows per export chunk (progress + resume granularity)
SNAPADMIN_EXPORT_DIRBASE_DIR/exportsDirectory the local export working files are written to (also the default storage's root)
SNAPADMIN_EXPORT_STORAGEDotted 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_ROWS0Row 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_MAX0Hard cap an explicit ?limit= on .../export/ is clamped down to; 0 = no clamp
SNAPADMIN_IMPORT_CHUNK_SIZE1000Rows per import chunk — the checkpoint/resume granularity for manage.py snapadmin_import (see Bulk Import)
SNAPADMIN_FETCH_BY_MAX_VALUES10000Hard 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

VariableDefaultDescription
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_KEYSid:key entries (comma- or newline-separated) configuring the same keyset from the environment
SNAPADMIN_ENCRYPTION_KEY_FILEPath to a mounted secret holding the same content
SNAPADMIN_AUDIT_LOG_ENABLEDTrueRecord admin create/update/delete as an immutable audit trail
SNAPADMIN_AUDIT_RETENTION_DAYS365Retention window for snapadmin_audit_export --purge
SNAPADMIN_ESTIMATED_COUNTTrueUse PostgreSQL's fast row estimate for huge, unfiltered changelists
SNAPADMIN_ESTIMATED_COUNT_THRESHOLD100000Only estimate the count above this many rows

Email, error monitoring & alerts

VariableDefaultDescription
EMAIL_HOST / EMAIL_PORTlocalhost / 587SMTP server for notification emails
EMAIL_HOST_USER / EMAIL_HOST_PASSWORDSMTP credentials
EMAIL_USE_TLSTrueUse STARTTLS for SMTP
DEFAULT_FROM_EMAILsnapadmin@localhostFrom address of alert/digest emails
SNAPADMIN_ERROR_MONITOR_ENABLEDTrueRecord unhandled exceptions / 5xx as ErrorEvents
SNAPADMIN_ERROR_ALERT_ENABLEDTrueEnable the error spike alert email
SNAPADMIN_ERROR_ALERT_THRESHOLD20Errors within the window that trigger the alert
SNAPADMIN_ERROR_ALERT_WINDOW_MINUTES15Rolling window for the spike alert
SNAPADMIN_ERROR_ALERT_EMAILSComma-separated alert recipients (empty = no alerts)
SNAPADMIN_ERROR_DIGEST_ENABLEDTrueEnable the daily grouped error digest
SNAPADMIN_ERROR_DIGEST_EMAILSDigest recipients; falls back to the alert emails
SNAPADMIN_ERROR_DIGEST_MAX_GROUPS20Max distinct error groups per digest email
SNAPADMIN_ERROR_DIGEST_HOUR / _MINUTE8 / 0Daily send time of the digest (Celery Beat)
SNAPADMIN_ERROR_RETENTION_DAYS30Purge ErrorEvents older than this

3-2-1 database backups

VariableDefaultDescription
SNAPADMIN_BACKUP_ENABLEDFalseEnable scheduled 3-2-1 database backups
SNAPADMIN_BACKUP_KEEP7Dumps kept per destination (oldest pruned)
SNAPADMIN_BACKUP_LOCAL_DIR./backupsCopy 1: directory on the same server
SNAPADMIN_BACKUP_LOCAL_EVERY_HOURS24How often the local copy becomes due
SNAPADMIN_BACKUP_NETWORK_DIRCopy 2: mounted share of a server on your network (empty = off)
SNAPADMIN_BACKUP_NETWORK_EVERY_HOURS24How often the network copy becomes due
SNAPADMIN_BACKUP_FTP_HOST / _PORT— / 21Copy 3: offsite FTP/FTPS server (empty host = off)
SNAPADMIN_BACKUP_FTP_USER / _PASSWORDFTP credentials
SNAPADMIN_BACKUP_FTP_DIR/Target directory on the FTP server
SNAPADMIN_BACKUP_FTP_TLSFalseUse FTPS (recommended for offsite)
SNAPADMIN_BACKUP_REMOTE_EVERY_HOURS168How often the offsite copy becomes due (weekly)
SNAPADMIN_BACKUP_SFTP_HOST / _PORT— / 22Copy 3 (alt): offsite SFTP server (empty host = off) — port 23 for Hetzner Storage Box
SNAPADMIN_BACKUP_SFTP_USER / _PASSWORD / _KEY_FILESFTP credentials — key file wins over password when both are set
SNAPADMIN_BACKUP_SFTP_DIR/Target directory on the SFTP server
SNAPADMIN_BACKUP_SFTP_EVERY_HOURS168How often the SFTP copy becomes due (weekly)
SNAPADMIN_BACKUP_S3_BUCKETCopy 3 (alt): S3-compatible bucket name (empty = off)
SNAPADMIN_BACKUP_S3_PREFIXOptional key prefix inside the bucket
SNAPADMIN_BACKUP_S3_ENDPOINT_URLAWS's own endpoint if unset; set to target MinIO/Backblaze B2/Hetzner Object Storage/Wasabi
SNAPADMIN_BACKUP_S3_REGIONAWS region (or the provider's equivalent)
SNAPADMIN_BACKUP_S3_ACCESS_KEY_ID / _SECRET_ACCESS_KEYExplicit credentials; leave both unset to use boto3's ambient credential chain (env vars, shared config, an IAM role)
SNAPADMIN_BACKUP_S3_EVERY_HOURS168How 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_FILERestore-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_PATHOverride 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_FILEPath to the .env file backed up when env is in SNAPADMIN_BACKUP_INCLUDE
SNAPADMIN_BACKUP_MEDIA_SIZE_WARNING_BYTES10 GiBPast this size, the media backup logs a warning — never aborts
SNAPADMIN_RESTORE_SNAPSHOT_DIR<local dir>/rollbackWhere snapadmin_restore --confirm's automatic pre-restore snapshots are stored (see The pre-restore safety net)
SNAPADMIN_RESTORE_SNAPSHOT_KEEP3How many pre-restore snapshots to keep (oldest pruned first) — separate from SNAPADMIN_BACKUP_KEEP
"Every hours" marks a copy due — it does not schedule it These intervals only decide whether a destination is stale when the backup task runs. Nothing fires on its own; point Celery Beat or system cron at snapadmin.run_db_backups. See Celery & Periodic Tasks.

Traefik (demo deployment)

VariableDefaultDescription
TRAEFIK_DOMAINyourdomain.comProduction domain for demo/docker-compose.traefik.prod.yml
TRAEFIK_ACME_EMAILEmail for Let's Encrypt certificate registration
TRAEFIK_DASHBOARD_USERadminReference username (see TRAEFIK_DASHBOARD_CREDENTIALS)
TRAEFIK_DASHBOARD_PASSWORDchangemeReference password (see TRAEFIK_DASHBOARD_CREDENTIALS)
TRAEFIK_DASHBOARD_CREDENTIALSadmin:$$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

  1. SnapAdmin only auto-registers SnapModel subclasses. 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.
  2. 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:

GoalHow
Add a package's admin behaviour on top of SnapAdmin's auto-configadmin_mixins = [ThePackageAdminMixin] on the SnapModel
Let a package fully own the admin for a modeladmin_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

PackageWorks 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-toolbarPurely middleware/URLs; no interaction with model or admin generation. Add its middleware as usual.
django-import-exportadmin_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.

When there's a genuine conflict If a package needs to be the sole owner of a model's admin (rare — usually a custom 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
Two spellings work. This is a 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

FlagEffect
--jsonEmit the raw report as JSON — one object per section — for a monitoring endpoint or CI step.
--section NAMELimit to one section (repeatable). Names: api, celery, checks, database, elasticsearch, features, graphql, inventory, version.
--briefShow only the top-level scalar values of each section, hiding nested detail.
--verboseInclude extra per-section detail — the online Celery workers, per-capability adoption counts, and the full text of every system-check message.
--health-checkRun 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

A broken subsystem costs you one line, not the report You run this command because something is wrong, so each section is isolated: if a collector raises — a half-migrated database, an optional package missing, a third-party integration throwing on import — that section prints 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
Pluggable sections. Each section is a small collector module under 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.
Monitoring. 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.

Informational, not legal advice. Licences change between versions — verify against what you install and consult counsel for commercial use. The command bundles no vulnerability database: it points you at 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

TierMeaning
🟢 permissiveMIT / BSD / Apache-2.0 / ISC — use freely, including closed-source and commercial products.
🟡 weak copyleftLGPL / MPL — fine for proprietary use as an unmodified, dynamically-imported dependency.
🔴 copyleft / commercialGPL / AGPL / SSPL / "GPL-or-commercial" — distribution obligations; kept out of the base install, opt-in only.

Flags

FlagEffect
--jsonEmit the report as JSON — packages, the commercial-compatibility verdict and the vulnerability-scan note — for a CI gate.
--critical-onlyShow only the non-permissive (🟡 / 🔴) licences — the ones worth a second look.
--compatible-with SPDXAdvisory per-package compatibility with a project licensed SPDX (e.g. MIT): ✓ yes, ✗ no, ? review.
--verboseAlso 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)
Base install is fully permissive. A plain 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.
The curated map is hand-maintained — it goes stale by design. Every report ends with the date the table was last checked against 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.

Only the package UI is translated The catalogs cover SnapAdmin's own strings — model labels, help texts, validator messages, dashboard copy. Your models' 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.

Scope, and what it means for your own strings Covered is the admin SnapAdmin actually renders: the shell, changelists, forms, filters, the command palette and the login/logout screens. Unfold's optional contribs (import/export, impersonation, object history) and its own rich-text toolbar are not — SnapAdmin doesn't wire those surfaces up. If your project already translates one of these msgids in its own LOCALE_PATHS, your wording wins: project catalogs are consulted before any app's.
A stopgap, held only until Unfold ships its own catalogs This layer exists because 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.

StylesheetScopeWhen 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.

Why the split is strict The stock-admin layout rewrite is not additive: applied on top of a theme it overrides that theme's own form layout. SnapAdmin used to scope those rules to a .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.
No flag to set The split keys off whether Unfold is importable, so there is nothing to configure — drop Unfold from 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"))]
Already own /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 touchAPIToken.allowed_models (AND-ed with Django perms)
Whether a token may call your own endpointAPIToken.allowed_scopes + token_has_scope() — see Token Management
How the Elasticsearch client is builtELASTICSEARCH_KWARGS / SNAPADMIN_ES_CLIENT_FACTORY
GraphQL auth / GraphiQL exposureSNAPADMIN_GRAPHQL_REQUIRE_AUTH / SNAPADMIN_GRAPHIQL_ENABLED
Hiding fields from every API surfaceapi_exclude_fields on the model
Restricting which fields REST create/update can writeapi_write_fields on the model
Filtering key-paths inside a JSON columnapi_json_filters on the model
Widening/narrowing a text field's auto-filter lookupsapi_filter_lookups on the model
GraphQL is generated dynamically from your 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.

ModuleOwns
modelsSnapModel, 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.
fieldsEvery Snap*Field type — the declarative field layer with its snap-only kwargs (stripped from deconstruct() so they add no migration).
validatorsSnapPhoneValidator / SnapColorValidator / SnapFileValidator (deconstructible).
admin, widgets, viewsThe 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, etlThe async export writer (+ pluggable row sources), the resumable bulk ES reindex, and the ETL upsert/prune helpers.
monitoring, health, backup, audit, maskingError 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, …).
licensingThe 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_configSystem 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.

MigrationGuide
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
SnapAdmin v0.1.0b8 — MIT License — GitHub

🧭 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.W001W018 and snapadmin.E001E019 — 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)"
Kept honest by tests. 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.

What it does to your machine. It fetches and runs the project's own demo release over HTTPS from the official repository, and (unless --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:

Flags

FlagEffect
--version X.Y.ZWhich release to fetch (default: the installed django-snapadmin version).
--path DIRWhere to extract demo/ (default: current directory).
--skip-installReuse the current environment — don't pip install the demo requirements.
--no-servePrepare everything but don't start the server.
--clear-cacheDelete cached downloads under ~/.cache/snapadmin-demo/ first.
-y, --yesReplace 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 policyone build per day, keep the last N build-days (N defaults to 3, override via SNAPADMIN_IMAGE_KEEP_DAYS):

Worked example. Builds a month ago, a week ago, yesterday, and today leave exactly three images after today's build — one each for a week ago, yesterday, and today; the month-ago image and all superseded same-day builds are gone.

The pruner can also run standalone (e.g. in CI), with a dry-run mode:

python -m 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 statusHTTPMeaning
healthy200Database reachable; the instance can serve.
degraded200An 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.
unhealthy503The database is unreachable. Restart or replace this instance.
Don't probe /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:

FieldValueWhy
Path/api/health/The trailing slash matters — Django's APPEND_SLASH would otherwise answer 301 first.
Port8000Whatever Gunicorn binds inside the container, not the published port.
Method / expected statusGET / 200Anything else means the database is unreachable.
Interval30sEvery probe opens a database cursor; much below this is pointless load.
Timeout5sThe check does no heavy work; a slow answer is itself a symptom.
Retries3Survives a single blip without flapping the container.
Start period / grace60sMigrations, 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 haveProtocolHow to use it
AWS S3S3SNAPADMIN_STORAGE_BACKEND=s3, leave AWS_S3_ENDPOINT_URL empty — boto3 derives it from the region.
Hetzner Object StorageS3-compatibleSame, 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-hostedS3-compatibleSame, plus AWS_S3_ADDRESSING_STYLE=path when you have no wildcard DNS.
Backblaze B2S3-compatibleSame, endpoint https://s3.<region>.backblazeb2.com.
Hetzner Storage BoxSFTP / CIFS / WebDAV — not S3Mount 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=localSTORAGES holds a dotted path Django resolves lazily — so the dependency stays optional.

Async exports need the same bucket. When the web process and the Celery worker do not share a filesystem, a finished export is only downloadable from the instance that wrote it. Point 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:

DestinationSettingFits
Offsite over SSHSNAPADMIN_BACKUP_SFTP_*Hetzner Storage Box, any SSH host. No mount needed; needs the [backup] extra.
Offsite over FTP/FTPSSNAPADMIN_BACKUP_FTP_*Storage Box also speaks FTPS.
Another machine on the networkSNAPADMIN_BACKUP_NETWORK_DIRAny 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
No S3 backup destination yet. Database dumps cannot be shipped straight to an S3 bucket — use SFTP/FTPS, or a mounted share. Media and exports do go to S3 through the storage layer above.
Worker and Beat containers have no HTTP port. Probe a Celery worker with 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.

ModelES ModeDemonstrates
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.