# SnapAdmin (django-snapadmin)

> Declarative Django admin and API generator. Define a model's fields once with `Snap*Field` and get a themed Django admin, a REST API with Swagger docs, a GraphQL endpoint, and optional Elasticsearch search. Every surface is a single `SNAPADMIN_*` settings toggle. Requires Python >= 3.10 and Django >= 5.2. Currently a pre-1.0 beta (`0.1.0b10`) — the public API is not yet covered by semantic versioning, though breaking changes are always announced (`SECURITY.md`); pin an exact version in production. The admin is what a base install gives you: the REST API and GraphQL are **off by default** and each needs its own extra.

Install with `pip install django-snapadmin` for the admin alone, or `pip install "django-snapadmin[api,graphql]"` to also serve the REST and GraphQL surfaces the settings block below turns on. Add `snapadmin` to `INSTALLED_APPS` (plus `rest_framework`, `drf_spectacular`, `django_filters` and `graphene_django` when using those extras), include `snapadmin.urls` in the root URLconf, then:

```python
# models.py
from snapadmin import fields as snap, models as snap_models

class Product(snap_models.SnapModel):
    name      = snap.SnapCharField(max_length=200, searchable=True, show_in_list=True)
    price     = snap.SnapDecimalField(max_digits=10, decimal_places=2, filterable=True)
    available = snap.SnapBooleanField(default=True, filterable=True)

# settings.py — REST and GraphQL default to False since 0.1.0b8; this opts into both
SNAPADMIN_REST_API_ENABLED = True
SNAPADMIN_GRAPHQL_ENABLED = True
SNAPADMIN_SWAGGER_ENABLED = True    # or just: SNAPADMIN_PROFILE = "api"

# admin.py
from snapadmin.models import SnapModel
SnapModel.register_all_admins()
```

Key facts an agent should not have to infer:

- Snap-only field kwargs (`searchable`, `filterable`, `show_in_list`, `wysiwyg`, …) configure the admin and API and are stripped before Django sees them — they add no migration. `editable` is the one passed through to Django on purpose (so `editable=False` also holds in a hand-written `ModelForm` or DRF serializer, not just the generated admin); it affects no column, so `deconstruct()` drops it and it adds no migration either. `snap_field(field, **kwargs)` sets the same kwargs on a plain `django.db.models.Field` instance in place, for a third-party field package or a model that cannot be rewritten onto the `Snap*Field` classes; it accepts every `Snap*Field` constructor kwarg, including `required` (mutates `null`/`blank` directly — the one kwarg that can produce a migration) and the file-upload trio `allowed_extensions`/`allowed_encodings`/`max_size_bytes` (a `FileField`/`ImageField` only).
- Rich-text fields (`wysiwyg=True` / `SnapRichTextField` / `snap_field(field, wysiwyg=True)`) sanitize HTML in `pre_save()`, so every ORM write path stores clean markup, and again when the changelist renders. This holds identically whether declared as a `Snap*Field` or via `snap_field()` — both reuse the same sanitizer and produce byte-identical stored output. Opt out per field with `safe_html=True` or `auto_sanitize=False`; `QuerySet.update()` bypasses it because Django never calls `pre_save()` there.
- Module import paths are the public contract and are never moved or renamed; `from snapadmin import SnapModel` and `from snapadmin.models import SnapModel` both work (the top-level re-exports are lazy).
- A model becomes a SnapAdmin model in one of two ways: subclassing `SnapModel`, or decorating a plain `django.db.models.Model` with `@snap_model(...)`. Both end in the same registry — `snapadmin.registry.is_registered(model)` is the gate every surface asks. `get_model_meta(model, name, default)` reads a model-level setting through the full precedence rule: explicit decorator argument > class attribute > a project-wide `SNAPADMIN_<NAME>` setting > this `default` argument. The settings tier only ever fires on the `@snap_model` route — a `SnapModel` subclass always answers from its class attribute (inherited or not), never falling through to it. The decorator is metadata only: it adds no field (so no migration) and attaches no `EsManager`, no `purge_expired()`, no generated admin — the ES, retention and admin sweeps skip a decorated plain model, which is why it accepts no `es_*`/`data_retention_*` keywords.
- `@snap_property` turns a method into a computed, display-only admin column — the decorator form of `SnapFunctionField` (no database column, no migration). It builds the identical `SnapFunctionField` instance the field form builds, so it renders on a `SnapModel` subclass through the same code today; on a `@snap_model`-decorated plain model it computes correctly right now but has no generated admin to display on yet (tracked separately, no ETA promised here).
- The Unfold theme is optional (`[theme]` extra). Without it SnapAdmin renders on the stock Django admin.
- `django-unfold` ships no translation catalogs, so SnapAdmin translates the theme's own interface strings in its ten catalogs (`snapadmin/theme_i18n.py` declares the msgids). A project's own `LOCALE_PATHS` still take precedence over both.
- Optional stacks never break an import: `snapadmin.tasks` imports without Celery (calling a task runs it in-process; `.delay()` raises `ImproperlyConfigured` naming the `[celery]` extra), and the core boots with DRF/graphene absent.
- Startup misconfiguration surfaces as Django system checks — warnings `snapadmin.W001`–`W027`, errors `snapadmin.E001`–`E028`. Read them before debugging behaviour; the ones worth knowing by id: `E001`–`E005` masking keys/fields/patterns that do not resolve (they would fail *open*); `E028` a masking setting in the wrong shape (a string or number where a list belongs); `E007` an `env` backup part with no AGE recipients; `E008` a `@snap_action` its own model's verb policy always rejects; `E009` an unenforceable `tenant_scoped`; `E011`/`E012` a missing or malformed `subject_path`; `E013`–`E016` `SNAPADMIN_SHARDING` shape, strategy and range errors; `E017`/`E018` an encryption key equal to `SECRET_KEY`, or an encrypted field with no keyset; `E020`–`E024`, `W019`/`W020` encrypted-field declarations the column cannot honour; `E025` `EXTRA_SETTINGS_ADMIN_APP` set to a label instead of the `INSTALLED_APPS` entry; `E026` an ES-mirrored model with an empty mapping (indexes ids only — `searchable=True` is not a mapping); `E027` `SnapModel`'s `EsManager` and a project's own `objects` manager replacing each other; `W010` a backup Beat entry slower than the shortest interval; `W012` retention configured but never scheduled; `W014` retired in 0.1.0b8, never reused; `W015` a generated admin form that would render empty; `W021` an off-host backup without encryption; `W022` an absolute `SNAPADMIN_BACKUP_SFTP_DIR` (keep it when `pwd` after login already prints it); `W023` backups configured but switched off; `W024` an `env` part with no env file; `W025` masked fields behind a hand-written admin that does not mask; `W026` the deprecated `admin_sections`; `W027` a `SNAPADMIN_STARTUP_REPORT` that is not `"auto"`/`True`/`False`.
- `SNAPADMIN_SHARDING = {"ENABLED": True, ...}` adds declarative multi-shard/read-replica database routing — a flat `DATABASES` list of DSNs auto-sliced into shards/replicas, or an explicit `SHARDS` mapping. `SnapAdminRouter` resolves a query's shard by `modulo`/`hash`/`range`/a custom function; a model opts in with `shard_key` (mirrors `tenant_scoped` — no model, including Django's own `auth`/`sessions`/`admin` tables, is ever sharded without asking). `snap_master_only()`/`snap_target(shard=..., replica=...)` force routing per block of code, as a context manager or a decorator, sync or `async def` alike. `manage.py snap_migrate` and the sharded branch of `manage.py snapadmin_db_backup` both touch every shard's *primary* only, never a replica. `STRATEGY` defaults to `modulo`, which needs an integer shard key — shard by a string/UUID key with `STRATEGY = "hash"` instead, or the router raises `ShardResolutionError` naming the field. `HA_SETTINGS['AUTO_FAILOVER']` (default `False`) fails writes over to a live replica when the primary is down: only enable it against a replica that can actually be promoted — a read-only standby rejects the write anyway, and one that accepts it diverges from the primary. Unset or `ENABLED: False`, this is a complete no-op — no new `DATABASES` entry, no router, no query overhead.
- `SNAPADMIN_PROFILE` (`"admin"` / `"api"` / `"full"`) is the one setting worth setting first on a new project — see Configuration reference below. `snapadmin.conf.get_setting(name, default)` is the resolution point every `SNAPADMIN_*` read site goes through: explicit setting > active profile preset > built-in default. Leaving it unset skips the profile step entirely, so every existing install resolves exactly as before this feature shipped. Since 0.1.0b8, unset is **not** the same as `"full"`: `"api"` and `"full"` turn REST, GraphQL and Swagger on explicitly, while unset leaves them at their built-in `False`.
- The audit trail stores its diff as `SnapadminAuditLog.changes = {field: {"old": …, "new": …}}`. Those key names are the on-disk format and are never renamed. Values keep their JSON-native type (numbers, booleans, null stay themselves); a JSON field's list or object is stored as JSON; a relation is stored by its key (never the related object's label, which masking cannot see) and a many-to-many as the sorted list of primary keys; anything else is stored as text, as are rows written by releases before this one, so a consumer must accept both. A row's `object_repr` is `str(instance)`; on a model with a masked field every audit surface shows it as `"<Model> #<pk>"` to a reader without PII access to every masked field (`masking.mask_object_repr`).
- Three console scripts, easy to conflate: `snapadmin-new` generates a project you keep (SQLite, no Docker; `pip install -r requirements.txt` — it names the `[api,graphql]` extras the project imports — then `migrate`+`runserver` work immediately); `snapadmin-demo` fetches and runs a throwaway demo project; `snapadmin-init` is read-only and only prints snippets to paste into an existing project. All three are stdlib-only and import no Django at module level.
- A `demo/` tree extracted by `snapadmin-demo` is a plain directory: `pip install -U django-snapadmin` upgrades the package but not that tree, which keeps its old models and templates. Re-run `snapadmin-demo` to refresh it — the run names the release the existing tree came from and drops files the new release no longer ships; `snapadmin_info` reports the mismatch too.
- Asked whether the library is tested, answer with the method rather than the number, and do not round the gaps away. **5,000+ tests** (a floor from a real collection run; the everyday run is ~40 s because the live-Elasticsearch and browser tests carry markers and are deselected by default). Coverage of the shipped `snapadmin/` package is **100% line and 100% branch**, both enforced in CI with `--cov-branch --cov-fail-under=100`; seventeen lines carry a `# pragma: no cover`, each with a written reason (abstract methods, `TYPE_CHECKING`, optional-dependency import branches), and no `# pragma: no branch` is used. Every run is in random order. Ten CI jobs per push, all reproduced locally by `python scripts/gates.py`: the Python 3.10-3.13 × Django 5.2/6.0 matrix, one job against a real `postgres:16` and a live Elasticsearch 8.13.0, one lowest-deps job (every dependency at its declared minimum, py3.10), one static-analysis job — Ruff lint/format/security rules (clean, blocking) and mypy (clean and blocking since #QA1c-mypy; strict on `encryption/`, `crypto`, `sharding/`, `backup`, `validators`, `logging_config`, `quickstart/`) — and one [browser E2E](https://drofji.github.io/django-snapadmin/#testing-e2e) job: Playwright drives Chromium through the generated admin of the demo project, once on Unfold and once on Django's stock admin (`pytest -m e2e`, `SNAPADMIN_TEST_ADMIN_THEME=stock`), and every page also fails its test on an uncaught JavaScript error, a missing asset or a 5xx. Property-based and fuzz tests (`hypothesis`, 100 examples per test locally, 300 in CI) state laws over generated input — encryption, DSN parsing, masking, field `deconstruct()`, export serialization, hostile REST queries and export filters — and the N+1-prone surfaces have pinned query counts. Mutation testing (`mutmut`, advisory) runs per push over the functions a push changed and weekly over the dangerous modules — `python scripts/mutation.py diff|modules`. **Not in place, and must not be claimed:** load testing, and Firefox/WebKit in CI (the browser suite runs on Chromium there; the other engines only locally).
- Facts that are easy to get wrong, all detailed on the linked pages: the `[elasticsearch]` extra pins the client to `>=8,<9` because a 9.x client answers `BadRequestError(400)` to every call on an 8.x cluster; an encrypted column cannot be compared, ordered or range-filtered in the database, and renaming its app, model or field means re-encrypting the rows; `xlsx` exports restart rather than resume; tenant isolation is logical, and backups contain every tenant's rows; a REST viewset action missing from the permission map is refused, never given the `view` floor.
- Run `snapadmin-info` for a diagnostic report of what is enabled in a live project. It is a `manage.py` command with shell shims, so `snapadmin-info`, `snapadmin_info` and `python manage.py snapadmin_info` are the same thing; likewise for `snapadmin-license-check`. It suppresses Django's automatic system-check dump and reports the counts in its own section instead. Each section is isolated: a collector that raises renders as `Title: unavailable — …` (`collector_error` in `--json`) without aborting the report, and a crashed health probe still fails `--health-check`. `runserver` under `DEBUG` also prints a short *startup report* to stderr — capabilities on / off / switched on without their extra, the licence verdict — built from configuration alone (no query, no connection, no key resolution); `SNAPADMIN_STARTUP_REPORT` (`"auto"` default, `True`, `False`) decides when, and `snapadmin_info --startup` prints the same block. When a project has a capability you did not expect to be off, that block is the first place to look.

## Decisions to settle with the developer

An assistant helping someone adopt SnapAdmin should raise these before writing integration code. Each one has a default that is *safe* but not necessarily *right*, and each fails quietly rather than loudly if nobody chooses — which is why they belong in a conversation, not in a guess. Ordered by how expensive they are to change later: the first three are paid for once and then forever, the rest are settings. The runnable form of this list, with the command that proves each answer, is the [integration checklist](https://drofji.github.io/django-snapadmin/#integration-checklist); the four sections after it turn the answers into a build order, a test plan, a scale checklist and a set of norms, all four with a human-readable twin at [Production playbook](https://drofji.github.io/django-snapadmin/#playbook).

1. **What is being built, and what load must it carry?** Nothing below can be chosen sensibly without it: which models are hot, how many rows each is expected to hold in a year, the read/write ratio, peak concurrent users, whether the admin is an internal tool for ten staff or the operations console for a marketplace, how sensitive the data is, and what the legal retention obligation is. No command proves this one — it is the conversation that decides which of the later checklists even apply. Write the answers down; the query-count pins, pagination caps and replica decisions later in this file are all read against them.
2. **`AUTH_USER_MODEL` — is the login an email or a username, and do staff and customers share one table?** A Django-level decision, made before the first migration and painful afterwards (swapping the user model later is a data migration across every foreign key that points at it). SnapAdmin does not assume `username`: its user API builds its field list from `UserModel.USERNAME_FIELD` and adds `email`/`first_name`/`last_name`/`date_joined`/`last_login` only when the model actually defines them, so email-as-login (`USERNAME_FIELD = "email"`, unique, no separate username), classic username, and an external identity provider with local shadow accounts all work — but only the project can say which one it is. Ask it first, and ask whether end customers belong in the same table as the people who log into the admin.
3. **What guards the login door?** SnapAdmin implements **no** SSO, **no** second factor and **no** login rate limiting of its own — the admin login is Django's. `SNAPADMIN_SSO_PROVIDERS` only *renders* buttons for backends the project already wired itself (`django-allauth`, `social-auth-app-django`, mozilla-django-oidc), dropping any entry whose URL is protocol-relative or outside `SNAPADMIN_SSO_ALLOWED_HOSTS`. So confirm, explicitly: password validators, `SESSION_COOKIE_SECURE`/`CSRF_COOKIE_SECURE`/`SECURE_HSTS_SECONDS` and the rest of `manage.py check --deploy`, session lifetime and where sessions are stored, brute-force protection (a reverse-proxy rule, `django-axes`, or a guard built on `snapadmin.limits.reserve()`), who is granted `is_staff` versus `is_superuser`, and whether `SNAPADMIN_URL_PREFIX` should move the whole surface off a guessable path. `SNAPADMIN_DASHBOARD_PUBLIC` is `False` by default and should stay that way unless the dashboard is genuinely public.
4. **Which surfaces are exposed at all?** `SNAPADMIN_PROFILE = "admin" | "api" | "full"` decides it in one line; REST and GraphQL are `False` by default since 0.1.0b8 and each needs its extra (`[api]`, `[graphql]`). Ask before assuming an API is wanted: a team migrating from a plain Django admin usually wants only the admin, and generating a writable endpoint per model is a decision about their attack surface, not a convenience.
5. **`subject_path` on every registered model — required, no default.** GDPR subject access needs to know how to reach the data subject from each model (a forward `__`-joined path of at most three hops, or an explicit `None`). Silence is `snapadmin.E011`, which fails `manage.py check`, so this is not deferrable — but *what* the right path is, and which model is the subject (`is_data_subject`/`subject_identifier`), is a domain question only the developer can answer.
6. **Who may write which fields?** `api_write_fields` / `api_exclude_fields` / `api_read_only` are unset by default, which means every field on every registered model is mass-assignable through the generated API. Fine for a demo; rarely right for real data. `snapadmin.W004` warns, but a warning is not a decision.
7. **Which fields are personal data?** `SNAPADMIN_MASKED_FIELDS` declares them and `SNAPADMIN_MASKING_RULES` says how each is obfuscated and which permission unlocks it; unset means the admin changelist, REST, GraphQL, exports and audit diffs all show raw values to any staff user. Decide alongside who holds `snapadmin.view_raw_pii` — it is all-or-nothing across every masked field unless a per-field `permission` narrows it.
8. **Should a field be invisible rather than starred?** `api_field_permissions` gates a field's presence and writability, which is orthogonal to masking: masking controls whether a visible field is raw, this controls whether it is there at all. Salary, cost price and internal notes usually want this one, not masking.
9. **One tenant or many?** `tenant_scoped = True` is per model and default-deny — once on, every surface refuses to read or write without a bound tenant. Two limits to state out loud before anyone designs around it: isolation is *logical*, not physical, and `snapadmin.backup`'s dumps run below the ORM, so a backup bundle contains every tenant's rows.
10. **How does the application reach the database, and as whom?** Outside SnapAdmin's code but inside its blast radius, and a question nobody asks until an incident: the application should connect as a least-privilege role that is not the schema owner and not a superuser, over TLS, with credentials from the environment or a secret manager rather than `settings.py`, and with `CONN_MAX_AGE` (plus a pooler such as PgBouncer) matched to the worker model. Then decide what reads may be routed elsewhere — `SNAPADMIN_ANALYTICS_DB_ALIAS` for a single read replica, `SNAPADMIN_SHARDING` for real multi-shard/replica routing, whose `HA_SETTINGS['AUTO_FAILOVER']` defaults to `False` and should only ever be enabled against a replica that can actually be promoted. And decide *now*, while the tables are still small, whether any column must be unreadable in a dump: `SnapEncrypted*Field` + `SNAPADMIN_ENCRYPTION` is the answer, adoption of an existing column means re-encrypting every row (`snapadmin_encrypt_fields --adopt`), an encrypted column cannot be compared, ordered or range-filtered in the database, and the key must never be `SECRET_KEY` (`snapadmin.E017`).
11. **Backups: where, encrypted, and has a restore ever been run?** Three separate questions. At least two destinations (the 3-2-1 rule), `SNAPADMIN_BACKUP_AGE_RECIPIENTS` for encryption — optional, strongly recommended, since an unencrypted dump on a rented offsite server is the whole database in someone else's hands — and an actual `snapadmin_restore --confirm` rehearsal — `--database <alias>` restores into a throwaway database and prints the row count per table — because an untested backup is the most common way to not have one. Settle two more while you are there: who holds the AGE identity file (a dump nobody can decrypt is not a backup), and how often the drill is repeated.
12. **What deletes old data, and what runs it?** `data_retention_days` per model (or `data_retention_date_field`, a per-row deadline column that overrides the window for the rows that carry one), `SNAPADMIN_AUDIT_RETENTION_DAYS` (365, on by default), `SNAPADMIN_EXPORT_RETENTION_DAYS` (off). None of them run by themselves: SnapAdmin ships no daemon, so without a `CELERY_BEAT_SCHEDULE` entry or a cron line the tables grow forever. `snapadmin.W012` catches the configured-but-unscheduled case; `SNAPADMIN_PURGE_EXTERNAL = True` declares an external cron running the management command.
13. **Authentication and limits on the API, if it is on.** The default is SnapAdmin's own token auth (`SNAPADMIN_API_AUTHENTICATION_CLASSES`) with default throttles (`SNAPADMIN_THROTTLE_ANON` `60/min`, `SNAPADMIN_THROTTLE_USER` `600/min`) and a page size of 25 (`SNAPADMIN_API_MAX_PAGE_SIZE` caps a client's `?page_size=` at 500) — all reasonable, none of them chosen for this project. GraphQL requires authentication by default (`SNAPADMIN_GRAPHQL_REQUIRE_AUTH`); the user-management API is off (`SNAPADMIN_USER_API_ENABLED`). Confirm each is the intended one rather than the inherited one.
14. **What records that something changed — and what silently does not?** The audit trail is append-only and covers creates/updates/deletes performed **through a SnapAdmin-generated admin** (plus token rename and rotation). It does **not** record REST or GraphQL writes, `QuerySet.update()`, `bulk_update()`, management commands or shell sessions — an assistant that assumes otherwise will tell a compliance team something false. If API writes must be auditable, call `snapadmin.audit.record_audit(request, action, instance, changes)` from a `perform_create`/`perform_update` override. Decide the retention window (365 days by default), who may read the trail, and whether it is exported to a SIEM (`manage.py snapadmin_audit_export`).
15. **Where does a failure become visible?** `snapadmin.middleware.SnapErrorMonitorMiddleware` captures errors, `SNAPADMIN_ERROR_ALERT_*` thresholds and `SNAPADMIN_ALERT_WEBHOOKS` deliver them (email, Slack, Discord, Teams, Telegram, plain JSON), `GET /api/health/` is the orchestrator's probe and `manage.py snapadmin_health_alert` the scheduled one. All are opt-in. Ask who is actually paged, where structured logs are shipped, and what the on-call runbook says — an alert channel nobody reads is the same as no alerting.
16. **Does the licence posture matter?** The base install is permissive only (MIT/BSD/Apache) and safe for commercial and proprietary use. The `[wysiwyg]` extra bundles CKEditor 5, which is GPL-or-commercial, and MySQL users pick between `mysqlclient` (GPL) and `PyMySQL` (MIT). Ask before shipping closed-source; `snapadmin-license-check --critical-only` answers it per dependency.
17. **What does the first migration look like?** Converting existing fields to `Snap*Field` changes every field's `deconstruct()` path, so the first `makemigrations` after adoption is a wall of `AlterField` — a no-op on PostgreSQL/MySQL, a table rebuild on SQLite. Expected, not a symptom; confirm with `sqlmigrate` that no column type or constraint actually changes. Snap-only kwargs add no migration at all, with two exceptions: `required=True` (it sets `null`/`blank`), and the upload limits on `SnapFileField`/`SnapImageField`, which are recorded like Django's own `validators=` — changing one is an `AlterField` that runs no SQL.
18. **Is there infrastructure for the optional stacks?** Elasticsearch (`es_storage_mode` per model) and Celery (`[celery]` extra) are opt-in and need real services. Everything degrades to a clear "not available" rather than a false green, so the honest answer to "not yet" is to leave them off.

## Build order for a new project

The answers above become this order. Each step is verifiable before the next one starts — skipping ahead is what produces a project that works under `DEBUG` and fails on its first real day. `snapadmin-new` scaffolds steps 1–4 for a greenfield project; `snapadmin-init` prints the same steps as ready-to-paste snippets for an existing one, and never edits source.

1. **Identity and the settings skeleton, before the first `migrate`.** Custom user model (even if it is an empty `AbstractUser` subclass — adding one later is the expensive migration), `USERNAME_FIELD`, `AUTH_PASSWORD_VALIDATORS`, and a settings layout that is one module per environment with every secret read from the environment. Nothing else is reversible this cheaply.
2. **Install the surfaces the project actually wants.** `pip install django-snapadmin` for the admin, plus `[api]`, `[graphql]`, `[theme]`, `[celery]`, `[elasticsearch]`, `[encryption]`, `[age]` as the answers require; `INSTALLED_APPS` with the Unfold apps before `django.contrib.admin`; `snapadmin.urls` in the root URLconf. One `SNAPADMIN_PROFILE` line settles which surfaces exist.
3. **Model the domain as ordinary Django first, then add the Snap kwargs.** Relations, constraints, indexes and `Meta` are Django decisions that SnapAdmin does not change; `searchable`/`filterable`/`show_in_list`/`wysiwyg` are presentation and API decisions layered on top and add no migration. A model that is wrong as a Django model is not rescued by a generated admin.
4. **Register, then look at it.** `SnapModel.register_all_admins()` (or `@snap_model` for models you cannot rewrite), then `manage.py check`, `manage.py snapadmin_info --section inventory` — every model, which door it came in by, and which capabilities are inactive on it — and an actual visit to `/admin/`.
5. **Permissions before data.** Groups and permissions per role, `api_write_fields`/`api_read_only`/`api_exclude_fields` on every model the API exposes, `api_field_permissions` on the fields that must not be visible at all, `SNAPADMIN_API_ACTION_PERMISSIONS` for every custom action (an unmapped action is refused outright, superusers included). Do this while the tables are empty and a mistake costs nothing.
6. **Declare the data-protection layer.** `subject_path` on every registered model (`E011` fails the check until you do), `SNAPADMIN_MASKED_FIELDS` + `SNAPADMIN_MASKING_RULES` for personal data, encrypted columns for anything that must be unreadable in a dump, `data_retention_days` where a legal window exists.
7. **Wire the operational layer.** Backups to at least two destinations with AGE recipients, a Celery worker and Beat schedule (nothing runs without one), the error-monitor middleware and one alert channel that reaches a human, structured logging in JSON, and `GET /api/health/` as the container probe — never `/admin/`, which answers 302 with the database down.
8. **Only now turn on what the load profile demands.** Elasticsearch mirroring per model, a read replica or sharding, caching, remote storage for static/media/exports. Each is an operational commitment; adding one because it might be needed is how a two-service project becomes a six-service one.
9. **Harden the deployment.** `DEBUG = False`, `ALLOWED_HOSTS`, `manage.py check --deploy` clean, `collectstatic` in the image build, a least-privilege database role, secrets from the environment, `SNAPADMIN_URL_PREFIX` if the admin should not sit at a guessable path.
10. **Prove it with tests** — the next section — and make `manage.py check`, `makemigrations --check --dry-run` and the suite a CI gate rather than a habit.
11. **Rehearse the recovery** before you need it: `snapadmin_restore --database <alias>` into a throwaway database, row counts compared, and the result written down with a date. Repeat it on a schedule.
12. **Pin the version and read the release notes on every upgrade.** The package is pre-1.0; pin an exact version in production, then re-run `manage.py check` and `makemigrations --check` immediately after each bump.

## Testing a project built on SnapAdmin

SnapAdmin's own suite (see [Testing & quality engineering](https://drofji.github.io/django-snapadmin/#testing)) covers the library and nothing about a project's models, permissions or data. A project needs its own, and these are the layers worth having — in the same discipline the library holds itself to: every assertion states a concrete contract (a status code, a value, an exception type, a side effect, a query count — never `assert x is not None` where a value is checkable), every test is order-independent with no shared mutable state and no reliance on the wall clock, Arrange-Act-Assert with names a reader understands a year later, a regression test for every fixed bug, and **no test is ever weakened, skipped, `xfail`ed, mocked into silence or given a longer timeout to make it pass**. A failing test is a diagnosis job first.

**The base.** `pytest` + `pytest-django` with a dedicated test settings module (SQLite is fine for unit tests, but run the suite against the real engine — PostgreSQL behaviour differs on constraints, ordering and transactions); `--reuse-db` locally and a fresh database in CI; random order (`pytest-randomly`) so ordering coupling fails loudly; factories rather than fixtures full of literals; frozen time instead of `datetime.now()`; no outbound network in unit tests. Measure coverage with `--cov-branch` and treat a high number as a floor for the domain layer, never as the goal — it proves lines ran, not that a mutation would be caught.

**Per app, what a complete suite covers.** Models: constraints, `clean()`, custom managers, computed properties, `__str__`. Migrations: `makemigrations --check --dry-run` in CI, and a forward run on both an empty database and a production-shaped copy. Admin: that the generated changelist renders for each role, that the columns and filters you declared are the ones that appear, and that a save produces the audit row. API: one test per role per action, the 401/403/404 distinction, validation errors as 400 (never 500), pagination, filtering, and every custom `@snap_action`. Background tasks: the task function called directly with real arguments, `.delay()` mocked only at the boundary. Anything reached through a signal or an override: asserted through the real entry point at least once, not only in isolation.

**The SnapAdmin-specific traps, one test each.** Each of these fails silently in production if it regresses: snap-only kwargs add no migration (assert `makemigrations --check` says "No changes detected"); `QuerySet.update()` bypasses `pre_save()`, so a wysiwyg or encrypted field written that way stores unsanitized markup or plaintext, while `bulk_create()` is safe (the insert compiler still calls `pre_save`); an API action missing from the permission map is refused for everyone, superusers included; a tenant-scoped model returns an empty read and refuses a write with no bound tenant, and refuses a create that names a different tenant; masking degrades toward *more* masking on every failure path and a hand-written admin needs `PIIMaskingAdminMixin`; the precedence `api_exclude_fields` > `api_field_permissions` > `api_write_fields` > masking; a `SNAPADMIN_API_DELETE_GUARD` or `api_can_delete` hook answers 403 rather than deleting; an Elasticsearch-mirrored model still answers from the database when the cluster is down (and that a delete propagates); an encrypted column raises `FieldError` on the lookups the database cannot do; `GET /api/health/` answers **503** when the database is unreachable and 200 when only Elasticsearch is; the retention purge deletes a row past its window only when the task actually runs, and keeps the ones a `PROTECT` key holds; an import with `--on-conflict fail` fails the row rather than overwriting it.

**Production points that need a test rather than a hope.** Pin the query count of every list surface that matters (`django_assert_num_queries`) — an N+1 introduced by a new `__str__` or a template that walks a relation is invisible at 50 rows and fatal at 50,000. Assert the pagination cap actually caps, that a client asking for a bigger page gets the maximum and not the whole table, and that throttling answers 429. Keep `manage.py check` and `manage.py check --deploy` (against production settings) in CI. Test the permission matrix as a matrix, not one happy path. Assert the fail-closed defaults stay fail-closed after every dependency upgrade. And make a restore drill part of the schedule, asserted by row count, not by the absence of an error message.

**What SnapAdmin's own suite cannot do for you:** it drives its generated admin in a real browser, but over the demo's models. A production project should keep browser E2E over its *own* critical admin flows — the same shape works: a live server, Playwright, and a check on every page for JavaScript errors and missing assets — and load tests against the API's real page sizes and filters, which a library has no traffic to run.

## Production readiness and high load

What the library does for scale, and where it deliberately stops and leaves the decision to the project. Every number here is the shipped default, and every one of them is a setting.

- **Database.** Index the columns you filter and order on; prefer keyset pagination over deep `OFFSET` for large lists; set `show_full_result_count = False` on huge tables so the admin skips the second, unfiltered `COUNT(*)` — usually the most expensive query on the page. `SNAPADMIN_ESTIMATED_COUNT` (default on) replaces that count with PostgreSQL's `pg_class.reltuples` estimate for **unfiltered** listings above `SNAPADMIN_ESTIMATED_COUNT_THRESHOLD` (100 000) and falls back to an exact count on other engines, on filtered querysets and on small tables. Match `CONN_MAX_AGE` and a pooler to the worker model.
- **Read scaling.** `SNAPADMIN_ANALYTICS_DB_ALIAS` routes reporting reads at one replica; `SNAPADMIN_SHARDING` is the general mechanism (modulo/hash/range routing, `shard_key` per model, `snap_master_only()`/`snap_target()` to force a block of code, sharding-aware `snap_migrate` and backups that touch primaries only). Nothing is sharded without opting in, including Django's own tables.
- **Admin at scale.** `list_select_related` is derived automatically from the foreign keys actually shown, so the changelist joins instead of firing one query per row; `list_per_page` (100) and `list_max_show_all` (200) are the knobs to lower for wide rows. `manage.py benchmark_list_view` in the demo reproduces the numbers on your own hardware.
- **API at scale.** Pagination is always on (25 per page, client-capped at 500) regardless of the project's own `REST_FRAMEWORK` defaults; throttles default to 60/min anonymous and 600/min authenticated; JSON-field filtering scans at most `SNAPADMIN_API_JSON_FILTER_SCAN_CAP` (100 000) rows; `fetch-by/` accepts at most `SNAPADMIN_FETCH_BY_MAX_VALUES` (10 000) keys. Large result sets belong in the async export (`POST /api/exports/`, streamed on a Celery worker in chunks of `SNAPADMIN_EXPORT_CHUNK_SIZE`, 1 000) rather than in a request.
- **Search.** For `DUAL`/`ES_ONLY` models the REST list endpoint serves from Elasticsearch, moving full-text search and deep pagination off the primary database; `SNAPADMIN_ES_SEARCH_LIMIT` (1 000) caps a search, `SNAPADMIN_ES_DB_FALLBACK` (default `True`) decides whether a dead cluster degrades to the database or fails loudly. Pick deliberately: a silent fallback keeps the site up and hides an outage.
- **Background work.** Backups, the retention purge, digests and async exports all need a Celery worker *and* a Beat entry — SnapAdmin ships no daemon, and a configured-but-unscheduled subsystem is `W012`/`W010`. Every scheduled task but `run_export` returns `status` (`ok`/`partial`/`noop`/`disabled`) plus `failed` and raises when every unit failed, so one monitoring rule covers all of them.
- **Shared state.** `snapadmin.limits` counters are per-process unless `SNAPADMIN_LIMITS_CACHE_ALIAS` names a shared cache, and an evicted counter fails open — with more than one web process, point it at Redis or accept that the quota is per worker. Sessions and Django's cache want the same deliberate choice.
- **Delivery.** `GET /api/health/` for orchestrator probes (200 healthy/degraded, 503 when the database is down); `collectstatic` in the image build; remote S3-compatible storage for static, media and exports; structured JSON logs shipped somewhere searchable.
- **Measure before tuning.** Query-count pins and a benchmark command beat intuition, and the estimate/cap defaults above are chosen to be safe on a small table — on a large one, the right value is the measured one.

## Development norms in a SnapAdmin project

- **Read model configuration through the registry**, never with a hand-rolled `getattr`: `snapadmin.registry.is_registered(model)` is the gate every surface asks, and `get_model_meta(model, name, default)` resolves decorator argument > class attribute > `SNAPADMIN_<NAME>` setting > default.
- **Prefer the change that needs no migration.** Snap-only kwargs, settings and admin-layer overrides cost nothing to deploy; a column change costs a maintenance window on every install. Run `makemigrations --check --dry-run` after every field or model touch and expect "No changes detected".
- **Write through the ORM save path** when a field carries behaviour. Sanitization and encryption live in `pre_save()`, so `save()`, the admin, serializers and `bulk_create()` are all covered and `QuerySet.update()`, raw SQL and direct database access are not.
- **Extend, do not fork, the generated surfaces.** `admin_overrides` wins over anything the generator produces, `get_admin_media()` extends the base asset lists instead of snapshotting them, and serializers, viewsets and templates are all replaceable in place — a copied-and-edited generated class silently stops inheriting fixes.
- **Keep new surfaces fail-closed.** The library's own defaults refuse rather than allow (an unmapped action, an unbound tenant, a masking rule that cannot resolve); project code that adds a bypass should make it explicit, audited and tested, the way `use_all_tenants()` is.
- **Type the public functions, log through `structlog`, and read secrets from the environment.** Every new `SNAPADMIN_*` or project setting gets a commented entry in the settings module and a line in the environment template, so the next person does not have to read the code to find it.
- **Document behaviour where users see it** — a changelog entry for anything user-visible, and a note in the project's own runbook for anything an operator must do (a new scheduled task, a new secret, a new restore step).
- **Upgrades are a task, not a bump.** Pin the exact version, read the release notes, run `manage.py check` and `makemigrations --check`, then the suite. The audit trail's `{field: {"old", "new"}}` shape and every `snapadmin.*` import path are stable across releases; behaviour behind a default can still change, which is what the notes are for.

## Getting started

- [Installation](https://drofji.github.io/django-snapadmin/#installation): requirements, `INSTALLED_APPS` ordering (Unfold apps must precede `django.contrib.admin`), and the optional extras table. [The smallest install that works](https://drofji.github.io/django-snapadmin/#installed-apps-minimal): six `django.contrib` apps + `snapadmin` and one `include("snapadmin.urls")` line give an admin-only install; the API apps and settings are added on top.
- [New project with snapadmin-new](https://drofji.github.io/django-snapadmin/#scaffold): console script that generates a project you keep — `manage.py`, settings, one app with a worked `SnapModel`, SQLite, `.env`/`dist.env`; after `pip install -r requirements.txt` (the extras its settings import), `migrate` then `runserver` work immediately, no Docker, no manual edits. `--full` adds a `Dockerfile`, `docker-compose.yml` and the Postgres/Redis/Elasticsearch wiring. `--admin-only` generates the admin alone (no API apps, switches off, base install).
- [Quick start with snapadmin-demo](https://drofji.github.io/django-snapadmin/#snapadmin-demo): console script that fetches and runs a throwaway demo project.
- [Container health check](https://drofji.github.io/django-snapadmin/#healthcheck): `GET /api/health/` answers 200 healthy/degraded and **503** when the database is unreachable — the endpoint to point Docker, Compose, Coolify/Dokploy or Kubernetes probes at. Never probe `/admin/`: it answers 302 with the database down.
- [Integrate an existing project with snapadmin-init](https://drofji.github.io/django-snapadmin/#snapadmin-init): read-only inspection of a project that prints ready-to-paste snippets; it never edits source. Its report is the [integration checklist](https://drofji.github.io/django-snapadmin/#integration-checklist) below, one row per check, ✅/❌/⚠️ — ⚠️ ("not checked") for anything that needs a live database or server, never a false green.
- [Integration checklist](https://drofji.github.io/django-snapadmin/#integration-checklist): runnable Check / Why it matters / How to verify table, grouped Must work / Should be configured before production / Data safety (backups, 2+ destinations, encryption strongly recommended, "have you run a restore?") / Optional-scale. Verified with `snapadmin-init` and `snapadmin_info --section features` / `--section inventory` / `--health-check`.
- [Production playbook](https://drofji.github.io/django-snapadmin/#playbook): the human-readable twin of the four checklist sections above — [the decisions to settle](https://drofji.github.io/django-snapadmin/#playbook-interview), [the build order](https://drofji.github.io/django-snapadmin/#playbook-build-order), [testing the project you build](https://drofji.github.io/django-snapadmin/#playbook-testing), [production readiness and high load](https://drofji.github.io/django-snapadmin/#playbook-scale) and [the development norms](https://drofji.github.io/django-snapadmin/#playbook-norms). Same material, same wording — send a human there and an assistant here.
- [Integrating with your project](https://drofji.github.io/django-snapadmin/#integrating): wiring SnapAdmin into an existing Django codebase alongside plain `models.Model`. [Every REST viewset action maps to one Django permission](https://drofji.github.io/django-snapadmin/#api-action-permissions) and an unmapped action is refused, superusers included — map a project's own with `SNAPADMIN_API_ACTION_PERMISSIONS = {"action": "view"|"add"|"change"|"delete"}`.
- [Ecosystem compatibility](https://drofji.github.io/django-snapadmin/#compatibility): which Django versions, databases and third-party admin packages are supported. [SnapAdmin and admin themes](https://drofji.github.io/django-snapadmin/#vs-themes): Unfold/Jazzmin/Grappelli restyle an admin you write; SnapAdmin generates it (and uses Unfold as its optional theme) — recommend a theme alone when a better-looking admin is all that is needed.
- [Testing & quality engineering](https://drofji.github.io/django-snapadmin/#testing): the test layers and their files, the 100% line + branch gate, random order, property-based and fuzz tests, query-count pins, the Python × Django matrix, the real PostgreSQL/Elasticsearch job — and what is not in place yet. The sdist (never the wheel) carries the suite: unpack, run `python -m pytest`.
- [Every CI gate as one command](https://drofji.github.io/django-snapadmin/#testing-gates): `python scripts/gates.py` runs what CI runs and reports a gate it cannot run as not run, never passed; `--release` fails on one. Also: [the security regression suite](https://drofji.github.io/django-snapadmin/#testing-security-regressions) (`pytest -m security_regression`, every shipped fix) and [the lowest-deps job](https://drofji.github.io/django-snapadmin/#testing-dependencies) running every declared minimum.

## Declaring models

- [Two ways to declare a model](https://drofji.github.io/django-snapadmin/#two-ways): new model → subclass `SnapModel`; existing model → `@snap_model` / `snap_field()`. One worked model shown both ways, plus the capability × door matrix. On the decorator route ES mirroring, the retention purge and the generated admin are absent, so a plain model's `?search=` reads the decorator's `search_fields` — `snap_field(searchable=True)` alone reaches no surface there.
- [SnapModel reference](https://drofji.github.io/django-snapadmin/#snap-model): the declarative base, `register_all_admins()`, `es_storage_mode`, `data_retention_days`/`data_retention_date_field`. [A mixin's own `objects` manager](https://drofji.github.io/django-snapadmin/#snap-model-managers) is hidden by `SnapModel`'s `EsManager` when `SnapModel` comes first in the MRO — `snapadmin.E027` fails the check; declare a manager inheriting from both on the model.
- [@snap_model for plain models](https://drofji.github.io/django-snapadmin/#snap-model-decorator): the decorator that opts a plain `django.db.models.Model` in without subclassing — its keyword table, `@snap_property` for computed columns, and the model-meta precedence table (registry entry > class attribute > `SNAPADMIN_<NAME>` setting > built-in default). The full capability comparison moved to [Two ways to declare a model](https://drofji.github.io/django-snapadmin/#two-ways).
- [Snap fields](https://drofji.github.io/django-snapadmin/#snap-fields): every `Snap*Field` and its snap-only kwargs. Django's positional `verbose_name` works (`SnapCharField("Label", max_length=200)`); relation fields take `to` first, as in Django. [`snap_field()`](https://drofji.github.io/django-snapadmin/#snap-field-wrapper) sets the same kwargs on a plain Django field (a third-party field package, or a model that cannot be rewritten onto the `Snap*Field` classes) instead of requiring a `Snap*Field` subclass.
- [Mixing Snap and plain fields](https://drofji.github.io/django-snapadmin/#mixing-fields): a `SnapModel` is a normal Django model — bare fields, `snap_field()`-wrapped fields and `Snap*Field`s freely mix in one class body; only the fields that need Snap behaviour get it.
- [Advanced layout](https://drofji.github.io/django-snapadmin/#advanced-layout): fieldsets, inlines, ordering and grouping of the generated admin form.
- [Status badges](https://drofji.github.io/django-snapadmin/#status-badges): `SnapStatusBadgeField` and coloured choice rendering in list views.
- [Admin registration](https://drofji.github.io/django-snapadmin/#admin-registration): `register_all_admins()`, per-model opt-out, extending the generated `ModelAdmin`. The token admin appears only while an API that accepts tokens is on (`SNAPADMIN_TOKEN_ADMIN_ENABLED` overrides); the pk column shows for integer keys only (`admin_list_display_pk`); `admin_sections` is deprecated (`W026`).
- [Extending the generated admin](https://drofji.github.io/django-snapadmin/#admin-extension-surface): `admin_overrides` always wins over what the generator produces (merged in last), `get_admin_fields()`'s pinned `AdminFieldSets` return shape, and `get_admin_media()` for extending the base JS/CSS lists instead of copying a snapshot.
- [Extending and overriding](https://drofji.github.io/django-snapadmin/#extending): replacing generated serializers, viewsets, admin classes and templates.

## APIs

- [REST API](https://drofji.github.io/django-snapadmin/#api-rest): generated CRUD routes, filtering, pagination, throttling and the write guards; [`@snap_action`](https://drofji.github.io/django-snapadmin/#snap-action) for model-method actions; [`ValidationError` becomes a 400](https://drofji.github.io/django-snapadmin/#api-validation-errors), never a 500; [`api_full_clean`](https://drofji.github.io/django-snapadmin/#api-full-clean) runs `Model.clean()` on API writes; `fetch-by/` fetches an explicit key set in one call.
- [GraphQL API](https://drofji.github.io/django-snapadmin/#api-graphql): the generated Graphene schema and the GraphiQL playground.
- [Token management](https://drofji.github.io/django-snapadmin/#api-tokens): `APIToken`, hashed storage, and authenticating API calls. `PATCH /api/tokens/<id>/` renames a token — `token_name` only, any other field is a `400`; audited.
- [Quotas and rate limits](https://drofji.github.io/django-snapadmin/#quotas): `snapadmin.limits.reserve(key, windows, concurrency)` — cache-backed multi-window quotas, a concurrency cap and `cooldown()` after an upstream 429, for inbound guards and outbound clients alike. Counters are per-process unless `SNAPADMIN_LIMITS_CACHE_ALIAS` names a shared cache; an evicted counter fails open.
- [Async background export](https://drofji.github.io/django-snapadmin/#async-export): `POST /api/exports/` streams rows to a file on a Celery worker with progress, cancellation and pluggable [row sources](https://drofji.github.io/django-snapadmin/#export-sources). Formats `csv`, `json` (NDJSON) and [`xlsx`](https://drofji.github.io/django-snapadmin/#export-xlsx); the line formats resume after a crash, xlsx restarts from the first row.
- [Bulk import](https://drofji.github.io/django-snapadmin/#bulk-import): `manage.py snapadmin_import --model app.Model --file data.csv` — header mapping, a natural key, `--on-conflict fail|skip|update` (fail by default), validation through `full_clean()`, an NDJSON report per row, crash-safe `--resume`, and the same write-surface and masking rules as the API, enforced before the first row.

## Operations

- [Elasticsearch integration](https://drofji.github.io/django-snapadmin/#elasticsearch): storage modes, query routing, reindexing and the database fallback. A mirrored model declares `es_mapping` or `es_auto_mapping = True` (`E026` otherwise). [Deletes stay in step](https://drofji.github.io/django-snapadmin/#es-delete-sync) even through `QuerySet.delete()` and cascades; `suppress_es_delete_receiver()` + `delete_pks_from_es()` batch a large delete.
- [Celery and periodic tasks](https://drofji.github.io/django-snapadmin/#celery): the tasks and Beat schedules. Every scheduled task but `run_export` returns `status` (`ok`/`partial`/`noop`/`disabled`) plus `failed`, and raises when every unit failed — one monitoring rule covers all six. `snapadmin.W010` flags a backup Beat entry slower than the shortest backup interval.
- [Management-command rename](https://drofji.github.io/django-snapadmin/#command-rename): every command is `snapadmin_*`-prefixed; `db_backup`, `purge_expired_data` and `send_error_digest` are deprecated aliases of the prefixed names. Celery task names are unchanged.
- [GDPR data retention](https://drofji.github.io/django-snapadmin/#gdpr): `data_retention_days`/`data_retention_field`, a per-row [`data_retention_date_field`](https://drofji.github.io/django-snapadmin/#retention-per-row), `data_retention_files`, plus the audit-log and export-job sweeps — all run by `snapadmin.purge_expired_data`. [Rows a `PROTECT` key holds](https://drofji.github.io/django-snapadmin/#retention-protected) are kept and retried; [the full purge table](https://drofji.github.io/django-snapadmin/#retention-table) lists every table SnapAdmin can delete from.
- [GDPR subject-access requests](https://drofji.github.io/django-snapadmin/#gdpr-subject-request): `manage.py snapadmin_subject_request export|delete` answers "everything about this person". Every registered model declares `subject_path` (`E011`/`E012`); the operator needs `snapadmin.view_raw_pii`; deletion is dry-run by default and a `PROTECT` relation refuses the whole run. Backups, unmirrored ES copies and third-party stores stay out of reach.
- [Database sharding](https://drofji.github.io/django-snapadmin/#sharding): declarative multi-shard/read-replica routing, opt-in per model via `shard_key`, `snap_master_only()`/`snap_target()` for forcing routing per block of code, and the sharding-aware `snap_migrate`/`snapadmin_db_backup` commands. A separate, more general mechanism from the single-alias read-replica routing [`SNAPADMIN_ANALYTICS_DB_ALIAS`](https://drofji.github.io/django-snapadmin/#enterprise-config) already provides.
- [Multi-tenancy](https://drofji.github.io/django-snapadmin/#multi-tenancy): opt-in row-level isolation — `tenant_scoped = True` plus `tenant_field()`. Default-deny on every surface: no bound tenant means an empty read and a refused write. `SNAPADMIN_TENANT_RESOLVER` + `SnapTenantMiddleware` bind it per request; `use_all_tenants()` is the one audited bypass; `E009` and `E027` catch an unenforceable declaration.
- [Field encryption](https://drofji.github.io/django-snapadmin/#field-encryption): eight `SnapEncrypted*Field` types (AES-256-GCM, the `[encryption]` extra), ciphertext in a self-describing envelope bound to its `app.model.field`. Lookups the database cannot do raise `FieldError`; `blind_index=True` restores equality and `unique`. `manage.py snapadmin_encrypt_fields --adopt/--rotate/--reindex` converts stored data, writing nothing without `--apply`.
- [Encryption keys & rotation](https://drofji.github.io/django-snapadmin/#encryption-keys): `SNAPADMIN_ENCRYPTION` resolves the keyset from `KEY_PROVIDER` (KMS/Vault), `KEY_FILE`, the `SNAPADMIN_ENCRYPTION_KEYS` variable or literal `KEYS` — most-secure first, never merged. The first key encrypts, all decrypt, so rotation is prepending a key from `snapadmin_encryption_key --rotate`. `E017`/`E018`/`W017` guard the keyset.
- [PII masking](https://drofji.github.io/django-snapadmin/#pii-masking): `SNAPADMIN_MASKED_FIELDS` declares sensitive fields — masked in the changelist, REST, GraphQL, exports and the audit diff, dropped from the change form, for anyone without `snapadmin.view_raw_pii`. [`SNAPADMIN_MASKING_RULES`](https://drofji.github.io/django-snapadmin/#masking-rules) sets the policy per field. Every failure path degrades to more masking; a hand-written admin needs `PIIMaskingAdminMixin` (`W025`).
- [Module-level permissions](https://drofji.github.io/django-snapadmin/#module-permissions): deliberately a recipe, not a subsystem — a permission-only unmanaged model per module (`managed = False`, `default_permissions = ()`, one codename per sub-package), Django groups per role, then `has_perm` in views, `@snap_action(permission=...)`, `api_field_permissions` and `admin_overrides` on the generated surfaces.
- [Field-level permission guards](https://drofji.github.io/django-snapadmin/#field-permissions): `api_field_permissions = {"salary": {"read": "hr.view_salary", "write": "hr.change_salary"}}` gates a field's presence and writability, orthogonal to masking. Denied read: absent in REST, nulled in GraphQL. Denied write: a 400 naming the field. `api_exclude_fields` > this > `api_write_fields` > masking.
- [Unalterable audit trail](https://drofji.github.io/django-snapadmin/#audit-trail): append-only `SnapadminAuditLog` for every admin create/update/delete, exported for a SIEM with `manage.py snapadmin_audit_export`. The admin renders each entry as a field-level diff and links every object to its [timeline](https://drofji.github.io/django-snapadmin/#audit-timeline) at `/admin/snapadmin/snapadminauditlog/timeline/<app_label>/<model>/<object_id>/` — capped at the 100 most recent entries per page, masked like every other surface.
- [Error monitoring and alerts](https://drofji.github.io/django-snapadmin/#error-monitoring): error capture, thresholds, cooldowns and digests.
- [Alert channels](https://drofji.github.io/django-snapadmin/#alert-channels): error and health alerts by email, Slack, Discord, Teams, Telegram or plain-JSON webhooks (`SNAPADMIN_ALERT_WEBHOOKS`), stdlib only. Thresholds and the cooldown are shared, delivery is fail-soft, and webhook URLs are treated as secrets — never logged, never in an alert body, never in `snapadmin_info`.
- [3-2-1 database backups](https://drofji.github.io/django-snapadmin/#backups): local, network, FTP(S), SFTP and S3-compatible destinations with rotation, each on its own `*_EVERY_HOURS` window (`SNAPADMIN_BACKUP_ALIGN_TO_SCHEDULE` pins it to the clock). [Hetzner Storage Box](https://drofji.github.io/django-snapadmin/#storage-box) is the `sftp` destination. `W021`–`W024` catch unencrypted off-host dumps, SFTP path traps and backups configured but switched off.
- [Encrypting backups (AGE)](https://drofji.github.io/django-snapadmin/#backup-encryption): `SNAPADMIN_BACKUP_AGE_RECIPIENTS` (age or SSH public keys) encrypts every dump in-stream before a byte reaches disk; any one recipient decrypts alone. Backends `pyrage` (`[age]` extra) or the `age` CLI; `manage.py snapadmin_age_keygen` makes a keypair. Optional, strongly recommended — `W021` flags an off-host destination without it.
- [Media and .env in the bundle](https://drofji.github.io/django-snapadmin/#backup-bundle): `SNAPADMIN_BACKUP_INCLUDE` (default `["db"]`) adds `media` and/or `env` (`SNAPADMIN_BACKUP_ENV_FILE`) as separate files plus an unencrypted `manifest.json` with checksums and a ready-to-paste restore command. `env` without AGE recipients is refused (`E007`); `env` with no file behind it is `W024`.
- [Restoring a backup](https://drofji.github.io/django-snapadmin/#restore): `manage.py snapadmin_restore <source>` — dry-run by default, checksums verified before anything is touched, `env` only when named, a pre-restore [snapshot](https://drofji.github.io/django-snapadmin/#restore-rollback) undone by `snapadmin_rollback`. [Restore drills](https://drofji.github.io/django-snapadmin/#restore-drill): `--database <alias>` restores `db` alone into another alias and prints the row count per table.
- [Remote static/media/export storage](https://drofji.github.io/django-snapadmin/#remote-storage): one env var switches Django `STORAGES` to any S3-compatible provider (AWS, Hetzner Object Storage, MinIO, B2) via django-storages. Hetzner Storage Box is SFTP/CIFS, not S3.
- [Structured logging](https://drofji.github.io/django-snapadmin/#logging): the structlog wiring SnapAdmin expects.
- [Diagnostics — snapadmin_info](https://drofji.github.io/django-snapadmin/#snapadmin-info): the system-check/runtime/database/API/feature-adoption report, its flags, and the shell shims.
- [Startup report](https://drofji.github.io/django-snapadmin/#startup-report): the block `runserver` prints at boot — what it lists, when it prints (`SNAPADMIN_STARTUP_REPORT`, `SNAPADMIN_STARTUP_REPORT_PROBES`), and why it never contains a secret.
- [Licence audit — snapadmin_license_check](https://drofji.github.io/django-snapadmin/#license-check): dependency licences and their tiers.
- [Offline mode](https://drofji.github.io/django-snapadmin/#offline): running with no outbound network access. The connectivity layer (health poll, save-blocking guard, sidebar sync badge) is opt-in via `SNAPADMIN_CONNECTIVITY_ENABLED` (default `False`) and only loads when at least one registered model has `offline_mode = True`.

## Configuration reference

- [SNAPADMIN_PROFILE presets](https://drofji.github.io/django-snapadmin/#profiles): `"admin"` / `"api"` / `"full"` set the handful of settings that matter for a new project in one line. Explicit setting > profile > built-in default; unset applies no profile at all. Since 0.1.0b8 `"full"` turns the API surfaces on while unset leaves them off.
- [Environment variables reference](https://drofji.github.io/django-snapadmin/#env-vars): every `SNAPADMIN_*` setting with its default. Start here for any "which setting turns X on" question.
- [Enterprise config](https://drofji.github.io/django-snapadmin/#enterprise-config): SSO, multi-database routing and hardening options.
- [Internationalization](https://drofji.github.io/django-snapadmin/#i18n): the ten shipped locales and how to add strings. [Native-reviewed](https://drofji.github.io/django-snapadmin/#i18n-review): `en` (source) and `ru` only — de, de_CH, es, fr, fr_CH, it, nl, pl are machine-assisted and await native review; never describe them as reviewed. Every catalog is linted mechanically (placeholders, markup, plurals, a matching `.mo`).
- [Theming and styles](https://drofji.github.io/django-snapadmin/#theming): the three CSS layers — one shared sheet plus exactly one theme layer (`admin-stock.css` without Unfold, `admin-unfold.css` with it) — and how to add your own.
- [Themed auth admin](https://drofji.github.io/django-snapadmin/#themed-auth-admin): with the Unfold theme installed, SnapAdmin re-registers Django's stock `User`/`Group` admins with Unfold's forms so the password row stays usable; `SNAPADMIN_THEME_AUTH_ADMIN = False` opts out.
- [Large-dataset performance](https://drofji.github.io/django-snapadmin/#performance) and [optimizations guide](https://drofji.github.io/django-snapadmin/#optimizations): estimated counts, pagination caps, and query-routing trade-offs.

## Project

- [README](https://github.com/drofji/django-snapadmin/blob/main/README.md): the short overview — the 60-second try, both quickstarts (`SnapModel` for a new project, `@snap_model` + `snap_field()` for an existing one), the kwargs cheat sheet, the commands and the quality claims; reference material lives on the docs site.
- [Changelog](https://github.com/drofji/django-snapadmin/blob/main/CHANGELOG.md): user-visible changes per release.
- [Migration guides](https://drofji.github.io/django-snapadmin/#migration-guides): upgrade steps between versions with breaking changes.
- [AI assistants](https://drofji.github.io/django-snapadmin/#ai-assistants): how this file and the in-package module map are meant to be used, and the tests that keep both honest.
- [Security policy](https://github.com/drofji/django-snapadmin/blob/main/SECURITY.md): supported versions, reporting, and the production-hardening checklist.
- [Third-party notices](https://github.com/drofji/django-snapadmin/blob/main/THIRD_PARTY_NOTICES.md): dependency licences; the base install is permissive-only (MIT/BSD/Apache) and safe for commercial use.
- [Source](https://github.com/drofji/django-snapadmin) · [PyPI](https://pypi.org/project/django-snapadmin/)

## Optional

- [Demo app model overview](https://drofji.github.io/django-snapadmin/#demo-models): the models the bundled demo declares, useful as worked examples.
- [Seed command](https://drofji.github.io/django-snapadmin/#demo-seed): populating the demo with sample data.
