0.1.0b6 — 2026-08-13 — Sixth Beta
=================================

A first-run polish release. Everything here came out of installing 0.1.0b5 into a fresh project
and walking the demo: the admin index rendered its theme shell twice, half the dashboard stayed
English in every other locale, three management commands lacked the package prefix, and the
container health endpoint answered 200 no matter what. Alongside those, the documentation gained
the two layers machines read, a written API-stability policy, and a docstring on every name the
public contract pins.

No migration, no import path moved, no setting renamed. Two dependencies the package never
imported are gone — see Removed if your own code was relying on them transitively.

Added
-----

* **Machine-readable entry points for AI coding assistants.** Assistants had no good way into
  SnapAdmin: the only documentation surface was a single 220 KB HTML page, which large language
  models parse poorly, and nothing in the installed package explained the library's shape. Two
  layers now fix that, and both are pinned by tests so they cannot quietly go stale.

  ``llms.txt`` — the `llmstxt.org <https://llmstxt.org/>`_ format — is a plain-Markdown map of the
  documentation: what SnapAdmin is, the three-step quickstart, the handful of facts an assistant
  should not have to infer (snap-only field kwargs add no migration, import paths are the public
  contract, the Unfold theme is optional, misconfiguration shows up as ``snapadmin.W001``–``W007``),
  then annotated links into every section of the documentation. It is published at
  https://drofji.github.io/django-snapadmin/llms.txt for assistants with web access, and a
  byte-identical copy ships in the source distribution at the repository root.

  The ``snapadmin`` package docstring is now a full quickstart and module map — the three-step
  example, what each module and management command is for, the ``SNAPADMIN_*`` setting families and
  the optional extras. Unlike ``llms.txt`` this layer reaches **every** install: it is what
  ``help(snapadmin)`` prints and what any tool reading the installed package finds, with no network
  and no repository access.

  Nothing about the library's behaviour changes — this is documentation that machines can read.

* **``snapadmin-info`` and ``snapadmin-license-check`` are now shell commands too.** Both are Django
  management commands, but the package also ships real console scripts (``snapadmin-demo``,
  ``snapadmin-init``) and the documentation lists all four together — so typing ``snapadmin_info`` in
  a shell and getting ``command not found`` was an easy first-run mistake. Thin shims now walk up
  from the current directory to find your ``manage.py``, forward every argument and return the exit
  code. **All four spellings work**: ``snapadmin-info``, ``snapadmin_info`` and
  ``python manage.py snapadmin_info`` are the same command, and likewise for the licence check. With
  no project in sight the shim explains that and points at ``snapadmin-demo``, rather than failing as
  a missing binary. Like the other console scripts they are stdlib-only and import no Django at
  module level.

* **A copy-pasteable container health check.** The demo image now carries a ``HEALTHCHECK``
  targeting ``/api/health/`` (it already bundled ``curl`` for it), the compose web service probes
  the same endpoint instead of ``/admin/``, and the documentation gains a
  `Container health check <https://drofji.github.io/django-snapadmin/#healthcheck>`_ section with
  the exact field values for Docker, Compose, **Coolify / Dokploy / Caprover** and Kubernetes —
  including why the start-up grace period matters and why a Celery worker needs
  ``celery inspect ping`` rather than an HTTP probe.

* **Remote static/media/export storage in the demo, from one environment variable.** Local disk is
  fine for one container; the moment there are two, media must move off it — a file uploaded through
  instance A is a 404 on instance B, and a restart loses it. ``SNAPADMIN_STORAGE_BACKEND=s3`` switches
  Django's ``STORAGES`` to any S3-compatible provider — **AWS S3, Hetzner Object Storage, MinIO,
  Backblaze B2** — with the same variables; only the endpoint and region differ. Safe defaults are
  wired in: signed time-limited URLs (private bucket), no silent overwrite of an existing key, no ACL
  header (modern buckets reject it), and static left on WhiteNoise unless you opt in. It needs the
  optional, BSD-licensed ``django-storages[s3]``; nothing is imported while the backend is ``local``.
  ``demo/dist.env`` carries the per-provider values, including that a **Hetzner Storage Box is not
  S3** (SFTP/CIFS/WebDAV — mount it, or use ``SNAPADMIN_BACKUP_SFTP_*`` for backups, which needs no
  mount). Database backups still have no S3 destination; that is stated explicitly in the docs.

* **A written API-stability and compatibility policy**, in
  `SECURITY.md <https://github.com/drofji/django-snapadmin/blob/main/SECURITY.md>`_. Upgrading for a
  security fix should not mean guessing what counts as a promise, so the policy states exactly what
  the public API is (import paths, ``SnapModel`` attributes and ``Snap*Field`` kwargs, ``SNAPADMIN_*``
  settings, management command names, REST/GraphQL routes and URL names, documented template block
  hooks) and what it is not (underscore-prefixed names, internals, admin HTML and CSS class names,
  log wording, migration contents). Most of that surface is already pinned by
  ``tests/test_public_contract.py``, so a breaking change fails the suite instead of reaching PyPI.

  It also sets out what happens from ``1.0``: semantic versioning, additions in a minor, removals
  only in a major, and a deprecated name kept working for at least one full minor release while
  saying so — a ``DeprecationWarning`` for a Python name, a stderr notice for a management command.
  Until then the ``0.x`` beta series may still break compatibility, but never silently.

* **Every name on the public contract now has a docstring**, with a usage example on the ones you
  actually type: ``SnapModel``, the ``SnapField`` mixin (the full kwarg list with its defaults),
  ``SnapFunctionField``, ``SnapStatusBadgeField``, the generated REST viewsets, the token
  authentication class and the error-monitor middleware. This is the documentation layer that
  reaches an installed package — what ``help()`` and an IDE tooltip show, with no network — and a
  new test keeps it from rotting: a public name added without a docstring fails the suite.

* **The README says how SnapAdmin relates to Unfold, Jazzmin and Grappelli.** It appears next to
  them in package directories and nothing explained why another layer exists. A short section after
  the three-step example makes the distinction concrete: those are themes that restyle the admin you
  write, while SnapAdmin generates that admin — plus the REST API, the GraphQL schema and the search
  mapping — from the same field declarations, and uses Unfold as its *optional* theme rather than
  competing with it. It also says plainly when a theme is the better choice.

* **An honest answer to "how fast is it?"** No benchmark numbers have been published, so the README
  quotes none; it points at the two commands the demo already ships — ``seed_large`` (100,000 rows)
  and ``benchmark_list_view`` (changelist query count and wall time, with and without the automatic
  ``list_select_related``) — so the figure you act on is measured on your own data.

* **New ``checks`` diagnostics section.** ``snapadmin_info`` reports a per-severity count of Django's
  system checks, and ``--health-check`` fails when any of them is an error — a misconfigured
  deployment now shows up in a readiness probe.

Changed
-------

* **Every management command is now ``snapadmin_*``-prefixed.** Three of them shipped without the
  prefix the rest use — ``db_backup``, ``purge_expired_data`` and ``send_error_digest`` next to
  ``snapadmin_info``, ``snapadmin_reindex`` and friends. Beyond the inconsistency, names that
  generic can **collide with a command of your own**: Django resolves duplicate command names
  silently by ``INSTALLED_APPS`` order, so whichever app wins, wins quietly. Use
  ``snapadmin_db_backup``, ``snapadmin_purge_expired_data`` and ``snapadmin_send_error_digest``.

  **The old names still work.** A command name lives in crontabs, Dockerfiles and CI, so each is
  kept as a deprecated alias with the same arguments and behaviour, plus one rename notice on
  stderr — stdout stays clean, so a piped cron job is unaffected. They will be removed in a future
  release; update your schedules when convenient. **Celery task names are unchanged** —
  ``snapadmin.purge_expired_data``, ``snapadmin.send_error_digest`` and
  ``snapadmin.run_db_backups`` were already prefixed, so no Beat entry needs touching.

* **``snapadmin_info`` output is readable at a glance.** Three things changed. Django prints every
  system-check message in full before any management command, so on a project with a dozen models
  the report you asked for arrived under a screen of advisory text — the command now opts out of
  that pass and reports the counts as its own section instead (``--verbose`` for the text, errors
  always shown). A uniform list of records renders as an **aligned table** rather than repeating
  every key name once per row — the model inventory went from 55 lines to 13 for 11 models. And a
  run of four or more booleans renders as one wrapped ``✓ on`` line and one ``✗ off`` line, so the
  feature-adoption checklist answers "what's on, what's off" without scrolling. ``--json`` output is
  unchanged apart from the new ``checks`` section.

* **The API write-guard checks now emit one grouped warning instead of one per model.**
  ``snapadmin.W004`` (no ``api_write_fields``) and ``snapadmin.W007`` (``api_write_fields = []`` but
  still write-exposed) used to repeat an identical message *and* an identical multi-line hint once
  per affected model, so a project with a dozen models printed a wall of near-duplicate text above
  the output of **every** ``manage.py`` command. Each check now returns a single warning that names
  the affected models in one comma-separated list (truncated past 20 with a ``(+N more)`` count).
  The check ids are unchanged, so anything keyed on ``snapadmin.W004``/``W007`` keeps working; code
  that counted the number of returned warnings will now see one.

* **W004 no longer fires for models that cannot be written through the API.** A model with
  ``api_read_only = True``, or an ``api_http_method_names`` allowlist containing no write verb,
  answers ``405`` to every write — there is no mass-assignment surface to guard, so asking it for an
  ``api_write_fields`` allowlist was noise. (This is what made the demo's read-only ``ExchangeRate``
  warn.)

* **The demo now dogfoods the write allowlist.** Every demo model declares an explicit
  ``api_write_fields``, so a fresh ``snapadmin-demo`` run starts with a clean ``manage.py check``
  and the demo *demonstrates* the mass-assignment guard instead of tripping its warning.

* **Loading a model no longer pulls in the REST framework.** ``snapadmin.models`` imports
  ``snapadmin.pagination`` for its estimated-count paginator — a pure-Django class that speeds up the
  *admin* changelist on large tables — and that module also defines the DRF paginator the API uses.
  Because the DRF import sat at the top, importing a model dragged in the whole REST stack, so an
  admin-only project paid for an API it never mounts. ``SnapDynamicPagination`` is now built on first
  access; the import path is unchanged and still public.

  Likewise ``snapadmin.urls`` now imports DRF, drf-spectacular and graphene **inside** the branches
  that need them, so including it with ``SNAPADMIN_REST_API_ENABLED = False`` no longer requires the
  REST stack to be installed. **This is groundwork.** Those packages are still ordinary
  dependencies of every install — moving them behind ``[api]`` / ``[graphql]`` extras changes the
  default dependency graph and is deliberately left to its own release.

* **A feature enabled without its dependencies now says what to do about it.** Where a missing
  package used to surface as an ``ImportError`` from inside a URLconf — which reads as a broken
  installation — the error now states both halves of the answer: install the packages
  (``pip install djangorestframework drf-spectacular django-filter``), or switch the feature off
  (``SNAPADMIN_REST_API_ENABLED = False``). It names the packages rather than an extra on purpose:
  there is no ``[api]`` extra to install *yet*, and pointing at one that does not exist would send
  you to a pip warning and no packages. GraphQL previously degraded further still, logging a
  warning and leaving the endpoint quietly absent; a missing ``graphene-django`` now raises. A schema
  that fails to build on your own models is still a warning, as before.

Removed
-------

* **``colorama`` is no longer a dependency.** It was installed by every base install and nothing in
  the package imported it — SnapAdmin's coloured console output is written with plain ANSI escapes.
  Install it directly if your own code relies on it.

* **``django-admin-rangefilter`` is no longer a dependency.** It was installed by every
  ``pip install django-snapadmin`` and the package never imported it — the range filters SnapAdmin
  generates for ``filterable`` date and number fields come from ``unfold.contrib.filters`` under the
  theme, and from Django's own list filters without it. One fewer package, one fewer licence to
  audit, for everyone.

  **If your own admin code imports it** (``from rangefilter.filters import …``) or lists
  ``"rangefilter"`` in ``INSTALLED_APPS``, it was only ever reaching you transitively: install it
  directly with ``pip install django-admin-rangefilter``. Nothing in SnapAdmin changes behaviour —
  no generated filter, admin screen or setting is affected.

Fixed
-----

* **``snapadmin_info --health-check`` honours ``SILENCED_SYSTEM_CHECKS``.** The new ``checks``
  section ran Django's system checks without filtering silenced messages, so a check you had
  deliberately silenced still counted as an error: the section reported ``ok: false`` and
  ``--health-check`` exited non-zero forever, on a configuration ``manage.py check`` itself calls
  clean. A readiness probe wired to it would never come up.

* **``SNAPADMIN_SWAGGER_ENABLED`` now follows ``SNAPADMIN_REST_API_ENABLED`` by default.** It
  defaulted to ``True`` independently, so switching the REST API off still left the OpenAPI views
  wired — and with the REST API off there is nothing for them to document. Setting it explicitly
  still wins, in either direction.

* **A database outage is reported as ``unhealthy`` again when Elasticsearch is also down.**
  ``GET /api/health/`` evaluated the two subsystems in order and let the Elasticsearch branch
  overwrite the overall status unconditionally, so a dead database (``unhealthy`` → ``503``) was
  downgraded to ``degraded`` — which answers ``200`` by design, because Elasticsearch is optional
  and pulling a still-serving instance would make an outage worse. The combination therefore
  reported an instance that could not answer a single query as fit to serve, and any container
  probe or load balancer pointed at the endpoint kept routing to it. Elasticsearch can now only
  raise the severity, never lower it.

* **The dashboard chart renders in French (and any locale whose translation contains an
  apostrophe).** The Chart.js dataset label was interpolated into a single-quoted JavaScript
  literal with ``{% translate %}``, whose output Django marks safe — so the apostrophe in the
  French "Nombre d'enregistrements" closed the string early and broke the entire inline
  ``<script>``. No chart appeared at all for those users. The label is now escaped with
  ``escapejs``.

* **The themed ``User``/``Group`` admin upgrade no longer depends on INSTALLED_APPS order.**
  It inspected the admin registry assuming autodiscovery had already run — but autodiscovery is
  triggered from ``django.contrib.admin``'s own ``ready()``, so a project that lists ``snapadmin``
  before it (plausible when third-party apps are grouped at the top, which Unfold's ordering
  requirement encourages) found an empty registry and silently got no theming. The upgrade now
  imports ``django.contrib.auth.admin`` itself instead of assuming it has been imported.

* **SnapAdmin's own stylesheet no longer fights the Unfold theme.** The opinionated form-layout
  rules — labels above full-width fields, uniform input padding, forced widths — were written for
  Django's built-in admin and scoped to a ``.unfold`` ancestor class for the themed case. Current
  Unfold puts no such class on the page, so **every rule in the Unfold layer was dead** while
  unscoped copies in the core sheet applied everywhere and overrode Unfold's own two-column form
  rows, its ``max-w-2xl`` field widths and the right-hand gutter its ``<select>`` chevron sits in.
  Visible symptoms: checkbox labels glued to their box, the ``filter_horizontal`` group picker
  flush against its border with no padding, date fields stretched full-width with the calendar
  shortcuts touching the input, and select text running under the arrow.

  The styling now ships as one shared sheet plus **exactly one** theme layer: ``admin.css``
  (design tokens, SnapAdmin's own widgets, changelist cosmetics) is always loaded;
  ``admin-stock.css`` (**new** — the layout rewrite, plus proper checkbox rows, a padded
  ``filter_horizontal`` picker, spaced date/time shortcuts and an opaque calendar popup) loads only
  when Unfold is **absent**; ``admin-unfold.css`` keeps only the gaps Unfold genuinely leaves.
  Mutual exclusivity *is* the scoping — neither layer carries a theme prefix. Nothing to configure.
  If you referenced ``snapadmin/css/admin.css`` expecting the layout rules and you run stock Django
  admin, they now arrive via ``admin-stock.css``, which is added automatically.

* **Django's built-in ``User`` admin has a working password field again under the Unfold theme.**
  Unfold ships template overrides for ``django.contrib.auth`` but not the admin classes that drive
  them, so a project that installed the ``[theme]`` extra and left ``User``/``Group`` on their stock
  admins rendered Unfold's templates against Django's forms. Unfold's default password-hash template
  targets Django < 5.1 and reads a ``summary`` context variable modern Django no longer provides:
  the password row rendered **completely empty**, and the "Reset password" button — which lives in
  Unfold's newer template variant, selected only from Unfold's own ``UserChangeForm`` — never
  appeared. There was no way to change a password from the admin at all. The group/permission
  pickers and the add-user flow were unstyled for the same reason.

  SnapAdmin now re-registers ``User`` and ``Group`` with Unfold's theme and forms from
  ``SnapAdminConfig.ready()``. It only ever replaces a registration whose admin class is *exactly*
  Django's stock one, so a project that subclassed ``UserAdmin`` is never touched. Set the new
  ``SNAPADMIN_THEME_AUTH_ADMIN = False`` to skip it. Without the theme installed it is a no-op.

* **The dashboard no longer crashes when a model opts out of the admin.** ``DashboardView``
  built its "Managed Models" cards by calling ``reverse()`` for every registered
  ``SnapModel``'s admin changelist URL — but a model can set ``admin_enabled = False`` (a
  documented, supported opt-out) and have no such URL at all, which raised
  ``NoReverseMatch`` and took the **entire dashboard** down, not just that model's card. It
  now skips any model that isn't actually registered on the admin site. Found while
  building the demo's merged root view below, whose tests exercise the dashboard far more
  than before.

* **The demo's root URL (``/``) now shows the dashboard for staff, not the plain landing
  page.** Before this, ``/`` was always a login form (anonymous) or a simple tabbed facts
  page (authenticated), and the polished system dashboard only appeared at ``/dashboard/``
  — a fragmented, less presentable entry point (user report: "I'd merge the index page
  with the dashboard — the dashboard looks nicer"). A staff visitor at ``/`` (or anyone,
  when ``SNAPADMIN_DASHBOARD_PUBLIC`` opts the dashboard into being public) now gets the
  dashboard directly; anonymous and non-staff visitors still get the login form / session
  facts page exactly as before. ``/dashboard/`` keeps working as a direct alias — no
  broken bookmarks or admin links.

  The dashboard gained a session bar ("Signed in as … · Log out") and the enabled/disabled
  surface checklist the landing page used to show, via **two new, optional block hooks**
  in the package's own ``snapadmin/dashboard.html`` (``header_actions_extra`` and
  ``dashboard_extra_bottom``) — both empty by default, so every other install's dashboard
  is pixel-identical to before. The demo fills them from a new template that
  ``{% extends %}`` the package one, which is also the pattern to follow if you want to
  add your own content to the dashboard in the same way.

* **``GET /api/health/`` returns 503 when the database is unreachable.** It always answered ``200``,
  whatever the body said — so a Docker, Compose, Coolify or Kubernetes probe pointed at it could
  never fail, and a broken instance reported as healthy. ``unhealthy`` (database down) is now
  ``503``; ``healthy`` and ``degraded`` stay ``200``, because a ``degraded`` instance — an optional
  subsystem such as Elasticsearch is down but the database is fine — can still serve, and pulling it
  out of the load balancer would make the outage worse. Alert on the body for that case. The JSON
  body is unchanged, and an anonymous caller still sees only ``{"status": …}``. **If you scripted
  around the always-200 behaviour, a database outage will now surface as an HTTP error.**

* **The documentation sidebar highlights the right entry when you scroll up.** Its scroll-spy
  activated whichever ``IntersectionObserver`` entry fired last, but several sections sit inside the
  observer band at once and the firing order depends on which edge they crossed — so scrolling down
  happened to be right while scrolling up landed one entry too low. It now tracks the whole visible
  set and activates the topmost section in document order, with a fallback for a section taller than
  the band.

* **The README is reorganised around what a newcomer actually needs.** It opens with the
  60-second ``snapadmin-demo`` path, then the three-step model→settings→register example with a
  table of what each generated URL gives you, then a **"four commands"** table that says plainly
  which two are shell commands (``snapadmin-demo``, ``snapadmin-init``) and which two are
  ``manage.py`` commands (``snapadmin_info``, ``snapadmin_license_check``) and what question each
  answers. ``INSTALLED_APPS`` is now shown twice — a **minimal** block that is the smallest working
  install and a **maximal** one where every block maps to one optional extra — with every line
  commented explaining why it is there.

* **The dashboard no longer renders half in English when a non-English locale is active.**
  ``DashboardView`` built a good part of the page in Python and those literals were never wrapped
  in ``gettext``: the quick-link names (Admin Panel / REST API Root / Swagger Docs / GraphQL API),
  the database service label, the ``online``/``offline``/``disabled`` status badges, the
  ``Local`` environment mode and the ``No description provided.`` cron fallback. They are now
  translatable and translated in all ten shipped locales. Product names (Elasticsearch, Docker,
  GraphQL) are deliberately left untranslated.

* **Model cards show the translated plural name instead of a title-cased singular.** The dashboard
  rendered ``model._meta.verbose_name.title()``, which force-evaluated the lazy translation and
  upper-cased every word — Russian "журналы аудита" came out as the mangled "Журналы Аудита", and a
  card counting many rows was labelled with a singular noun. It now renders
  ``capfirst(verbose_name_plural)``. If you assert on dashboard context, ``registered_models[*]["name"]``
  is now the plural form.

* **The dashboard chart no longer interpolates model names straight into a ``<script>`` body.**
  Chart labels and counts are serialised with ``json_script`` instead of ``|safe``, so a
  ``verbose_name`` containing a quote can neither break nor inject into the page script.

* **The demo is fully translated in all ten languages it offers.** ``demo/locale/`` did not exist,
  so everything the demo itself declares — model ``verbose_name``s, the landing page, the admin
  dashboard panel, the Unfold navigation titles, the Celery beat descriptions — always rendered in
  English while Django's and SnapAdmin's own strings rendered in the visitor's language. A Russian
  visitor got "Categories" and "Customers" sitting next to "Журналы аудита". Catalogs now ship for
  ``en, ru, de, de_CH, fr, fr_CH, es, it, pl, nl`` (144 strings each, ``en`` header-only as the
  source language), and ``tests/test_demo_i18n.py`` fails on an empty ``msgstr``, a diverging msgid
  set, a ``ß`` in ``de_CH`` or a dropped ``%(...)s`` placeholder so they cannot go stale. The demo's
  landing page had the same ``verbose_name_plural.title()`` bug as the dashboard and is fixed the
  same way.

* **The demo's admin index no longer renders the admin shell twice.** The demo overrides
  ``admin/index.html`` with its own dashboard panel. That panel used to live in a template that
  extended an Unfold layout and was pulled in with ``{% include %}`` — and including a template
  that extends a base renders the base too, so the sidebar, header and menu appeared a second
  time inside the content area. The body is now a partial rendered by a ``{% demo_dashboard %}``
  inclusion tag, which cannot re-enter a layout. The same panel also stopped displaying invented
  placeholder figures ("--", "5,678", "New order #890 by John Doe"): every number on it is now a
  live query against the demo database.
