Skip to content

Releases: drofji/django-snapadmin

v0.1.0b9

v0.1.0b9 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 15 Sep 05:46

0.1.0b9 — 2026-09-15 — Ninth Beta

Field-level encryption, and the quality layer made visible. 0.1.0b8 shipped encryption key
management with nothing yet to encrypt; this release adds the cipher, eight SnapEncrypted*Field
types, lookup guards with an opt-in blind index for equality, a tested default on every surface
that could re-emit a plaintext, and manage.py snapadmin_encrypt_fields for data already in the
table. Retention gains data_retention_date_field, a per-row deadline that overrides the
model-wide window; the API write path gains api_full_clean, so a Model.clean() cross-field
rule finally holds for API clients as it already did in the admin.

The other half of the release is the test suite and the documentation that describes it. The suite
now runs in a random order on every invocation, and one CI job runs the whole of it against a real
postgres:16 plus a marker-gated suite against a live Elasticsearch — the check a mocked client
cannot perform, since a MagicMock accepts the malformed query a cluster answers 400 to. The
first run of that job found three tests that had silently assumed SQLite. The README and the
documentation site now describe the method rather than only the totals, and — deliberately — the
layers that are not in place yet: mutation testing, property-based testing, browser E2E, and
lint/type/security static analysis in CI. A guard suite fails the build if any of those claims
stops being true in either direction.

Read Breaking first: five entries, four of them reachable only from a state that was already
broken, and one — snapadmin.E026 — that will stop a deployment whose Elasticsearch search has
silently never worked. This is a beta release: the public API is not yet covered by semantic
versioning (see SECURITY.md's current API-stability policy) — breaking changes remain possible
in the 0.x series, always announced here and in CHANGELOG.md, with a migration guide when
manual steps are involved.

Breaking

Five. Four of them can only be reached by a project already in a broken or unsupported state, but
all five are things a caller may have to act on, so none of them is buried in another section.

  • snapadmin.E026 fails manage.py check on a model that mirrors to Elasticsearch without a
    mapping.
    A model with es_storage_mode = DUAL/ES_ONLY (or es_index_enabled = True)
    and neither es_mapping nor es_auto_mapping = True used to index its primary key and
    nothing else, silently, while index creation, every save and es_reindex_all() all reported
    success. That configuration now stops startup. This is the one entry here likely to affect a
    working-looking deployment
    — though a project it fires on has never had a functioning search.
    Two one-line fixes are in the check's hint (declare es_mapping, or set
    es_auto_mapping = True); 'snapadmin.E026' in SILENCED_SYSTEM_CHECKS is the documented
    way out if an id-only index is genuinely wanted. Full reasoning under Changed.

  • The [elasticsearch] extra no longer resolves a 9.x client. The pin was >=8.0.0 with no
    upper bound and is now >=8,<9. If you already have a 9.x client installed, run
    pip install "elasticsearch<9". No 8.x install is affected, and a 9.x client never worked
    against SnapAdmin's code paths in the first place — it answers 400 to every request and has
    removed the body=/ignore= arguments the indexing paths use. Full reasoning under Fixed.

  • snapadmin.E025 fails manage.py check on a misresolved EXTRA_SETTINGS_ADMIN_APP.
    Only reachable with the [extra-settings] extra installed and admin autodiscovery deferred
    (SimpleAdminConfig, a custom AdminSite, or no django.contrib.admin). With Django's
    default AdminConfig the upstream ImproperlyConfigured already aborts startup before any
    check runs, so nothing changes there. The check names the exact INSTALLED_APPS entry to write.

  • A django.core.exceptions.ValidationError raised on an API write now answers 400, not
    500.
    An observable change to the status code every SnapAdmin endpoint returns for a
    model-level rule (Model.clean(), a guard in save(), a pre_save receiver). The body is
    the shape a client already parses for a serializer error. A project that configures its own DRF
    EXCEPTION_HANDLER keeps first refusal and is unaffected; a project that worked around this
    with a custom handler can now delete it. Full reasoning under Fixed.

  • Deleting rows of an Elasticsearch-mirrored model no longer uses Django's fast-delete path.
    Keeping the mirror honest on a bulk QuerySet.delete() and on an on_delete=CASCADE sweep
    requires a post_delete receiver, and connecting one forces Django's collector to materialise
    the rows rather than issue a single DELETE. That is a cost, not a defect, and it lands
    automatically on mirrored models: one es.delete() round trip per row. For a large delete use
    the bulk path instead — collect the primary keys, then call the now-public
    SnapModel.delete_pks_from_es(pks), optionally inside
    snapadmin.models.suppress_es_delete_receiver(). A DB_ONLY model gets no receiver and is
    completely unaffected. Full reasoning under Fixed.

Everything else this cycle is additive and inert until configured: every new setting defaults to
off (api_full_clean, SNAPADMIN_BACKUP_ALIGN_TO_SCHEDULE, SNAPADMIN_BACKUP_SFTP_KNOWN_HOSTS
— "leaving it unset keeps the previous behaviour byte for byte"), the new field types and the
[encryption] extra add surface rather than change it, snapadmin.W021/W022 are warnings
that do not fail manage.py check, snapadmin.W015 only ever stops firing, and the
editable=False migration fix is explicitly a no-op for a project that already generated one of
those migrations.

Added

  • data_retention_date_field — a per-row deletion date. data_retention_days is one
    constant for a whole table, measured off one timestamp column, so it can express "delete 90 days
    after this row was created" and nothing else. A table whose records each arrive with their own
    agreed expiry — a delete_at an upstream supplier sets per row — had no way in. The closest
    approximation was to point data_retention_field at the expiry column and accept a whole-day
    offset of at least one day (data_retention_days is disabled at 0), which also reads the
    column as an age rather than as a deadline.

    data_retention_date_field names that column, and it resolves through get_model_meta()
    like every other model-level option, so a @snap_model-decorated plain model and a
    SnapModel subclass declare it the same way. The two rules combine, with the more specific one
    winning: a row past its own date is purged whatever the window says; a row whose date is still
    ahead of it is kept even when it is older than data_retention_days, because an expiry set
    per record overrides the house rule rather than racing it; and a row whose date is NULL falls
    back to data_retention_days measured on data_retention_field. When no window is configured
    at all, a NULL row is never purged — "no expiry declared" has to mean "keep", never "delete
    now".

    Set it alone for a table where every row carries its own deadline and there is no house rule: the
    Celery task, manage.py snapadmin_purge_expired_data and snapadmin.W012 all treat a model
    configured that way as a model with retention, rather than skipping it for lack of a
    data_retention_days. The command's report line names the rule that applied instead of printing
    a window nobody set. ES_ONLY models get the identical three-way rule expressed as a query — a
    range on the deadline, OR an age range restricted to documents carrying no deadline
    (must_not exists) — so the behaviour does not quietly differ by storage layer; the deadline
    column has to be mapped as a date in the index, exactly like data_retention_field. The
    demo's AuditLog now dogfoods it with a nullable delete_at alongside its existing 90-day
    window, and snapadmin_info --section features counts the models using one.

  • api_full_clean — the model's own rules now hold on the API write path. A
    Model.clean() rule is enforced wherever a ModelForm runs, which in practice means the
    admin and nothing else. The generated serializer validated every field, so the API looked
    validated; what it skipped were exactly the rules a field validator cannot state — the cross-field
    ones, "a completed record needs a file". The admin refused such a row and the API accepted it, and
    the two surfaces disagreed about what a valid row was until a bad one turned up downstream.

    Setting api_full_clean = True on a model runs its full_clean()clean_fields(),
    clean(), then the model's constraints — inside the generated serializer's validate(), so
    the rule answers 400 naming the field instead of writing the row.
    SNAPADMIN_API_FULL_CLEAN turns it on for every registered model at once; a model's own
    attribute still wins over the setting, so a single model can opt out of a project-wide default.

    It is off by default and that is deliberate. Turning it on starts rejecting writes the API
    used to accept. That is the correct answer — the admin was already refusing them — but a
    behaviour change that arrives by itself on someone's production API is not a bug fix, so it is a
    line you write rather than one you discover.

    Two limits are worth knowing. Validation is scoped to the fields the serializer can actually
    write: an auto_now_add column is empty on an unsaved row, and judging it would reject every
    create with an error no client could act on — the s...

Read more

v0.1.0b8

v0.1.0b8 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 07 Sep 12:49

0.1.0b8 — 2026-09-06 — Eighth Beta

This release closes the beta-series deprecation window announced across several 0.1.0b
releases — the deprecated command aliases and underscored console scripts named under
Removed below are the result — and adds the largest batch of new capability the project has
shipped in one release: row-level multi-tenancy, GDPR subject-access export/deletion, CSV/NDJSON
import, a cache-backed quota primitive, user-defined REST actions, field-level permission
guards, field-encryption key management, declarative database sharding and read-replica
routing, and a from-scratch documentation completeness sweep. models.py (2882 lines) was
also split into snapadmin.es/snapadmin.jobs/snapadmin.admin_gen behind an unchanged
public facade — an internal reorganisation with no import-path impact, mentioned here for
completeness rather than under a heading below. This is a beta release: the public API is not
yet covered by semantic versioning (see SECURITY.md's current API-stability policy) —
breaking changes remain possible in the 0.x series, always announced here and in
CHANGELOG.md, with a migration guide when manual steps are involved.

Breaking

  • SNAPADMIN_PROFILE = "full" (and "api") now really turn the REST and GraphQL surfaces on
    — check this one before upgrading if you set either.
    Read the Fixed entry above for why; the
    consequence is what matters here. Originally, both presets were empty dicts that fell
    through to the built-in defaults, and those defaults had just flipped to False — so a project
    carrying SNAPADMIN_PROFILE = "full" (documented at the time as a no-op) was actually running
    admin-only, with no API mounted at all. After this release the same setting mounts REST,
    GraphQL and Swagger for every registered model, which is what the profile table in the
    documentation has always promised and what the name says.

    That is a widening of your HTTP attack surface on upgrade, not a cosmetic change, and it lands
    without any edit to your settings. It matters most because api_write_fields is unset by
    default: on a model that never restricted it, every field not named in api_exclude_fields
    becomes writable through the generated API (snapadmin.W004 warns about exactly this). Before
    upgrading, either confirm you want those surfaces — and audit the write allowlists and PII
    masking on every registered model first — or pin SNAPADMIN_REST_API_ENABLED = False and
    SNAPADMIN_GRAPHQL_ENABLED = False explicitly, which still wins over any profile. Removing
    SNAPADMIN_PROFILE entirely also restores the admin-only behaviour, since an unset profile
    applies no preset at all.

  • The shipped admin.js's select2 auto-init is now opt-in. Only a <select> carrying a
    snapadmin-select2 class (or data-snapadmin-select2 attribute) gets initialised — not
    every <select> on the page minus a denylist. The old broad selector reached the changelist's
    own action dropdown; a themed admin renders that dropdown through Alpine, and select2 taking the
    element over silently broke bulk actions, with no error anywhere. Add the class to a field's
    widget to opt it back in.

  • SNAPADMIN_CONNECTIVITY_ENABLED now defaults to False (previously always on). The
    admin-wide health poll, save-blocking guard and sidebar sync badge no longer load unless
    explicitly enabled and at least one registered model has offline_mode = True. A deployment
    with SNAPADMIN_REST_API_ENABLED = False (a documented, supported combination —
    SNAPADMIN_PROFILE = "admin" produces it) used to poll a permanently-404ing /api/health/
    and block every Save button after the first interval. Set
    SNAPADMIN_CONNECTIVITY_ENABLED = True to restore the previous behaviour. See Offline Mode <https://drofji.github.io/django-snapadmin/#offline>_.

  • SNAPADMIN_REST_API_ENABLED and SNAPADMIN_GRAPHQL_ENABLED now default to False
    (previously True). A project migrating from a plain Django admin no longer gets a writable
    REST/GraphQL surface for every registered model just by including snapadmin.urls — it has to
    ask for one. This is the flip the beta series announced via snapadmin.W014 (now retired —
    its premise, "left unset and still mounted," is unreachable once unset means off); pin either
    setting to True to restore the previous behaviour. See the migration guide:
    docs/migrations/0.1.0b7_to_0.1.0b8.md.

  • djangorestframework, drf-spectacular, django-filter and graphene-django are no
    longer core dependencies.
    They moved behind two new extras: [api] (the first three — REST
    plus its OpenAPI schema/Swagger/ReDoc) and [graphql] (graphene-django, independent of
    [api]). A bare pip install django-snapadmin now pulls only Django, structlog and nh3. Both
    features above default to False, so most installs only need the matching extra once they turn
    a surface on: pip install django-snapadmin[api,graphql] (or [all], which reproduces the
    pre-0.1.0b8 dependency graph — a no-op upgrade for an install that already has everything). Turning a
    feature on with its extra missing fails loudly — snapadmin.urls raises
    ImproperlyConfigured naming the extra, and the new check snapadmin.E010 catches it even
    earlier, at manage.py check. See the migration guide: docs/migrations/0.1.0b7_to_0.1.0b8.md.

Retro-note (this heading is new — two known historical cases predate it and were never called out
per release):

  • SnapModel.get_admin_fields()'s return arity silently grew from four values to five in an
    earlier pre-1.0 release, with no changelog entry. Downstream code that unpacked it positionally
    broke with ValueError: too many values to unpack during admin autodiscovery. The shape is
    pinned going forward (see the generated-admin fix in this same release) so it cannot shift
    silently again.

  • django-admin-rangefilter stopped being a dependency in 0.1.0b6 — already documented
    under that release's Removed heading; see docs/releases/0.1.0b6.txt rather than
    duplicating the note here.

  • snapadmin.purge_expired_data now also purges the audit log. SNAPADMIN_AUDIT_RETENTION_DAYS
    already documented a 365-day default, but until now the only thing that ever read it was
    snapadmin_audit_export --purge — the scheduled task/command never touched
    SnapadminAuditLog at all. A project that already schedules purge_expired_data via Celery
    Beat and has audit rows older than 365 days will see them deleted on the first run after
    upgrading. Set SNAPADMIN_AUDIT_RETENTION_DAYS = 0 to keep every audit row indefinitely, as
    before.

  • Every registered model must now declare subject_path. New check snapadmin.E011 fails
    manage.py check for any registered SnapModel/@snap_model model that never declares
    subject_path at all — subject_path = None is a valid, explicit answer ("this model carries
    nothing reachable from a GDPR data subject"), but silence is not. This is unconditional, not behind
    a feature flag: it is the declaration the new snapadmin_subject_request export/deletion command
    depends on to know what it can safely reach, and the whole point of the check is that a model
    nobody looked at cannot silently opt itself out of a legally-binding export. Every existing
    registered model in every project needs one line added
    before manage.py check passes again
    after upgrading::

    class Product(SnapModel):
        ...
        subject_path = None   # add this — or a real path for a model that does carry PII
    

    A model whose rows are genuinely reachable from a subject (an order, a support ticket, anything with
    a foreign key back to a customer) should get a real path instead — see GDPR Subject-Access Requests <https://drofji.github.io/django-snapadmin/#gdpr-subject-request>_ for the declaration shape.

Added

  • Encrypted model fields get their key management layer. Field-level encryption — ciphertext at
    rest in the database, ordinary Python values in application code — rests entirely on one thing
    being configured correctly, and this release ships that part: the keyset. One dict,
    SNAPADMIN_ENCRYPTION, is resolved from four sources, most secure first, and the first one
    configured wins — they are never merged
    , so a stray environment variable cannot half-override a
    secret store. In order: KEY_PROVIDER (a dotted path to a callable, the hook for KMS, Vault or
    Secrets Manager, so nothing secret touches settings or the environment; called once per process,
    never per query), 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 — the .env path), and finally literal
    KEYS in the settings module, which works but is warned about whenever DEBUG is off,
    because a key in a settings module is a key in version control.

    python manage.py snapadmin_encryption_key generates a key and prints it once, as the
    environment line to paste into a secret store — it writes it nowhere. --rotate prints a key to
    prepend plus the ids already configured, and never their material: 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, deploying, re-encrypting, and only then dropping the old
    key.

    Two properties are enforced rather than documented. The encryption key can never be Django's
    SECRET_KEY (snapadmin.E017 fails manage.py check): SECRET_KEY is rotated for
    session and CSRF reasons, and reusing it means the damage only bec...

Read more

v0.1.0b7

v0.1.0b7 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 24 Aug 22:21

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

Read more

v0.1.0b6

v0.1.0b6 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 12 Aug 22:08

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.W001W007),
    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 ...

Read more

v0.1.0b5

v0.1.0b5 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 24 Jul 05:26

0.1.0b5 — 2026-07-24 — Fifth Beta

Scale-hardening and operability. This beta finishes the production-scale Elasticsearch query
layer (es_count, db_fallback opt-out, es_scan PK streaming, OOM-safe reindex), makes the
auto-generated REST filters richer and safer (isnull/__in, JSON comma-OR + lazy queryset + scan
cap, per-model read-only guards, a swappable filter backend), scales etl.stale_sync past an
in-memory key set, makes async export sources pluggable, and adds a feature-adoption audit to
snapadmin_info. Everything is additive and backward-compatible; two additive migrations ship
(a demo-only watermark column and SnapExportJob.source).

Added

Project- and model-wide default lookup set for auto-generated REST text filters
The auto-generated REST filters give every text field (CharField/TextField/EmailField/URLField/
SlugField) an exact + icontains/startswith/in lookup set. api_filter_lookups could
already override that, but only per field — so making a large table index-friendly (dropping the
leading-wildcard icontains that can't use an index) meant enumerating every column, and any column
added later silently re-enabled icontains. Two broader knobs now set the default once: the per-model
SnapModel.api_default_text_lookups attribute and the project-wide SNAPADMIN_API_TEXT_LOOKUPS
setting. Resolution takes the first non-empty source in the order per-field api_filter_lookups
per-model api_default_text_lookupsSNAPADMIN_API_TEXT_LOOKUPS → the library default, so a
project can adopt the index-friendly posture globally while still widening or narrowing a single field.
Both default to today's behaviour when unset.

snapadmin_reindex — probe runs with --limit and a configurable --tune default
The bulk reindex command gains --limit N to reindex only the first N rows — a probe or canary
run to sanity-check a mapping change or measure throughput before committing to a full load, with
progress measured against the limit. --tune becomes a --tune / --no-tune pair whose default
is the new SNAPADMIN_REINDEX_TUNE_DEFAULT setting (default False = unchanged), so a project that
always wants a mass load to relax the index (refresh off, replicas 0) can set the posture once and still
override it per run. The reindex now also fetches only the ES-mapped columns each chunk — a document
is built from just the primary key plus the mapped fields, so a wide table's large unmapped TEXT
bodies are no longer dragged through every batch (via the new SnapModel.es_reindex_only_fields(),
which restricts the queryset with .only() and safely falls back to fetching all columns when a
mapping key isn't a plain concrete field). All three are opt-in / automatic and change nothing for an
existing full reindex.

SnapModel.es_scan() — stream the primary keys of N-million matches with source=False and limit
es_scan() gains two opt-in fast paths for very large result sets. source=False streams
primary keys only: the request sends "_source": false so Elasticsearch never ships the
document body, and each pk is read straight from the sort cursor — so a DUAL model also skips its
per-page in_bulk() database round-trip. When you only need the ids of millions of matches (to feed
a queue, a bulk job, or a downstream pk__in query), hydrating a full model per hit is wasted work;
because the pk comes from ES alone, a pk indexed in ES but missing from the table is still yielded
(the default full-hydration path drops it). limit=N stops the walk after N results and caps the
ES request size to what remains, so a limit below page_size never over-fetches. Both flags are
honoured by the disabled-ES database fallback too — it streams pks via values_list("pk") and applies
the limit. The default call (source=None) keeps full object hydration, byte-identical to before; the
scan keeps its unique id sort, already the cheapest stable search_after order since the primary
key needs no separate tiebreak.

SnapModel.es_count() — true match count of a structured Elasticsearch query
New classmethod SnapModel.es_count(*, query_string=None, **terms) -> int, the counting
counterpart to es_filter(). It uses the same term resolution and filter context (a scalar builds
a term clause, a list a terms clause, a __ path reaches into a JSON/object mapping, and a
text field targets its keyword sub-field) but hits Elasticsearch's _count API instead of
_search. Because es_filter()/es_search() cap their results at SNAPADMIN_ES_SEARCH_LIMIT
and can never see past ES's index.max_result_window, len(es_filter(...)) silently under-reports
once a query matches more rows than the limit; es_count() returns the exact total no matter how
large the result set — the number you need for pagination, a dashboard tile, or a guard before a bulk
job. It fails safe like its siblings: a DUAL model whose Elasticsearch is disabled or erroring
falls back to the equivalent database count() (failing closed to 0 for a term field with no
backing column), an ES_ONLY model returns 0, and an unknown or analysed-text-only field raises
ValueError.

Opt out of the silent Elasticsearch→database fallback (db_fallback=False)
The structured ES query methods — es_filter(), es_aggregate(), es_count() and
es_scan() — silently fall back to the database when Elasticsearch is disabled or a query errors.
That is the right default for a modest table, but on a large, DB-unindexable one it can be worse than
a clear failure: es_aggregate() recomputes a full-table GROUP BY on an unindexed column and
es_scan() streams an unbounded .iterator(). Each method now accepts db_fallback=False,
which raises the new snapadmin.models.SnapEsUnavailable exception (chaining the original ES error
as __cause__) instead of running the database equivalent when ES can't answer — so a team that
chose Elasticsearch deliberately can fail loudly rather than quietly run a query that can't scale. The
new SNAPADMIN_ES_DB_FALLBACK setting (default True) sets the project-wide posture once; a
per-call db_fallback= always overrides it. Nothing changes by default — ES_ONLY models (no
database to fall back to) and DB_ONLY models (the database is their primary store) never raise,
and a mid-stream es_scan() failure still stops rather than raising, since its search_after
cursor is already gone.

Null checks and membership lists in the auto-generated REST filters
The dynamic REST FilterSet now exposes null-checks and comma-separated membership lists automatically,
with no per-field configuration. Numeric fields (Integer/Float/Decimal…) gain ?field__in=1,2,3 and
?field__isnull=true alongside the existing __gte/__lte range; foreign keys gain
?field_id__in=1,2 and ?field_id__isnull=true (rows with — or without — a related object) next
to the existing exact ?field_id= match; date and datetime fields gain ?field__isnull=true (no
__in, since an exact-timestamp list is rarely useful and ranges are covered by __gte/__lte).
Text fields can opt into a null check by adding "isnull" to api_filter_lookups (or a model-/
project-wide default); it is not in the library default set. All of these are additive — existing query
parameters are unchanged.

Top-level re-exports — from snapadmin import SnapModel, SnapCharField, …
The most common public names — SnapModel, every Snap*Field type, the EsStorageMode enum, the
APIToken model, the SnapEsUnavailable/SnapPurgeError exceptions and the Snap*Validator
classes — are now importable directly from the package root, so from snapadmin import SnapModel, SnapCharField works alongside the existing deep paths (from snapadmin.models import SnapModel), which
are unchanged. The re-exports are lazy (PEP 562), so importing snapadmin — or a console script such as
snapadmin-demo that runs before Django is configured — never eagerly imports the Django-backed
modules. A new docs/index.html module map documents what each top-level module owns.

Pluggable async-export row sources (SNAPADMIN_EXPORT_SOURCES)
The async export job was hard-wired to one row source — model.objects.filter(**filters) serialized as
raw column rows. Three shapes a large-scale integrator needs couldn't be expressed: a result set defined
by a structured Elasticsearch query (routing it through filters would force the DB-fallback scan the ES
query exists to avoid), an explicit key list (encoding it as a __in filter re-evaluates a giant clause
on every cursor page), and a custom document shape (not raw values() rows). A new
SNAPADMIN_EXPORT_SOURCES = {name: "dotted.path.to.factory"} registry plus a source field on
SnapExportJob let a project register a custom source — an object with field_names(), count() and
iter_batches(*, cursor, chunk_size) — without subclassing the job or its runner. The writer keeps
everything else: the resumable primary-key-cursor chunking, progress/ETA, single-flight claim,
cancellation, crash-safe checkpointing and configurable storage all work unchanged for a custom source, as
proven by the resume test. A blank source (the default) is byte-for-byte the built-in ORM export, so
existing jobs are unaffected. An unknown source name fails the job cleanly rather than crashing the worker.
This adds one database migration (SnapExportJob.source). The demo registers a product_catalog
source that emits a compact catalogue line per product.

Feature-adoption audit — snapadmin_info --section features
``snapadmin_info...

Read more

v0.1.0b4 - Fourth Beta Release

Pre-release

Choose a tag to compare

@drofji drofji released this 21 Jul 05:56

0.1.0b4 — 2026-07-21 — Fourth Beta

An operability, onboarding and decoupling release. Four new operator/onboarding commands —
snapadmin_info (config & health report), snapadmin_license_check (runtime licence audit),
snapadmin-demo (autonomous 30-second demo bootstrapper) and snapadmin-init (read-only
integration doctor) — plus a subsystem health-alert email channel and Docker self-healing in the
demo. django-unfold becomes an optional [theme] extra: the admin falls back to Django's
built-in theme when Unfold is absent, so the base install is leaner and strictly permissive while
existing themed installs are byte-identical. No model, no migration, and every existing import path,
setting and signature is unchanged.

Added

snapadmin_info management command
A single command that reports SnapAdmin's configuration and health for operators and CI.
python manage.py snapadmin_info prints the installed version, the Django/Python runtime and the
resolved SNAPADMIN_* feature toggles. --json emits the same data machine-readably (for
monitoring dashboards), --section NAME limits the report to one section (repeatable),
--brief/--verbose adjust the level of detail, and --health-check runs the connection
probes for the configured services and exits non-zero if any fails (usable as a readiness check).
Secrets — passwords, keys, token values — are never printed. Report sections are pluggable, so
database, Elasticsearch, Redis/Celery and model detail extend the command without changing it.

snapadmin_license_check management command
The runtime counterpart of THIRD_PARTY_NOTICES.md: it audits the licences of the SnapAdmin
dependencies actually installed and tells you whether your install is safe for commercial /
proprietary use. It lists each core dependency and optional extra with its SPDX licence and a
🟢 permissive / 🟡 weak-copyleft / 🔴 copyleft-or-commercial tier, marks what is installed, and
gives an overall commercial-compatibility verdict (the base install is fully permissive; the
[wysiwyg] CKEditor extra is the one flagged). --json feeds CI, --critical-only shows
just the 🟡/🔴 licences, --compatible-with <SPDX> reports per-package compatibility with a
chosen project licence, and --verbose adds any uncurated declared dependency. It bundles no
vulnerability database — it points at pip-audit for a CVE scan and never claims "no known
vulnerabilities". Informational, not legal advice.

snapadmin-demo console script — 30-second demo bootstrapper
A new snapadmin-demo command (also python -m snapadmin.quickstart), installed on the PATH by
pip install django-snapadmin, brings up the SnapAdmin demo with no existing project. It
downloads the demo/ directory from the GitHub source tarball of the matching release tag (the
wheel doesn't ship it), caches it under ~/.cache/snapadmin-demo/ and checksums it so re-runs work
offline, extracts only demo/ (with a zip-slip guard and an overwrite confirmation), then installs
its requirements, migrates, seeds and serves. Flags: --version, --path, --skip-install,
--no-serve, --clear-cache and -y/--yes. --interactive runs a wizard (run mode,
SQLite/PostgreSQL, admin password, secret-key generation, debug) that writes a .env the demo
reads; --save-config/--load-config capture and replay a setup so a team shares one
environment, and the same choices are available as non-interactive flags (--mode, --database,
--db-host …) for CI. It is stdlib-only (no new dependency) and never imports Django in-process —
it drives manage.py as a subprocess.

snapadmin-init console script — integrate into an existing project
A new read-only snapadmin-init command (also python -m snapadmin.integrate) inspects an
existing Django project and reports what SnapAdmin wiring is already present and what is missing,
printing the exact block to paste for each gap: INSTALLED_APPS (noting the unfold theme is
optional), the urls.py include, a SNAPADMIN_* settings block, optional REST/GraphQL config
(--api / --graphql), the pip install line (--extras), and advisory model-conversion
hints. It never edits your files — it only prints snippets you review before pasting, so there is
no risk of a bad automatic edit. --json feeds tooling and --settings / --urls point it at
a non-standard layout. Stdlib-only, no Django import at import time.

Health alerting — email when a subsystem goes down
A new snapadmin.health.send_health_alert runs the same probes as
snapadmin_info --health-check — database, Elasticsearch, the REST API and GraphQL — and emails the
configured recipients when one reports a failure, so an outage reaches an operator instead of only the
logs. Each probe honours its feature toggle (ELASTICSEARCH_ENABLED, SNAPADMIN_REST_API_ENABLED,
SNAPADMIN_GRAPHQL_ENABLED), so a subsystem you turned off is never a false alarm. Run it on a
schedule: the snapadmin.send_health_alert Celery task (Celery Beat) or the new
snapadmin_health_alert management command (system cron; it also exits non-zero while a probe is
failing, so it doubles as a monitoring gate — --force re-sends within the cooldown). Recipients
come from SNAPADMIN_HEALTH_ALERT_EMAILS and fall back to SNAPADMIN_ERROR_ALERT_EMAILS; a
cache-based cooldown (SNAPADMIN_HEALTH_ALERT_COOLDOWN_MINUTES, default 60) limits a persistent
outage to one email, and a recovery re-arms it. No new dependency, no migration; delivery uses
Django's standard email machinery.

Demo — Docker restarts containers that go unhealthy, not just ones that crash
The demo docker-compose.yml gains a willfarrell/autoheal sidecar (demo-only) that restarts any
container labelled autoheal=true when its healthcheck flips to unhealthy — covering the "process
hung but never exited" case that restart: unless-stopped alone can't. The Celery worker gained
a celery inspect ping healthcheck, and db/redis/app/worker/elasticsearch are opted in. Paired with
the health-alert email above, a hung subsystem is both restarted and reported.

Changed

django-unfold is now an optional theme, not a core dependency
The Unfold admin theme has moved out of the base install into a new [theme] extra
(pip install django-snapadmin[theme], also part of [all]). SnapAdmin resolves its admin base
class lazily: when Unfold is installed and enabled in INSTALLED_APPS the themed UI is
byte-identical to before — existing installs that keep Unfold see no change — and when it is
absent the admin, its token/error/audit screens and the optional django-extra-settings restyle
all fall back cleanly to Django's built-in admin. This keeps the base package's dependency graph
leaner and strictly permissive without giving up the themed experience for those who want it. A new
informational system check (snapadmin.I001) surfaces the stock-admin fallback so it is never
silent, and snapadmin_license_check / THIRD_PARTY_NOTICES.md now list django-unfold under
the [theme] extra. Upgrading: if you use the Unfold theme, install django-snapadmin[theme]
(or [all]); no code, settings or migration changes are required, and the REST/GraphQL stacks are
unchanged.

v0.1.0b3

v0.1.0b3 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 20 Jul 12:54

0.1.0b3 — 2026-07-20 — Third Beta

A large security and Elasticsearch release. Ten security fixes close permission, masking and
open-redirect gaps across the GraphQL API, the export and audit surfaces, the SSO login helper and
the database-backup path. Elasticsearch grows a structured query layer — es_filter(),
es_aggregate() and es_scan() — each with a database fallback so the API is the same whether
or not ES is enabled. Imports gain a guarded stale_sync() prune and a resumable bulk reindex.

One breaking change: auto-generated REST API text filters now default to exact match instead of
substring — see the first entry under Changed for the one-line fix if you rely on the old behaviour.
No migration guide is needed; no schema or data migration is required.

Added

Stale-row pruning for recurring imports (snapadmin.etl.stale_sync())
upsert_from_source() writes the rows a source reports, but a recurring full-table sync also
needs to delete the rows the source stopped reporting — and doing that by hand is where imports
go wrong: a truncated or half-downloaded feed deletes almost the whole table. The new
stale_sync(model, seen_keys, key_field=..., max_fraction=0.1) helper handles the delete half
safely. It removes every local row whose natural key is absent from seen_keys (the keys present
in the latest sync), but refuses — deleting nothing and raising StaleSyncAbort — if that would
remove more than max_fraction of the candidate rows, so a bad fetch can't silently wipe the
table. It returns a summary (total/stale/deleted/fraction), accepts dry_run=True
to preview counts and a queryset= to scope the sync to one source's slice of a shared table, and
for a DUAL/ES-mirrored model clears the deleted documents from Elasticsearch in the same bulk
call (raising SnapPurgeError if the DB delete lands but the ES mirror can't be cleared — the same
no-two-phase-commit contract as purge_expired()). stale_sync and StaleSyncAbort are
importable from snapadmin.etl; the demo sync_exchange_rates command grows --only N and
--prune flags to show it end to end.

Resumable, progress-tracking bulk reindex (snapadmin_reindex)
SnapModel.es_reindex_all is a single helpers.bulk over the whole table: no feedback, no
resume, no load tuning. On a multi-million-row table that means you can't tell "running" from
"hung", a crash restarts from zero, and the index refreshes on every write. The
snapadmin_reindex management command now drives a resumable, observable job instead, reusing
the async-export pattern — each run is tracked on a new SnapReindexJob row. It prints
processed/total (percent%) ETA Ns per chunk; DB-backed models are paged by a pk__gt cursor
checkpointed on the job after each chunk, so --resume continues the most recent
unfinished/failed run for a model from that checkpoint rather than restarting the table
(reindexing writes each document under _id = pk, so a resumed — or fully restarted — run only
ever overwrites, never duplicates). --tune sets the index's refresh_interval to -1 and
number_of_replicas to 0 for the duration of the load and restores both (to their captured
values) in a finally when the run ends or crashes; --parallel N indexes each chunk with
helpers.parallel_bulk (thread_count=N), with the pk cursor only advancing once a chunk
fully completes so out-of-order completions never corrupt the checkpoint; and setting a job's
status to cancelled stops the run between chunks, leaving partial progress in place. ES_ONLY
models have no DB pk to cursor over and reindex in a single pass (no resume). The existing
es_reindex_all method, the POST /api/es/reindex/ endpoint and the run_es_reindex Celery
task are unchanged — this adds a new, richer command path alongside them. A SnapReindexJob
model migration (0003) ships with this change.

SNAPADMIN_EXPORT_MAX_ROWS — row ceiling on the synchronous streaming export
GET .../export/ (the synchronous, no-Celery counterpart to POST /api/exports/) previously
had no upper bound: an unbounded or accidentally-ineffective filter with no explicit ?limit=
just started streaming the entire matching table, holding a database connection open until the
client gave up or the table finished streaming — with nothing steering the caller toward the
async export endpoint that exists specifically for large result sets. The new optional
SNAPADMIN_EXPORT_MAX_ROWS setting (default 0 = unlimited, unchanged from today) sets a
ceiling: when it's configured and no valid ?limit= was passed, export() now runs a
count() on the filtered queryset before streaming, and responds 413 Payload Too Large
(reporting the actual match count and pointing at POST /api/exports/) instead of opening a
stream that may never finish. An explicit, valid ?limit= is treated as the caller opting into
a bounded response themselves and is never blocked by the ceiling, however large.

SNAPADMIN_EXPORT_LIMIT_MAX — hard cap on ?limit=
A new, separate optional setting caps any explicit ?limit= passed to .../export/ down to
a configured maximum (default 0 = no clamp, unchanged from today) instead of always honouring
an arbitrarily large caller-supplied value.

JSON key-path filtering for the auto-generated REST API (api_json_filters)
The auto-generated filter set had no branch at all for JSONField — a model whose payload lives
in a JSON column could not be filtered through the dynamic REST API in any way. A new optional
model attribute, api_json_filters, declares which key-paths within which JSON field should be
filterable: api_json_filters = {"payload": ["a.b", "a.c"]} exposes ?payload__a__b=value and
?payload__a__c=value as query parameters (a dotted key-path becomes double-underscore-separated
in the parameter name, mirroring Django's own lookup convention). A match covers two cases in one
query parameter, since the same key-path can hold either shape from row to row: a scalar match
(the JSON value at the path equals the given value exactly) and a list-membership match (the
JSON value at the path is itself a list and the given value is one of its elements). The scalar case
uses Django's JSON key-transform exact lookup, which every backend supports natively, including
SQLite. The list-membership case prefers Django's native __contains=[value] JSON-containment
lookup where the backend supports it, but SQLite reports
connection.features.supports_json_field_contains = False and raises NotSupportedError for
that lookup — since SQLite is the default database for local development and the test suite, the
filter detects this via connection.features.supports_json_field_contains and falls back to a
row-by-row Python membership check on the extracted JSON value instead, so list-membership filtering
works out of the box on SQLite too, not just on PostgreSQL/MySQL. A model that doesn't set
api_json_filters (the default) exposes no JSON filters at all, matching prior behavior. JSON
columns carry no index
, so any of these filters — on every backend — is always a full table scan;
for filtering JSON data at scale on large tables, use SnapModel.es_search() (the Elasticsearch
integration) instead of the DB-backed auto-filters.

Structured Elasticsearch term filters (SnapModel.es_filter())
es_search() builds exactly one kind of query — a fuzzy multi_match over text fields — so
there was no way to run a structured term filter against Elasticsearch (e.g. "every document
whose keyword-mapped field is one of these values"), which matters most for fields a relational
database can't index at all, such as a JSON column. The new es_filter() classmethod fills that
gap: Product.es_filter(available=True, price=[999, 1299]) runs the constraints in ES filter
context (no relevance scoring, cacheable), with a scalar building a term clause and a
list/tuple/set a terms clause. Field names resolve through the model's effective ES mapping —
an exact-typed field (keyword/boolean/numeric/date/ip) filters directly, an analysed text field
automatically targets its keyword sub-field, and a __ path descends into an object mapping's
properties so a JSON column mapped in ES can be filtered by nested key path
(es_filter(payload__status="paid") → the ES field payload.status); an unknown or
analysed-text-only field raises ValueError rather than silently matching nothing. An optional
query_string is added alongside as a scored full-text must clause, so structured filtering
and fuzzy search compose in one call. Results mirror es_search(): a primary-key-ordered database
queryset for DUAL models (relevance order preserved), an EsQuerySet of reconstructed objects
for ES_ONLY. When Elasticsearch is disabled or the query errors, a DUAL model falls back to
the equivalent database filter — failing closed to an empty result if a term field has no backing
column — while an ES_ONLY model returns empty; the result carries the same
X-Snap-Query-Backend marker (elasticsearch/database) as es_search(). Like
es_search() this is a model-level query method; if you expose its results through your own view,
apply your own permission and PII-masking checks as the built-in REST/GraphQL layers already do.

Elasticsearch facets / aggregations (SnapModel.es_aggregate())
The faceting counterpart to es_filter(): where a term filter selects documents,
es_aggregate() counts them per value. Each positional field runs one Elasticsearch terms
aggreg...

Read more

v0.1.0b2

v0.1.0b2 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 13 Jul 18:36

0.1.0b2 — 2026-07-13 — Second Beta

A security fix for the dynamic model API plus a packaging fix for the installed CHANGELOG's doc
links. No breaking changes; a drop-in upgrade from 0.1.0b1.

Security

  • DynamicModelViewSet (the generic /api/models/<app>/<model>/ endpoint) resolved any
    Django model registered in the project, not just SnapModel subclasses — unlike the schema
    endpoint, which already filtered to SnapModel. A caller with Django permissions on a
    non-SnapModel (e.g. auth.User) could list, retrieve, create, update or delete it through the
    generic API, bypassing the opt-in SnapModel surface entirely and exposing fields such as
    password hashes. The viewset now resolves SnapModel subclasses only; any other model 404s,
    mirroring the schema endpoint's existing behavior.

Fixed

  • The root CHANGELOG.md (shipped in the wheel since 0.1.0b1) linked to
    docs/migrations/0.1.0a11_to_0.1.0b1.md and docs/releases/0.1.0b1.txt using relative
    paths. Those files are sdist-only, so pip-installed users following the links from a wheel
    install got a 404. Links now point at the absolute GitHub URLs, which resolve regardless of
    installation source.

v0.1.0b1 - First Beta Release

Pre-release

Choose a tag to compare

@drofji drofji released this 08 Jul 09:37

0.1.0b1 — 2026-07-08 — First Beta

The alpha series graduates to beta. This release completes a downstream-integrator feedback pass,
hardens the dashboard, and — notably — reorganises optional dependencies so a base install is fully
permissively licensed and safe for commercial use. It carries a few breaking changes (Celery task
rename, dashboard staff gate, dependencies moved behind extras); see the migration guide.

Upgrading: see docs/migrations/0.1.0a11_to_0.1.0b1.md for the exact steps.

Changed

Celery tasks moved to snapadmin/tasks.py and renamed to the snapadmin.* namespace (BREAKING)
The background tasks previously lived in snapadmin/api/tasks.py and were named api.tasks.* (e.g. api.tasks.purge_expired_data). Because Celery's autodiscover_tasks() only scans <app>/tasks.py for each installed app, a standard Celery setup never registered them — Beat schedules produced "Received unregistered task" and the GDPR purge, error digest, database backups and async export silently never ran. The tasks now live in snapadmin/tasks.py (autodiscovered by the stock app.autodiscover_tasks()) and are namespaced under snapadmin.*. Action required: update every CELERY_BEAT_SCHEDULE entry, replacing "task": "api.tasks.X" with "task": "snapadmin.X" (purge_expired_tokens, purge_expired_data, send_error_digest, run_export, run_db_backups). If you imported these tasks in Python, change from snapadmin.api.tasks import ... to from snapadmin.tasks import .... No back-compat aliases are kept — the old names no longer resolve. Step-by-step upgrade instructions (with the full old→new task-name table) are in docs/migrations/0.1.0a11_to_0.1.0b1.md.

The async-export endpoint now fails cleanly when Celery is not installed
POST /api/exports/ enqueues a Celery task, but Celery is an optional dependency. Calling it without Celery installed used to raise a bare ModuleNotFoundError (HTTP 500). It now returns HTTP 503 with an actionable message telling you to install the celery extra and configure a broker.

django-admin-autocomplete-filter is now an optional extra
It was a core dependency but the package core never imported it (it was only listed in the sandbox's INSTALLED_APPS). As an LGPL-3.0 package it was also the last non-permissive item in the base tree. It is now the opt-in django-snapadmin[autocomplete-filter] extra (also in [all]), so a base install is now fully permissive (MIT / BSD / Apache-2.0) — no copyleft or commercial code by default. Add the extra only if you use AutocompleteFilter list filters in your own admin.

The wysiwyg rich-text editor (CKEditor 5) is now an optional extra
django-ckeditor-5 bundles CKEditor 5, which is dual-licensed GPL-2.0+ or commercial. To keep the base package fully permissive (MIT/BSD/Apache) and safe for commercial/proprietary use, it is no longer a core dependency — install django-snapadmin[wysiwyg] (also in [all]) only if you use rich-text fields (SnapRichTextField / wysiwyg=True). SnapModel no longer imports the CKEditor widget at module load; the import happens lazily when a wysiwyg field is actually rendered, and if the extra is missing it raises a clear ImproperlyConfigured pointing at pip install django-snapadmin[wysiwyg]. Action required only if you use wysiwyg fields: add the [wysiwyg] extra and keep django_ckeditor_5 in INSTALLED_APPS. Projects without rich-text fields can drop django_ckeditor_5 from INSTALLED_APPS entirely.

django-extra-settings is now an optional extra, not a core dependency
SnapAdmin's core never imported django-extra-settings — only the demo uses it for its dynamic key/value Setting model — yet it was pulled in on every pip install django-snapadmin. It is now an opt-in extra: pip install django-snapadmin[extra-settings] (also part of [all]). Action required only if you relied on SnapAdmin installing it transitively and use its Setting model — add the extra, or depend on django-extra-settings directly. The README documents two integration gotchas that bit downstream users: EXTRA_SETTINGS_ADMIN_APP must match an INSTALLED_APPS entry (use the AppConfig dotted path if that is how you register apps, not the bare label), and the shipped Setting admin is not Unfold-themed (re-home it via EXTRA_SETTINGS_ADMIN_APP and subclass its admin if you want the theme — SnapAdmin does not ship a themed replacement, which would re-introduce the hard dependency).

Removed

  • snapadmin/api/tasks.py and the api.tasks.* task names — replaced by snapadmin/tasks.py / snapadmin.* (see Changed).

Security

The system dashboard is now staff-gated by default
DashboardView (the SnapAdmin dashboard) rendered infrastructure details — hostname, processor, operating system, database name, live service health and ALLOWED_HOSTS — to anonymous callers, an information-disclosure risk on any deployment that wired it into a public URLconf. Access now requires an authenticated staff user (is_staff): unauthenticated callers are redirected to the login page and authenticated non-staff users get 403. Set SNAPADMIN_DASHBOARD_PUBLIC = True to restore the old open behaviour (e.g. an intentionally public status page). If you relied on the dashboard being reachable without logging in, add that setting or log in as staff. See docs/migrations/0.1.0a11_to_0.1.0b1.md.

Wysiwyg field values are now sanitized before they are rendered in the admin changelist
Rich-text (wysiwyg) fields store raw HTML and default to show_in_list=True, so their value is shown on the changelist page. Previously that value was passed straight to mark_safe, which meant anyone able to write the field — a REST API token holder, a low-privileged staff member, or a bulk import — could store markup such as <img src=x onerror=...> that executed in an administrator's browser session (stored XSS, privilege escalation from field-write to admin-session). SnapAdmin now runs every wysiwyg value through an HTML sanitizer (nh3, a new core dependency) before marking it safe: common formatting is kept while <script>, inline event handlers and unsafe URL schemes are stripped. Fields whose HTML is fully trusted can opt back into verbatim rendering with safe_html=True (e.g. SnapRichTextField(safe_html=True)), and projects that need a custom policy can point the new SNAPADMIN_HTML_SANITIZER setting at their own Callable[[str], str]. No database migration is required.

Added

A CHANGELOG is now shipped to pip users, and stale issue references were removed
A concise, version-by-version CHANGELOG.md now lives at the repository root and is included in both the source distribution and the wheel (previously the per-version notes under docs/releases/ shipped in the sdist only, so pip install-only users had no changelog). A Changelog project URL points PyPI and pip show at it; docs/releases/*.txt remains the full, authoritative release notes and CHANGELOG.md the short index. Separately, the (issue #N) markers scattered through the package's docstrings and comments were removed — they referred to a planning notebook, not a public tracker, so they resolved to nothing for anyone reading the installed source.

Compatibility matrix and Django 6.0 support declared
The README now carries a Python × Django compatibility matrix (supported range vs the versions the suite is actively exercised against) and calls out the alpha API-stability caveat. The package metadata adds Framework :: Django :: 6.0 and per-minor Programming Language :: Python :: 3.103.13 classifiers to match the declared python >= 3.10 / Django >= 5.2 support; the full suite currently runs green on Django 6.0. There is still no automated multi-version CI grid, so combinations outside the exercised cells are supported-but-untested.

Security policy and third-party licence notices
The project now ships a SECURITY.md (how to report a vulnerability, the built-in protections, and a production hardening checklist) and a THIRD_PARTY_NOTICES.md (every runtime dependency and optional extra with its licence and a permissive/weak-copyleft/commercial tier, so it is clear at a glance what a base install pulls in versus what is opt-in). THIRD_PARTY_NOTICES.md ships in the sdist and wheel; SECURITY.md in the sdist. Both are linked from the README.

Relocate the whole URL surface with SNAPADMIN_URL_PREFIX
Projects that already own the path SnapAdmin is mounted at — most often /api/ — can now move every SnapAdmin route (REST, Swagger/ReDoc and GraphQL) under one extra segment without editing their URLconf. Set SNAPADMIN_URL_PREFIX = "snapadmin/" and include("snapadmin.urls") serves .../snapadmin/models/…, .../snapadmin/docs/, .../snapadmin/graphql/ and so on. Route names are unchanged, so reverse("model-list", …) and {% url %} keep working regardless of the prefix; the default (empty) keeps the historical layout. The simpler fix — mounting under an unused path via path("snapadmin/", include("snapadmin.urls")) — still works and is preferred when you control the mount point; the setting is for cases where you don't (SnapAdmin included at the site root, or pinned by an intermediate URLconf).

Admin-only bulk Elasticsearch reindex endpoint
Ops can now trigger a full ES reindex over HTTP without shell access. POST /api/es/reindex/ reindexes every ES-enabled SnapModel (the same set as the snapadmin_reindex command, via es_reindex_all). It is off by default and only served when SNAPADMIN_REINDEX_API_ENABLED = True (while disabled it responds 404), and it requires a Django staff user (``I...

Read more

v0.1.0a11 - Eleventh Alpha Release

Pre-release

Choose a tag to compare

@drofji drofji released this 05 Jul 21:08

Breaking (pre-stable): migration history reset.

pip install django-snapadmin==0.1.0a11

snapadmin and demo each carried five incremental migrations accumulated since the first alpha
(00010006). Squashed both down to a single 0001_initial.py per app reflecting the current
schema — no more incremental history to carry into 0.1.0 stable.

If you installed a previous alpha and already ran migrate, drop and recreate your database (or
run manage.py migrate snapadmin zero && manage.py migrate demo zero first) before migrating on
this version — the old and new 0001_initial are not compatible migration histories.

No model or API changes; this is purely a migration-file reorganization.

Changed

  • snapadmin/migrations/ — collapsed 00010006 into one 0001_initial.py.
  • demo/migrations/ — collapsed 00010006 into one 0001_initial.py.