Releases: drofji/django-snapadmin
Release list
v0.1.0b9
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.E026failsmanage.py checkon a model that mirrors to Elasticsearch without a
mapping. A model withes_storage_mode = DUAL/ES_ONLY(ores_index_enabled = True)
and neitheres_mappingnores_auto_mapping = Trueused to index its primary key and
nothing else, silently, while index creation, every save andes_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 (declarees_mapping, or set
es_auto_mapping = True);'snapadmin.E026'inSILENCED_SYSTEM_CHECKSis 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.0with 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 answers400to every request and has
removed thebody=/ignore=arguments the indexing paths use. Full reasoning under Fixed. -
snapadmin.E025failsmanage.py checkon a misresolvedEXTRA_SETTINGS_ADMIN_APP.
Only reachable with the[extra-settings]extra installed and admin autodiscovery deferred
(SimpleAdminConfig, a customAdminSite, or nodjango.contrib.admin). With Django's
defaultAdminConfigthe upstreamImproperlyConfiguredalready aborts startup before any
check runs, so nothing changes there. The check names the exactINSTALLED_APPSentry to write. -
A
django.core.exceptions.ValidationErrorraised on an API write now answers400, not
500. An observable change to the status code every SnapAdmin endpoint returns for a
model-level rule (Model.clean(), a guard insave(), apre_savereceiver). The body is
the shape a client already parses for a serializer error. A project that configures its own DRF
EXCEPTION_HANDLERkeeps 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 bulkQuerySet.delete()and on anon_delete=CASCADEsweep
requires apost_deletereceiver, and connecting one forces Django's collector to materialise
the rows rather than issue a singleDELETE. That is a cost, not a defect, and it lands
automatically on mirrored models: onees.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(). ADB_ONLYmodel 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_daysis 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 — adelete_atan upstream supplier sets per row — had no way in. The closest
approximation was to pointdata_retention_fieldat the expiry column and accept a whole-day
offset of at least one day (data_retention_daysis disabled at0), which also reads the
column as an age rather than as a deadline.data_retention_date_fieldnames that column, and it resolves throughget_model_meta()
like every other model-level option, so a@snap_model-decorated plain model and a
SnapModelsubclass 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 thandata_retention_days, because an expiry set
per record overrides the house rule rather than racing it; and a row whose date isNULLfalls
back todata_retention_daysmeasured ondata_retention_field. When no window is configured
at all, aNULLrow 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_dataandsnapadmin.W012all 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_ONLYmodels get the identical three-way rule expressed as a query — a
rangeon 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 adatein the index, exactly likedata_retention_field. The
demo'sAuditLognow dogfoods it with a nullabledelete_atalongside its existing 90-day
window, andsnapadmin_info --section featurescounts 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 aModelFormruns, 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 = Trueon a model runs itsfull_clean()—clean_fields(),
clean(), then the model's constraints — inside the generated serializer'svalidate(), so
the rule answers400naming the field instead of writing the row.
SNAPADMIN_API_FULL_CLEANturns 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: anauto_now_addcolumn is empty on an unsaved row, and judging it would reject every
create with an error no client could act on — the s...
v0.1.0b8
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 toFalse— so a project
carryingSNAPADMIN_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 becauseapi_write_fieldsis unset by
default: on a model that never restricted it, every field not named inapi_exclude_fields
becomes writable through the generated API (snapadmin.W004warns 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 pinSNAPADMIN_REST_API_ENABLED = Falseand
SNAPADMIN_GRAPHQL_ENABLED = Falseexplicitly, which still wins over any profile. Removing
SNAPADMIN_PROFILEentirely 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-select2class (ordata-snapadmin-select2attribute) 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_ENABLEDnow defaults toFalse(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 hasoffline_mode = True. A deployment
withSNAPADMIN_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 = Trueto restore the previous behaviour. SeeOffline Mode <https://drofji.github.io/django-snapadmin/#offline>_. -
SNAPADMIN_REST_API_ENABLEDandSNAPADMIN_GRAPHQL_ENABLEDnow default toFalse
(previouslyTrue). A project migrating from a plain Django admin no longer gets a writable
REST/GraphQL surface for every registered model just by includingsnapadmin.urls— it has to
ask for one. This is the flip the beta series announced viasnapadmin.W014(now retired —
its premise, "left unset and still mounted," is unreachable once unset means off); pin either
setting toTrueto restore the previous behaviour. See the migration guide:
docs/migrations/0.1.0b7_to_0.1.0b8.md. -
djangorestframework,drf-spectacular,django-filterandgraphene-djangoare 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 barepip install django-snapadminnow pulls only Django, structlog and nh3. Both
features above default toFalse, 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.urlsraises
ImproperlyConfigurednaming the extra, and the new checksnapadmin.E010catches it even
earlier, atmanage.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 withValueError: too many values to unpackduring 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-rangefilterstopped being a dependency in0.1.0b6— already documented
under that release'sRemovedheading; seedocs/releases/0.1.0b6.txtrather than
duplicating the note here. -
snapadmin.purge_expired_datanow 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
SnapadminAuditLogat all. A project that already schedulespurge_expired_datavia Celery
Beat and has audit rows older than 365 days will see them deleted on the first run after
upgrading. SetSNAPADMIN_AUDIT_RETENTION_DAYS = 0to keep every audit row indefinitely, as
before. -
Every registered model must now declare
subject_path. New checksnapadmin.E011fails
manage.py checkfor any registeredSnapModel/@snap_modelmodel that never declares
subject_pathat all —subject_path = Noneis 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 newsnapadmin_subject_requestexport/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 beforemanage.py checkpasses again
after upgrading::class Product(SnapModel): ... subject_path = None # add this — or a real path for a model that does carry PIIA 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 — seeGDPR 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), theSNAPADMIN_ENCRYPTION_KEYSenvironment variable
(id:keyentries, comma- or newline-separated — the.envpath), and finally literal
KEYSin the settings module, which works but is warned about wheneverDEBUGis off,
because a key in a settings module is a key in version control.python manage.py snapadmin_encryption_keygenerates a key and prints it once, as the
environment line to paste into a secret store — it writes it nowhere.--rotateprints 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.E017failsmanage.py check):SECRET_KEYis rotated for
session and CSRF reasons, and reusing it means the damage only bec...
v0.1.0b7
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 plaindjango.db.models.Modelcan now opt in, without subclassing.
Until now the only way to declare a SnapAdmin model was to inherit fromSnapModel, 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 fromdjango-money,phonenumber_fieldormodel-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 thesnapadmin.W00xsystem
checks see it, andsnapadmin_infoinventories it. Its keywords mirror theSnapModelclass
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_limitandsearch_fields— and only the
ones you pass are recorded, so applying it to aSnapModelsubclass 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
ofSnapModel's runtime machinery, and the surfaces that need that machinery skip a decorated
plain model rather than half-work. NoEsManager/EsQuerySet, so noes_search(), no
mirroring on save, no index created onpost_migrateand no selection bysnapadmin_reindex.
Nopurge_expired(), so neither the retention command nor the retention task touches it. No
generated admin —register_all_admins()passes it by, because withoutSnap*Fieldflags there
is nothing to derive fieldsets, list columns or filters from — and none of the base class's admin
niceties (formatted_id, the audit/PIIsave()hooks,admin_overrides). That is why the
decorator deliberately accepts noes_*ordata_retention_*keywords: storing them would
promise indexing and purging that never happen. Needing any of it means subclassingSnapModel;
both routes end in the same registry, so switching later changes nothing else. -
snapadmin.registryis now public API. The module shipped in the previous release as an
internal seam —SnapModelsubclasses registering themselves as they are declared, so every
"is this a SnapAdmin model?" gate became a lookup instead of anissubclass()walk. With
@snap_modelit 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 existingSnapModelchanging
behaviour. -
snap_field()puts SnapAdmin metadata on any Django field, not just aSnap*Field.
EverySnap*Fieldis 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 withgetattr(field, "...", default). Until now getting
those attributes onto a field meant using the matchingSnap*Fieldsubclass — no option for a
field type SnapAdmin does not ship, such asdjango-money'sMoneyField,
model-utils'sStatusField,phonenumber_field'sPhoneNumberField, 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, sodeconstruct()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 ofrequired/allowed_extensions/
allowed_encodings/max_size_bytes, which only mean something inside aSnap*Field's
own__init__(derivingnull/blank, or building a validator) — raisesValueError
naming it, rather than silently doing nothing. -
snapadmin-newgenerates 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(alsopython -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 runserverThat writes
manage.py, a settings package, one app carrying a workedSnapModelexample (a
Productwith a handful ofSnap*Field\ s, so the generated admin, REST API and GraphQL
schema are actually worth looking at), SQLite, and a.env/dist.env.migratethen
runserverwork immediately — no Docker, no manual settings edits, and the worked model ships
with its initial migration already generated so there is nomakemigrationsstep to remember.Pass
--fullfor the same project plus aDockerfile,docker-compose.ymland the
PostgreSQL / Redis / Elasticsearch wiring (PostgreSQL whenPOSTGRES_HOSTis set — which
docker-compose.ymldoes for the app container — SQLite otherwise, somanage.py check/migratestill work with no services running).--app-namenames the example app
(defaultcatalog); the project and app name are both validated the waydjango-admin startprojectvalidates one — a valid Python identifier that doesn't shadow an existing
importable module (stdlib, Django, orsnapadminitself).Templates ship inside the wheel under
snapadmin/scaffold/templates/and render with the
standard library'sstring.Template— no Jinja, no new runtime dependency, consistent with the
other console scripts. Likesnapadmin-demoandsnapadmin-init, it never overwrites: writing
into a non-empty target directory is a hard refusal, not a prompt. -
snapadmin-demonow stamps the tree it extracts, and refreshes it properly. Upgrading the
package (pip install -U django-snapadmin) never touched an already-extracteddemo/
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.jsonstamp — the release it came from plus the list of
files that extraction wrote. Re-runningsnapadmin-demonames 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...
v0.1.0b6
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— thellmstxt.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 assnapadmin.W001–W007),
then annotated links into every section of the documentation. It is published at
https://drofji.github.io/django-snapadmin/llms.txt for assistants with web access, and a
byte-identical copy ships in the source distribution at the repository root.The
snapadminpackage docstring is now a full quickstart and module map — the three-step
example, what each module and management command is for, theSNAPADMIN_*setting families and
the optional extras. Unlikellms.txtthis 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-infoandsnapadmin-license-checkare 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 typingsnapadmin_infoin
a shell and gettingcommand not foundwas an easy first-run mistake. Thin shims now walk up
from the current directory to find yourmanage.py, forward every argument and return the exit
code. All four spellings work:snapadmin-info,snapadmin_infoand
python manage.py snapadmin_infoare the same command, and likewise for the licence check. With
no project in sight the shim explains that and points atsnapadmin-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 bundledcurlfor 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 pingrather 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=s3switches
Django'sSTORAGESto 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-licenseddjango-storages[s3]; nothing is imported while the backend islocal.
demo/dist.envcarries the per-provider values, including that a Hetzner Storage Box is not
S3 (SFTP/CIFS/WebDAV — mount it, or useSNAPADMIN_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,SnapModelattributes andSnap*Fieldkwargs,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 — aDeprecationWarningfor a Python name, a stderr notice for a management command.
Until then the0.xbeta 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, theSnapFieldmixin (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 — whathelp()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)
andbenchmark_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
checksdiagnostics section.snapadmin_inforeports a per-severity count of Django's
system checks, and--health-checkfails 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_dataandsend_error_digestnext to
snapadmin_info,snapadmin_reindexand friends. Beyond the inconsistency, names that
generic can collide with a command of your own: Django resolves duplicate command names
silently byINSTALLED_APPSorder, so whichever app wins, wins quietly. Use
snapadmin_db_backup,snapadmin_purge_expired_dataandsnapadmin_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_digestand
snapadmin.run_db_backupswere already prefixed, so no Beat entry needs touching. -
snapadmin_infooutput 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 (--verbosefor 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✓ online and one✗ offline, so the
feature-adoption checklist answers "what's on, what's off" without scrolling.--jsonoutput is
unchanged apart from the newcheckssection. -
The API write-guard checks now emit one grouped warning instead of one per model.
snapadmin.W004(noapi_write_fields) andsnapadmin.W007(api_write_fields = []but
still write-exposed) used to repeat an identical message and an identical multi-line ...
v0.1.0b5
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_lookups → SNAPADMIN_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...
v0.1.0b4 - Fourth Beta Release
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
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...
v0.1.0b2
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 justSnapModelsubclasses — unlike the schema
endpoint, which already filtered toSnapModel. 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-inSnapModelsurface entirely and exposing fields such as
password hashes. The viewset now resolvesSnapModelsubclasses 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.mdanddocs/releases/0.1.0b1.txtusing 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
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.pyand theapi.tasks.*task names — replaced bysnapadmin/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.10–3.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...
v0.1.0a11 - Eleventh Alpha Release
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
(0001–0006). 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/— collapsed0001–0006into one0001_initial.py.demo/migrations/— collapsed0001–0006into one0001_initial.py.