0.1.0b7 — 2026-08-25 — Seventh Beta
===================================

Two ways to declare a model instead of one, plus a full-project scaffolder. Subclassing
``SnapModel`` has always been the whole story; ``@snap_model`` now opts a plain
``django.db.models.Model`` in from the outside — no rewrite, for a brownfield schema or a
model whose base class belongs to a third-party package — and its sibling ``snap_field()``
does the same for one field at a time. ``snapadmin-new`` generates a project you actually
keep, not another throwaway demo. Alongside those: alert delivery to Slack/Discord/Teams/
Telegram, XLSX exports, a readable audit-log diff with a per-object timeline, and per-field
masking rules with their own permissions.

No breaking changes and no required migration — every addition here is opt-in. The rich-text
sanitizer now also runs on write rather than only on render (see Changed) and, separately,
now fails closed instead of open if its ``nh3`` dependency were ever missing (see Security) —
neither changes behaviour for an install that has not opted into the new field flags.

Added
-----

* **``@snap_model`` — a plain ``django.db.models.Model`` can now opt in, without subclassing.**
  Until now the only way to declare a SnapAdmin model was to inherit from ``SnapModel``, which meant
  rewriting the model layer of any project that already had one. That is the wrong price for a
  brownfield schema, for a model whose base class belongs to a third-party package, or for fields
  that come from ``django-money``, ``phonenumber_field`` or ``model-utils``. A class decorator now
  opts such a model in from the outside::

      from django.db import models
      from snapadmin import snap_model

      @snap_model(
          api_write_fields=["name", "price"],   # mass-assignment allowlist
          api_exclude_fields=["cost_price"],    # never leaves the server
          search_fields=["name"],               # what ?search= matches on
      )
      class Product(models.Model):
          name = models.CharField(max_length=200)
          price = models.DecimalField(max_digits=10, decimal_places=2)
          cost_price = models.DecimalField(max_digits=10, decimal_places=2)

  The decorator adds no field and no attribute to the class, so it needs **no migration**. From then
  on the model is a SnapAdmin model everywhere the question is asked: the REST API mounts CRUD routes
  for it, the GraphQL schema gains a type, the offline endpoints and the ``snapadmin.W00x`` system
  checks see it, and ``snapadmin_info`` inventories it. Its keywords mirror the ``SnapModel`` class
  attributes of the same name — ``api_exclude_fields``, ``api_write_fields``, ``api_read_only``,
  ``api_http_method_names``, ``api_filter_lookups``, ``api_default_text_lookups``,
  ``api_json_filters``, ``offline_mode``, ``offline_cache_limit`` and ``search_fields`` — and only the
  ones you pass are recorded, so applying it to a ``SnapModel`` subclass overrides exactly those and
  leaves the rest of the class-level configuration alone.

  **Be clear about what it does not give you.** This is registration and metadata; it attaches none
  of ``SnapModel``'s runtime machinery, and the surfaces that need that machinery skip a decorated
  plain model rather than half-work. No ``EsManager``/``EsQuerySet``, so no ``es_search()``, no
  mirroring on save, no index created on ``post_migrate`` and no selection by ``snapadmin_reindex``.
  No ``purge_expired()``, so neither the retention command nor the retention task touches it. No
  generated admin — ``register_all_admins()`` passes it by, because without ``Snap*Field`` flags there
  is nothing to derive fieldsets, list columns or filters from — and none of the base class's admin
  niceties (``formatted_id``, the audit/PII ``save()`` hooks, ``admin_overrides``). That is why the
  decorator deliberately accepts no ``es_*`` or ``data_retention_*`` keywords: storing them would
  promise indexing and purging that never happen. Needing any of it means subclassing ``SnapModel``;
  both routes end in the same registry, so switching later changes nothing else.

* **``snapadmin.registry`` is now public API.** The module shipped in the previous release as an
  internal seam — ``SnapModel`` subclasses registering themselves as they are declared, so every
  "is this a SnapAdmin model?" gate became a lookup instead of an ``issubclass()`` walk. With
  ``@snap_model`` it becomes something a project can use directly, and it is documented and pinned
  accordingly: ``is_registered(model)`` is the gate every surface asks, ``meta_for(model)`` returns a
  model's recorded settings, ``register(model, **meta)`` is the underlying registration call, and
  ``get_model_meta(model, name, default)`` is the accessor every SnapAdmin surface now reads a
  model-level setting through — the registry entry first, the class attribute second. That two-step is
  what lets both declaration styles read identically without a single existing ``SnapModel`` changing
  behaviour.

* **``snap_field()`` puts SnapAdmin metadata on any Django field, not just a ``Snap*Field``.**
  Every ``Snap*Field`` is sugar over two things: an ordinary Django field, plus a handful of
  attributes (``searchable``, ``filterable``, ``show_in_list``, ``wysiwyg``, ``tab``, ``row``, …)
  that every SnapAdmin reader looks up with ``getattr(field, "...", default)``. Until now getting
  those attributes onto a field meant using the matching ``Snap*Field`` subclass — no option for a
  field type SnapAdmin does not ship, such as ``django-money``'s ``MoneyField``,
  ``model-utils``'s ``StatusField``, ``phonenumber_field``'s ``PhoneNumberField``, or a brownfield
  model whose fields cannot be rewritten. ``snap_field()`` sets the same attributes directly on a
  field instance you already have::

      from django.db import models
      from snapadmin.fields import snap_field

      class Product(models.Model):
          name = snap_field(models.CharField(max_length=255), searchable=True, filterable=True)

  Every reader treats the result exactly like a ``Snap*Field`` — there is nothing new to look up,
  only the same attribute names set a different way. It returns the field, so the call composes
  inline with the field declaration, and it adds **no migration**: the attributes are set *after*
  ``Field.__init__`` already recorded its constructor arguments, so ``deconstruct()`` never reports
  them, whatever field type is wrapped.

  Only the metadata flags are accepted (the same table the "Snap Fields" docs page lists); an
  unrecognised keyword — a typo, or one of ``required`` / ``allowed_extensions`` /
  ``allowed_encodings`` / ``max_size_bytes``, which only mean something inside a ``Snap*Field``'s
  own ``__init__`` (deriving ``null``/``blank``, or building a validator) — raises ``ValueError``
  naming it, rather than silently doing nothing.

* **``snapadmin-new`` generates a project you keep.** Until now the package offered a throwaway demo
  (``snapadmin-demo``) and a read-only doctor for an existing project (``snapadmin-init``) — neither
  produced something you actually kept, which was the gap between the package and its "quick
  backend" promise. ``snapadmin-new`` (also ``python -m snapadmin.scaffold``) closes it::

      pip install django-snapadmin
      snapadmin-new myshop
      cd myshop
      python manage.py migrate
      python manage.py createsuperuser
      python manage.py runserver

  That writes ``manage.py``, a settings package, one app carrying a worked ``SnapModel`` example (a
  ``Product`` with a handful of ``Snap*Field``\ s, so the generated admin, REST API and GraphQL
  schema are actually worth looking at), SQLite, and a ``.env``/``dist.env``. ``migrate`` then
  ``runserver`` work immediately — no Docker, no manual settings edits, and the worked model ships
  with its initial migration already generated so there is no ``makemigrations`` step to remember.

  Pass ``--full`` for the same project plus a ``Dockerfile``, ``docker-compose.yml`` and the
  PostgreSQL / Redis / Elasticsearch wiring (PostgreSQL when ``POSTGRES_HOST`` is set — which
  ``docker-compose.yml`` does for the app container — SQLite otherwise, so ``manage.py
  check``/``migrate`` still work with no services running). ``--app-name`` names the example app
  (default ``catalog``); the project and app name are both validated the way ``django-admin
  startproject`` validates one — a valid Python identifier that doesn't shadow an existing
  importable module (stdlib, Django, or ``snapadmin`` itself).

  Templates ship inside the wheel under ``snapadmin/scaffold/templates/`` and render with the
  standard library's ``string.Template`` — no Jinja, no new runtime dependency, consistent with the
  other console scripts. Like ``snapadmin-demo`` and ``snapadmin-init``, it never overwrites: writing
  into a non-empty target directory is a hard refusal, not a prompt.

* **``snapadmin-demo`` now stamps the tree it extracts, and refreshes it properly.** Upgrading the
  package (``pip install -U django-snapadmin``) never touched an already-extracted ``demo/``
  directory: it kept serving the models, templates and settings of the release it came from, so
  problems fixed in the installed release stayed on screen with nothing to explain why. Each
  extraction now leaves a ``.snapadmin-demo.json`` stamp — the release it came from plus the list of
  files that extraction wrote. Re-running ``snapadmin-demo`` names both versions before it touches
  anything (*"Refreshing the existing demo tree at …: v0.1.0b5 → v0.1.0b6"*) and, because extraction
  overlays a tree rather than replacing it, deletes the files the new release no longer ships — a
  template removed upstream used to linger and keep rendering. Only files recorded in the previous
  stamp are candidates for deletion, so anything you added yourself (a ``.env``, your own app, your
  database) is never touched, and the deletions go through the same confirmation as an overwrite.

* **Alerts can now go to Slack, Discord, Teams or Telegram instead of (or beside) email.**
  Delivery used to be hard-wired to Django's email machinery, so a team without SMTP — or one that
  simply lives in a chat channel — got no error-spike alert, no daily digest and no health alert at
  all. Those three alerts now go through a set of **channels**: email is one of them, and
  ``SNAPADMIN_ALERT_WEBHOOKS`` adds Slack, Discord and Teams incoming webhooks, the Telegram Bot API,
  or a plain JSON POST to an endpoint of your own::

      SNAPADMIN_ALERT_WEBHOOKS = [
          {"type": "slack", "url": os.environ["SLACK_ALERT_WEBHOOK"]},
          {"type": "telegram", "token": os.environ["TELEGRAM_BOT_TOKEN"],
                               "chat_id": os.environ["TELEGRAM_CHAT_ID"]},
          {"type": "json", "url": "https://ops.example.com/hooks/snapadmin",
           "events": ["health", "error_spike"]},
      ]

  The webhooks are posted with the standard library, so **no new dependency** enters your install,
  and an entry's optional ``events`` list (``error_spike`` / ``error_digest`` / ``health``) keeps the
  daily digest out of an on-call channel. ``SNAPADMIN_ALERT_EMAIL_ENABLED = False`` turns email off
  entirely for a chat-only deployment; ``SNAPADMIN_ALERT_WEBHOOK_TIMEOUT`` (default 5s) caps how long
  a POST may take, since the spike alert can fire inside a request.

  **Nothing about alert *frequency* changed.** The thresholds, the grouping and the cache-based
  cooldowns are shared by every channel rather than reimplemented per channel, so adding a webhook
  changes where an alert goes, never how often it fires. Delivery is fail-soft in both directions: an
  unreachable webhook is logged and skipped without breaking the request that recorded the error, the
  digest task or the ``snapadmin_health_alert`` command, and without stopping the other channels — and
  if *every* channel fails, the cooldown claimed for that send is released again, so the next
  occurrence alerts instead of being silently swallowed for the rest of the window. A malformed entry
  (unknown ``type``, missing ``url``) is logged and skipped, never raised.

  **Webhook URLs are treated as credentials.** A Slack URL's path and a Telegram bot token are the
  secret, so they are never written to a log line (failures are logged with the host only, as
  ``https://hooks.slack.com/…``), never included in an alert body, and never reported by
  ``snapadmin_info``. Keep them in the environment.

  Two behaviours changed as a consequence, both additive: an install with **no** email recipients but a
  configured webhook now alerts (it used to return "no recipients" and stay silent), and
  ``send_error_digest()`` reports ``{"sent": False, "reason": "delivery_failed"}`` when no channel
  accepted the digest — the retention purge still runs.

* **Async exports can now be written as XLSX.** ``POST /api/exports/`` has taken ``export_format``
  ``csv`` or ``json`` since it existed; it now also takes ``xlsx`` and produces a real workbook
  rather than a CSV renamed to look like one. Cells keep their types, which is the whole point of
  asking for a spreadsheet: a ``DecimalField`` lands as a number you can sum, a ``BooleanField`` as
  a boolean, and a ``DateTimeField`` as a date — converted to the project's current timezone and
  stripped of its ``tzinfo``, because Excel has no concept of one. Values a spreadsheet cannot hold
  (a ``UUID``, a ``JSONField``'s dict) are written as text, exactly as the CSV and JSON writers do.

  It rides on the new **``[xlsx]`` extra**: ``pip install django-snapadmin[xlsx]``, which pulls in
  openpyxl. openpyxl is MIT — this one is optional for size rather than for its licence, since most
  deployments export CSV or JSON and have no reason to carry a spreadsheet writer. Asking for
  ``xlsx`` without it gets a ``400`` from the API naming the extra, and a job created another way
  fails with the same instruction instead of a ``ModuleNotFoundError`` in a worker log.

  **Two behaviours differ from csv/json, deliberately.** A workbook is a zip archive that only
  becomes readable once it is closed, so it cannot be appended to chunk by chunk. Rows are streamed
  into a temporary spool — memory stays at one chunk however large the export — and the finished
  workbook is moved into place in one step. So an ``xlsx`` job **does not resume**: a re-dispatched
  one re-exports from the first row rather than continuing from its checkpoint (progress polling and
  cancellation work as they always did). And a cancelled or failed ``xlsx`` job leaves **no** file
  to download, where a cancelled CSV leaves the rows it managed to write. Nothing about ``csv`` or
  ``json`` changes.

  One more thing worth stating outright: text that begins with ``=`` is stored as **text**, never as
  a formula. A spreadsheet writer normally infers the type from the value, which would turn a row
  somebody typed into your database into something the spreadsheet executes when the file is opened.
  Control characters the format forbids are stripped, and over-long text is clamped to the per-cell
  limit, so a single awkward row cannot fail an entire export.

  Parquet was considered alongside XLSX and deliberately left out: ``pyarrow`` is a ~100 MB
  dependency for a format an admin export is rarely asked for. Say so if you need it.

* **The Unfold theme's own interface is now translated.** ``django-unfold`` ships no translation
  catalogs at all, so on a themed admin its chrome — "All applications", "Apply Filters", "No
  results found", "Select action", "Select value", the command-palette hints, the logout screen —
  stayed English while the page's own labels were translated. That is the mixed-language admin the
  demo showed. Django resolves a msgid against every installed app's catalogs, so SnapAdmin now
  answers for them: ``snapadmin/theme_i18n.py`` declares the msgids and all ten shipped catalogs
  translate them. Nothing to configure, Unfold is not patched, and a project that translates one of
  these strings in its own ``LOCALE_PATHS`` still wins. Scope is the admin SnapAdmin renders — the
  shell, changelists, forms, filters and the login/logout screens; Unfold's optional contribs
  (import/export, impersonation, object history) and its own rich-text toolbar are deliberately out.
  The German, Spanish, French, Italian, Dutch and Polish wordings are machine-assisted; Russian was
  written by hand. Corrections are welcome.

* **The audit log has a readable diff, and a per-object timeline.**
  ``SnapadminAuditLog.changes`` has always stored a structured before/after diff
  (``{field: {"old": …, "new": …}}``), but the admin only ever rendered it as a blob of JSON —
  legible to a developer, not to the compliance officer the trail exists for. Each entry now shows a
  field-level table: one row per changed field, the value before, the value after, with unchanged
  rows muted.

  The *Object* column links each entry to a **timeline** — every recorded change to that one object
  on a single page, newest first, each rendered as the same diff table. It lives at
  ``/admin/snapadmin/snapadminauditlog/timeline/<app_label>/<model>/<object_id>/`` and answers the
  question the changelist cannot: *what has happened to this record?* Both views mask exactly as the
  rest of the admin does, and both are gated on the audit log's own view permission — the same
  permission that already exposes these rows in the changelist — so the timeline never reveals more
  than the list it is reached from. A long history is capped at the 100 most recent entries per page
  (override ``timeline_max_entries`` on the model admin); ``manage.py snapadmin_audit_export`` remains
  the way to read the whole history.

* **``SNAPADMIN_MASKING_RULES``: masking rules and permissions, per field.**
  Which fields are sensitive has been configurable since ``SNAPADMIN_MASKED_FIELDS`` shipped; *how*
  each one is obfuscated was not — the masker looked at the value's type and applied a fixed policy,
  which is right for an email address and wrong for a card number, an IBAN or a field that should
  simply never be shown. A new setting sets the policy per field::

      SNAPADMIN_MASKING_RULES = {
          "customers.Profile": {
              # keep the last four digits, star the rest → ************1111
              "card_number": {"pattern": r"\d(?=\d{4})", "replacement": "*"},
              # never show it below the field permission
              "billing_address": {"replacement": "[redacted]",
                                  "permission": "customers.view_profile_address"},
          },
      }

  A rule may carry a ``pattern`` (a regex applied with ``re.sub``), a ``replacement`` (on its own, a
  flat redaction of the whole value), and a ``permission``. That last one is the second half of the
  change: a permission named on a rule unlocks the raw value of **that one field** for whoever holds
  it, without handing out the blanket ``snapadmin.view_raw_pii`` that reveals every masked field of
  every model. ``masking.user_can_view_pii()`` grew a matching, additive ``field`` argument —
  ``user_can_view_pii(user, "demo.Customer.email")`` — so every existing call keeps its meaning.

  Naming a field in ``SNAPADMIN_MASKING_RULES`` also declares it sensitive, so the new setting can be
  used on its own; ``SNAPADMIN_MASKED_FIELDS`` keeps working exactly as before, and a field listed
  only there still gets the built-in masker. The rules apply on **every** surface that masks — the
  admin changelist, the REST serializer, GraphQL, background exports and the audit-log diff — so a
  rule cannot hold on one and quietly not on another.

  Patterns come from settings, but a bad one would still meet production data, so they are guarded:
  each is compiled once and cached; a pattern that does not compile, one whose replacement references
  a group it does not have, one shaped like a catastrophic-backtracking bomb (a quantified group
  containing a quantifier, ``(a+)+``), and any value longer than 4096 characters all fall back to the
  built-in masker. Every failure path degrades to *more* masking, never to raw data, and the reason
  is logged once.

  Configuration mistakes get the same treatment as ``SNAPADMIN_MASKED_FIELDS``: a rule naming a model
  or a field that does not exist, a rule that is not a dict, and a pattern that cannot be used are
  Django system-check **errors** (``snapadmin.E003``–``E005``), raised by ``manage.py check`` and
  ``runserver``. They are errors rather than warnings because this setting fails open — a key with a
  typo in it masks nothing, and nothing on screen would say so.

Fixed
-----

* **``SnapStatusBadgeField`` accepts its source field and choices positionally.**
  ``SnapStatusBadgeField("status", [...])`` now works alongside the existing
  ``field_name=…, choices=…`` form. Both arguments used to be keyword-only, so writing the one
  argument the field is *about* in the obvious place failed with "missing 1 required keyword-only
  argument: 'field_name'" — a message that reads as "you forgot it" about an argument that was
  supplied. Everything else (``verbose_name``, ``style_arguments``, ``show_in_list``) stays
  keyword-only, and no existing call changes meaning. Declaring one wrongly now raises a
  ``ValueError`` that names the field and shows the call to write — a missing ``field_name``, an
  empty ``choices`` list, or an entry that is not a ``SnapStatusBadgeFieldChoice`` (passing bare
  values is the natural slip, since the colours live on the choice object). Model modules are
  imported at startup, so the mistake surfaces there instead of as a blank column in the changelist.

* **One failing section no longer takes down the whole ``snapadmin_info`` report.** Collectors are
  fail-soft for the failures they anticipate, but an unanticipated exception — a half-migrated
  database, a missing optional package, a third-party integration raising on import — propagated out
  and killed every section after it, exactly when the report was most needed. Each collector now
  runs isolated: a crash renders as ``Title: unavailable — ExceptionType: message`` (``--json``
  carries it as ``collector_error``) and the rest of the report continues. The message is a single
  line rather than a traceback, and credentials inside it are redacted, since a driver's error text
  routinely quotes the whole connection string. A crashed **health probe** reports ``ok=False``, so
  ``--health-check`` still exits non-zero — isolating a failure must never make a broken subsystem
  look healthy. ``KeyboardInterrupt`` still stops the command.

* **``snapadmin.tasks`` no longer requires Celery to import.** Celery is an optional extra, but the
  module opened with a bare ``from celery import shared_task``, so importing it on a base install
  raised ``ModuleNotFoundError`` — an optional dependency behaving like a required one. The
  decorator now falls back to a stand-in (``snapadmin/celery_compat.py``) that keeps every task
  name: **calling** a task runs its body synchronously in the current process, while **queueing**
  it (``delay()``, ``apply_async()``, ``retry()``) raises ``ImproperlyConfigured`` naming the
  ``[celery]`` extra. The split is deliberate — a no-op ``delay()`` would leave a caller believing
  work was queued when nothing would ever run it. Nothing changes when Celery is installed: the
  real decorator is used, and the API endpoints that enqueue work still answer 503 when it is not.

Changed
-------

* **The README is now a landing page rather than a manual.** It opens with the problem SnapAdmin
  solves, stated in plain language and without code — an internal tool needs an admin screen, an API
  for the mobile app, an API for the frontend and a search box, and those are normally four separate
  descriptions of the same data — followed by the 60-second quickstart and the command table. A new
  *For teams and enterprise* section answers what a lead or a manager asks before approving a
  dependency (commercial licensing and how to prove it, the audit trail, GDPR retention, PII masking,
  test coverage, the upgrade policy, scale, SSO, monitoring, backups, lock-in), phrased as questions
  instead of as a feature list. Reference material — both ``INSTALLED_APPS`` listings, the extras
  table, the theme comparison, the performance note, the extending guide and the Docker demo — now
  sits behind collapsible sections, so a first-time reader scrolls past about half of what they used
  to. Nothing was removed and no link changed target.

* **Rich-text HTML is now sanitized when it is written, not only when it is rendered.**
  ``wysiwyg=True`` / ``SnapRichTextField`` values were cleaned by the admin changelist on the way
  out, so the admin was safe — but the column itself held whatever was submitted, and every other
  reader (a project template using ``|safe``, a frontend consuming the REST API, an export) got the
  raw payload. Sanitizing now happens in the field's ``pre_save()``, which covers **every ORM write
  path**: admin form, REST and GraphQL serializers, ``Model.save()`` and ``bulk_create()``. Render
  still sanitizes as well, so rows written before this release stay safe to display.

  **This changes what your database stores**, and it is lossy: markup outside the sanitizer's
  allowlist (embeds, ``<iframe>``, custom attributes) is dropped rather than preserved. Existing
  rows are never rewritten — nothing migrates your data — but new writes are cleaned. Three ways
  out: ``safe_html=True`` on a field you vouch for (stored and rendered verbatim), the new
  ``auto_sanitize=False`` (store exactly what was submitted; rendering is still sanitized), or a
  wider allowlist via ``SNAPADMIN_HTML_SANITIZER``. ``QuerySet.update()`` is **not** covered:
  Django never calls ``pre_save()`` for a bulk update, so sanitize such values yourself.

* **``snapadmin_info`` reports demo-tree drift.** Run from inside a tree extracted by
  ``snapadmin-demo``, the *Version & Status* section now also lists which release that tree came
  from and, when it disagrees with the installed package, says so in one line instead of leaving
  the mismatch to be discovered as unexplained behaviour. A normal project sees no change — the
  lines appear only when a stamp is present.

* **Audit-log diffs preserve JSON-native types.**
  ``audit.format_value()`` ran every value through ``str()`` before storing it, so the diff could not
  distinguish ``42`` from ``"42"``, or ``False`` from the string ``"False"`` — an annoyance when
  reading the trail and a real problem when a SIEM parses it. Numbers, booleans, strings and ``null``
  are now stored as themselves. Everything without a JSON representation (``Decimal``, dates, UUIDs,
  related objects) is still stringified, as are ``inf`` and ``nan``, which have no JSON literal and
  are rejected by strict JSON columns.

  Rows written by earlier releases are not migrated and still hold the string form, so anything
  reading ``SnapadminAuditLog.changes`` should accept both. The ``old``/``new`` key names are
  **unchanged**: they are on disk in every existing install, and renaming them to ``before``/``after``
  would be a data migration over every row for no functional gain.

Deprecated
----------

* **The removal window for every currently-deprecated alias is now fixed: 1.0.** Nothing was newly
  deprecated in this release, but the "future release" wording that used to sit on these was never
  actually a date — it now is, everywhere someone would look: the stderr notice each aliased
  command prints, its ``help`` text, its module docstring, ``SECURITY.md``'s API-stability section
  and this documentation. Covered:

  - The three unprefixed management-command aliases — ``db_backup``, ``purge_expired_data`` and
    ``send_error_digest`` — keep working exactly as before (same arguments, same behaviour, a
    rename notice on stderr) until ``1.0``, when they are deleted in favour of
    ``snapadmin_db_backup``, ``snapadmin_purge_expired_data`` and ``snapadmin_send_error_digest``.
  - The **underscored** console-script spellings, ``snapadmin_info`` and ``snapadmin_license_check``
    — duplicates of the dashed ``snapadmin-info`` / ``snapadmin-license-check`` commands — are also
    scheduled for removal at ``1.0``. The dashed spelling and ``python manage.py snapadmin_info`` /
    ``snapadmin_license_check`` both stay; only the extra underscored shell command goes. Nothing
    changes about them in this release — this is advance notice, not a behaviour change.

  Nothing to do yet: every name above still works exactly as it does today. Update crontabs, Celery
  Beat entries, deploy scripts and any shell alias before upgrading to ``1.0``.

Security
--------

* **The audit-log change form no longer renders the unmasked diff to a viewer without PII access.**
  The admin swapped the raw ``changes`` field for a masked copy in ``readonly_fields``. That swap
  removed the real field from the read-only list — and Django therefore put it back in the *form*,
  where a view-only user (which every audit-log viewer is, the trail being read-only) had it
  rendered straight from the model. The masked table and the unmasked JSON appeared on the same
  page. The raw field is now excluded from the form outright, and only the rendered diff is shown.
  Anyone who could open an audit entry for a model with ``SNAPADMIN_MASKED_FIELDS`` configured could
  read the masked values; the changelist, the REST API and the export command were never affected.

* **Wysiwyg HTML sanitization now fails closed if ``nh3`` cannot be imported.** ``nh3`` is imported
  lazily and cached rather than at module load, and a missing import now raises a pointed
  ``ImproperlyConfigured`` at the moment sanitization is attempted — on save (``pre_save()``) and on
  changelist render — instead of letting unsanitized HTML through. **This does not change behaviour
  for any install today**: ``nh3`` is still a required core dependency, so the failure path this adds
  cannot currently be reached. It is preparatory hardening for a future release that plans to move
  ``nh3`` behind an optional extra (mirroring ``[wysiwyg]``/``[xlsx]``) — that packaging change is not
  part of this release. The ``SNAPADMIN_HTML_SANITIZER`` escape hatch was and remains unaffected
  either way, since it never imports ``nh3``.
