Server Changelog
Integrator-facing changes for the vueda Python package.
Use this page for changes that affect server package consumers: Django apps, settings, serializers, viewsets, permissions, metadata responses, management commands, migrations, REST behavior, and compatibility notes.
Public Baseline
Earlier VUEDA server versions existed for internal or private use. The v3 prerelease series is the first public-facing documentation baseline.
v3.0.0a1.post1 (unreleased)
Fixes
- Server wheel contents:
- Removes stale files from the published
3.0.0a1wheel, including deleted history modules that caused Django startup to fail withModuleNotFoundError: No module named 'simple_history'. The framework source is unchanged apart from the version number. Upgrade to3.0.0a1.post1or later. No additional dependency or migration is required for this packaging correction.
- Removes stale files from the published
v3.0.0a1 (2026-09-14)
Breaking Changes
Workflow participation and object data are authorized separately:
WorkflowObjectPermissions.has_permissionadmitted any request whose target workflow held aStatePermissionrow. The rule ignored the caller's groups, the requested codename, the model content type, and whether the rule granted or denied. Adding or removing an unrelated state rule therefore changed whether the class enforced the target model's own permission. It now defers a model-level denial only for an action that decides against each concrete object it touches, and only when a state rule grants, matches the caller's groups, the requested codename, the model content type, and the workflow.vueda_workflow.read_workflownow admits a caller to workflow definitions, transition discovery, and transition execution only. Current object state and workflow state history report the target object's own data, so they require that object'sreadpermission and no longer requireread_workflow. A caller who can read an object, and holds no workflow permission, now reads its state and state history.- Every object-addressed workflow and history path checks the object's permission before it reads workflow participation, current state, or history. Object transition discovery and transition execution evaluate the workflow's configured permissions against the object rather than against the model.
HasWorkflowModelMixin.check_workflow_permissiontakes anobjargument for that, and no longer treats an unrelated state rule as sufficient authorization. - GET and HEAD require the same object read permission. Transition execution requires the target object's
readpermission for every object it touches, and does not require the target model'supdate_*. Object permissions are rechecked under the row lock before writing. A bulk request reports an object the caller cannot read with the404a missing id produces, wording included, and rolls back the entire batch. WorkflowViewis removed.vueda.core.views.DynamicObjectViewis the base for an endpoint that names its model in the URL, and it checks the object's permissions whenever it resolves one.vueda.core.permissions.DynamicObjectPermissionsis the permission class it uses, andWorkflowObjectPermissionsnow subclasses it. Both name a CRUDL action and resolve it throughPERMISSION_NAMES_MAPPINGwhen the check runs, sopatch_djangono longer rewrites a workflowperms_mapat import.GetObjectHistoryViewbecomesWorkflowStateHistoryView, its route becomesworkflow-state-history/<app_label>/<model>/<object_id>/, and its URL name becomesworkflow-state-history. The endpoint returns the history of the target object'sObjectStaterow, not the target model's audit log, and the old names said otherwise. VUEDA v3 is the first public release, so these names change without a compatibility alias. Grant the target model'sreadpermission to any group that discovers or executes workflow transitions;read_workflowalone no longer reaches an object. Review eachStatePermissionrow: one that never matched a caller, codename, content type, and workflow now changes no decision, and a group that depended on the old bypass needs a real grant. ReplaceWorkflowViewwithvueda.core.views.DynamicObjectView, andGetObjectHistoryViewwithWorkflowStateHistoryView. Update anyreverse("get-object-history")call and any client URL pointing atobject-history/.
Model history moves to PostgreSQL triggers:
vueda.historynow records changes throughdjango-pghistoryanddjango-pgtriggerinstead of Django signals, and it tracks every eligibleVuedaModelsubclass by default rather than only those inheriting a history-specific base. A model controls its own tracking throughclass Vueda.History:enabledopts a model out,exclude_fieldskeeps named columns out of the event model, andreasonrecords why.passwordis always excluded, whatever a model declares, because a declaration replaces inherited policy rather than adding to it.get_defaults()addspghistoryandpgtriggertoTHIRD_PARTY_APPSand setsPGHISTORY_APPEND_ONLY, so event tables reject updates and deletes at the database. It also setsPGHISTORY_CREATED_AT_FUNCTIONtoclock_timestamp(). An event then records the moment of its own write, not the moment its transaction began. That is what lets the events of one request be ordered. VUEDA ships both settings inside its trigger SQL, so overriding either one diverges from the shipped migrations. VUEDA keeps pghistory'sContextForeignKey, row-level trigger, and indexing defaults, and ships no retention policy.- Every app containing a tracked model gains an event model and trigger migrations of its own.
vueda.historyis a required app:VUEDA_APPSmust include it, andget_defaults()raisesImproperlyConfiguredotherwise. VUEDA ships the event models and trigger operations for its own tracked models in its own migrations, so those migrations load in every supported configuration. - A model with a composite primary key cannot be tracked and reports
vueda_core.E013until itsHistorysection setsenabled = False. Anexclude_fieldsentry naming a field the model does not have, or a field a retained generated field's expression reads, reports the same check. Proxy models write to the concrete model's event table and generate no event model of their own. Unmanaged models are skipped, because VUEDA does not own their tables. - Workflow's own records now produce pghistory event models too.
Workflowalready did, because it subclassesLookupand theclass Vueda.Historycontributor reaches it.WorkflowPermission,State,StatePermission,InitialState,Transition,TransitionPermission,TransitionSource, andObjectStateare plainmodels.Modelsubclasses that the contributor never sees, sovueda.workflowregisters them throughvueda.history.apps.track_model. Their event models carry the same context field, append-only behaviour, and mandatory exclusions as a policy-driven model, so one workflow edit groups its writes across tables and one transition's state change joins the action that applied it.vueda_workflowmigration0008ships the eight event models and their triggers. - Both Copier templates'
Usermodel now declaresclass Vueda.Historywithexclude_fields = ("last_login",), and both ship the generated event model and trigger migration. A generated project therefore records user history without turning every sign-in into an entry. Runmakemigrationsafter upgrading and review the generated event models and triggers in each app that owns a tracked model. Purging event rows requirespgtrigger.ignore, because append-only otherwise blocks the delete. A model that should not be tracked needsclass Vueda.Historywithenabled = False.
VUEDA drops django-simple-history:
- VUEDA no longer depends on
django-simple-history.get_defaults()dropssimple_historyfromTHIRD_PARTY_APPS, dropssimple_history.middleware.HistoryRequestMiddlewarefromMIDDLEWARE, and no longer setsSIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD. pghistory records every model VUEDA tracks. vueda.history.models.VuedaHistoryModel,vueda.core.simple_history.SimpleHistoryModelMixin,vueda.core.simple_history.ProxyAwareHistoricalRecords,vueda.history.utils.create_historical_record, andvueda.user.models.AbstractVUEDAUserWithHistoryare gone, along with the three modules that held them. A proxy model needs no history-aware base class now, because pghistory's triggers fire on the table a proxy shares with its concrete parent.vueda_workflowmigrations0001and0002no longer importsimple_history, and thebasestuple of eachHistorical*model they create becomes(models.Model,).HistoricalChangesdefines no fields and Django has no operation for a bases change, sosqlmigrateoutput is byte-identical andmakemigrationsfinds nothing to generate. Every published migration node keeps its name. SubclassVuedaModelwhere you subclassedVuedaHistoryModel, andAbstractVUEDAUserwhere you subclassedAbstractVUEDAUserWithHistory. Both keep recording history, through pghistory rather than signals.makemigrationswill then generate aDeleteModelfor eachHistorical*table those base classes built; review it before applying, because it drops the rows too.DeleteModelremoves no content type, so a project that created permissions for those models in a data migration must delete the staleContentTyperows itself. Until it does, the workflow permission picker keeps offering permissions on tables that are gone, because the picker cannot recognise a content type whose model class no longer resolves. Django imports every migration module while it loads the graph. So a project whose own published migrations importsimple_historymust either declaredjango-simple-historyas a direct dependency, or make the same import andbasesedit.
- VUEDA no longer depends on
History records the action behind a change:
get_defaults()insertsvueda.history.middleware.VuedaHistoryMiddlewareintoMIDDLEWAREimmediately afterAuthenticationMiddleware. It records the acting user, the request path, the request method, and the action kindrequest. Every event one request produces then shares one action identity while remaining a separate row. A project that builds its ownMIDDLEWARErather than taking the default getsvueda_history.W001when the middleware is missing andvueda_history.W002when it precedesAuthenticationMiddleware. Both are warnings: a request still records its events, but they carry no shared action or no user.vueda.core.audit.audited_actionprovides the same grouping where there is no request. VDQ tasks now run inside one, named for the task and recorded as kindtask. An action that names no kind keeps the kind of the action it runs inside and otherwise recordssystem. A write outside any action still records its event, with no action identity to associate it with the others. Wrap a management command, a background job, or a service operation that should read as one action inaudited_action("your.action"). Passkind="command"from a management command so the history API can tell it apart from a request or a task.
Object history is returned as the user actions behind it:
- The
history-listendpoint returned one django-simple-history record per entry, so a single request that changed several rows read as unrelated revisions. It now returns action groups. A group carries the events of one action, each event names its tracked model, object, event type, and field changes, and page metadata counts actions rather than events, so one action is never split across pages. A referenced row that no longer exists is marked absent rather than shown as a raw identifier, and the client supplies the wording. - Events the requester may not read are removed before pagination and leave no trace. Events on the requested object are visible because the endpoint authorizes it. An event on a related row follows that row: visible while the requester can read it through the ordinary object-permission path, and after deletion only when the model's visibility never depended on the row.
current_history_idis replaced byobject_revision, the object's newest event identifier, published by the ordinary serializer in the same form the response uses for events. Thecurrentaction is removed: a retrieve answers the same question. Only the object a request asks for carries a revision; an expanded object publishes none.VuedaHistoryViewSetandVuedaHistorySerializerare removed.VuedaViewSetcarries the history endpoint, and it offers no history action, model-info metadata, or route for a model that records none. Thehistory,first_history_entry, andlast_history_entryexpands go with the serializer, so a model's history is read from the endpoint and never embedded in the object. The history-awareUserSerializer,WhoIsSerializer, andWhoIsViewvariants invueda.historyare removed as well; the core who-is view publishesobject_revisionitself. ReplaceVuedaHistoryViewSetwithVuedaViewSet; the history endpoint comes with it. Readobject_revisionwhere you readcurrent_history_id, and compare it against an event's own identifier instead of calling the removedcurrentaction. ReplaceVuedaHistorySerializerwithVuedaSerializer, and drophistory,first_history_entry, andlast_history_entryfrom any expand request; thehistory-listendpoint is the one history read.
- The
Workflow responses publish an object-state revision:
- The workflow object-state and execute-transition responses replace
current_history_idwithobject_state_revision. The value is a string of the formvueda_workflow.ObjectState:<event id>, matching the identifiers the history endpoint publishes, so a client compares it against an event directly. - The object-state response previously reported the
ObjectStaterow's own primary key under that name, which is copied onto every simple-history revision and therefore never changed. A client comparing it could not detect a stale copy. The execute-transition response reported the revision correctly, so the two responses published different kinds of value under one name. Both now report the newest object-state event. - The field is named for the row it describes.
object_revisionon the ordinary serializer is the tracked object's own newest event; this is the newest event of that object'sObjectStaterow. Readobject_state_revisionwhere you readcurrent_history_idon either workflow response, and expect a string rather than an integer. A client that stored the old value as an object identifier was storing a primary key; compare the new value against an event identifier instead.
- The workflow object-state and execute-transition responses replace
Read-only serializer permission metadata:
- Model-info
model_permissionsnow exposes only the mappedlistandreadpermissions when a model's canonical serializer subclassesVuedaReadonlySerializer. Other permission rows remain in Django's permission table, and the change does not alter server authorization. If an integration treatedmodel_permissionsas a complete database permission inventory, account for the filtered read-only surface or query Django's permission model directly.
- Model-info
Filter choice empty options:
model_info_filter_choicesno longer prepends or returns empty-valued options. This removes the previous synthetic option driven byempty_label,empty_value,EMPTY_CHOICE_LABEL, andEMPTY_CHOICE_VALUE; it also omits blank values discovered by all-values filters. A missing filter query parameter now represents "no filter" in this endpoint's contract. Render any clear, all, or no-selection affordance in the client outside the server-provided choices list.
File and image field representation:
VuedaSerializernow mapsmodels.FileFieldandmodels.ImageFieldcolumns to VUEDA's serializer fields, which represent a stored file as{"name": ..., "url": ...}(with an absoluteurlwhen a request is in context) instead of DRF's plain URL string. This applies to any file or image column auto-built by a VUEDA serializer. Update client or integration code that read a bare URL string from these fields. The v3 client widgets (WidgetFile,WidgetImage) already consume the{name, url}shape. To keep the previous plain-string behavior on a specific field, declare a stockrest_framework.serializers.FileField/ImageFieldexplicitly on your serializer.
get_expandable_fields()renamed toget_expand_model_info():- The serializer override hook for customizing
model_expandsmetadata is renamed and its signature changed. It now receives the already-generated list of expand descriptors and must return a list in the same shape, instead of being called with no arguments and callingsuper().get_expandable_fields()to obtain the base list. It remains defined onVuedaExpandableFieldsSerializerMixin. Rename anyget_expandable_fields(self)override toget_expand_model_info(self, expands), drop thesuper()call, and operate on theexpandsparameter directly.
- The serializer override hook for customizing
VuedaValidationErrorno longer supportsis_warning:- The
is_warningconstructor argument and attribute, the_get_error_detailswarning branching, and theget_error_details_as_warninghelper are removed.vueda.core.logging_filters(theFilterOutVuedaValidationWarningslog filter andcontains_only_warningshelper) is deleted, along with itsLOGGINGfilter registration inget_defaults(). It is no longer possible to raise a warning-coded validation error that still returns 400 and blocks the write; useget_warnings()(see below) for advisory, confirm-before-write feedback instead. - Sentry capture in
debug_stack_exception_handlernow checksisinstance(exc, rest_framework.exceptions.ValidationError)directly instead ofcontains_only_warnings(exc). The captured set is unchanged: validation errors are still captured, and handled 404, permission, andConfirmationRequiredresponses are still not. No repository production code raisedis_warning=True; only a logging test fixture did. If a downstream project raised it, migrate toget_warnings(). If a downstream project importedvueda.core.logging_filtersor referenced theignore_validation_warningsLOGGINGfilter, remove that import and filter reference.
- The
valid_transitionsentries are objects, not code strings (AvailableTransitionField):AvailableTransitionFieldnow returns each entry as{"code": ..., "name": ...}instead of a bare transition-code string. Model info reports the field'stype_serializerasDictFieldinstead ofCharFieldaccordingly. This lets clients render a transition's display name without a second lookup, and is what allows the v3 client'suseDetailViewto read available transitions straight off the fetched object instead of issuing a separate per-object transitions request. Update any code readingvalid_transitionsentries as plain strings to read thecode(and optionallyname) key off each object instead.
VuedaValidationErrorno longer supportsis_warning:- The
is_warningconstructor argument and attribute, the_get_error_detailswarning branching, and theget_error_details_as_warninghelper are removed.vueda.core.logging_filters(theFilterOutVuedaValidationWarningslog filter andcontains_only_warningshelper) is deleted, along with itsLOGGINGfilter registration inget_defaults(). It is no longer possible to raise a warning-coded validation error that still returns 400 and blocks the write; useget_warnings()(see below) for advisory, confirm-before-write feedback instead. - Sentry capture in
debug_stack_exception_handlernow checksisinstance(exc, rest_framework.exceptions.ValidationError)directly instead ofcontains_only_warnings(exc). The captured set is unchanged: validation errors are still captured, and handled 404, permission, andConfirmationRequiredresponses are still not. No repository production code raisedis_warning=True; only a logging test fixture did. If a downstream project raised it, migrate toget_warnings(). If a downstream project importedvueda.core.logging_filtersor referenced theignore_validation_warningsLOGGINGfilter, remove that import and filter reference.
- The
Model-info default ordering (
model_ordering):model_orderingchanges from a flat array of{name, type}sortable fields to an object with two keys:default, the field names DRF actually orders by when no?o=param is given (the viewset's ownorderingwhen declared, otherwise the model'sMeta.ordering— one or the other, never a merge of both); andfields, the fields a client may explicitly order by. Readmodel_ordering.fieldswhere code previously readmodel_orderingdirectly, and readmodel_ordering.defaultto show or apply the default ordering instead of inferring it from the model or viewset.- Every field named in
defaultalso appears infields, carrying anascendingkey for its direction in the default ordering, even whenordering_fieldsdoesn't otherwise whitelist it — matchingVuedaOrderingFilter, which now accepts an explicit?o=request for any default-ordering field regardless ofordering_fields(see theVuedaOrderingFilterentry under Features). - When a viewset sets
ordering_fields = "__all__"(DRF's shorthand for allowing any model field),fieldslists the model's own fields instead of the literal string"__all__", along with the annotations the viewset's ownget_querysetadds. DRF accepts an explicit?o=request on either under"__all__", so both are advertised. An annotation is typed from its expression's output field, falling back toalphawhen Django won't resolve one; aformatted_nameannotation is typed from the column its lookup expression lands on instead. - When a viewset doesn't declare
ordering_fieldsat all, DRF defaults to allowing ordering on any readable field of the canonical serializer, resolved by each field'ssourcerather than its serializer name (so a renamed field is sortable under its underlying model field name, not its serializer name).fieldsnow reflects that same resolution, instead of staying empty. - A
"pk"ordering term — Django's alias for a model's primary key, valid in a model'sMeta.ordering, a viewset'sordering, and a viewset'sordering_fields— is reported as the field name(s) it stands for:idfor the usual auto-generated primary key, or every field aCompositePrimaryKeyis built from.defaultandfieldsnever contain the literal"pk", so every name a client receives is a real field name it may send back in?o=. formatted_nameis reported as an orderable field whenever the database can sort it: as its own generated-field column, or through theformatted_name_lookup_expressionannotation thatVuedaViewSet.get_querysetalready adds. Previously only a realformatted_namecolumn resolved, so a model using a lookup expression didn't advertise the field at all. Metadata reports the client-facing name (formatted_name), not the lookup expression behind it, so?o=formatted_nameworks either way. Aformatted_namecomputed by aget_formatted_name()method is still omitted, since it can only be sorted in Python (seevueda_info.E005under Features).defaultis reported as a whole or not at all. When any of its terms doesn't resolve to a real model field path (a field renamed or removed without its ordering being updated to match),defaultis empty instead of listing only the terms that do resolve, since a partial default ordering would describe an order the rows don't arrive in. A viewset's own unresolvableorderingdoesn't fall back to the model'sMeta.ordering.- A default
orderingterm built from a scalar database function is reported under the field it reads:Lower("name")is reported asname, withascendingtaken from the term's.desc()/.asc()(a bare expression sorts ascending). Thetypedescribes that field, not what the function returns, soLength("name")reportsnameasalpha. A client that requests such a field with?o=sorts by the column itself, not by the function over it — the same divergence an explicit request already has with a default that carries a nulls placement. - A term that reads more than one column, such as
Concat("first_name", "last_name"), has no single field name that stands for the sort it performs, sodefaultis empty for that ordering rather than naming both. Naming both would tell the client the rows arrive sorted by the first field and then the second, which is not what such an expression does. Each field the term reads still appears infields, without anascendingkey, because each is a valid explicit?o=target on its own — what is withheld is the claim about how the rows currently arrive, not the fields themselves. Declare the default as separate terms (ordering = ["first_name", "last_name"]) where the sort really is field-by-field and you want the default advertised, or give the expression a real column to sort — aGeneratedFieldwhere it reads only this row, a database view where it needs a join — and order by that column's name. Annotating the expression inget_querysetand ordering by the annotation's name makes the ordering work but does not get it advertised; see the next entry. - A
defaultordering term naming a queryset annotation is not advertised either:defaultcomes back empty for it, and the annotation reachesfieldsonly whenordering_fieldsalso covers it (named outright, or through"__all__"). The ordering still runs andVuedaOrderingFilterstill accepts?o=on that name — what is missing is the metadata describing it. Ordering metadata resolves each term to the model field behind it, and an annotation has no such field to read atypefrom; nor is there any declaration to inspect, since a queryset can pick up an annotation anywhere on its way to the metadata layer, including inside a manager.vueda_info.E006deliberately accepts annotations, so nothing reports this at startup. Give the sort a real database column when a client needs to see it — a model field, aGeneratedFieldwhere the value derives from the same row, or a database view (amanaged = Falsemodel related byOneToOneField) where it needs a join or an aggregate. Otherwise name the annotation inordering_fieldsas well, so it at least appears infieldsand a client can offer it as an explicit sort. model_orderingno longer reportsnulls_first/nulls_lastfor any field. Nulls placement for the?o=param is still controlled server-side (seenulls_ordering/nulls_ordering_flipunder Features); it just isn't advertised to clients as metadata.
FormattedNameManageris the default manager onFormattedNameBaseModel:FormattedNameBaseModel(and soVuedaModel,Lookup, and every model built on them) now declaresobjects = FormattedNameManager(). On a model that reaches its formatted name throughformatted_name_lookup_expressionand has noformatted_namecolumn, the manager annotates that expression asformatted_nameon every queryset the model builds — not just the onesVuedaViewSet.get_querysetbuilds. A model with the generated-field column, or one usingget_formatted_name(), gets no annotation and is unaffected. This is what makesformatted_nameusable outside a request:Model.objects.all()in a management command, the admin, a reverse relation, ordumpdatanow resolves and can order and filter by it. It is also what makes a model-levelordering = ["formatted_name"]safe (see the entry under Features), sinceMeta.orderingapplies to every queryset rather than only a viewset's.Model.objects.values()with no arguments includes annotations, so it now returns aformatted_namekey for such models. Name the columns explicitly (values("id", "label")) where that matters.A model that declares its ownobjectsshadows the default manager. Inherit fromFormattedNameManagerrather thanmodels.Manager(or pass it as the base toManager.from_queryset()) to keep the annotation. A manager declared on an abstract base shadows it just as readily, and is the easier case to miss, since the model naming the lookup expression can be several classes away from the one naming the manager.VUEDAUserManagerandSentItemManagersit on models that have no annotation to lose. The newvueda_info.E009check reports a model left without it.Model._base_manageris not this manager and carries noformatted_nameannotation. Django builds the base manager itself as a plainmodels.ManagerunlessMeta.base_manager_namenames one, deliberately — it is what fetches related objects, and a default manager may filter them out. Django doesn't use it when querying on a related model. Only a model whoseMeta.orderingnamesformatted_namehas to care: a base-manager queryset carrying that ordering has no annotation to sort and raisesFieldError. Anything reached throughget()is safe, sinceget()clears ordering — that coversrefresh_from_dband dereferencing a foreign key — and so areselect_relatedandprefetch_related, which order by nothing of the related model's own. What is not safe is a base-manager queryset evaluated as a whole, which is the related-object collection a cascade delete performs (Collector.related_objectshands back a plain_base_managerqueryset thatCollector.collectevaluates without clearing ordering).dumpdata --allreads the base manager but replaces the ordering with the primary key, so it is safe too.SetMeta.base_manager_name = "objects"on a model in that position to make Django use this manager for those paths too, or order by the lookup expression's own path instead of byformatted_name. The newvueda_core.E017check reports a model that did neither, so the configuration failsmanage.py checkrather than a cascade delete. It is a model check, so it reaches a model whether or not anything registered it, and it accepts any base manager whose queryset carries the annotation.
Unrecognized query parameters are rejected on every route:
NoExtraFieldsForViewSetMixin.retrievenow rejects any query parameter other than the flex-fields params (e,f,om) with a 400.NoExtraFieldsForViewSetMixin.listnow applies the same rejection to a viewset with nofilterset_class, accepting only pagination, ordering, search, and flex-fields params. Previously,retrieveaccepted any query parameter unconditionally on every viewset, andlistaccepted any query parameter unconditionally on a viewset with nofilterset_class; onlyliston a viewset with afilterset_classrejected unrecognized parameters.liston a viewset with afilterset_classis unchanged. Drop any query parameter sent to aretrieveendpoint that is note,f, orom. Drop any query parameter sent to alistendpoint without a filterset that is not a pagination, ordering, search, or flex-fields param (for example, a cache-busting parameter appended by a client or proxy).
Features
Workflow records history only through pghistory:
- The workflow state history endpoint reads object-state events instead of simple-history rows, and its entries change shape.
history_idbecomesid, an event identifier of the formvueda_workflow.ObjectState:<event id>;state__codebecomesstate;history_datebecomesrecorded_at; andhistory_userbecomesactor.history_change_reasonis gone, because pghistory records none. - The nine workflow models no longer record through django-simple-history, and
vueda_workflowmigration0009drops their nineHistorical*tables. Their events already carry the same history, recorded by the triggers rather than by signals. - A record naming a deleted referent, such as a
WorkflowPermissionwhose workflow is gone, rebuilds it from its last recorded event throughvueda.history.snapshots.last_recorded.check_transitionreads the acting user from the action history middleware records, rather than from simple-history's request context. The workflow permission picker and the content-type list no longer test for simple-history models, because none exist. Runupdateworkflowmigrationsfor each app with a generated workflow migration before applying0009. A migration generated by an earlier version still refers to theHistorical*models, and fails once those tables are gone.
- The workflow state history endpoint reads object-state events instead of simple-history rows, and its entries change shape.
Generated workflow migrations no longer write history by hand:
- A generated workflow migration built its own
Historical*rows, because a migration fires none of the signals django-simple-history recorded through. The triggers record whatever a migration writes, so the eighthandle_*functions andadd_history_to_dataare gone from the generated source, along with the field bookkeeping that only existed to fill those rows. manage_state_objectsdecides whether an object was moved by reading the object state's events rather than counting history rows, and takes the object-state event model where it took the historical one. A workflow with no initial state is now skipped instead of raising. Runupdateworkflowmigrationsfor each app with a generated workflow migration, so its copy of these functions matches. A migration left un-updated still applies and still writesHistorical*rows, which nothing reads.
- A generated workflow migration built its own
Generated workflow migrations record the events they write:
- A generated workflow migration now wraps its writes in one action, so every workflow row it touches records an event grouped under that action. The action is labelled with the migration that wrote it, carries the kind
migration, and is attributed to the system user, which is what separates a migration's writes from a person's edit when the history is read back. The migration depends on the newestvueda_workflowmigration, so the triggers that record those events are always installed before it runs. makeworkflowmigrationswritesworkflow_migration_actionalongside the forward and reverse functions, andupdateworkflowmigrationsinserts it into a migration generated by an earlier version. Runupdateworkflowmigrationsfor each app with a generated workflow migration. A migration left un-updated still applies, but its writes record events with no action behind them.
- A generated workflow migration now wraps its writes in one action, so every workflow row it touches records an event grouped under that action. The action is labelled with the migration that wrote it, carries the kind
Ordered feature contributions:
FeatureSectiongainscontribute_order, which sequences contributors lowest first with the section name breaking a tie. A feature that reads a model's finished field list now runs after every feature that adds one. History uses this so an event model includes fields another feature contributed. No integrator action unless a project registers its own feature section. Setcontribute_orderwhen the order in which a contributor sees the model matters.
Optional workflow app boundary:
- Applications may omit
vueda.workflowandvueda.vdqtogether. Model-info, ordinary CRUDL routes, history URL imports, and schema setup no longer import workflow models when workflow is absent.vueda.vdqstill requiresvueda.workflowand now fails early with a clear configuration error if installed without it. Guardinclude("vueda.workflow.urls")andinclude("vueda.vdq.urls")behind installed-app checks, or rely on the app URL modules returning no routes when the apps are absent.
- Applications may omit
class Vuedamodel feature policy:- A VUEDA model now declares which framework features it participates in through one nested
class Vueda, with a section per feature (class History,class Workflow).Metastays limited to Django's own model options. Read the resolved policy withvueda.core.options.get_vueda_options(model), which returns every registered section with its defaults, inherited values, and overrides applied. Declarations on abstract and concrete bases reach concrete subclasses, each multi-table child resolves its own overrides, and a proxy takes the policy of its concrete model. - Feature apps register their own sections through
vueda.core.features.register_feature_section(), supplying each option's default, accepted types, validation, inheritance behaviour, and whether it affects migration generation. A section may also contribute fields or behaviour to each enabled model as Django prepares it. Invalid sections are reported by system checks before their contributors run.vueda.historyandvueda.workflowregister theHistoryandWorkflowsections; history defaults on for eligible models, and workflow is opt-in. An explicitHistory.exclude_fieldsreplaces the inherited value; extend it by including the parent entries explicitly. - New system checks (
vueda_core.E010-vueda_core.E016) report an unknown section, a section whose feature app is not installed, an unknown option, an invalid value, an option written outside a section, a declaration on a proxy, and a declaration on a model that is not a VUEDA model. History derives from this policy: every eligibleVuedaModelsubclass is tracked unless itsHistorysection says otherwise. Workflow still followsHasWorkflowModelMixininheritance, so an explicitWorkflow.enabledthat disagrees with a model's base classes is a check error rather than a setting that is accepted and ignored. See Model Feature Policy.
- A VUEDA model now declares which framework features it participates in through one nested
expandable_fieldssystem check:- A new Django system check (
vueda_core.E001-E006) validates each serializer'sMeta.expandable_fieldsatmanage.py checktime. It flags list values (flex-fields only supports tuples), malformed(serializer, options)tuples, a tuple whose second element is not an options dict, serializer strings that fail to resolve, values that are not a serializer class, tuple, or serializer string, and values that resolve to a class which is not aSerializer/Fieldsubclass — catching misconfiguration at startup instead of on first request.
- A new Django system check (
formatted_nameordering system check:- A new Django system check (
vueda_info.E005) reports ordering declared on aformatted_namethat aget_formatted_name()method computes in Python. It covers a model'sMeta.orderingand a registered viewset'sorderingandordering_fields, and names which of those declared it. Such aformatted_namehas no column or annotation to sort by, so DRF'slistrequest would fail with aFieldError(or, forordering_fields, silently ignore the client's?o=); the check reports it atmanage.py checktime instead. Give the model aformatted_name_lookup_expressionif the value needs to be sortable.
- A new Django system check (
formatted_namein a model'sMeta.ordering:- A model that reaches its formatted name through
formatted_name_lookup_expressioncan now declareordering = ["formatted_name"]in its ownMeta, written the same way as on a viewset or on a model with a realformatted_namecolumn. Such a model has no column of that name, so Django'smodels.E015check previously reported the declaration as a nonexistent field even though the ordering is valid at query time —FormattedNameManagerannotates the lookup expression under that name before the query is compiled.FormattedNameBaseModel._check_orderingnow withholds that one term frommodels.E015on models that declare a lookup expression.Meta.orderingapplies to every queryset, not only a viewset's, so this depends on the annotation being on the model's default manager (seeFormattedNameManagerunder Breaking Changes). A model that replaces its default manager without inheritingFormattedNameManagerpasses the checks and then raisesFieldErrorwhen a queryset from that manager is evaluated.Only that term, and only on those models. Every other term in the sameMeta.orderingis still checked, so a stale field name is still reported; so is aformatted_namereached across a relation (owner__formatted_name), which no annotation covers, and one computed by aget_formatted_name()method, whichvueda_info.E005explains. Third-party models patched forformatted_namesupport outsideVuedaModelneed_check_orderingassigned along with_has_formatted_name_fieldand_get_formatted_name, and aFormattedNameManageras their default manager, to get the same treatment.AnF("formatted_name").asc()ordering expression, whichmodels.E015skips regardless, remains valid and is still the way to declare nulls placement on a model default.
- A model that reaches its formatted name through
Queryset ordering system check:
- A new Django system check (
vueda_info.E010) reports a registered viewset whose class-levelquerysetcarries anorder_by()that its declarations don't describe. DRF'sOrderingFilterreads a view'sorderingattribute and nothing else, so with none declared it applies no ordering and the queryset's own survives to the response — whilemodel_ordering.defaultreports the model'sMeta.ordering, or nothing at all when the model declares none. The rows and the metadata then disagree with nothing failing, which is the case this check exists to surface. - Three shapes get three messages, because the fix differs: no viewset
orderingand aMeta.orderingthat disagrees (declare the queryset's order asordering, or drop theorder_by()and accept the model's order, which reverses the list); no ordering declared anywhere (declare it, sodefaultstops reporting no ordering for a sorted list); and a viewsetorderingthat disagrees, where the metadata is accurate and the queryset's ordering reaches nothing while reading as if it set the list's order. - Direction is part of the comparison, since
ordering = ["queued"]against a queryset ordered by-queuednames the same field and reverses every row. A"pk"alias and the field behind it compare equal, as do aformatted_nameand the column itsformatted_name_lookup_expressionnames, so one sort spelled two valid ways isn't reported. The check reads thequerysetclass attribute and never callsget_queryset(), so an ordering applied insideget_queryset, in a manager, or conditionally per request is not covered, and neither is a viewset whosefilter_backendsomits the ordering backend or a bareorder_by()that clears ordering entirely. Those leave the same mismatch with nothing to report it; see Queryset Ordering.
- A new Django system check (
Viewset ordering system check:
- A new Django system check (
vueda_info.E006) validates each registered viewset'sorderingandordering_fieldsagainst its model atmanage.py checktime, reporting any term that names no field, related field, lookup, or queryset annotation. Django's ownmodels.E015already covers a model'sMeta.ordering; nothing covered the same drift on a viewset, where a staleorderingfails everylistrequest that doesn't override it with?o=, and a staleordering_fieldsentry fails nothing at all — model-info metadata just leaves it out ofmodel_ordering.fields, so it can sit misconfigured indefinitely with no error. - Terms are resolved the way the metadata resolves them, so a
"pk"alias, aformatted_namereached throughformatted_name_lookup_expression,"?"(Django's random ordering), and an annotation added by the viewset's ownget_querysetare all accepted.ordering_fields = "__all__"is skipped, since it expands to the model's own fields. Ordering declared on a method-backedformatted_nameis left tovueda_info.E005, so it is reported once rather than twice.
- A new Django system check (
formatted_name_lookup_expressionpath system check:- A new Django system check (
vueda_info.E008) reports aformatted_name_lookup_expressionthat reaches through a relation which can match more than one row — a reverse foreign key, a many-to-many, or aGenericRelation. VUEDA annotates the expression asformatted_nameon every queryset the model builds, so such a path joins a row per related object: the model returns more rows than its table holds, everywhere, with nothing raising to trace it back to the declaration. Single-valued relations are unaffected at any depth, nullable or not — a forward foreign key produces aLEFT OUTER JOINthat still matches at most one row. Point the expression at a column on the model, or at one reached through forward foreign keys and one-to-ones. Where the value needs a join or an aggregate, model it as a database view (amanaged = Falsemodel related byOneToOneField) and point the expression at a real column on that view.This is the same rule that already governed ordering and filtering by a related model'sformatted_name, which refuses a multi-valued path rather than rewriting it. Both now read it from one place, so a path one refuses is a path the other refuses — including the half the request-time rule can't see, where the prefix is single-valued but the related model's own lookup expression is not.
- A new Django system check (
formatted_namedefault manager system check:- A new Django system check (
vueda_info.E009) reports a model that reaches its formatted name throughformatted_name_lookup_expressionbut whose default manager isn't aFormattedNameManager. Django takes the first manager inMeta.managersorder as the default, so a model (or an abstract base it inherits) declaring its ownobjectsshadows the oneFormattedNameBaseModelprovides and silently loses the annotation:formatted_namethen resolves only on querysetsVuedaViewSet.get_querysetbuilds. - It pairs with the
models.E015suppression, which asks the same question for itself:FormattedNameBaseModel._check_orderingwithholds aformatted_nameterm from Django's own check only when the model's default manager will actually annotate it, so a model without that manager keeps Django's error on aMeta.orderingnamingformatted_namewhether or not it is registered. E009 reports the manager itself, for a registered model, with a hint aimed at fixing it. Filtering and ordering byformatted_nameoutside a request break the same way and are reported by E009 alone. SubclassFormattedNameManagerinstead ofmodels.Manager, pass it toManager.from_queryset()as the base, or pointMeta.default_manager_nameat a manager that already does.
- A new Django system check (
Nulls ordering system check:
- A new Django system check (
vueda_info.E007) validates each registered viewset'snulls_orderingandnulls_ordering_flipatmanage.py checktime. It reports a placement outside"first"/"last"(the only two values that have anulls_first/nulls_lastkeyword to become), anulls_orderingthat isn't a dict, anulls_ordering_flipthat isn't a list of field names, and a field listed innulls_ordering_flipthatnulls_orderinggives no placement to flip. - Both attributes are validated on every run, and every problem found in either is reported. They are separate declarations that fail independently, so reporting only the first would make the reader fix it, re-run
manage.py check, and discover the second. An unusablenulls_orderingis read as giving no field a placement, which is what it does, so eachnulls_ordering_flipentry is then reported as having nothing to flip; all of them clear with the one fix that caused them. VuedaOrderingFilternow ignores an unusable placement instead of raising, so alistrequest returns rows in the database's default nulls order rather than failing with aKeyErrororTypeError. The check is what surfaces the declaration, since nothing else would. A viewset whoseget_querysetneeds a request to build its queryset is skipped rather than reported, since its annotations can't be known at check time.
- A new Django system check (
formatted_namethrough a relation:- A viewset's
ordering, itsordering_fields, a client's?o=, and a filter'sfield_namemay now name a related model'sformatted_name(customer__formatted_name) when that model reaches the value throughformatted_name_lookup_expression.VuedaOrderingFilterrewrites the path to the column behind it (customer__data__formatted_name) before the query runs, and the newFormattedNamePathFilterSetMixindoes the same for a filter'sfield_name, so the database sorts or filters a real column while the client sends, and is told, the declared name. Paths of any depth work (cart__customer__formatted_name), and the rewrite is server-side only:customer__data__formatted_nameis not a query parameter thelistnamespace check accepts. Following a relation is what makes the rewrite necessary. The annotationVuedaViewSet.get_querysetadds belongs to the queryset being ordered or filtered, not to the tables it joins, so the related path raisedFieldErrorbefore this.VuedaFilterSetandVuedaCompositePrimaryKeyFilterSetboth includeFormattedNamePathFilterSetMixin, so a filterset on either base gets the rewrite with no change. A filterset built on django-filter'sFilterSetdirectly does not: mixFormattedNamePathFilterSetMixinin ahead of theFilterSetbase to declare a filter on a relatedformatted_name. Nothing reports a missing mixin at startup — the system checks validate a viewset's ordering declarations, not a filterset'sfield_names — so such a filter raisesFieldErroron the requests that send it.Two shapes are deliberately not rewritten, and are left out ofmodel_ordering/model_filteringand reported byvueda_info.E006instead. Aformatted_namethe related model computes with aget_formatted_name()method has no column behind it at any depth. A path reaching the related model through a multi-valued relation — a reverse foreign key, a many-to-many — would join a row per related object and silently multiply the rows alistrequest returns.A model's ownMeta.orderingis not covered. It applies to every queryset, including the ones no backend touches, so Django'smodels.E015still rejects a relatedformatted_namedeclared there. Only the model's own un-prefixedformatted_nameis withheld from that check.
- A viewset's
Read-only relation metadata (model info):
- Model-info field metadata now includes
app_labelandmodelfor read-only foreign-key and many-relation serializer fields when the related model can be resolved. This lets clients build relation-aware list columns without per-column fallback configuration, while still leavingchoicesdisabled for read-only relation fields.
- Model-info field metadata now includes
Display-only field labels (model info):
VuedaExpandableFieldsSerializerMixinandVuedaSerializernow supportfield_display_choices, a serializer-level mapping for display-only value labels. Model-info field metadata emits these labels asdisplay_choices, separate from editablechoices, so a boolean field can keep its toggle behavior while read-only views display labels such asSubmitted,-, orUnknown. Usechoicesfor validation and editable choice widgets. Usefield_display_choiceswhen only read-only display needs custom labels.
Submit-time warning confirmation (
get_warnings):VuedaSerializergained a non-raisingget_warnings()hook. Override it to return advisory warnings as{field: [messages], "non_field_errors": [messages]}. It is called after validation succeeds, soself.validated_dataand (on update)self.instanceare available.- When
get_warnings()returns warnings,VuedaViewSetwithholds the create/update and responds409 Conflictwith{"confirmation_required": true, "digest": ..., "warnings": {...}}instead of saving. Resubmitting with theAcknowledge-Warningsrequest header set to thatdigestlets the write proceed. A changed warning set yields a different digest and re-prompts. Blocking errors (VuedaValidationError) are unaffected and still return 400 before warnings are evaluated. This is the recommended way to surface non-blocking, must-confirm concerns. Theacknowledge-warningsheader is added to the defaultCORS_ALLOW_HEADERS.
Warning confirmation for destroy, activate, deactivate, and custom actions:
WarningConfirmationMixin(and thereforeVuedaViewSet) gained two viewset-level hooks for writes that have no per-object serializer:get_warnings_for_object(action, obj)for a single object, andget_warnings(action, objs)for a bulk request.actionis the action name ("destroy","activate", or"deactivate") for both. Overrideget_warnings_for_objectto return the aggregate{field: [messages]}shape forobj; the defaultget_warningscalls it once per instance inobjs(a queryset) and keys each non-empty result bystr(pk), building the per-object{object_id: {field: [messages]}}shape — so overridingget_warnings_for_objectalone gates both the single-object and bulk forms ofactionwith the same rule. Overrideget_warningsitself instead only when bulk needs different logic. Single-object and bulk variants are both gated: when the resolved warnings are non-empty and the request has not acknowledged them, the viewset responds409 Conflictwith{"confirmation_required": true, "digest": ..., "warnings": {...}}before anything is written, the same contract as the create/update gate. Both hooks default to{}, so no confirmation is required unless you override one of them. Bulk gating is all-or-nothing: a 409 blocks the whole batch, and confirming runs all of it. A targetless custom action with no natural object at all has nothing for this hook pair to key by; it continues to callgate_warningsdirectly from the action body, as custom actions already do.- Added
vueda.core.exceptions.gate_warnings(request, warnings), the standalone gate that all warning-gated paths route through. Call it from a custom action body afterserializer.is_valid(raise_exception=True)(so blocking 400s surface before the 409) and before any write or side effect; it raisesConfirmationRequiredunless theAcknowledge-Warningsrequest header matches the warnings digest.ACKNOWLEDGE_WARNINGS_HEADERmoved tovueda.core.exceptionsand is re-exported fromvueda.core.decorators, so existing imports keep working. - The
@actiondecorator gainedconfirm=True, which declares an always-on consequence warning: the first unacknowledged mutating request returns 409 without executing the body, and resubmitting with the digest acknowledged runs it. The message comes from aconfirm_messageattribute set on the action function after its definition (my_action.confirm_message = "..."), falling back to "This action requires confirmation." Because this gate runs before the body, it suits input-less consequence actions; actions with input should callgate_warningsexplicitly after validation so 400s precede the 409. Warnings must be computable before the write, from the request input plus current database state; conditions discoverable only by performing the write are errors that abort the transaction, not warnings. Bulk/list-serializer create and update saves remain ungated.
Warning confirmation for workflow transitions (
get_transition_warnings):HasWorkflowModelMixingained aget_transition_warnings(transition, user=None)hook besideallow_transition. Override it to return advisory warnings in the aggregate{field: [messages]}shape; the default returns{}, so no confirmation is required unless you override it.WorkflowViewSet.execute_transitionnow evaluatesget_transition_warningsbefore any write, for both the single-object and bulk (object_ids) forms, and gates through the samegate_warnings/Acknowledge-Warningscontract as create, update, destroy, activate, and deactivate: an unacknowledged warning set responds409 Conflictwith{"confirmation_required": true, "digest": ..., "warnings": {...}}before the transition is applied, and resubmitting with the digest lets it proceed. A changed warning set yields a different digest and re-prompts. Thewarningsshape differs by request form: single-object is the aggregate{field: [messages]}mapping; bulk is{object_id: {field: [messages]}}, keeping each instance's own warnings mapping nested under its object id. Bulk transitions collect warnings across every instance in the batch and gate once with one digest over that combined mapping; the write remains all-or-nothing.HasWorkflowModelMixin.apply_transitionis unchanged in behavior but is now composed from two new public methods:check_transition(transition_code, user=None)validates permission and availability and returns(transition, resolved_user)without writing, andapply_checked_transition(transition, user=None, dry_run=False)performs the write for an already-checked transition. Transition authorization and error behavior (permission checks,InvalidTransitionError, locking, dry-run) are unchanged. Overrideget_transition_warningson models usingHasWorkflowModelMixinto gate a transition behind confirmation; no action is required otherwise.
ImageFieldserializer field:- Added
vueda.core.fields.serializers.ImageField, the image counterpart to the existingFileField. It shares the{"name", "url"}representation and subclassesFileFieldrather than DRF'sImageField, so it does not require Pillow; image content validation is left to the model field and upload pipeline.
- Added
updategroupmigrationsmanagement command:- Added a new management command that scans all installed apps for group migrations created by
makegroupmigrationsand rewrites their import and function sections with the current implementations frommakegroupmigrations.py. - The
changed_datavariable and theclass Migrationblock are preserved; only the embedded function bodies and imports are updated. - Accepts a
--dry-runflag to preview which files would be changed without writing anything. - Run this command after any VUEDA upgrade that changes the function implementations in
makegroupmigrations.py.
- Added a new management command that scans all installed apps for group migrations created by
updateworkflowmigrationsmanagement command:- Added a new management command that scans all installed apps for workflow migrations created by
makeworkflowmigrationsand rewrites their import and function sections with the current implementations frommakeworkflowmigrations.py. - The recorded change data (
changed_data,history_change_reason,migration_app_label) and theclass Migrationblock are preserved; only the embedded function bodies, imports, and any stale function names inoperationsare updated. - Accepts an optional
app_labelargument to limit the update to a specific app, and a--dry-runflag to preview which files would be changed without writing anything. - Run this command after any VUEDA upgrade that changes the function implementations in
makeworkflowmigrations.py.
- Added a new management command that scans all installed apps for workflow migrations created by
GenericForeignKeySerializer:- Added
GenericForeignKeySerializertovueda.core.serializersfor declaringGenericForeignKeyexpandable fields. Declare it inexpandable_fieldsusing theGenericForeignKeyfield name as the key. The serializer resolves the concrete related model's canonical registered serializer at representation time viaget_serializer_for_model, so every model that can appear through the generic foreign key must be registered viaregisterorregister_serializer. - Generic foreign key expands are always read-only. Model-info metadata for these expands reports
type_model: "GenericForeignKey",type_serializer: "GenericForeignKeySerializer", andtype_db: null. FIELDS_PARAMandOMIT_PARAMentries in theexpandable_fieldsoptions now support model-targeted specifiers of the form_<app_label>__<model_name>__<field_name>. Specifiers matching the concrete type of the related object are resolved to their bare field name before the concrete serializer is instantiated; specifiers targeting a different model are silently dropped. Plain field names and wildcards continue to apply to every related model type.
- Added
get_serializer_for_model:- Added
get_serializer_for_modelto the public API ofvueda.info.registration. Returns the canonical serializer class registered for a given model by looking up the in-process registry directly, without a database query. ReturnsNoneif the model is not registered. Use this when you need the registered serializer class for a model and want to avoid theContentTypelookup required byget_registration.
- Added
Django built-in model
formatted_namesupport:InfoConfig.ready()now patches Django'sGroup,Permission, andContentTypemodels with the_has_formatted_name_field,_get_formatted_name, andformatted_name_lookup_expression(orget_formatted_name) attributes that VUEDA's viewset and serializer layers require.GroupandPermissionusenameas their display field;ContentTypeusesapp_labeled_name. All three can now be used as expandable fields without any application-level configuration.
get_field_model_info()hook (model info):VuedaExpandableFieldsSerializerMixin(included inVuedaSerializer) gained aget_field_model_info(fields)hook for customizingmodel_fieldsmetadata. It receives the generated field metadata dict, keyed by field name, and must return a dict in the same shape. Override it to correct the generated metadata for aSerializerMethodField, which has no model column or fixed field type to derive metadata from automatically. The default implementation appliesfield_display_choicesand returnsfields.- For
VuedaHistorySerializersubclasses, this hook is also applied to the same fields as they appear embedded in thehistory,first_history_entry, andlast_history_entrymodel_expandsdescriptors, since those embed the root model's own fields. A single override corrects the field's metadata everywhere it's reported. - An expand's nested serializer (the class declared in
Meta.expandable_fields) now also has its ownget_field_model_infoapplied to the field metadata embedded in that expand's descriptor, the same way it is applied to the nested serializer's ownmodel_fieldswhen it is used as a root canonical serializer. Previously, only the root serializer'sget_field_model_inforan; a nested serializer's correction of one of its own fields did not carry over when that serializer appeared as someone else's expand.
OpenAPI schema reuses
model_expands/model_fieldsgeneration:get_schema_expandable_fields()(drives theexpandquery parameter's documented values) now callsgenerate_expand_model_info()and runs the result throughget_expand_model_info(), the same generation and customization hook the/info/meta-API uses formodel_expands, instead of its own separate (and more limited) traversal ofMeta.expandable_fields. A serializer that already overridesget_expand_model_infoto describe aSerializerMethodField-backed expand no longer needs a second, schema-specific override for that.- Added
get_schema_fields(), which builds this serializer's own field metadata for its OpenAPI schema the same waymodel_fieldsis built for/info/(viaModelInfoSerializer.get_model_fields_dataandget_field_model_info), and now documents thefieldsquery parameter's valid values in the schema, which was previously undocumented. - Both methods reduce the generated metadata to schema-relevant keys (
label,type,required,choices), dropping the database/model type detail (type_db/type_model, withtype_serializerrenamed totype), themany/read_onlyflags, thehiddenflag, help text, and constraint bookkeeping (max_value,min_value,max_length,min_length,max_digits,decimal_places,pk) that/info/also reports but the schema does not need. These are dropped from an expand descriptor itself as well as from its nested fields, so an expand's ownmany/read_onlyflags don't appear in the schema either. Remove any customget_schema_expandable_fieldsoverride that duplicatedget_expand_model_infologic just to describe aSerializerMethodFieldexpand for schema purposes; the base implementation now covers it automatically.
Django 6.0 and 6.1 support:
- The server package now accepts Django 6.0 and 6.1 in addition to 5.2 (
django>=5.2.14,<6.2).Model.save()overrides invueda.core,vueda.vdq, andvueda.workflowwere updated for Django 6.0's keyword-onlysave()signature, and the removeddjango.utils.itercompatimport was replaced with a standard-librarycollections.abc.Iterablecheck. Django 6.0 and 6.1 both require Python 3.12+; installations on Python 3.11 continue to resolve Django 5.2 viauv.lock. Pindjango<6in your own application if you need to stay on Django 5.2 while running Python 3.12 or newer, or pindjango<6.1if you need to stay on Django 6.0. - The
dj-rest-authconstraint was also raised (dj-rest-auth>=7.0.0,<8.0) to bring in adj-rest-authrelease that supports Django 6.0.dj-rest-authitself declares support fordjango>=4.2with no upper bound, so no action is required in your own application regardless of which supported Django version you run.
- The server package now accepts Django 6.0 and 6.1 in addition to 5.2 (
MAILERSsupport (Django 6.1+):get_defaults()gained ause_mailerskeyword argument. It defaults toFalse, which keeps configuring the deprecatedEMAIL_BACKENDandEMAIL_TIMEOUTsettings; passuse_mailers=Trueto configure Django 6.1'sMAILERSsetting instead (MAILERS = {"default": {"BACKEND": ..., "OPTIONS": {"timeout": 5}}}), built from the sameEMAIL_BACKENDenvironment/config value.EMAIL_BACKENDcontinues to work unchanged on Django 6.1, so no action is required until you choose to opt in. See Django's MAILERS migration guide before passinguse_mailers=True, and confirm any third-party packages your project relies on (for exampledjango-anymail) supportMAILERSfirst.use_mailers=Truenow raisesImproperlyConfiguredon Django < 6.1 instead of returning aMAILERSsetting those versions silently ignore (which left them running the default SMTP backend instead of the configured one). Only passuse_mailers=Trueon Django 6.1+.
VuedaOrderingFilter:DEFAULT_FILTER_BACKENDSnow usesvueda.core.filters.VuedaOrderingFilterin place of DRF's stockrest_framework.filters.OrderingFilter. Declarenulls_orderingon a viewset as a dict of field name to"first"or"last"to give an explicit?o=request on that field the same nulls placement aF(...).asc(nulls_first=True)-style defaultorderingalready applies, instead of falling back to the database's default nulls placement the moment a client requests that field by name.- List a field name in
nulls_ordering_flipto have its declared nulls placement flip (firstbecomeslastand vice versa) when the field is sorted descending, instead of keeping the same placement regardless of sort direction. - A declared nulls placement applies wherever the field is sorted by name — an explicit
?o=request, and equally a defaultorderingwritten as plain strings (ordering = ["due_date"]), since DRF hands a string default to the backend as a string too. A default term written as an expression is left alone, because it already states its own placement or deliberately states none. Sonulls_orderingstates the placement once for both routes, whereordering = [F("due_date").asc(nulls_first=True)]states it for the default and still loses it on?o=due_date. Only"first"and"last"are usable; they become thenulls_first/nulls_lastkeyword ofF().asc()/F().desc(). Any other value is ignored at request time rather than failing the request, andvueda_info.E007reports the declaration. - A field named in the viewset's default
ordering(or the model'sMeta.orderingwhen the viewset doesn't declare one) is now always a valid explicit?o=target, even whenordering_fieldsdoesn't whitelist it. Previously, an explicit request for a default-only field was silently ignored and fell back to the default ordering. - A
"pk"term in the default ordering contributes both spellings to the valid?o=set: the alias itself, and the field name(s) behind it (id, or every column of aCompositePrimaryKey). The expanded name is whatmodel_orderingadvertises, so a metadata-driven client sending?o=idand a reader of the viewset's source sending?o=pkboth get primary-key order. Previously only the literal"pk"was added to the valid set, so a viewset combiningordering = ["pk"]with anordering_fieldsthat didn't nameidadvertisedidand then silently ignored?o=id. - A default
orderingterm may now be any expressionorder_by()accepts, not only a plain field name or anF(...).asc()/.desc(). A scalar database function works —Lower("name"),Coalesce("nickname", Value("")),Concat("first_name", "last_name"),TruncDate("created")— with or without an.asc()/.desc()wrapper (a bare expression sorts ascending). Every field a term references becomes a valid explicit?o=target, so a term reading two columns offers both. Aformatted_namepath inside such a term is rewritten to the column behind it in place, soLower("customer__formatted_name")still applies theLower. Previously any ordering expression other than a plainFraisedNotImplementedErrorfromVuedaOrderingFilterand from the model-info serializer, so aLower(...)default ordering failed everylistrequest and everymodel_orderingrequest for that model.VuedaOrderingFilteris a drop-in subclass of DRF'sOrderingFilter. Existing viewsets that don't declarenulls_orderingsee no change to nulls placement, but every viewset automatically gains the default-field-ordering behavior described above.
Automatic
select_related/prefetch_relatedfor expanded fields:VuedaViewSet.get_queryset()now derivesselect_related/prefetch_relatedfrom the fields alistorretrieverequest's?e=(expand) actually resolves, so an expanded list response no longer issues one extra query per expanded relation per row.?f=(sparse fields) and?om=(omit) do not change what gets planned: an expand named in?e=already renders regardless of either, so the plan follows?e=alone. The plan follows an expanded field'ssource(a dottedsource=in anexpandable_fieldsdeclaration resolves through the named relation chain, not the serializer field's own name), and recurses to the same depth the request's expansion already validated.- A
GenericForeignKeyexpand (GenericForeignKeySerializer) is not planned, since it resolves its concrete serializer per-instance at representation time, after queryset planning could run; it continues to resolve lazily as before. A reverseGenericRelationonto a fixed, known model is a normal to-many relation and is planned like any other. No action is required to adopt this; it changes query counts, not response shapes. A viewset that already applies its ownselect_related/prefetch_relatedfor an expanded relation keeps serving that relation from its own lookup; the derived plan detects the overlap and defers to it rather than adding a second lookup for the same path.
formatted_name_select_related:- A model resolving
formatted_namethroughget_formatted_name()may now declareformatted_name_select_relatedas a tuple of relation paths — the same paths it would pass toqueryset.select_related()itself.annotate_formatted_name(), the helperFormattedNameManager.get_queryset,VuedaViewSet.get_queryset,VuedaListSerializer.to_representation, the expand prefetch-plan builder, and the/info/field-choices and filter-choices resolvers (ModelInfoChoicesViewSet,ModelInfoFilterSetChoicesViewSet) all share, now applies thisselect_relatedwherever it applies aformatted_name_lookup_expressionannotation. Previously, resolvingformatted_namein bulk for a model whoseget_formatted_name()traverses a relation (self.customer.user.email, say) cost one extra query per row per relation traversed at every one of those call sites, since nothing prepared the relation ahead of time. Declareformatted_name_select_relatedon a model whoseget_formatted_name()reaches through a relation, naming every relation path it reads. A model with noformatted_name_select_relatedbehaves exactly as before. - Added the
vueda_info.E011system-check error, reported bymanage.py check, when a model declares bothformatted_name_lookup_expressionandformatted_name_select_related. The latter only has an effect alongsideget_formatted_name(); a lookup expression resolvesformatted_nameentirely through a database annotation, so there is no per-instance computation forselect_relatedto prepare relations for.
- A model resolving
Fixes
OpenAPI validation:
- Generated schemas describe bulk and single-object transition execution as separate paths. The bulk path no longer declares an optional
object_idpath parameter, which made the document invalid; the single-object path declares the required identifier. Handwritten model-info and workflow response schemas use the validreadOnlykeyword. Runtime URLs and behavior are unchanged.
- Generated schemas describe bulk and single-object transition execution as separate paths. The bulk path no longer declares an optional
ordering_fields = Nonein model-info metadata:- A viewset that spells out
ordering_fields = Noneno longer turns everymodel_inforequest for its model into a 500.NoneisOrderingFilter's own class default and DRF reads it as "not declared" —get_valid_fieldsfalls through toget_default_valid_fields— somodel_ordering.fieldsnow reports the serializer-derived fields, the same as for a viewset that omits the attribute. Previously the metadata treated the attribute as present and tried to iterateNone, raisingTypeError.ordering_fields = []remains the declaration that offers nothing, subject to the default-ordering fieldsVuedaOrderingFilteralways accepts.
- A viewset that spells out
Queryset annotations named in
ordering_fields:- An annotation added by the viewset's own
get_querysetand named outright inordering_fields(rather than reached throughordering_fields = "__all__") now appears inmodel_ordering.fields, typed from the annotation'soutput_field. Previously it resolved to no model field path and was dropped from the metadata, leaving a field DRF accepts a?o=request for that no metadata-driven client was ever offered.
- An annotation added by the viewset's own
A
"pk"alias named inordering_fields:VuedaOrderingFilternow expands a"pk"entry inordering_fieldsto the field name(s) behind it, the same way it already did for a"pk"term in the defaultordering. Metadata expands the alias wherever it is declared, soordering_fields = ["pk"]advertisedidinmodel_ordering.fieldsand then silently ignored?o=id, dropping the request back to the default ordering with no error — a name every metadata-driven client was offered and none could use. Both spellings are now accepted, as they already were for a default-ordering"pk".
ordering_fieldsentries that aren't(name, label)pairs:VuedaOrderingFilternow reads anordering_fieldsentry positionally, the way DRF's ownremove_invalid_fieldsdoes, instead of unpacking it into exactly two values. DRF passes an entry through unchanged when it isn't a plain string, so an entry carrying more than a name and a label is DRF's to tolerate; unpacking it raisedValueErrorand failed everylistrequest that sent?o=.
ordering_fieldsentries written as(field_name, label)pairs:- An entry declared in DRF's pair form now appears in
model_ordering.fields, under the same name and type it would carry as a plain string. DRF offers?o=the same field either way — a pair's label captions its own browsable-API control and nothing else — so a pair was previously a field the server accepted and no metadata-driven client was ever offered. The label itself is not reported. vueda_info.E006reads a pair's field name the same way, so an entry naming a field the model doesn't have is reported atmanage.py checktime whichever form it is declared in. Previously a stale pair was reported by nothing.
- An entry declared in DRF's pair form now appears in
Ordering on a searched list that deduplicates:
VuedaSearchFilterBackendnow takes the ordering it re-applies from the querysetVuedaOrderingFilterhanded it, rather than re-reading the raw?o=value. That backend runs second and, when a search reaches through a multi-valued relation, re-applies the ordering alongside aDISTINCT ONthat has to match it — so it was undoing both of the things the ordering filter had just resolved. A?o=naming a related model'sformatted_namefailed the request withFieldError, because the rewritten path (customer__data__formatted_name) was replaced by the declared one (customer__formatted_name), which names no column. A field with anulls_orderingplacement silently lost it and came back in the database's default nulls order. Neither happened without a search, or with a search that needed no deduplication. A?o=naming a field the viewset actually offers sorts the same way it did; what changes is that the two cases above now behave on a searched list the way they do everywhere else.- A
?o=naming a field the viewset does not offer is no longer applied to a searched list that deduplicates. Taking the ordering from the raw parameter meant this one path sorted by a field name DRF had already rejected, andordering_fieldsgated?o=everywhere except here — so a field left out ofordering_fields, and out ofmodel_ordering.fieldswith it, was orderable as long as a search term came along too. Such a request now sorts by relevance, which is what the same request without?o=does. Add the field to the viewset'sordering_fieldsif clients are meant to order by it; that is what makes it a valid?o=target on every other list request, and what puts it inmodel_ordering.fieldsfor a metadata-driven client to find. - An ordering the
DISTINCT ONcannot match no longer reaches the query on a searched list that deduplicates. Pairing an ordering term with a distinct column tested how many columns the term read. That accepted two shapes PostgreSQL rejects. A function over one column (Lower("name")) pairs the column against the function over it. A plain relation name whose related model declares its ownMeta.ordering(?o=customer, whereCustomerorders by["user__name"]) is replaced by that ordering over the joined table, while the distinct column trims the join back to the local foreign key. Both failed the request withSELECT DISTINCT ON expressions must match initial ORDER BY expressions, an unhandled 500. Such a request now sorts by relevance, which is what the same request without?o=does. What still sorts as it did: a concrete field, a path through relations, a queryset annotation, the"pk"alias, and a relation whose related model declares no ordering. The function case is reached only through the viewset's own defaultordering, because a?o=value is a plain field name and never carries the function.ordering_fields = "__all__"advertises every relation inmodel_ordering.fields, so the relation case was reachable from a metadata-driven client's own sort control.
Serializer context when resolving default ordering fields:
- The canonical serializer is now instantiated with the model-info serializer's own
contextwhen resolving the ordering fields a viewset withoutordering_fieldsallows. A serializer whoseget_fields()needs the view —ExcludeFieldsSerializerMixinreadscontext["view"].action— no longer raisesKeyErrorthere. DRF's ownget_default_valid_fieldspasses a context for the same reason. Theviewin that context is the model-info viewset, not the viewset being described, so a serializer that varies its field set byview.actionis resolved againstretrieveon/info/rather thanliston the endpoint the client will call, and can advertise a different set of ordering fields than that endpoint accepts. There is no better context to pass — DRF's own call supplies{"request": request}with noviewat all, so the same serializer raisesKeyErroron the reallistrequest too. Declareordering_fieldson such a viewset, which takes both paths out of the picture.
- The canonical serializer is now instantiated with the model-info serializer's own
Duplicate
formatted_nameordering check errors:vueda_info.E005now reports a model'sMeta.orderingonce, rather than once per registered viewset for that model. The per-viewset half of the check still runs for every registration, since two viewsets on the same model can declare different orderings.
models.E015suppression on a model that loses theformatted_nameannotation:FormattedNameBaseModel._check_orderingnow checks that the model's default manager actually annotatesformatted_namebefore withholding that term from Django'smodels.E015. A model reaching the value throughformatted_name_lookup_expressionwhose default manager doesn't inheritFormattedNameManagerkeeps Django's error, which is right about it: nothing annotates the name, soMeta.orderingraisesFieldErroron every query. Previously the term was withheld for any model with a lookup expression, andvueda_info.E009was relied on to catch the rest — but E009 only reaches registered models that setformatted_name = Noneon the concrete class, so a model inheriting that from an abstract base, or one with no CRUDL surface, passedmanage.py checkand then failed on every query. No change for a model whose default manager is aFormattedNameManageror a subclass of one, which is every VUEDA model that hasn't replaced it. Fix a reported model the same way E009 asks: subclassFormattedNameManager, pass it toManager.from_queryset()as the base, or pointMeta.default_manager_nameat a manager that already does.
History records stay out of the workflow permission picker:
- The workflow permission form excluded django-simple-history models from its choices but not pghistory event models, so every tracked model added four permissions a project would never grant. The form now excludes event models too. No integrator action.
Group migration generation with module-style migrations:
updategroupmigrationsfailed withAttributeErrorwhen any installed app shipped its migrations as a single module rather than a package. Such an app is now skipped, since it has no directory a generated migration could live in. No integrator action.
Workflow state permission query cost:
HasWorkflowModelMixin.check_state_permissionissued one query per permission string. A caller evaluating several permissions against one object paid a round trip for each. One query now resolves every codename the object's current state grants or denies, andavailable_transitionsruns its whole pass inside one cached-state block.- Measured against
store.CustomerOrderon theorder_fulfillmentworkflow,available_transitionscost8 + 4nqueries in the number of transitions leaving the current state, and now costs6 + n. A list response carryingvalid_transitionsfor rows with three candidate transitions cost 17 queries per row and now costs 9. - Permission results do not move. State grants, state denies, deny-wins across conflicting groups, the no-matching-rule case, and both
RowLevelPermissionshooks return what they returned before.VuedaUserMixin.has_permremains the single authority, and a transition run through the workflow endpoint still re-reads the object's state after the row lock. No integrator action. A project that calledcheck_state_permissiondirectly gets the same answer, and its signature gains only an optionalcallerargument.
PERMISSION_NAMES_MAPPINGread at call time:override_settings(PERMISSION_NAMES_MAPPING=...)now changes the codenameget_permission_codenamereturns, and reaches every place that resolves a mapped permission name: row-level filtering, model-info metadata, workflow state checks, and object history access. These six call sites previously bound the setting to a module global at import, so an override taken after import — asoverride_settingsdoes — reached none of them. The value is cached and invalidated onsetting_changed, so a normal request still resolves the mapping without a settings lookup on every permission check. No action is required for an application that configuresPERMISSION_NAMES_MAPPINGonce at startup. A test that usedoverride_settings(PERMISSION_NAMES_MAPPING=...)and got no effect now sees the override applied.
available_transitions_fortransition permissions:HasWorkflowModelMixin.available_transitions_fornow applies each transition's configuredTransitionPermissionrows to the calling user. Its filter previously reachedcheck_transition_permissionthrough the model class rather than an object, which bound the transition toselfand leftuseratNone, so the method's first branch admitted every candidate. A caller holding the workflow's own permissions received transitions whose transition permissions they did not hold, disagreeing with the single-objectavailable_transitionsfor the same object and user.- A transition is returned when the caller may take it on at least one of the given objects, matching the source-state filter, which already admits a transition leaving any of the objects' states. The method resolves the concrete instances so that workflow state grants, state denies, and
RowLevelPermissionshooks apply per object. Passinguser=Nonestill returns every candidate. No VUEDA endpoint calls this method. If an application called it directly and compensated for the missing filter, remove that workaround.
Workflow state permission deferral:
- Model viewsets now defer a baseline permission denial only for a state grant matching the caller's groups, action-specific codename, model content type, and workflow, and only when the request has a guaranteed later state-aware decision. Unrelated rules and state denies no longer admit list or create requests, and workflow state data no longer suppresses authentication, composite permission expressions, or additional DRF permission classes.
- Workflow model lists now apply state grants and denies before pagination even when the model does not define
RowLevelPermissions. A statelist_*grant can admit the endpoint but returns only rows in matching granted states; matching denies remove rows from users with baseline list permission. Create remains model-authorized because a new object has no current workflow state. If an application overrides a VUEDA list action, preserve the call toapply_row_level_filter. If a custom action relies on state grants to overcome a baseline denial, add the action name toworkflow_object_permission_actionsonly when the action always performs an object permission check.
Optional VDQ notifications:
- Applications can now install
vueda.userwithout installingvueda.vdq. The default user adapter sends account email through Django's configured email backend and sends two-factor authentication SMS messages directly through Twilio when VDQ is absent; applications with VDQ installed continue to queue notifications.
- Applications can now install
Writable nested history serializer responses:
- History-enabled objects created through a writable nested serializer now include their annotated
current_history_idin the response. Nested serializers re-fetch the new object through its own model manager instead of the parent view's queryset.
- History-enabled objects created through a writable nested serializer now include their annotated
Dependency security floor:
- The server package now requires
cryptography48.0.1 or newer andstarlette1.3.1 or newer so installs resolve to versions with the published security fixes. No action is required unless your application pins either dependency below those versions.
- The server package now requires
UserSerializernon-mapping input:- Submitting non-mapping data to
UserSerializeron create — for example, a bare primary key sent through a writable nested or expanded user field — now returns the base serializer's standard "Expected a dictionary" DRF validation error instead of raising an unhandledAttributeError.
- Submitting non-mapping data to
Sparse field requests and
formatted_name:- Requests scoped to a subset of fields via
FIELDS_PARAM/OMIT_PARAMno longer rejectformatted_nameas an invalid submitted field.formatted_nameis a virtual, model-computed field and is now always accepted regardless of the requested field subset.
- Requests scoped to a subset of fields via
Stale choices on value-derived filters:
AllValuesFilterandAllValuesMultipleFilterbuild their choices from the values currently stored in a column.VuedaViewSet.listand model-info metadata read those filters from the filterset class rather than from a per-request instance, and django-filter caches the built form field on the filter it is read from, so the first request handled by a process froze that filter's choices for the life of the process. Values added afterward were rejected as invalid choices, and model-info reported the stale set. Both now read the filters from a filterset instance, so the choices are current on every request.
Workflow state permissions on the VDQ queue viewsets:
DefaultSendQueueViewSetandDefaultSentItemViewSetnow useHasWorkflowViewMixin.QueueItem(andSentItem, which subclasses it) is aHasWorkflowModelMixinmodel, so access to a queue item can come entirely from workflow state permissions, which are row level by nature. Without the mixin the generichas_perm(..., obj=None)check ran first and returned 403 before any row was considered, so a user whose only grant was a state permission could not list the send queue or sent items.
Declared default ordering on the VDQ queue viewsets:
DefaultSendQueueViewSetandDefaultSentItemViewSetnow declareordering = ["queued"]instead of ordering their querysets with.order_by("queued"). Both lists still arrive oldest first, which is the order a send queue is worked in; what changes is thatmodel_ordering.defaultreports["queued"]for them rather thanQueueItem.Meta.ordering(("-queued", "-last_updated")), which neither list ever applied. A metadata-driven client that sends the reported default as?o=now gets the same rows in the same order as a request that sends no ordering param at all. A subclass that overrodeget_querysetto re-order these lists, or one that relied on the inherited queryset'sorder_by, should declareorderinginstead;vueda_info.E010reports the queryset form.
Model-info choices for value-derived filters:
- Model-info
model_filteringnow reportsAllValuesFilterandAllValuesMultipleFilteras model-backed ("choices": true, withapp_label,model, andfilterset_name) even when the column currently holds no values. Previously an empty column made these filters look like a filter with no choices at all, so a client had no way to know the values were dynamic and should be read frommodel_info_filter_choices.
- Model-info
FIELDS_PARAM/OMIT_PARAMno longer narrow write validation:FlexFieldsWriteableNestedSerializerMixinappliedFIELDS_PARAM/OMIT_PARAMto the field set before deserialization, so a required field excluded by?f=/?om=lost its validator:is_valid()passed with the field missing, andsave()either raised a databaseNOT NULL/IntegrityErroror stored the field's blank default.?f=/?om=now narrow only the representation, applied aftersave(), so aPOST/PUT/PATCHalways validates against the serializer's full field set: a field the request body supplies validates fully regardless of?f=/?om=. OnPOST/PUT, this also means every required field stays required regardless of the requested subset.PATCHis unaffected by this change either way: whether an omitted field is required continues to follow the normal partial-update rule, not?f=/?om=. The response a write returns is still narrowed to the requested subset, unchanged.EXPAND_PARAM(e) is unaffected: it still swaps a relation for its nested serializer before deserialization, so nested writes through an expanded relation continue to work the same way. If a caller relied on?f=/?om=to omit a required field from aPOST/PUTbody, that request now returns400naming the field instead of a500integrity error or a silently incomplete row. Supply every required field in aPOST/PUTbody regardless of?f=/?om=; aPATCHmay still omit a field it is not changing,?f=/?om=or not.
Workflow transition check cost:
HasWorkflowModelMixin.allow_transitionnow resolves only the transition it is asked about. It previously built the object's whole permitted set throughavailable_transitionsand tested membership, so answering about one transition ran an object-level permission evaluation for every transition leaving the current state. Its cost no longer grows with that count.available_transitionsis unchanged and remains the way to obtain the permitted set itself.HasWorkflowModelMixingainedcached_workflow_state(), a context manager that holds the object's workflow and current state for the duration of a block so one authorization pass reads them once instead of once per permission check.check_transitionopens one per call, which keeps the post-lock re-check inexecute_transitionreading the state the lock protects. Theobject_statelookup also selects the related state in the same query. Executing a single transition on the reference workflow drops from 114 queries to 57, and a two-object bulk transition from 217 to 103. Transition authorization results are unchanged.
Redundant
QueueItem.allow_transitionoverride:vueda.vdq.models.QueueItemno longer overridesallow_transition. The override duplicated the inherited implementation verbatim and would otherwise have kept VDQ on the superseded per-candidate path.
permitted_transitionsfor models without a workflow:WorkflowViewSet.permitted_transitionsno longer requiresvueda_workflow.read_workflowwhen the requestedapp_label/modelpair has no configured workflow. A user who can read that model now gets200with an empty transition list instead of403.read_workflowis still required whenever a workflow is configured for the model, and every other workflow endpoint is unchanged.
model_fieldsmetadata resolved through the wrong attribute:model_fieldsnow resolves a field'stype_db/type_modelthrough the serializer field'ssource(including a dotted source such asauthor.name) instead of the serializer field's own name. A field declared with an explicitsource=previously reportednullfor both types even when its source named a real model field; it now reports that field's actual type.sourceis walked exactly as DRF resolves it at runtime: split only on., and an intermediate segment matching only a foreign key's scalar*_idattname is not followed as a relation, since DRF's own attribute lookup would not follow it that way either. A model's traversing<field>_lookup_expression(for example"state__name") now also reports the terminal field's type instead of stopping at the first segment's relation type, resolved through Django's own query-expression semantics so a transform ("when__year") resolves correctly too, not just a relation. If client code special-cased anulltype_db/type_modelfor a field with an explicitsource=or a traversing lookup expression, it may now see a real type instead.nullstill means the same thing it always did for aSerializerMethodFieldor a field whose source doesn't resolve at all.- Added the
vueda_info.W001system-check warning, reported bymanage.py check. It flags a field whosesourceor model<field>_lookup_expressionfails to resolve to a model field, naming the serializer, field, and path. It is advisory only and never blocks/info/or a management command from completing. Asourcefailure can avoid the warning three ways: two structural opt-outs checked up front — a field bound to the whole object (field.source == "*"—SerializerMethodFieldforces this, andObjectRevisionField,AvailableActionsField, andAvailableTransitionFieldset it explicitly, since each overridesget_attribute()outright and never readssource), or a model defining a matchingget_<field_name>()method (the same conventionformatted_name'sget_formatted_name()establishes) — or a correction applied after the fact: the check also runs the serializer'sget_field_model_infoand only warns iftype_db/type_modelare still null once that correction is applied. Absent all three, asourceis flagged on any failure to resolve — whether it was set explicitly or left to DRF's default (the field's own name), and whether the failure is at its very first segment or partway through a relation. A<field>_lookup_expressionis flagged on any failure the same way and none of the threesource=escapes apply to it, since it is fed directly tomodels.F()for queryset annotation and to Django admin'slookup_field()and so has no legitimate non-model-backed reading at all. _A registered model whose serializer has asource=that doesn't resolve (a@property-backed one, or a field-name typo with nosource=given at all) will newly report avueda_info.W001warning undermanage.py check, unless itsget_field_model_infoalready describes the field's real type (HasWorkflowSerializerMixin.workflow_state_code/workflow_state_namenow does exactly this, so those two fields reportCharFieldin/info/and produce no warning). This is advisory; if a flagged field is intentionally not model-backed, describe it withget_field_model_info, which both corrects/info/'s response and silences the check, or give the field an explicitsource="*"/a matchingget_<field*name>()model method if it's a reusable custom field.*
Choices requests dispatched twice:
ModelInfoChoicesBaseViewSet.dispatchran the whole request, discarded the response unless it was a200, and ran it again. Only the second run could deny.get_querysetsetchoices_permissionsas a side effect, andcheck_permissionsskips its body while that attribute isNone. Both choices viewsets now resolve the addressed field and the permissions it requires inresolve_choices(), which runs before the permission check. One request is one dispatch.- A denied request no longer builds the choices it will not return.
validate_querysetalso stopped readingfield.choicesto decide whether a field offers choices, because DRF evaluates the related queryset to answer that. An unauthorized request now reads no table belonging to the model it addressed. - Measured on
GET model_info_choices/store/product/tangible_type/, a successful field-choices response drops from 17 queries to 9. The filterset equivalent drops from 19 to 11. No integrator action. Response bodies, status codes, and the 404 naming the valid choice fields are unchanged. A project that subclassesModelInfoChoicesBaseViewSetdirectly must implementresolve_choices().
List validation answered by cache state:
- A list request carrying both an unrecognized query parameter and an over-deep
?e=now reports the unrecognized parameter, whatever the process has already cached. Discovering the parameter names a viewset's filterset accepts built the view's queryset, andVuedaViewSet.get_querysetconstructs a serializer that rejects the expansion. Which of the two errors came back therefore depended on whether an earlier successful request had cached those names. A filterset that names its model inMetais now read from that model's own default queryset, and one that takes its model from a supplied queryset still gets the view's. No integrator action. This is visible only on a viewset whose permission checks build no queryset. The defaultObjectPermissionsbuilds one before the handler runs, so such a request still reports the expansion depth.
- A list request carrying both an unrecognized query parameter and an over-deep
Serializer system checks missed metadata-only registrations:
- Both serializer system checks now seed discovery from two sources: the serializers routed through the resolved URL conf, and every serializer in the
vueda.inforegistry. Registrations made withregister()and withregister_serializer()both count.check_expandable_fields_configurationandcheck_exclude_fields_serializer_usagewalk one shared graph from that seed, following declared nested serializer fields andMeta.expandable_fields. A serializer registered for metadata alone has no route of its own, so nothing in the old URL-conf walk reached it.manage.py checkpassed a malformedexpandable_fieldsentry, or a nestedExcludeFieldsSerializerMixinchild, through to the first/info/request. The checks walk a serializer found through both a route and a registration once, and report each fault once. _unwrap_expandable_field()no longer indexes an empty tuple. Anexpandable_fieldsentry written as()crashedcheck_exclude_fields_serializer_usagewithIndexErrorinstead of letting the check finish. The entry now resolves to no child, so traversal passes over it andcheck_expandable_fields_configurationreports it asvueda_core.E002. A project that registers a serializer for metadata alone may see newvueda_core.E001tovueda_core.E008errors frommanage.py check. Each one names a configuration fault that would otherwise surface as a request-time failure.
- Both serializer system checks now seed discovery from two sources: the serializers routed through the resolved URL conf, and every serializer in the
v3.0.0a0 (2026-05-27)
Migration Summary
This is the first public-facing v3 server baseline. The major migration work is around the metadata contract, composite primary key support, permissions/workflow tooling, more predictable REST error shapes, and generated API documentation.
Review any application code that customizes VUEDA serializers, viewsets, filtersets, workflow/group migration commands, password reset flows, search behavior, or model metadata consumed by the client.
Breaking Changes
- Object payload
available_actions:VuedaSerializernow removesavailable_actionsfrom ordinary object responses unless the sparse field request explicitly includes it. Review client or integration code that readavailable_actionsdirectly from ordinary object payloads. Request the field explicitly or use the metadata/action-contract surfaces instead.
- Composite primary key models:
- Composite primary key support now uses dedicated serializer, filterset, and URL conversion behavior. For composite primary key models, use VUEDA's composite primary key serializer/filterset path instead of assuming the default integer
pkfilter.
- Composite primary key support now uses dedicated serializer, filterset, and URL conversion behavior. For composite primary key models, use VUEDA's composite primary key serializer/filterset path instead of assuming the default integer
- Password reset and error response shapes:
- Password reset and unhandled error responses now follow DRF-style
detailand field-error shapes more consistently. Review code that matched olderresult/messageorerrorresponse keys.
- Password reset and unhandled error responses now follow DRF-style
Features
- Server version endpoint:
- Added documented server version metadata so the docs and client can identify which server package version they are paired with.
- Metadata contract improvements:
- Serializer and model-info output now exposes more of the contract needed by the v3 client, including hidden field metadata and cleaner generated API documentation.
- Composite primary key support:
- Added support for serializing, filtering, URL parsing, and documenting composite primary key models.
- Search and filtering:
- Expanded
VuedaSearchFilterBackendsupport for ranked search, trigram similar lookups, word-similar lookups, deterministic ordering, and distinct handling.
- Expanded
- Group and workflow migration tooling:
- Improved group and workflow migration commands so permission and workflow changes can be captured and replayed more reliably.
- VDQ and async dependencies:
- Added
channelsas a runtime dependency and updated async/background-work related server dependencies for the v3 package set.
- Added
Fixes
- REST error consistency:
- Unhandled server errors now return a
detailkey, 404 responses use a DRF-styledetailpayload, andImproperlyConfigurederrors are converted into client-readable validation details.
- Unhandled server errors now return a
- Forgot-password flow:
- Forgot-password now validates the submitted email through serializer data, returns 204 on success, returns field errors for inactive or missing users, and returns
detailfor rate limiting.
- Forgot-password now validates the submitted email through serializer data, returns 204 on success, returns field errors for inactive or missing users, and returns
- Ranked search distinct handling:
- Ranked search now preserves ordering and primary-key tie-breaking when distinct results are required.
- Expanded history fields:
- History field filtering now respects wildcard field selection when applying flex-like omit/field controls to historical records.
- Workflow permission messages:
- Transition attempts without permissions now return a clearer message for the client to display.