Skip to content

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.0a1 wheel, including deleted history modules that caused Django startup to fail with ModuleNotFoundError: No module named 'simple_history'. The framework source is unchanged apart from the version number. Upgrade to 3.0.0a1.post1 or later. No additional dependency or migration is required for this packaging correction.

v3.0.0a1 (2026-09-14)

Breaking Changes

  • Workflow participation and object data are authorized separately:

    • WorkflowObjectPermissions.has_permission admitted any request whose target workflow held a StatePermission row. 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_workflow now 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's read permission and no longer require read_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_permission takes an obj argument 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 read permission for every object it touches, and does not require the target model's update_*. Object permissions are rechecked under the row lock before writing. A bulk request reports an object the caller cannot read with the 404 a missing id produces, wording included, and rolls back the entire batch.
    • WorkflowView is removed. vueda.core.views.DynamicObjectView is 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.DynamicObjectPermissions is the permission class it uses, and WorkflowObjectPermissions now subclasses it. Both name a CRUDL action and resolve it through PERMISSION_NAMES_MAPPING when the check runs, so patch_django no longer rewrites a workflow perms_map at import.
    • GetObjectHistoryView becomes WorkflowStateHistoryView, its route becomes workflow-state-history/<app_label>/<model>/<object_id>/, and its URL name becomes workflow-state-history. The endpoint returns the history of the target object's ObjectState row, 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's read permission to any group that discovers or executes workflow transitions; read_workflow alone no longer reaches an object. Review each StatePermission row: 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. Replace WorkflowView with vueda.core.views.DynamicObjectView, and GetObjectHistoryView with WorkflowStateHistoryView. Update any reverse("get-object-history") call and any client URL pointing at object-history/.
  • Model history moves to PostgreSQL triggers:

    • vueda.history now records changes through django-pghistory and django-pgtrigger instead of Django signals, and it tracks every eligible VuedaModel subclass by default rather than only those inheriting a history-specific base. A model controls its own tracking through class Vueda.History: enabled opts a model out, exclude_fields keeps named columns out of the event model, and reason records why. password is always excluded, whatever a model declares, because a declaration replaces inherited policy rather than adding to it. get_defaults() adds pghistory and pgtrigger to THIRD_PARTY_APPS and sets PGHISTORY_APPEND_ONLY, so event tables reject updates and deletes at the database. It also sets PGHISTORY_CREATED_AT_FUNCTION to clock_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's ContextForeignKey, 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.history is a required app: VUEDA_APPS must include it, and get_defaults() raises ImproperlyConfigured otherwise. 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.E013 until its History section sets enabled = False. An exclude_fields entry 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. Workflow already did, because it subclasses Lookup and the class Vueda.History contributor reaches it. WorkflowPermission, State, StatePermission, InitialState, Transition, TransitionPermission, TransitionSource, and ObjectState are plain models.Model subclasses that the contributor never sees, so vueda.workflow registers them through vueda.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_workflow migration 0008 ships the eight event models and their triggers.
    • Both Copier templates' User model now declares class Vueda.History with exclude_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. Run makemigrations after upgrading and review the generated event models and triggers in each app that owns a tracked model. Purging event rows requires pgtrigger.ignore, because append-only otherwise blocks the delete. A model that should not be tracked needs class Vueda.History with enabled = False.
  • VUEDA drops django-simple-history:

    • VUEDA no longer depends on django-simple-history. get_defaults() drops simple_history from THIRD_PARTY_APPS, drops simple_history.middleware.HistoryRequestMiddleware from MIDDLEWARE, and no longer sets SIMPLE_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, and vueda.user.models.AbstractVUEDAUserWithHistory are 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_workflow migrations 0001 and 0002 no longer import simple_history, and the bases tuple of each Historical* model they create becomes (models.Model,). HistoricalChanges defines no fields and Django has no operation for a bases change, so sqlmigrate output is byte-identical and makemigrations finds nothing to generate. Every published migration node keeps its name. Subclass VuedaModel where you subclassed VuedaHistoryModel, and AbstractVUEDAUser where you subclassed AbstractVUEDAUserWithHistory. Both keep recording history, through pghistory rather than signals. makemigrations will then generate a DeleteModel for each Historical* table those base classes built; review it before applying, because it drops the rows too. DeleteModel removes no content type, so a project that created permissions for those models in a data migration must delete the stale ContentType rows 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 import simple_history must either declare django-simple-history as a direct dependency, or make the same import and bases edit.
  • History records the action behind a change:

    • get_defaults() inserts vueda.history.middleware.VuedaHistoryMiddleware into MIDDLEWARE immediately after AuthenticationMiddleware. It records the acting user, the request path, the request method, and the action kind request. Every event one request produces then shares one action identity while remaining a separate row. A project that builds its own MIDDLEWARE rather than taking the default gets vueda_history.W001 when the middleware is missing and vueda_history.W002 when it precedes AuthenticationMiddleware. Both are warnings: a request still records its events, but they carry no shared action or no user.
    • vueda.core.audit.audited_action provides the same grouping where there is no request. VDQ tasks now run inside one, named for the task and recorded as kind task. An action that names no kind keeps the kind of the action it runs inside and otherwise records system. 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 in audited_action("your.action"). Pass kind="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-list endpoint 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_id is replaced by object_revision, the object's newest event identifier, published by the ordinary serializer in the same form the response uses for events. The current action is removed: a retrieve answers the same question. Only the object a request asks for carries a revision; an expanded object publishes none.
    • VuedaHistoryViewSet and VuedaHistorySerializer are removed. VuedaViewSet carries the history endpoint, and it offers no history action, model-info metadata, or route for a model that records none. The history, first_history_entry, and last_history_entry expands go with the serializer, so a model's history is read from the endpoint and never embedded in the object. The history-aware UserSerializer, WhoIsSerializer, and WhoIsView variants in vueda.history are removed as well; the core who-is view publishes object_revision itself. Replace VuedaHistoryViewSet with VuedaViewSet; the history endpoint comes with it. Read object_revision where you read current_history_id, and compare it against an event's own identifier instead of calling the removed current action. Replace VuedaHistorySerializer with VuedaSerializer, and drop history, first_history_entry, and last_history_entry from any expand request; the history-list endpoint is the one history read.
  • Workflow responses publish an object-state revision:

    • The workflow object-state and execute-transition responses replace current_history_id with object_state_revision. The value is a string of the form vueda_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 ObjectState row'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_revision on the ordinary serializer is the tracked object's own newest event; this is the newest event of that object's ObjectState row. Read object_state_revision where you read current_history_id on 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.
  • Read-only serializer permission metadata:

    • Model-info model_permissions now exposes only the mapped list and read permissions when a model's canonical serializer subclasses VuedaReadonlySerializer. Other permission rows remain in Django's permission table, and the change does not alter server authorization. If an integration treated model_permissions as a complete database permission inventory, account for the filtered read-only surface or query Django's permission model directly.
  • Filter choice empty options:

    • model_info_filter_choices no longer prepends or returns empty-valued options. This removes the previous synthetic option driven by empty_label, empty_value, EMPTY_CHOICE_LABEL, and EMPTY_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:

    • VuedaSerializer now maps models.FileField and models.ImageField columns to VUEDA's serializer fields, which represent a stored file as {"name": ..., "url": ...} (with an absolute url when 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 stock rest_framework.serializers.FileField/ImageField explicitly on your serializer.
  • get_expandable_fields() renamed to get_expand_model_info():

    • The serializer override hook for customizing model_expands metadata 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 calling super().get_expandable_fields() to obtain the base list. It remains defined on VuedaExpandableFieldsSerializerMixin. Rename any get_expandable_fields(self) override to get_expand_model_info(self, expands), drop the super() call, and operate on the expands parameter directly.
  • VuedaValidationError no longer supports is_warning:

    • The is_warning constructor argument and attribute, the _get_error_details warning branching, and the get_error_details_as_warning helper are removed. vueda.core.logging_filters (the FilterOutVuedaValidationWarnings log filter and contains_only_warnings helper) is deleted, along with its LOGGING filter registration in get_defaults(). It is no longer possible to raise a warning-coded validation error that still returns 400 and blocks the write; use get_warnings() (see below) for advisory, confirm-before-write feedback instead.
    • Sentry capture in debug_stack_exception_handler now checks isinstance(exc, rest_framework.exceptions.ValidationError) directly instead of contains_only_warnings(exc). The captured set is unchanged: validation errors are still captured, and handled 404, permission, and ConfirmationRequired responses are still not. No repository production code raised is_warning=True; only a logging test fixture did. If a downstream project raised it, migrate to get_warnings(). If a downstream project imported vueda.core.logging_filters or referenced the ignore_validation_warnings LOGGING filter, remove that import and filter reference.
  • valid_transitions entries are objects, not code strings (AvailableTransitionField):

    • AvailableTransitionField now returns each entry as {"code": ..., "name": ...} instead of a bare transition-code string. Model info reports the field's type_serializer as DictField instead of CharField accordingly. This lets clients render a transition's display name without a second lookup, and is what allows the v3 client's useDetailView to read available transitions straight off the fetched object instead of issuing a separate per-object transitions request. Update any code reading valid_transitions entries as plain strings to read the code (and optionally name) key off each object instead.
  • VuedaValidationError no longer supports is_warning:

    • The is_warning constructor argument and attribute, the _get_error_details warning branching, and the get_error_details_as_warning helper are removed. vueda.core.logging_filters (the FilterOutVuedaValidationWarnings log filter and contains_only_warnings helper) is deleted, along with its LOGGING filter registration in get_defaults(). It is no longer possible to raise a warning-coded validation error that still returns 400 and blocks the write; use get_warnings() (see below) for advisory, confirm-before-write feedback instead.
    • Sentry capture in debug_stack_exception_handler now checks isinstance(exc, rest_framework.exceptions.ValidationError) directly instead of contains_only_warnings(exc). The captured set is unchanged: validation errors are still captured, and handled 404, permission, and ConfirmationRequired responses are still not. No repository production code raised is_warning=True; only a logging test fixture did. If a downstream project raised it, migrate to get_warnings(). If a downstream project imported vueda.core.logging_filters or referenced the ignore_validation_warnings LOGGING filter, remove that import and filter reference.
  • Model-info default ordering (model_ordering):

    • model_ordering changes 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 own ordering when declared, otherwise the model's Meta.ordering — one or the other, never a merge of both); and fields, the fields a client may explicitly order by. Read model_ordering.fields where code previously read model_ordering directly, and read model_ordering.default to show or apply the default ordering instead of inferring it from the model or viewset.
    • Every field named in default also appears in fields, carrying an ascending key for its direction in the default ordering, even when ordering_fields doesn't otherwise whitelist it — matching VuedaOrderingFilter, which now accepts an explicit ?o= request for any default-ordering field regardless of ordering_fields (see the VuedaOrderingFilter entry under Features).
    • When a viewset sets ordering_fields = "__all__" (DRF's shorthand for allowing any model field), fields lists the model's own fields instead of the literal string "__all__", along with the annotations the viewset's own get_queryset adds. 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 to alpha when Django won't resolve one; a formatted_name annotation is typed from the column its lookup expression lands on instead.
    • When a viewset doesn't declare ordering_fields at all, DRF defaults to allowing ordering on any readable field of the canonical serializer, resolved by each field's source rather than its serializer name (so a renamed field is sortable under its underlying model field name, not its serializer name). fields now 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's Meta.ordering, a viewset's ordering, and a viewset's ordering_fields — is reported as the field name(s) it stands for: id for the usual auto-generated primary key, or every field a CompositePrimaryKey is built from. default and fields never contain the literal "pk", so every name a client receives is a real field name it may send back in ?o=.
    • formatted_name is reported as an orderable field whenever the database can sort it: as its own generated-field column, or through the formatted_name_lookup_expression annotation that VuedaViewSet.get_queryset already adds. Previously only a real formatted_name column 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_name works either way. A formatted_name computed by a get_formatted_name() method is still omitted, since it can only be sorted in Python (see vueda_info.E005 under Features).
    • default is 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), default is 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 unresolvable ordering doesn't fall back to the model's Meta.ordering.
    • A default ordering term built from a scalar database function is reported under the field it reads: Lower("name") is reported as name, with ascending taken from the term's .desc()/.asc() (a bare expression sorts ascending). The type describes that field, not what the function returns, so Length("name") reports name as alpha. 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, so default is 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 in fields, without an ascending key, 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 — a GeneratedField where it reads only this row, a database view where it needs a join — and order by that column's name. Annotating the expression in get_queryset and ordering by the annotation's name makes the ordering work but does not get it advertised; see the next entry.
    • A default ordering term naming a queryset annotation is not advertised either: default comes back empty for it, and the annotation reaches fields only when ordering_fields also covers it (named outright, or through "__all__"). The ordering still runs and VuedaOrderingFilter still 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 a type from; 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.E006 deliberately 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, a GeneratedField where the value derives from the same row, or a database view (a managed = False model related by OneToOneField) where it needs a join or an aggregate. Otherwise name the annotation in ordering_fields as well, so it at least appears in fields and a client can offer it as an explicit sort.
    • model_ordering no longer reports nulls_first/nulls_last for any field. Nulls placement for the ?o= param is still controlled server-side (see nulls_ordering/nulls_ordering_flip under Features); it just isn't advertised to clients as metadata.
  • FormattedNameManager is the default manager on FormattedNameBaseModel:

    • FormattedNameBaseModel (and so VuedaModel, Lookup, and every model built on them) now declares objects = FormattedNameManager(). On a model that reaches its formatted name through formatted_name_lookup_expression and has no formatted_name column, the manager annotates that expression as formatted_name on every queryset the model builds — not just the ones VuedaViewSet.get_queryset builds. A model with the generated-field column, or one using get_formatted_name(), gets no annotation and is unaffected. This is what makes formatted_name usable outside a request: Model.objects.all() in a management command, the admin, a reverse relation, or dumpdata now resolves and can order and filter by it. It is also what makes a model-level ordering = ["formatted_name"] safe (see the entry under Features), since Meta.ordering applies to every queryset rather than only a viewset's.Model.objects.values() with no arguments includes annotations, so it now returns a formatted_name key for such models. Name the columns explicitly (values("id", "label")) where that matters.A model that declares its own objects shadows the default manager. Inherit from FormattedNameManager rather than models.Manager (or pass it as the base to Manager.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. VUEDAUserManager and SentItemManager sit on models that have no annotation to lose. The new vueda_info.E009 check reports a model left without it.Model._base_manager is not this manager and carries no formatted_name annotation. Django builds the base manager itself as a plain models.Manager unless Meta.base_manager_name names 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 whose Meta.ordering names formatted_name has to care: a base-manager queryset carrying that ordering has no annotation to sort and raises FieldError. Anything reached through get() is safe, since get() clears ordering — that covers refresh_from_db and dereferencing a foreign key — and so are select_related and prefetch_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_objects hands back a plain _base_manager queryset that Collector.collect evaluates without clearing ordering). dumpdata --all reads the base manager but replaces the ordering with the primary key, so it is safe too.Set Meta.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 by formatted_name. The new vueda_core.E017 check reports a model that did neither, so the configuration fails manage.py check rather 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.retrieve now rejects any query parameter other than the flex-fields params (e, f, om) with a 400. NoExtraFieldsForViewSetMixin.list now applies the same rejection to a viewset with no filterset_class, accepting only pagination, ordering, search, and flex-fields params. Previously, retrieve accepted any query parameter unconditionally on every viewset, and list accepted any query parameter unconditionally on a viewset with no filterset_class; only list on a viewset with a filterset_class rejected unrecognized parameters. list on a viewset with a filterset_class is unchanged. Drop any query parameter sent to a retrieve endpoint that is not e, f, or om. Drop any query parameter sent to a list endpoint 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_id becomes id, an event identifier of the form vueda_workflow.ObjectState:<event id>; state__code becomes state; history_date becomes recorded_at; and history_user becomes actor. history_change_reason is gone, because pghistory records none.
    • The nine workflow models no longer record through django-simple-history, and vueda_workflow migration 0009 drops their nine Historical* 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 WorkflowPermission whose workflow is gone, rebuilds it from its last recorded event through vueda.history.snapshots.last_recorded. check_transition reads 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. Run updateworkflowmigrations for each app with a generated workflow migration before applying 0009. A migration generated by an earlier version still refers to the Historical* models, and fails once those tables are gone.
  • 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 eight handle_* functions and add_history_to_data are gone from the generated source, along with the field bookkeeping that only existed to fill those rows.
    • manage_state_objects decides 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. Run updateworkflowmigrations for each app with a generated workflow migration, so its copy of these functions matches. A migration left un-updated still applies and still writes Historical* rows, which nothing reads.
  • 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 newest vueda_workflow migration, so the triggers that record those events are always installed before it runs.
    • makeworkflowmigrations writes workflow_migration_action alongside the forward and reverse functions, and updateworkflowmigrations inserts it into a migration generated by an earlier version. Run updateworkflowmigrations for each app with a generated workflow migration. A migration left un-updated still applies, but its writes record events with no action behind them.
  • Ordered feature contributions:

    • FeatureSection gains contribute_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. Set contribute_order when the order in which a contributor sees the model matters.
  • Optional workflow app boundary:

    • Applications may omit vueda.workflow and vueda.vdq together. Model-info, ordinary CRUDL routes, history URL imports, and schema setup no longer import workflow models when workflow is absent. vueda.vdq still requires vueda.workflow and now fails early with a clear configuration error if installed without it. Guard include("vueda.workflow.urls") and include("vueda.vdq.urls") behind installed-app checks, or rely on the app URL modules returning no routes when the apps are absent.
  • class Vueda model 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). Meta stays limited to Django's own model options. Read the resolved policy with vueda.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.history and vueda.workflow register the History and Workflow sections; history defaults on for eligible models, and workflow is opt-in. An explicit History.exclude_fields replaces 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 eligible VuedaModel subclass is tracked unless its History section says otherwise. Workflow still follows HasWorkflowModelMixin inheritance, so an explicit Workflow.enabled that disagrees with a model's base classes is a check error rather than a setting that is accepted and ignored. See Model Feature Policy.
  • expandable_fields system check:

    • A new Django system check (vueda_core.E001-E006) validates each serializer's Meta.expandable_fields at manage.py check time. 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 a Serializer/Field subclass — catching misconfiguration at startup instead of on first request.
  • formatted_name ordering system check:

    • A new Django system check (vueda_info.E005) reports ordering declared on a formatted_name that a get_formatted_name() method computes in Python. It covers a model's Meta.ordering and a registered viewset's ordering and ordering_fields, and names which of those declared it. Such a formatted_name has no column or annotation to sort by, so DRF's list request would fail with a FieldError (or, for ordering_fields, silently ignore the client's ?o=); the check reports it at manage.py check time instead. Give the model a formatted_name_lookup_expression if the value needs to be sortable.
  • formatted_name in a model's Meta.ordering:

    • A model that reaches its formatted name through formatted_name_lookup_expression can now declare ordering = ["formatted_name"] in its own Meta, written the same way as on a viewset or on a model with a real formatted_name column. Such a model has no column of that name, so Django's models.E015 check previously reported the declaration as a nonexistent field even though the ordering is valid at query time — FormattedNameManager annotates the lookup expression under that name before the query is compiled. FormattedNameBaseModel._check_ordering now withholds that one term from models.E015 on models that declare a lookup expression. Meta.ordering applies to every queryset, not only a viewset's, so this depends on the annotation being on the model's default manager (see FormattedNameManager under Breaking Changes). A model that replaces its default manager without inheriting FormattedNameManager passes the checks and then raises FieldError when a queryset from that manager is evaluated.Only that term, and only on those models. Every other term in the same Meta.ordering is still checked, so a stale field name is still reported; so is a formatted_name reached across a relation (owner__formatted_name), which no annotation covers, and one computed by a get_formatted_name() method, which vueda_info.E005 explains. Third-party models patched for formatted_name support outside VuedaModel need _check_ordering assigned along with _has_formatted_name_field and _get_formatted_name, and a FormattedNameManager as their default manager, to get the same treatment.An F("formatted_name").asc() ordering expression, which models.E015 skips regardless, remains valid and is still the way to declare nulls placement on a model default.
  • Queryset ordering system check:

    • A new Django system check (vueda_info.E010) reports a registered viewset whose class-level queryset carries an order_by() that its declarations don't describe. DRF's OrderingFilter reads a view's ordering attribute and nothing else, so with none declared it applies no ordering and the queryset's own survives to the response — while model_ordering.default reports the model's Meta.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 ordering and a Meta.ordering that disagrees (declare the queryset's order as ordering, or drop the order_by() and accept the model's order, which reverses the list); no ordering declared anywhere (declare it, so default stops reporting no ordering for a sorted list); and a viewset ordering that 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 -queued names the same field and reverses every row. A "pk" alias and the field behind it compare equal, as do a formatted_name and the column its formatted_name_lookup_expression names, so one sort spelled two valid ways isn't reported. The check reads the queryset class attribute and never calls get_queryset(), so an ordering applied inside get_queryset, in a manager, or conditionally per request is not covered, and neither is a viewset whose filter_backends omits the ordering backend or a bare order_by() that clears ordering entirely. Those leave the same mismatch with nothing to report it; see Queryset Ordering.
  • Viewset ordering system check:

    • A new Django system check (vueda_info.E006) validates each registered viewset's ordering and ordering_fields against its model at manage.py check time, reporting any term that names no field, related field, lookup, or queryset annotation. Django's own models.E015 already covers a model's Meta.ordering; nothing covered the same drift on a viewset, where a stale ordering fails every list request that doesn't override it with ?o=, and a stale ordering_fields entry fails nothing at all — model-info metadata just leaves it out of model_ordering.fields, so it can sit misconfigured indefinitely with no error.
    • Terms are resolved the way the metadata resolves them, so a "pk" alias, a formatted_name reached through formatted_name_lookup_expression, "?" (Django's random ordering), and an annotation added by the viewset's own get_queryset are all accepted. ordering_fields = "__all__" is skipped, since it expands to the model's own fields. Ordering declared on a method-backed formatted_name is left to vueda_info.E005, so it is reported once rather than twice.
  • formatted_name_lookup_expression path system check:

    • A new Django system check (vueda_info.E008) reports a formatted_name_lookup_expression that reaches through a relation which can match more than one row — a reverse foreign key, a many-to-many, or a GenericRelation. VUEDA annotates the expression as formatted_name on 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 a LEFT OUTER JOIN that 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 (a managed = False model related by OneToOneField) 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's formatted_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.
  • formatted_name default manager system check:

    • A new Django system check (vueda_info.E009) reports a model that reaches its formatted name through formatted_name_lookup_expression but whose default manager isn't a FormattedNameManager. Django takes the first manager in Meta.managers order as the default, so a model (or an abstract base it inherits) declaring its own objects shadows the one FormattedNameBaseModel provides and silently loses the annotation: formatted_name then resolves only on querysets VuedaViewSet.get_queryset builds.
    • It pairs with the models.E015 suppression, which asks the same question for itself: FormattedNameBaseModel._check_ordering withholds a formatted_name term 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 a Meta.ordering naming formatted_name whether or not it is registered. E009 reports the manager itself, for a registered model, with a hint aimed at fixing it. Filtering and ordering by formatted_name outside a request break the same way and are reported by E009 alone. Subclass FormattedNameManager instead of models.Manager, pass it to Manager.from_queryset() as the base, or point Meta.default_manager_name at a manager that already does.
  • Nulls ordering system check:

    • A new Django system check (vueda_info.E007) validates each registered viewset's nulls_ordering and nulls_ordering_flip at manage.py check time. It reports a placement outside "first"/"last" (the only two values that have a nulls_first/nulls_last keyword to become), a nulls_ordering that isn't a dict, a nulls_ordering_flip that isn't a list of field names, and a field listed in nulls_ordering_flip that nulls_ordering gives 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 unusable nulls_ordering is read as giving no field a placement, which is what it does, so each nulls_ordering_flip entry is then reported as having nothing to flip; all of them clear with the one fix that caused them.
    • VuedaOrderingFilter now ignores an unusable placement instead of raising, so a list request returns rows in the database's default nulls order rather than failing with a KeyError or TypeError. The check is what surfaces the declaration, since nothing else would. A viewset whose get_queryset needs a request to build its queryset is skipped rather than reported, since its annotations can't be known at check time.
  • formatted_name through a relation:

    • A viewset's ordering, its ordering_fields, a client's ?o=, and a filter's field_name may now name a related model's formatted_name (customer__formatted_name) when that model reaches the value through formatted_name_lookup_expression. VuedaOrderingFilter rewrites the path to the column behind it (customer__data__formatted_name) before the query runs, and the new FormattedNamePathFilterSetMixin does the same for a filter's field_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_name is not a query parameter the list namespace check accepts. Following a relation is what makes the rewrite necessary. The annotation VuedaViewSet.get_queryset adds belongs to the queryset being ordered or filtered, not to the tables it joins, so the related path raised FieldError before this.VuedaFilterSet and VuedaCompositePrimaryKeyFilterSet both include FormattedNamePathFilterSetMixin, so a filterset on either base gets the rewrite with no change. A filterset built on django-filter's FilterSet directly does not: mix FormattedNamePathFilterSetMixin in ahead of the FilterSet base to declare a filter on a related formatted_name. Nothing reports a missing mixin at startup — the system checks validate a viewset's ordering declarations, not a filterset's field_names — so such a filter raises FieldError on the requests that send it.Two shapes are deliberately not rewritten, and are left out of model_ordering/model_filtering and reported by vueda_info.E006 instead. A formatted_name the related model computes with a get_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 a list request returns.A model's own Meta.ordering is not covered. It applies to every queryset, including the ones no backend touches, so Django's models.E015 still rejects a related formatted_name declared there. Only the model's own un-prefixed formatted_name is withheld from that check.
  • Read-only relation metadata (model info):

    • Model-info field metadata now includes app_label and model for 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 leaving choices disabled for read-only relation fields.
  • Display-only field labels (model info):

    • VuedaExpandableFieldsSerializerMixin and VuedaSerializer now support field_display_choices, a serializer-level mapping for display-only value labels. Model-info field metadata emits these labels as display_choices, separate from editable choices, so a boolean field can keep its toggle behavior while read-only views display labels such as Submitted, -, or Unknown. Use choices for validation and editable choice widgets. Use field_display_choices when only read-only display needs custom labels.
  • Submit-time warning confirmation (get_warnings):

    • VuedaSerializer gained a non-raising get_warnings() hook. Override it to return advisory warnings as {field: [messages], "non_field_errors": [messages]}. It is called after validation succeeds, so self.validated_data and (on update) self.instance are available.
    • When get_warnings() returns warnings, VuedaViewSet withholds the create/update and responds 409 Conflict with {"confirmation_required": true, "digest": ..., "warnings": {...}} instead of saving. Resubmitting with the Acknowledge-Warnings request header set to that digest lets 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. The acknowledge-warnings header is added to the default CORS_ALLOW_HEADERS.
  • Warning confirmation for destroy, activate, deactivate, and custom actions:

    • WarningConfirmationMixin (and therefore VuedaViewSet) gained two viewset-level hooks for writes that have no per-object serializer: get_warnings_for_object(action, obj) for a single object, and get_warnings(action, objs) for a bulk request. action is the action name ("destroy", "activate", or "deactivate") for both. Override get_warnings_for_object to return the aggregate {field: [messages]} shape for obj; the default get_warnings calls it once per instance in objs (a queryset) and keys each non-empty result by str(pk), building the per-object {object_id: {field: [messages]}} shape — so overriding get_warnings_for_object alone gates both the single-object and bulk forms of action with the same rule. Override get_warnings itself 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 responds 409 Conflict with {"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 call gate_warnings directly 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 after serializer.is_valid(raise_exception=True) (so blocking 400s surface before the 409) and before any write or side effect; it raises ConfirmationRequired unless the Acknowledge-Warnings request header matches the warnings digest. ACKNOWLEDGE_WARNINGS_HEADER moved to vueda.core.exceptions and is re-exported from vueda.core.decorators, so existing imports keep working.
    • The @action decorator gained confirm=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 a confirm_message attribute 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 call gate_warnings explicitly 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):

    • HasWorkflowModelMixin gained a get_transition_warnings(transition, user=None) hook beside allow_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_transition now evaluates get_transition_warnings before any write, for both the single-object and bulk (object_ids) forms, and gates through the same gate_warnings/Acknowledge-Warnings contract as create, update, destroy, activate, and deactivate: an unacknowledged warning set responds 409 Conflict with {"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. The warnings shape 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_transition is 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, and apply_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. Override get_transition_warnings on models using HasWorkflowModelMixin to gate a transition behind confirmation; no action is required otherwise.
  • ImageField serializer field:

    • Added vueda.core.fields.serializers.ImageField, the image counterpart to the existing FileField. It shares the {"name", "url"} representation and subclasses FileField rather than DRF's ImageField, so it does not require Pillow; image content validation is left to the model field and upload pipeline.
  • updategroupmigrations management command:

    • Added a new management command that scans all installed apps for group migrations created by makegroupmigrations and rewrites their import and function sections with the current implementations from makegroupmigrations.py.
    • The changed_data variable and the class Migration block are preserved; only the embedded function bodies and imports are updated.
    • Accepts a --dry-run flag 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.
  • updateworkflowmigrations management command:

    • Added a new management command that scans all installed apps for workflow migrations created by makeworkflowmigrations and rewrites their import and function sections with the current implementations from makeworkflowmigrations.py.
    • The recorded change data (changed_data, history_change_reason, migration_app_label) and the class Migration block are preserved; only the embedded function bodies, imports, and any stale function names in operations are updated.
    • Accepts an optional app_label argument to limit the update to a specific app, and a --dry-run flag 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.
  • GenericForeignKeySerializer:

    • Added GenericForeignKeySerializer to vueda.core.serializers for declaring GenericForeignKey expandable fields. Declare it in expandable_fields using the GenericForeignKey field name as the key. The serializer resolves the concrete related model's canonical registered serializer at representation time via get_serializer_for_model, so every model that can appear through the generic foreign key must be registered via register or register_serializer.
    • Generic foreign key expands are always read-only. Model-info metadata for these expands reports type_model: "GenericForeignKey", type_serializer: "GenericForeignKeySerializer", and type_db: null.
    • FIELDS_PARAM and OMIT_PARAM entries in the expandable_fields options 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.
  • get_serializer_for_model:

    • Added get_serializer_for_model to the public API of vueda.info.registration. Returns the canonical serializer class registered for a given model by looking up the in-process registry directly, without a database query. Returns None if the model is not registered. Use this when you need the registered serializer class for a model and want to avoid the ContentType lookup required by get_registration.
  • Django built-in model formatted_name support:

    • InfoConfig.ready() now patches Django's Group, Permission, and ContentType models with the _has_formatted_name_field, _get_formatted_name, and formatted_name_lookup_expression (or get_formatted_name) attributes that VUEDA's viewset and serializer layers require. Group and Permission use name as their display field; ContentType uses app_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 in VuedaSerializer) gained a get_field_model_info(fields) hook for customizing model_fields metadata. 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 a SerializerMethodField, which has no model column or fixed field type to derive metadata from automatically. The default implementation applies field_display_choices and returns fields.
    • For VuedaHistorySerializer subclasses, this hook is also applied to the same fields as they appear embedded in the history, first_history_entry, and last_history_entry model_expands descriptors, 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 own get_field_model_info applied to the field metadata embedded in that expand's descriptor, the same way it is applied to the nested serializer's own model_fields when it is used as a root canonical serializer. Previously, only the root serializer's get_field_model_info ran; 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_fields generation:

    • get_schema_expandable_fields() (drives the expand query parameter's documented values) now calls generate_expand_model_info() and runs the result through get_expand_model_info(), the same generation and customization hook the /info/ meta-API uses for model_expands, instead of its own separate (and more limited) traversal of Meta.expandable_fields. A serializer that already overrides get_expand_model_info to describe a SerializerMethodField-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 way model_fields is built for /info/ (via ModelInfoSerializer.get_model_fields_data and get_field_model_info), and now documents the fields query 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, with type_serializer renamed to type), the many/read_only flags, the hidden flag, 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 own many/read_only flags don't appear in the schema either. Remove any custom get_schema_expandable_fields override that duplicated get_expand_model_info logic just to describe a SerializerMethodField expand 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 in vueda.core, vueda.vdq, and vueda.workflow were updated for Django 6.0's keyword-only save() signature, and the removed django.utils.itercompat import was replaced with a standard-library collections.abc.Iterable check. Django 6.0 and 6.1 both require Python 3.12+; installations on Python 3.11 continue to resolve Django 5.2 via uv.lock. Pin django<6 in your own application if you need to stay on Django 5.2 while running Python 3.12 or newer, or pin django<6.1 if you need to stay on Django 6.0.
    • The dj-rest-auth constraint was also raised (dj-rest-auth>=7.0.0,<8.0) to bring in a dj-rest-auth release that supports Django 6.0. dj-rest-auth itself declares support for django>=4.2 with no upper bound, so no action is required in your own application regardless of which supported Django version you run.
  • MAILERS support (Django 6.1+):

    • get_defaults() gained a use_mailers keyword argument. It defaults to False, which keeps configuring the deprecated EMAIL_BACKEND and EMAIL_TIMEOUT settings; pass use_mailers=True to configure Django 6.1's MAILERS setting instead (MAILERS = {"default": {"BACKEND": ..., "OPTIONS": {"timeout": 5}}}), built from the same EMAIL_BACKEND environment/config value. EMAIL_BACKEND continues 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 passing use_mailers=True, and confirm any third-party packages your project relies on (for example django-anymail) support MAILERS first.use_mailers=True now raises ImproperlyConfigured on Django < 6.1 instead of returning a MAILERS setting those versions silently ignore (which left them running the default SMTP backend instead of the configured one). Only pass use_mailers=True on Django 6.1+.
  • VuedaOrderingFilter:

    • DEFAULT_FILTER_BACKENDS now uses vueda.core.filters.VuedaOrderingFilter in place of DRF's stock rest_framework.filters.OrderingFilter. Declare nulls_ordering on 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 a F(...).asc(nulls_first=True)-style default ordering already 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_flip to have its declared nulls placement flip (first becomes last and 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 default ordering written 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. So nulls_ordering states the placement once for both routes, where ordering = [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 the nulls_first/nulls_last keyword of F().asc()/F().desc(). Any other value is ignored at request time rather than failing the request, and vueda_info.E007 reports the declaration.
    • A field named in the viewset's default ordering (or the model's Meta.ordering when the viewset doesn't declare one) is now always a valid explicit ?o= target, even when ordering_fields doesn'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 a CompositePrimaryKey). The expanded name is what model_ordering advertises, so a metadata-driven client sending ?o=id and a reader of the viewset's source sending ?o=pk both get primary-key order. Previously only the literal "pk" was added to the valid set, so a viewset combining ordering = ["pk"] with an ordering_fields that didn't name id advertised id and then silently ignored ?o=id.
    • A default ordering term may now be any expression order_by() accepts, not only a plain field name or an F(...).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. A formatted_name path inside such a term is rewritten to the column behind it in place, so Lower("customer__formatted_name") still applies the Lower. Previously any ordering expression other than a plain F raised NotImplementedError from VuedaOrderingFilter and from the model-info serializer, so a Lower(...) default ordering failed every list request and every model_ordering request for that model.VuedaOrderingFilter is a drop-in subclass of DRF's OrderingFilter. Existing viewsets that don't declare nulls_ordering see no change to nulls placement, but every viewset automatically gains the default-field-ordering behavior described above.
  • Automatic select_related/prefetch_related for expanded fields:

    • VuedaViewSet.get_queryset() now derives select_related/prefetch_related from the fields a list or retrieve request'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's source (a dotted source= in an expandable_fields declaration 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 GenericForeignKey expand (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 reverse GenericRelation onto 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 own select_related/prefetch_related for 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_name through get_formatted_name() may now declare formatted_name_select_related as a tuple of relation paths — the same paths it would pass to queryset.select_related() itself. annotate_formatted_name(), the helper FormattedNameManager.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 this select_related wherever it applies a formatted_name_lookup_expression annotation. Previously, resolving formatted_name in bulk for a model whose get_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. Declare formatted_name_select_related on a model whose get_formatted_name() reaches through a relation, naming every relation path it reads. A model with no formatted_name_select_related behaves exactly as before.
    • Added the vueda_info.E011 system-check error, reported by manage.py check, when a model declares both formatted_name_lookup_expression and formatted_name_select_related. The latter only has an effect alongside get_formatted_name(); a lookup expression resolves formatted_name entirely through a database annotation, so there is no per-instance computation for select_related to prepare relations for.

Fixes

  • OpenAPI validation:

    • Generated schemas describe bulk and single-object transition execution as separate paths. The bulk path no longer declares an optional object_id path parameter, which made the document invalid; the single-object path declares the required identifier. Handwritten model-info and workflow response schemas use the valid readOnly keyword. Runtime URLs and behavior are unchanged.
  • ordering_fields = None in model-info metadata:

    • A viewset that spells out ordering_fields = None no longer turns every model_info request for its model into a 500. None is OrderingFilter's own class default and DRF reads it as "not declared" — get_valid_fields falls through to get_default_valid_fields — so model_ordering.fields now 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 iterate None, raising TypeError. ordering_fields = [] remains the declaration that offers nothing, subject to the default-ordering fields VuedaOrderingFilter always accepts.
  • Queryset annotations named in ordering_fields:

    • An annotation added by the viewset's own get_queryset and named outright in ordering_fields (rather than reached through ordering_fields = "__all__") now appears in model_ordering.fields, typed from the annotation's output_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.
  • A "pk" alias named in ordering_fields:

    • VuedaOrderingFilter now expands a "pk" entry in ordering_fields to the field name(s) behind it, the same way it already did for a "pk" term in the default ordering. Metadata expands the alias wherever it is declared, so ordering_fields = ["pk"] advertised id in model_ordering.fields and 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_fields entries that aren't (name, label) pairs:

    • VuedaOrderingFilter now reads an ordering_fields entry positionally, the way DRF's own remove_invalid_fields does, 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 raised ValueError and failed every list request that sent ?o=.
  • ordering_fields entries 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.E006 reads a pair's field name the same way, so an entry naming a field the model doesn't have is reported at manage.py check time whichever form it is declared in. Previously a stale pair was reported by nothing.
  • Ordering on a searched list that deduplicates:

    • VuedaSearchFilterBackend now takes the ordering it re-applies from the queryset VuedaOrderingFilter handed 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 a DISTINCT ON that has to match it — so it was undoing both of the things the ordering filter had just resolved. A ?o= naming a related model's formatted_name failed the request with FieldError, because the rewritten path (customer__data__formatted_name) was replaced by the declared one (customer__formatted_name), which names no column. A field with a nulls_ordering placement 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, and ordering_fields gated ?o= everywhere except here — so a field left out of ordering_fields, and out of model_ordering.fields with 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's ordering_fields if 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 in model_ordering.fields for a metadata-driven client to find.
    • An ordering the DISTINCT ON cannot 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 own Meta.ordering (?o=customer, where Customer orders 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 with SELECT 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 default ordering, because a ?o= value is a plain field name and never carries the function. ordering_fields = "__all__" advertises every relation in model_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 context when resolving the ordering fields a viewset without ordering_fields allows. A serializer whose get_fields() needs the view — ExcludeFieldsSerializerMixin reads context["view"].action — no longer raises KeyError there. DRF's own get_default_valid_fields passes a context for the same reason. The view in that context is the model-info viewset, not the viewset being described, so a serializer that varies its field set by view.action is resolved against retrieve on /info/ rather than list on 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 no view at all, so the same serializer raises KeyError on the real list request too. Declare ordering_fields on such a viewset, which takes both paths out of the picture.
  • Duplicate formatted_name ordering check errors:

    • vueda_info.E005 now reports a model's Meta.ordering once, 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.E015 suppression on a model that loses the formatted_name annotation:

    • FormattedNameBaseModel._check_ordering now checks that the model's default manager actually annotates formatted_name before withholding that term from Django's models.E015. A model reaching the value through formatted_name_lookup_expression whose default manager doesn't inherit FormattedNameManager keeps Django's error, which is right about it: nothing annotates the name, so Meta.ordering raises FieldError on every query. Previously the term was withheld for any model with a lookup expression, and vueda_info.E009 was relied on to catch the rest — but E009 only reaches registered models that set formatted_name = None on the concrete class, so a model inheriting that from an abstract base, or one with no CRUDL surface, passed manage.py check and then failed on every query. No change for a model whose default manager is a FormattedNameManager or a subclass of one, which is every VUEDA model that hasn't replaced it. Fix a reported model the same way E009 asks: subclass FormattedNameManager, pass it to Manager.from_queryset() as the base, or point Meta.default_manager_name at 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:

    • updategroupmigrations failed with AttributeError when 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_permission issued 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, and available_transitions runs its whole pass inside one cached-state block.
    • Measured against store.CustomerOrder on the order_fulfillment workflow, available_transitions cost 8 + 4n queries in the number of transitions leaving the current state, and now costs 6 + n. A list response carrying valid_transitions for 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 RowLevelPermissions hooks return what they returned before. VuedaUserMixin.has_perm remains 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 called check_state_permission directly gets the same answer, and its signature gains only an optional caller argument.
  • PERMISSION_NAMES_MAPPING read at call time:

    • override_settings(PERMISSION_NAMES_MAPPING=...) now changes the codename get_permission_codename returns, 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 — as override_settings does — reached none of them. The value is cached and invalidated on setting_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 configures PERMISSION_NAMES_MAPPING once at startup. A test that used override_settings(PERMISSION_NAMES_MAPPING=...) and got no effect now sees the override applied.
  • available_transitions_for transition permissions:

    • HasWorkflowModelMixin.available_transitions_for now applies each transition's configured TransitionPermission rows to the calling user. Its filter previously reached check_transition_permission through the model class rather than an object, which bound the transition to self and left user at None, 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-object available_transitions for 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 RowLevelPermissions hooks apply per object. Passing user=None still 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 state list_* 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 to apply_row_level_filter. If a custom action relies on state grants to overcome a baseline denial, add the action name to workflow_object_permission_actions only when the action always performs an object permission check.
  • Optional VDQ notifications:

    • Applications can now install vueda.user without installing vueda.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.
  • Writable nested history serializer responses:

    • History-enabled objects created through a writable nested serializer now include their annotated current_history_id in the response. Nested serializers re-fetch the new object through its own model manager instead of the parent view's queryset.
  • Dependency security floor:

    • The server package now requires cryptography 48.0.1 or newer and starlette 1.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.
  • UserSerializer non-mapping input:

    • Submitting non-mapping data to UserSerializer on 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 unhandled AttributeError.
  • Sparse field requests and formatted_name:

    • Requests scoped to a subset of fields via FIELDS_PARAM/OMIT_PARAM no longer reject formatted_name as an invalid submitted field. formatted_name is a virtual, model-computed field and is now always accepted regardless of the requested field subset.
  • Stale choices on value-derived filters:

    • AllValuesFilter and AllValuesMultipleFilter build their choices from the values currently stored in a column. VuedaViewSet.list and 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:

    • DefaultSendQueueViewSet and DefaultSentItemViewSet now use HasWorkflowViewMixin. QueueItem (and SentItem, which subclasses it) is a HasWorkflowModelMixin model, so access to a queue item can come entirely from workflow state permissions, which are row level by nature. Without the mixin the generic has_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:

    • DefaultSendQueueViewSet and DefaultSentItemViewSet now declare ordering = ["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 that model_ordering.default reports ["queued"] for them rather than QueueItem.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 overrode get_queryset to re-order these lists, or one that relied on the inherited queryset's order_by, should declare ordering instead; vueda_info.E010 reports the queryset form.
  • Model-info choices for value-derived filters:

    • Model-info model_filtering now reports AllValuesFilter and AllValuesMultipleFilter as model-backed ("choices": true, with app_label, model, and filterset_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 from model_info_filter_choices.
  • FIELDS_PARAM/OMIT_PARAM no longer narrow write validation:

    • FlexFieldsWriteableNestedSerializerMixin applied FIELDS_PARAM/OMIT_PARAM to the field set before deserialization, so a required field excluded by ?f=/?om= lost its validator: is_valid() passed with the field missing, and save() either raised a database NOT NULL/IntegrityError or stored the field's blank default. ?f=/?om= now narrow only the representation, applied after save(), so a POST/PUT/PATCH always validates against the serializer's full field set: a field the request body supplies validates fully regardless of ?f=/?om=. On POST/PUT, this also means every required field stays required regardless of the requested subset. PATCH is 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 a POST/PUT body, that request now returns 400 naming the field instead of a 500 integrity error or a silently incomplete row. Supply every required field in a POST/PUT body regardless of ?f=/?om=; a PATCH may still omit a field it is not changing, ?f=/?om= or not.
  • Workflow transition check cost:

    • HasWorkflowModelMixin.allow_transition now resolves only the transition it is asked about. It previously built the object's whole permitted set through available_transitions and 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_transitions is unchanged and remains the way to obtain the permitted set itself.
    • HasWorkflowModelMixin gained cached_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_transition opens one per call, which keeps the post-lock re-check in execute_transition reading the state the lock protects. The object_state lookup 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_transition override:

    • vueda.vdq.models.QueueItem no longer overrides allow_transition. The override duplicated the inherited implementation verbatim and would otherwise have kept VDQ on the superseded per-candidate path.
  • permitted_transitions for models without a workflow:

    • WorkflowViewSet.permitted_transitions no longer requires vueda_workflow.read_workflow when the requested app_label/model pair has no configured workflow. A user who can read that model now gets 200 with an empty transition list instead of 403. read_workflow is still required whenever a workflow is configured for the model, and every other workflow endpoint is unchanged.
  • model_fields metadata resolved through the wrong attribute:

    • model_fields now resolves a field's type_db/type_model through the serializer field's source (including a dotted source such as author.name) instead of the serializer field's own name. A field declared with an explicit source= previously reported null for both types even when its source named a real model field; it now reports that field's actual type. source is walked exactly as DRF resolves it at runtime: split only on ., and an intermediate segment matching only a foreign key's scalar *_id attname 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 a null type_db/type_model for a field with an explicit source= or a traversing lookup expression, it may now see a real type instead. null still means the same thing it always did for a SerializerMethodField or a field whose source doesn't resolve at all.
    • Added the vueda_info.W001 system-check warning, reported by manage.py check. It flags a field whose source or model <field>_lookup_expression fails 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. A source failure can avoid the warning three ways: two structural opt-outs checked up front — a field bound to the whole object (field.source == "*"SerializerMethodField forces this, and ObjectRevisionField, AvailableActionsField, and AvailableTransitionField set it explicitly, since each overrides get_attribute() outright and never reads source), or a model defining a matching get_<field_name>() method (the same convention formatted_name's get_formatted_name() establishes) — or a correction applied after the fact: the check also runs the serializer's get_field_model_info and only warns if type_db/type_model are still null once that correction is applied. Absent all three, a source is 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_expression is flagged on any failure the same way and none of the three source= escapes apply to it, since it is fed directly to models.F() for queryset annotation and to Django admin's lookup_field() and so has no legitimate non-model-backed reading at all. _A registered model whose serializer has a source= that doesn't resolve (a @property-backed one, or a field-name typo with no source= given at all) will newly report a vueda_info.W001 warning under manage.py check, unless its get_field_model_info already describes the field's real type (HasWorkflowSerializerMixin.workflow_state_code/workflow_state_name now does exactly this, so those two fields report CharField in /info/ and produce no warning). This is advisory; if a flagged field is intentionally not model-backed, describe it with get_field_model_info, which both corrects /info/'s response and silences the check, or give the field an explicit source="*"/a matching get_<field*name>() model method if it's a reusable custom field.*
  • Choices requests dispatched twice:

    • ModelInfoChoicesBaseViewSet.dispatch ran the whole request, discarded the response unless it was a 200, and ran it again. Only the second run could deny. get_queryset set choices_permissions as a side effect, and check_permissions skips its body while that attribute is None. Both choices viewsets now resolve the addressed field and the permissions it requires in resolve_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_queryset also stopped reading field.choices to 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 subclasses ModelInfoChoicesBaseViewSet directly must implement resolve_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, and VuedaViewSet.get_queryset constructs 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 in Meta is 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 default ObjectPermissions builds one before the handler runs, so such a request still reports the expansion depth.
  • 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.info registry. Registrations made with register() and with register_serializer() both count. check_expandable_fields_configuration and check_exclude_fields_serializer_usage walk one shared graph from that seed, following declared nested serializer fields and Meta.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 check passed a malformed expandable_fields entry, or a nested ExcludeFieldsSerializerMixin child, 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. An expandable_fields entry written as () crashed check_exclude_fields_serializer_usage with IndexError instead of letting the check finish. The entry now resolves to no child, so traversal passes over it and check_expandable_fields_configuration reports it as vueda_core.E002. A project that registers a serializer for metadata alone may see new vueda_core.E001 to vueda_core.E008 errors from manage.py check. Each one names a configuration fault that would otherwise surface as a request-time failure.

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:
    • VuedaSerializer now removes available_actions from ordinary object responses unless the sparse field request explicitly includes it. Review client or integration code that read available_actions directly 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 pk filter.
  • Password reset and error response shapes:
    • Password reset and unhandled error responses now follow DRF-style detail and field-error shapes more consistently. Review code that matched older result / message or error response keys.

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 VuedaSearchFilterBackend support for ranked search, trigram similar lookups, word-similar lookups, deterministic ordering, and distinct handling.
  • 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 channels as a runtime dependency and updated async/background-work related server dependencies for the v3 package set.

Fixes

  • REST error consistency:
    • Unhandled server errors now return a detail key, 404 responses use a DRF-style detail payload, and ImproperlyConfigured errors are converted into client-readable validation details.
  • 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 detail for rate limiting.
  • 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.

Documents matching: server v3.0.0a1.post1client v3.0.0-alpha.2