# 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.0b8`) — 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. `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`–`W018` and errors `snapadmin.E001`–`E019` (a `SNAPADMIN_MASKED_FIELDS` or `SNAPADMIN_MASKING_RULES` key/field that does not resolve, or an unusable masking pattern, or an `env` bundle part requested with no AGE recipients configured; those fail *open* or would leak secrets, so they are errors; `E008` is a `@snap_action` whose declared methods the model's own `api_read_only`/`api_http_method_names` policy would always reject; `E009` is a `tenant_scoped = True` declaration that cannot actually be enforced (no resolvable tenant field, or a `@snap_model`-decorated plain model rather than a `SnapModel` subclass); `E011`/`E012` are the GDPR subject-access `subject_path` declaration — required on every registered model, no implicit default; `E013`–`E016` are `SNAPADMIN_SHARDING` misconfiguration (an unresolvable DSN/shard shape, an unrecognised `STRATEGY`/`REPLICA_SELECTION`, a `'custom'` strategy with no importable `CUSTOM_ROUTER_FUNC`, a `'range'` shard missing its `RANGE` or two overlapping ones); `W014` was retired in 0.1.0b8 and its id is never reused — the flip it announced has happened, so "left unset" now simply means off; `W015` is a registered model whose generated admin form would render empty; `E017`/`E018` are field encryption's fail-closed pair — an encryption key that is Django's `SECRET_KEY`, and an encrypted field declared with no keyset configured). Read them before debugging behaviour.
- `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); anything else is stored as text, as are rows written by releases before this one, so a consumer must accept both.
- Three console scripts, easy to conflate: `snapadmin-new` generates a project you keep (SQLite, no Docker, `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.
- 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`.

## 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 runnable form of this list, with the command that proves each answer, is the [integration checklist](https://drofji.github.io/django-snapadmin/#integration-checklist).

1. **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.
2. **`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.
3. **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.
4. **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.
5. **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.
6. **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.
7. **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, because an untested backup is the most common way to not have one.
8. **What deletes old data, and what runs it?** `data_retention_days` per model, `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.
9. **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`/`_USER`) and a page size of 25 — all reasonable, none of them chosen for this project. Confirm each is the intended one rather than the inherited one.
10. **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.
11. **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; `required=True` is the single exception.
12. **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.

## 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.
- [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`; `migrate` then `runserver` work immediately, no Docker, no manual edits. `--full` adds a `Dockerfile`, `docker-compose.yml` and the Postgres/Redis/Elasticsearch wiring.
- [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`.
- [Integrating with your project](https://drofji.github.io/django-snapadmin/#integrating): wiring SnapAdmin into an existing Django codebase alongside plain `models.Model`.
- [Ecosystem compatibility](https://drofji.github.io/django-snapadmin/#compatibility): which Django versions, databases and third-party admin packages are supported.

## Declaring models

- [Two ways to declare a model](https://drofji.github.io/django-snapadmin/#two-ways): the decision (new model → subclass `SnapModel`; existing model → `@snap_model`/`snap_field()`), the same worked model shown both ways, and the honest capability × door matrix covering both the model half (ES mirroring and retention purge are the two real gaps, tracked as `#RFC1g`, not yet shipped; backups and PII masking are identical on both doors) and the field half (full parity — every `Snap*Field` flag, including wysiwyg sanitize-on-write, `required` and the file-upload validators, is reachable from `snap_field()`).
- [SnapModel reference](https://drofji.github.io/django-snapadmin/#snap-model): the declarative base, `register_all_admins()`, `es_storage_mode`, `data_retention_days`.
- [@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. [`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): auto-registration, per-model opt-out, and hooking custom `ModelAdmin` behaviour.
- [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 delete/write guards. [`@snap_action`](https://drofji.github.io/django-snapadmin/#snap-action) turns a model method into a user-defined action (`POST /api/models/<app>/<Model>/<pk>/<name>/`, or the list-level route with `detail=False`), bound by the model's own `api_read_only`/`api_http_method_names` policy (structurally — DRF rejects a disallowed verb before the action's own code runs) and a Django permission derived from its methods or given explicitly; `snapadmin.E008` catches a declared conflict at boot. GraphQL has no mutation counterpart. Every model's registered actions are listed at `GET /api/models/schema/`. `POST /api/models/<app>/<Model>/fetch-by/` (body `{"field": ..., "values": [...]}`) fetches an explicit key set in one call — `field` must be `unique=True` or `db_index=True` (400 otherwise, naming the constraint), `values` is capped at `SNAPADMIN_FETCH_BY_MAX_VALUES` (default 10000, 400 not a truncation over it), same NDJSON streaming/permissions/masking as `export`; reachable via POST even on an `api_read_only` model since it never writes.
- [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.
- [Quotas and rate limits](https://drofji.github.io/django-snapadmin/#quotas): `snapadmin.limits.reserve(key, windows, concurrency)` — a cache-backed primitive for per-tenant/per-token quotas across several time windows at once, a concurrency cap, and an explicit `cooldown()` after an upstream 429. No opinion about what `key` means, so it serves an inbound API guard and an outbound client call alike. Counters are per-process unless `SNAPADMIN_LIMITS_CACHE_ALIAS` points at a shared cache, and a counter evicted under cache pressure fails open (allows), never closed.
- [Async background export](https://drofji.github.io/django-snapadmin/#async-export): `POST /api/exports/` streams a model's rows to a file on a Celery worker, with progress/ETA polling, cancellation and pluggable [row sources](https://drofji.github.io/django-snapadmin/#export-sources). `export_format` is `csv`, `json` (newline-delimited) or [`xlsx`](https://drofji.github.io/django-snapadmin/#export-xlsx). The line formats resume from a primary-key checkpoint after a crash; **xlsx does not** — a workbook is written whole, so a retry re-exports from the first row and a cancelled job leaves no partial file. `xlsx` needs the `[xlsx]` extra (openpyxl) and is rejected with a 400 without it; its cells keep their types and text starting with `=` is stored as text, never a formula.
- [Bulk import](https://drofji.github.io/django-snapadmin/#bulk-import): `manage.py snapadmin_import --model app.Model --file data.csv` mirrors the export job's architecture in reverse (`SnapImportJob`, `snapadmin.importing`). Header-name column mapping (plus an optional explicit `--map` override); the duplicate key (`--natural-key`) defaults to the model's first `unique=True` field or the mapped primary key; `--on-conflict` is `fail` (default, never a silent overwrite) / `skip` / `update`. Validation runs through `full_clean()` — no parallel layer. One NDJSON report line per row (`row`/`action`/`pk`/`errors`) plus a summary line. Crash-safe: every row's write, the job's counters and the report's confirmed length commit together per chunk, so `--resume` can never re-create an already-committed row. Write-surface rules (`api_write_fields`/`api_exclude_fields`/`api_read_only`/masking) are enforced before a single row is processed, not as a follow-up — a masked/PII column needs `--requested-by` naming a user with PII access.

## Operations

- [Elasticsearch integration](https://drofji.github.io/django-snapadmin/#elasticsearch): storage modes, query routing, reindexing, and database fallback.
- [Celery and periodic tasks](https://drofji.github.io/django-snapadmin/#celery): background tasks and the Beat schedules SnapAdmin registers. Every scheduled task but `run_export` returns a `status` (`"ok"`/`"partial"`/`"noop"`/`"disabled"`) and `failed` list, and **raises** instead of returning when every unit of work failed — one monitoring rule (`status != "ok"` alerts, Celery `FAILURE` pages) covers all six. `snapadmin.W010` warns when the `run_db_backups` Beat entry runs less often than the shortest configured 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): per-model `data_retention_days`/`data_retention_field`, plus `data_retention_files` (storage-backed field names) so an expiring row takes its uploaded files with it — files deleted before rows, a path another live row still references is skipped, a storage failure raises `SnapPurgeError` and leaves the row intact for retry. The same `snapadmin.purge_expired_data` task/command also purges `SnapadminAuditLog` (`SNAPADMIN_AUDIT_RETENTION_DAYS`, default 365 — on by default) and, if `SNAPADMIN_EXPORT_RETENTION_DAYS` is set (off by default), finished `SnapExportJob`/`SnapReindexJob` rows and their files plus an orphan-file sweep. `snapadmin.W012` warns when retention is configured anywhere but no `CELERY_BEAT_SCHEDULE` entry runs the purge task. [The full purge table](https://drofji.github.io/django-snapadmin/#retention-table) lists every table SnapAdmin can auto-delete, its setting and its recommended schedule.
- [GDPR subject-access requests](https://drofji.github.io/django-snapadmin/#gdpr-subject-request): `manage.py snapadmin_subject_request export|delete --model app.Model --identifier VALUE --user USERNAME` answers "everything about this one person", not "how old is too old". Every registered model declares `subject_path` (a forward `__`-joined ORM path, ≤3 relation hops, to the subject-identifying field, or `None` — required, `snapadmin.E011` fails `manage.py check` for silence, `E012` for a malformed declaration) plus, on the subject model itself, `is_data_subject=True` and `subject_identifier` (must equal `subject_path`). `--user` must hold `snapadmin.view_raw_pii` — export is unmasked by design (it reuses the existing `SnapExportJob` machinery with `requested_by` set to the operator, the same masking bypass a PII-privileged requester already gets elsewhere) and is written to the audit trail either way. `--recipient` AGE-encrypts the bundle afterward, plaintext removed. Deletion is dry-run by default; both modes run the identical pre-flight (a Django deletion `Collector` walk, which also discovers cascade spillover) — any protected relation (`on_delete=PROTECT`) refuses the **whole** run up front rather than deleting in dependency order. The deletion audit entry cannot itself be swept by a later request for the same subject: `SnapadminAuditLog` carries no `subject_path`, being outside the general registry entirely. Honest limits stated on every run: unreachable are a backup bundle, an Elasticsearch copy the model does not mirror, and any third-party store.
- [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): row-level tenant isolation, opt-in per model — `tenant_scoped = True` plus a tenant column (`snapadmin.tenancy.tenant_field()`, a nullable indexed `CharField`). Once opted in, the admin, REST, GraphQL, Elasticsearch routing (`es_search`/`es_filter`/`es_aggregate`/`es_count`/`es_scan` — the tenant term is forced into the ES query body, overriding any caller-supplied value for the same field), async export/import jobs and the offline cache all become unreachable without a bound tenant — **default-deny**: no tenant bound means an empty read and a refused write, never every row. `SNAPADMIN_TENANT_RESOLVER` (dotted path, `resolver(request) -> tenant | None`) plus `snapadmin.tenancy.SnapTenantMiddleware` resolve the tenant per request; `SNAPADMIN_TENANT_USER_RESOLVER` (`resolver(user) -> tenant | None`) resolves it once at export/import job creation, replayed by the worker via `use_tenant()` since a Celery task has no request. `use_all_tenants()` is the one explicit, audited bypass — reserved for the retention purge and the Elasticsearch reindex, both inherently cross-tenant. `snapadmin.E009` fails `manage.py check` when `tenant_scoped = True` is declared but unenforceable (no resolvable tenant field, or the model is a `@snap_model`-decorated plain model rather than a `SnapModel` subclass — the scoping hook lives in `SnapModel`'s `EsManager`). Honest limit stated in the docs and `SECURITY.md`: isolation is *logical*, not physical, and `snapadmin.backup`'s database dumps are not tenant-scoped at all — they run below the ORM entirely, so a backup bundle contains every tenant's data.
- [Field encryption](https://drofji.github.io/django-snapadmin/#field-encryption): encrypted model fields store ciphertext in the database and hand application code the ordinary Python value; this section is the keyset they depend on. One dict, `SNAPADMIN_ENCRYPTION`, with four sources resolved most-secure-first and **never merged** — `KEY_PROVIDER` (a dotted path to a callable, for KMS/Vault: nothing secret in settings or the environment), `KEY_FILE` (a mounted container secret, also settable as `SNAPADMIN_ENCRYPTION_KEY_FILE`), the `SNAPADMIN_ENCRYPTION_KEYS` environment variable (`id:key` entries, comma- or newline-separated), then literal `KEYS` in the settings module (warned about via `snapadmin.W017` whenever `DEBUG` is off). The keyset is ordered: the first key encrypts, every key decrypts, and each ciphertext records the id of the key that wrote it — so rotation is prepending one line from `manage.py snapadmin_encryption_key --rotate`, with no downtime and no migration. Two guarantees are asserted by tests rather than intended: the key is never Django's `SECRET_KEY` (`snapadmin.E017`; a `SECRET_KEY` rotation would otherwise make every encrypted column unreadable), and key material is never rendered into a log, a `repr` or an exception — only a key id and a short fingerprint. Fail-closed by default: an encrypted field with no resolvable keyset is `snapadmin.E018` at startup, and `STRICT: False` relaxes only that check, never the runtime refusal to fall back to plaintext. Nothing is read, imported or slowed down until a model actually declares an encrypted field.
- [PII masking](https://drofji.github.io/django-snapadmin/#pii-masking): `SNAPADMIN_MASKED_FIELDS` declares which fields are sensitive; they are obfuscated in the admin changelist, dropped from the admin change form, and masked in REST, GraphQL and background exports for anyone without `snapadmin.view_raw_pii`. [`SNAPADMIN_MASKING_RULES`](https://drofji.github.io/django-snapadmin/#masking-rules) then sets the policy per field — a regex `pattern`+`replacement`, a flat `replacement` redaction, and/or a `permission` that unlocks that one field. Naming a field there also declares it sensitive. A pattern that will not compile, one that could backtrack catastrophically, or a value over 4096 characters falls back to the built-in masker, never to raw data.
- [Field-level permission guards](https://drofji.github.io/django-snapadmin/#field-permissions): `api_field_permissions = {"salary": {"read": "hr.view_salary", "write": "hr.change_salary"}}` (model-level, resolved through `get_model_meta` like `api_write_fields`) gates a field's very presence/writability — orthogonal to masking, which only controls whether an already-visible field is raw or starred. Denied read → absent from a REST response, nulled in GraphQL (a documented, deliberate asymmetry — the GraphQL schema is built once at import time, so a per-request field cannot be removed from the response shape). Denied write → an explicit `400` naming the field. Precedence: `api_exclude_fields` (absolute) > `api_field_permissions` > `api_write_fields` (its own, unchanged, silent-drop contract) > masking. Wired into REST and GraphQL; the admin form and export are a follow-up.
- [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): the same spike/digest/health alerts delivered by email and/or Slack, Discord, Teams, Telegram and plain-JSON webhooks, configured in `SNAPADMIN_ALERT_WEBHOOKS`. Posted with the stdlib — no new dependency. Thresholds, grouping and the cooldown are shared by all channels, so a webhook changes where an alert goes, never how often it fires; delivery is fail-soft and a run where every channel failed releases the cooldown instead of consuming it. Webhook URLs are 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, SFTP, FTP(S) and S3-compatible (`SNAPADMIN_BACKUP_S3_*`, the `[s3]` extra — AWS, MinIO, Backblaze B2, Hetzner Object Storage, Wasabi via `SNAPADMIN_BACKUP_S3_ENDPOINT_URL`) targets with rotation (S3 lifecycle rules are the better answer for real deployments). [Hetzner Storage Box](https://drofji.github.io/django-snapadmin/#storage-box) is SFTP/SCP/WebDAV, not S3 — it uses the `sftp` destination (port 23, a sub-account, key auth, pre-populated `known_hosts`).
- [Encrypting backups (AGE)](https://drofji.github.io/django-snapadmin/#backup-encryption): `SNAPADMIN_BACKUP_AGE_RECIPIENTS` (a list of age/SSH public keys) encrypts every dump in-stream — `pg_dump`/SQLite → gzip → age → the `.age`-suffixed file — before a byte reaches disk; empty (the default) changes nothing. Any one of N recipients' private keys decrypts a bundle independently, no shared secret. Two interchangeable backends, `SNAPADMIN_BACKUP_AGE_BACKEND`: `pyrage` (`[age]` extra, MIT, in-process) or `binary` (the `age` CLI, BSD-3-Clause, e.g. `apt install age` on Debian 12+/Ubuntu 22.04+). Optional but strongly recommended — an unencrypted dump on a rented offsite server is the whole database in someone else's hands. A private key never appears in a setting, log line, or `snapadmin_info` output. `manage.py snapadmin_age_keygen` generates a keypair through either backend, writes it to a git-ignored `.age/` directory (adding a `.gitignore` rule first if none already covers it — recognising more than a literal `.age/` line, including a blanket `.*`), prints only the public recipient, and closes with a reminder to move the private key to secure storage and delete the local copy.
- [Media and .env in the bundle](https://drofji.github.io/django-snapadmin/#backup-bundle): `SNAPADMIN_BACKUP_INCLUDE` (default `["db"]`) opts a run into bundling `media` (MEDIA_ROOT, tarred and streamed, `SNAPADMIN_BACKUP_MEDIA_EXCLUDE` glob list, `SNAPADMIN_BACKUP_MEDIA_SIZE_WARNING_BYTES` warns without aborting) and/or `env` (`SNAPADMIN_BACKUP_ENV_FILE`) alongside the database. Each part is its own file sharing one run's timestamp, plus one always-unencrypted `manifest.json` sidecar (versions, DB engine, per-part ciphertext checksum, recipients, a ready-to-paste restore command) — never a single combined archive. Including `env` with no AGE recipients configured is refused (system check `snapadmin.E007`, plus a runtime guard) — a `.env` file's secrets are never written to a backup destination unencrypted. An unreadable media file is skipped with a warning, not an aborted backup. Retention (`SNAPADMIN_BACKUP_KEEP`) applies per part, so media never starves the database dump's own retention headroom.
- [Restoring a backup](https://drofji.github.io/django-snapadmin/#restore): `manage.py snapadmin_restore <source> [--only db,media,env] [--skip ...] [--identity PATH] [--confirm] [--no-snapshot] [--list]` — `<source>` is a local manifest path or `<destination>:<name>` to pull from a configured destination. Dry-run by default; `--confirm` performs it. The manifest's per-part checksum is verified before anything is touched; an encrypted bundle with no `--identity` prints the recipient count and fingerprints instead of failing opaquely. `env` is never restored by a bare `--confirm` — it must be named explicitly in `--only`. Restoring `db` is not live-safe: connections are terminated and, for PostgreSQL, the database is dropped and recreated before the dump loads. Before any `--confirm`ed restore touches anything, [the pre-restore safety net](https://drofji.github.io/django-snapadmin/#restore-rollback) automatically snapshots the current live state (`SNAPADMIN_RESTORE_SNAPSHOT_DIR`/`SNAPADMIN_RESTORE_SNAPSHOT_KEEP`, default 3, its own retention separate from `SNAPADMIN_BACKUP_KEEP`) — a failed snapshot aborts the restore. `manage.py snapadmin_rollback [<id>] [--identity PATH] [--confirm] [--list]` restores a snapshot, defaulting to the most recent one; also dry-run by default.
- [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.
- [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"` — collapses the ~90 `SNAPADMIN_*` settings to one line for a new project. Explicit setting > active profile > built-in default; unset applies no profile at all and is byte-identical to every install before this feature existed. `"full"` and unset stopped being equivalent in 0.1.0b8 — `"full"` turns the API surfaces on, 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.
- [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 same overview with the extras table and screenshots.
- [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.
