Changelog¶
4.1.2 (2026-08-10)¶
Maintenance release. No library code changed; nothing here alters runtime behaviour.
Security¶
The development lockfile pins
cryptography50.0.0, which fixes GHSA-g6cj-pr64-35w5 / CVE-2026-69247: PKCS#7EnvelopedDatadecryption leaked a Bleichenbacher oracle through distinguishable errors and timing.django_utils.crypto_fieldsusesFernet/MultiFernetonly and never touchespkcs7_decrypt_*, so the vulnerable path was not reachable through this package. The published floor stayscryptography>=42.0-- installs of thecryptoextra resolve to the newest release anyway, and pinning a ten-day-old version on every consumer buys them nothing here.[tool.uv] exclude-newer-packagegivescryptographya seven-day soak instead of the project-wide fourteen, so a security release can enter the lockfile without waiting out the full quarantine.
Changed¶
Documentation, README and docstrings use plain ASCII punctuation throughout, with filler trimmed.
4.1.1 (2026-08-09)¶
Added¶
JSONWidgetsyntax-highlights as you type: keys, strings, numbers andtrue/false/nullare colorized live via a dependency-free tokenizer injson_widget.js, painting a colored overlay on the still fully native<textarea>. Focus, caret, undo, paste and form submission stay unchanged, and it degrades to a plain textarea without JavaScript. Follows the admin's light, dark and auto themes.
Fixed¶
The
*Select2admin filters (AllValuesFieldListFilterSelect2,JSONFieldFilterSelect2, ...) navigate again when an option is picked. select2 announces a selection through jQuery's event system only, anddropdown_filter.jswires navigation with a nativechangelistener (it must also work without jQuery). In 4.1.0 picking an option updated the widget but silently never filtered.select2_filter.jsnow re-dispatches a nativechangeon select2's selection event.
Changed¶
The dropdown filter docs demo got an admin-sidebar look (docs-only CSS) and a live select2 section: the docs build stages Django's own vendored jQuery + select2 so the autocomplete variant is exercised in-browser too.
4.1.0 (2026-08-06)¶
Added¶
django_utils.crypto_fields:EncryptedCharField,EncryptedTextField,EncryptedJSONField: Fernet-encrypted model fields storing a base64 token in a plainTEXTcolumn (behind the newcryptoextra:pip install "django-utils2[crypto]"). Keys come fromsettings.DJANGO_UTILS_FERNET_KEYS. The first key encrypts, every key is tried on decrypt (MultiFernet), so rotation is prepend-a-key-and-save. Every lookup exceptisnullraisesNotImplementedError, because Fernet salts every encryption, so equality can never match at the database level anyway. A token nothing in the keyring can decrypt raisesValidationError(never silently returned as ciphertext).EncryptedCharField.max_lengthvalidates the plaintext, not the (necessarily longer) stored column. Importing the module never requirescryptography. Instantiating a field without it does, with anImproperlyConfiguredpointing at the extra.django_utils.admin.mixins.ReadOnlyModelAdminMixin: turn any existing admin into a safe read-only view: add/change/delete denied for everyone (superusers included), all fields read-only, list configuration and search working untouched. Built on stable publicModelAdminAPI.django_utils.admin.mixins.CountColumnMixin: sortable related-object count columns forlist_display(count_columns = ('review', 'topping')addsreview_count/topping_count), annotated viadjango_utils.aggregates.SubqueryCountso combining several relations never fans out through a JOIN. Columns already placed inlist_display, or backed by a method you defined yourself, are left untouched.django_utils.admin.export.ExportMixin:export_as_csv/export_as_jsonchangelist actions, streamed viaStreamingHttpResponseandqueryset_iteratorso a million-row export never materialises in memory. Runs against the changelist's own (already filtered) queryset: filter first, select second, export third. CSV values are guarded against formula/CSV injection (OWASP mitigation: a leading=,+,-or@gets a single-quote prefix).export_fieldsrestricts the exported columns. Unset, it defaults to every concrete field's attname. Dependency-free and scoped on purpose to CSV and JSON, export only. See django-import-export for XLSX/import/resource classes.django_utils.auth:superuser_required/staff_requiredview decorators andpermission_string(), the helpers behind django/new-features #47 and #137 (67 combined reactions) that every project hand-rolls. Both decorators work bare (@superuser_required) and parameterized (@superuser_required(raise_exception=True)), with redirect or 403 options, and work on both sync and async views.permission_string()builds the'app_label.action_modelname'string everyuser.has_perm()call needs from a model class.django_utils.query_debug.query_budget: production-safe query counting viaconnection.execute_wrapper(). Warn or raise on N+1 regressions in real code paths, whereassertNumQueries(test-only) and profilers (dev-only) cannot live. The dominant N+1 package (nplusone) has been unmaintained since 2018.django_utils.middleware.FetchMetadataMiddleware: opt-in header-based CSRF hardening viaSec-Fetch-Site/Origin(django/new-features #98, 59 reactions). Strict by default, fails closed on unknown header values, allows header-less clients (token CSRF stays the backstop), withfetch_metadata_exemptdecorator for opt-outs. Defense-in-depth: run alongsideCsrfViewMiddleware, never instead of it.django_utils.aggregates:SubqueryCount,SubquerySum,SubqueryAvg,SubqueryMin,SubqueryMax: aggregate annotations that run as independent subqueries, immune to the JOIN fan-out that makesannotate(Count('a'), Count('b'))silently multiply counts. All five also accept a relation name in place of a queryset (SubqueryCount('review'),SubquerySum('topping', 'price')) for reverse FK and (reverse or forward) many-to-many relations, resolved against the annotated model.django_utils.bulk.bulk_update_or_create: chunked upsert on Django's nativebulk_create(update_conflicts=True): oneINSERT ... ON CONFLICT DO UPDATEper batch instead of 2N racy queries. Existing upsert packages predate the native API and reinvent raw SQL. All argument validation happens before any query runs. PostgreSQL and SQLite honourunique_fieldsas the conflict target, MySQL/MariaDB fire on any unique constraint (documented in the module).django_utils.management.commands.base_command.ChunkedCommand: management-command base that iterates any queryset throughqueryset_iteratorwith progress logging,--resume-fromcheckpointing for resumable runs,--limitfor early stops, and transactional--dry-run(scoped to the queryset's database alias) that rolls back via exception unwinding (safe inside pytest-django's per-test transactions).django_utils.context: contextvars-native current request/user access (RequestContextMiddleware,get_current_request(),get_current_user(),current_request()context manager). Safe under ASGI where thread-local equivalents like django-crum leak state between interleaved requests.Choiceaccepts arbitrary keyword metadata, reachable as attributes:Choice('a', 'Active', color='green')givesStatus.choices['a'].color. Django'sTextChoiceshas no equivalent.Choices.as_enum()returns a realenum.Enumbuilt from the choices, so application code can use runtimeisinstancechecks andmatchon real enum members while model fields keep taking the raw-value class.ChoicesDict.by_key()exposes the choices keyed by attribute name, returning a copy so callers can't mutate the original mapping.ChoicesDict.grouped()returns choices nested into Django's<optgroup>structure ([(group_label, [(value, label), ...]), ...]), verified against a real Django model field and form widget.SlugMixinnow resolves slug collisions with a numeric suffix (my-thing,my-thing-2, ...) instead of silently producing duplicate slugs. Overrideslugify_max_attemptsto change the retry ceiling.queryset_iteratorgained three keyword-only options, all backwards compatible:pk_fielditerates by any unique, indexed, ordered column instead of the primary key (useful when the pk is a random UUID but a monotoniccreated_at/idcolumn exists).start_afterresumes from a known cursor value so a batch job that died partway through can continue instead of restarting.gc_collectrestores the optional per-chunkgc.collect()call (off by default, see below).django_utils.admin.widgets.JSONWidget: aJSONFieldadmin textarea that pretty-prints and key-sorts a well-formed value (Django renders it on one line) and validates it inline as you type, via a small, CSP-safe vanilla-JS static asset (no inline handlers, noeval) that degrades to a plainTextareawithout JavaScript. Django already preserves malformed input across the round-trip (forms.JSONField.bound_data()returnsInvalidJSONInput). The widget does not change that.django_utils.admin.widgets.JSONWidgetMixin: opts aModelAdminintoJSONWidgetfor itsJSONFields viaformfield_overrides. Nothing is patched globally: a project using only the filters below sees no change to its forms.django_utils.admin.filters.LookupFilterMixin: adds an operator selector to a list filter. The operator is read from<parameter_name>__opand validated at request time against the filter's ownoperators(default('exact',)) before use, raisingSuspiciousOperationfor anything not in that set. A custom filter that mixes this in must also pointtemplateatdjango_utils/admin/lookup_filter.htmlto render the operator<select>and value input. Without it the operator is still enforced, just with no UI to choose one.JSONFieldFilter.create()gained anoperatorskeyword to enable the above on JSON sub-path filters, e.g.create('data__price', operators=('gte',), cast=int). The keyword itself is validated against a fixed allowlist (exact,contains,icontains,startswith,gt,gte,lt,lte,range) atcreate()time. This isJSONFieldFilter.create()'s own check, not the request-time one described above.containsandrangeare further rejected atcreate()time for JSON sub-paths:containson aKeyTransformresolves to PostgreSQL's@>containment lookup, not substring matching, and raisesNotSupportedErroron SQLite (useicontains).rangeexpects a two-element sequence but a filter only ever supplies one scalar. Omittingoperatorskeeps the default,exact-only matching behaviour, but the query string is not fully inert even then:<parameter_name>__opis now always claimed and validated, so e.g.?data__price=10&data__price__op=gteagainst a filter created withoutoperatorsnow raisesSuspiciousOperation(HTTP 400) instead of the previous silent zero-row match.django_utils.views.error_400: completes the shipped error-handler set (403/404/500 already existed), so a project'shandler400can point here too. Rendersdjango_utils/error_400.html, which shipped in every release but had no view referencing it.Test infrastructure: template coverage.
django-coverage-pluginnow measures the shipped.htmltemplates as part of the combined 100% coverage gate, so an unrendered template line fails CI instead of being invisible to coverage.py. Enabling it immediately surfaceddropdown_filter.htmlandselect2_filter.html, which no test had ever rendered, and forced render tests for both.django_utils.pg_enum.EnumField: aCharFieldwired to adjango_utils.choices.Choicesclass, withchoices/max_lengthderived from it, and on PostgreSQL the column's real type is a nativeCREATE TYPE ... AS ENUMtype (plainVARCHARon every other backend, so models stay portable). Closes the README's oldest promise ("coming soon"). Ships with three explicitmigrations.Operationsubclasses (CreateEnumType,DropEnumType,AddEnumValue) added to a migration BY HAND (Django's autodetector has no concept of "create this standalone database object first"). All three are DB-only and no-ops on non-PostgreSQL vendors.AddEnumValueisatomic = False(PostgreSQL cannot runALTER TYPE ... ADD VALUEinside a transaction on versions before 12) and irreversible (PostgreSQL has noDROP VALUE, and the README documents the recreate-and-migrate escape hatch). Test infrastructure: thepostgrespytest marker registered in Phase D (until now unused) gets its first real consumers here, verified both skipped (SQLite) and executed (tox -e py313-django52-postgres, live PostgreSQL 16).
Fixed¶
The
testsextra pinspytest-django>=4.8,<4.13: pytest-django 4.13.0 dropped Django 4.2 support without a version floor and crashes on it (AttributeError: _pre_setup_ran_eagerly). The cap lifts when Django 4.2 leaves the support matrix.dropdown_filter.htmlrenders{{ spec.Media }}again: a master-only hotfix (a285130, shipped on top of 3.0.2) merged back into this branch at release time. Django's admin never consults a list filter'sMedia, so the template must emit it itself. That is what actually loadsSelect2Mixin's select2/jQuery assets.Mediarenderssrc/href-only tags, so the CSP guarantees of the 4.1.0 template rewrite are unaffected (render-test enforced).to_jsonserialisesdatetime,date,DecimalandUUIDviaDjangoJSONEncoderinstead of raisingTypeError.dropdown_filter.htmlandselect2_filter.html(the templates behindDropdownMixin/Select2Mixinand theirJSONFieldFilter*variants) no longer emit an inlinestyle=attribute, an inlineonchange=navigation handler, or an inline<script>. All three broke under a real Content-Security-Policy, and with JavaScript disabled the oldonchange-driven<select>(shown once there are more than three choices) did nothing at all. The plain link list is now always rendered as a working no-JS fallback. When there are more than three choices a<select>is also rendered,hiddenuntil external, CSP-safedropdown_filter.jsunhides it, hides the links, and wires navigation onchange.select2_filter.htmlmoves its activation into externalselect2_filter.js, guarded onwindow.django && django.jQuery && django.jQuery.fn.select2. Behavior added, not removed: JavaScript-enabled pages keep today's dropdown/select2 UX. JavaScript-disabled pages gain a working filter where before they had a dead<select>.
Changed¶
Test infrastructure: the full test suite now also runs against PostgreSQL 16 in CI (a new, parallel
postgresjob), not just SQLite.tests/settings.pygained an opt-in env-var switch (DJANGO_UTILS_TEST_POSTGRES=1, plusPOSTGRES_HOST/POSTGRES_PORT/POSTGRES_USER/POSTGRES_PASSWORD, all with local-Postgres-friendly defaults) that flips both configured database aliases to PostgreSQL. Localpytest/toxruns are unaffected unless the var is set. Deliberate deviation from the originating spec's "apostgresmarker; one tox env running the marked tests" text: running the full suite against PostgreSQL strictly exceeds that (every ORM-touching claim gets real cross-backend coverage, not a hand-picked subset picked by whoever adds the next test). Thepostgrespytest marker is still registered (tests/conftest.py, new) and auto-skips when the env var is not set. Kept for future PostgreSQL-only tests (e.g. an upcoming ENUM field with no SQLite equivalent), not as the current PostgreSQL-suite selector. See the[testenv:py313-django52-postgres]comment intox.inifor the same note in context.A prior version of this changelog reported that
queryset_iteratorwas benchmarked againstQuerySet.iterator(chunk_size=...)and lost. That conclusion was wrong and has been corrected. The benchmark (benchmarks/queryset_iterator.py) ran on SQLite and measuredtracemallocpeak, which tracks Python-level allocations only. It cannot see a database driver's C-level result buffer, which is exactly whatqueryset_iteratorexists to bound. Django opens a server-side cursor forQuerySet.iterator()on PostgreSQL only. Off that path,iterator()'s peak memory is set by the driver, not bychunk_size, on backends whose driver buffers the whole result set client-side. Verified for MySQL with mysqlclient (its default cursor callsstore_result()). Driver-dependent, and not established here, for Oracle and for PostgreSQL withDISABLE_SERVER_SIDE_CURSORS = True.queryset_iteratoravoids that by issuing each chunk as its own boundedLIMITquery rather than one unbounded query (confirmed: 1 query foriterator()vs. N forqueryset_iterator()at any table size), so the driver never receives more than one chunk at a time. SQLite has no server-side cursor to bypass in the first place, but its stdlib driver steps rows lazily rather than buffering the whole result set, so it cannot demonstrate this effect either way. The benchmark was structurally incapable of observing the failure mode the function prevents. The wall-clock cost is real and unchanged from before: ~1.09x-1.14x on SQLite. (An earlier version calledgc.collect()after every chunk, which raised that ratio to ~1.6x. That call is now opt-in viagc_collect=True, off by default.) No out-of-memory failure has been reproduced in this repository's benchmark. The claim is narrower: bounded driver-side memory by construction on backends that buffer, not a demonstrated fix for a specific crash. The docstring has been rewritten accordingly.queryset_iterator's docstring now documents why the chunked query shape helps beyond client-side driver memory: bounded memory on the database server (each chunk is an indexed range scan the planner can satisfy incrementally, instead of one query that may force the server to materialise and sort the full result set) and distribution across read replicas (N independent statements can be load-balanced, one long-running query cannot). It also covers the operational blast radius of a single heavy query and how short, resumable chunks (start_after) avoid it. These three points follow from the query shape and were not independently benchmarked.Documentation moved to the Sphinx/Furo site at https://django-utils-2.readthedocs.io/en/latest/: a quickstart, a "why this over the alternatives" page, and one feature-area page per admin/models/querysets/commands/middleware topic, with runnable examples and (for the admin features) screenshots or a live in-browser demo.
README.mdis slimmed to the pitch, install, a single quickstart example, and a feature index linking out to each page. It is no longer where feature details live.
4.0.0 (never published separately, its changes first shipped in 4.1.0)¶
Modernization release. Runtime behavior of retained APIs is unchanged except for the documented fixes below. The removed modules were packaging/metadata only.
Breaking¶
Dropped support for Python < 3.10 and Django < 4.2. Supported: Python 3.10-3.14, Django 4.2 / 5.2 / 6.0.
Removed
django_utils.__about__. Package metadata now lives inpyproject.toml(useimportlib.metadata.version('django-utils2')).Removed the empty
django_utils/models.pymodule.Django is now an explicit install dependency (
django>=4.2). 3.x releases only declaredpython-utils.A JSONP
callbackparameter must now be a valid Python identifier. Deployments relying on dotted or subscripted callback names such asangular.callbacks._0,window.cb, orcb[0]will now get a 400 response instead.RecursiveFieldno longer inherits the parent's value when the child's own value is falsy (0,'',False). Previously such values were treated as unset. Code relying on the parent value being substituted for a falsy child will now see the child's value instead.
Fixed¶
FilterBase.create(formatter=..., cast=...): user-supplied callables were bound as methods and crashed when invoked with the documented single argument. They are now wrapped instaticmethod.Filter lookup caching:
timeout=timedelta(0)now disables caching instead of silently using the 10-minute default.ChoicesMetano longer accumulates attributes from everyLiteralChoicessubclass defined in the process.Admin filters module is now fully covered by tests (it previously had none and was excluded from coverage).
The
settingsmanagement command no longer reports the deprecatedUSE_L10Nsetting on Django 4.2.The
?debug=1view of an ajax response is now restricted tosettings.DEBUGor clients insettings.INTERNAL_IPS, and its output is HTML-escaped. A JSONPcallbackparameter must now be a valid Python identifier.The
debugtemplate filter returns an empty string unlesssettings.DEBUGis enabled, matching Django's own{% debug %}tag, and no longer renders protected attributes.Admin filter lookups now honour the
ModelAdmin's per-request queryset instead of querying the model's default manager, and cached lookups are scoped per user. Overrideget_lookups_cache_scope()to share the cache between users.queryset_iteratorno longer skips rows whose primary key is zero or negative. A queryset whose primary keys were all negative previously yielded nothing.RecursiveFieldno longer treats a falsy child value (0,'',False) as unset and inherits the parent's value in its place.Choicessubclasses may declare_ignore_to keep constants from being collected as choices.
Changed¶
Packaging:
pyproject.tomlwith theuv_buildbackend. Added a proper BSD-3-ClauseLICENSEfile and apy.typedmarker (the whole package is strictly typed and checked by mypy, basedpyright, pyrefly and ty). Views decorated withenvcan now be typed against the newdjango_utils.view_decorators.EnvRequestrequest class.Linting/formatting: ruff (replaces flake8).
CI: split into ci/codeql/publish workflows. Releases publish to PyPI via Trusted Publishing on
v*tags.Docs: furo theme, README converted to Markdown.
Filter lookup cache keys are now hashed (
django_utils.lookups.<sha256>) so they are valid on every cache backend. Previously the raw request path and filter title were concatenated, producing keys with spaces that memcached rejects. Cached lookups are invalidated once on upgrade.
3.0.2 and earlier¶
See the git history.