Skip to content

Create a CRUDL Surface for a New Model ​

This guide walks through the end-to-end process of standing up a fully functional CRUDL surface; list, create, read, update, and delete; for a new Django model in VUEDA. By the end, the model will be served by the REST API, discoverable through model-info, and navigable in the Vue client with metadata-driven routes, forms, and permission gating.

The guide assumes familiarity with the framework's layered architecture. If you have not yet read Architecture Overview, start there; the server responsibility layers and convention-over-configuration principles it describes are the foundation for everything below. This flow starts with VuedaModel and builds a complete CRUDL surface from server conventions.

Goal and Preconditions ​

The objective is a single model that supports all five standard CRUDL actions; list, create, retrieve, update, and destroy; and that is fully registered with the metadata API so the client can discover it, generate routes, and render views without hand-wired per-model code.

Before you begin, ensure the following are in place:

The model's Django app is installed in INSTALLED_APPS and has an AppConfig with a ready() method available for registration. The database migrations for the model have been created and applied. The project's URL configuration includes the VUEDA info URLs (vueda.info.urls) and has a router path where your app's viewset URLs will be mounted.

Model and Permission Baseline ​

VUEDA's conventions begin at the model layer. Extend VuedaModel to inherit the framework's base infrastructure, including the default formatted_name generated field and the BaseModelMeta permission set.

BaseModelMeta defines default_permissions as ("create", "read", "update", "delete", "list"). These replace Django's default add/change/delete/view codenames with VUEDA's own CRUDL codenames. Permission evaluation throughout the stack; model-info action filtering, viewset permission checks, and client-side route gating; relies on these codenames being present. If your model's Meta does not inherit from BaseModelMeta (either directly or through VuedaModel), the permission machinery will not find the expected codenames and action visibility will break.

The formatted_name Contract ​

formatted_name is a client-facing object representation distinct from __str__. It is used as the display label in choice dropdowns, expanded-field references, and anywhere the client needs a human-readable label for an object instance. VuedaModel defines it as a GeneratedField with expression F("name"), which works when the model has a name field. Four strategies exist for models where this default does not apply:

Inherited generated field (default). If your model has a name field, the inherited GeneratedField works without changes. The database materializes the value and it is available for efficient querying.

Custom generated-field expression. Override the formatted_name field with a different expression; for example, Cast(F("order_number"), output_field=CharField()). This keeps the value database-persisted while deriving it from a different source.

Null field with a lookup expression. Set formatted_name = None on the model and define formatted_name_lookup_expression as a string pointing to an alternate field path (e.g., "data__formatted_name"). FormattedNameManager — the default manager FormattedNameBaseModel provides — annotates every queryset the model builds with this expression, and VuedaViewSet.get_queryset does the same for the querysets DRF builds, so formatted_name returns the resolved value in all list and retrieve responses. Because the annotation is on the model's own manager, formatted_name also works on a plain Model.objects.all(): in a management command, in the admin, through a reverse relation, or anywhere else a queryset is built (see Replacing the default manager). When the model appears as an expanded field in another serializer, formatted_name is populated in list and retrieve expand responses as well. VuedaViewSet.get_queryset loads each expanded relation to the model, to-one or to-many, through a prefetch query that carries the annotation. When the viewset prefetches the relation with a Prefetch queryset of its own, that queryset loads the relation instead. Build it from the model's FormattedNameManager so that it carries the annotation. Query Cost of List and Retrieve Expansion describes how the plan places each join. VuedaListSerializer applies the same annotation to a to-many relation that arrives without it. Choice endpoints use the same annotation when resolving labels. Point the expression at a real column: the annotation is a plain F(...) over that path, so every segment has to be a database field. An expression aimed at another model's formatted_name works only when that model has a formatted_name column of its own — it is not followed through a second formatted_name_lookup_expression, and pointing at one raises a FieldError when the queryset is annotated. Every relation the path crosses also has to be single-valued: a forward foreign key or a one-to-one, nullable or not. Reaching through a reverse foreign key, a many-to-many, or a GenericRelation would join a row per related object, so the model would quietly return more rows than its table holds on every queryset rather than failing anywhere — vueda_info.E008 reports that at startup.

Null field with a Python method. Set formatted_name = None on the model and implement a get_formatted_name() method for runtime computation. This is the most flexible option but requires explicit wiring in the serializer (covered in the next section). Choice endpoints resolve labels using a priority order: get_formatted_name() method, then formatted_name_lookup_expression annotation, then the direct formatted_name field, then static field choices.

A get_formatted_name() that traverses a relation — self.customer.user.email, say — costs one extra query per row per relation traversed whenever formatted_name is resolved in bulk, unless the model also declares formatted_name_select_related as a tuple of relation paths, the same paths it would pass to queryset.select_related() itself:

python
class Cart(VuedaModel):
    customer = models.ForeignKey(Customer, on_delete=models.PROTECT)

    formatted_name = None
    formatted_name_select_related = ("customer__user",)

    def get_formatted_name(self):
        if self.customer and self.customer.user:
            return self.customer.user.email

annotate_formatted_name — the helper FormattedNameManager.get_queryset, VuedaViewSet.get_queryset, VuedaListSerializer.to_representation, and the prefetch-plan builder all share — applies this select_related the same way it applies a formatted_name_lookup_expression annotation, so every bulk path that resolves formatted_name joins the declared relations in the same query instead of resolving them per row. A to-one expand onto the model joins the same relations, after the expanded relation's own path. A model with no formatted_name_select_related is unaffected; declaring it is only worthwhile when get_formatted_name() actually reaches through a relation.

Setting formatted_name = None without providing either formatted_name_lookup_expression or get_formatted_name() is caught at startup by a Django system check (vueda_info.E001), which reports the misconfiguration before any requests are served. Providing both alternatives triggers vueda_info.E002; decorating get_formatted_name with @property instead of leaving it as a plain method triggers vueda_info.E003; passing a non-string value for formatted_name_lookup_expression triggers vueda_info.E004; pointing formatted_name_lookup_expression through a relation that can match more than one row triggers vueda_info.E008; declaring a formatted_name_lookup_expression on a model with a manager that doesn't annotate formatted_name triggers vueda_core.E019, unless that manager is not the default and opts out; an opted-out manager whose querysets cannot compile the model's Meta.ordering triggers vueda_core.E020; and declaring both formatted_name_lookup_expression and formatted_name_select_related triggers vueda_info.E011, since formatted_name_select_related only has an effect alongside get_formatted_name().

Ordering by formatted_name. The first three strategies are sortable in the database, so formatted_name may be named as a plain field name in a viewset's ordering or ordering_fields, or in the model's own Meta.ordering, and clients may request it with ?o=formatted_name. A generated field is sorted as its own column; a lookup expression is sorted through the annotation VuedaViewSet.get_queryset already adds. Either way, model-info metadata reports the field as formatted_name — the lookup expression behind it stays a server-side detail.

On a viewset, whichever strategy the model uses:

python
class DeliveryViewSet(VuedaViewSet):
    queryset = Delivery.objects.all()
    serializer_class = DeliverySerializer
    ordering = ["formatted_name"]

Or as the model's own default, which applies whenever the viewset declares no ordering of its own:

python
class Delivery(VuedaModel):
    formatted_name = None
    formatted_name_lookup_expression = "recipient__name"

    class Meta(VuedaModel.Meta):
        ordering = ["formatted_name"]

Prefix the name with - for descending order. To control where rows with a null formatted_name land, declare the term as an F("formatted_name").asc(nulls_last=True) expression instead, the same as for any other field (see Filtering and Ordering Semantics).

A term may also be a scalar database function — Lower("formatted_name") for a case-insensitive sort, Coalesce("formatted_name", "code") to fall back to another column — the same as for any other field. Metadata still reports the field the function reads, so a client that sorts by it explicitly sends ?o=formatted_name either way, and that request sorts by the column rather than the function. A list the reader has not sorted sends no ordering param at all, so the function still applies (see Database Functions in a Default Ordering).

A model-level ordering on a lookup-expression formatted_name names something that is not one of the model's own fields, which Django's models.E015 system check would normally reject. FormattedNameBaseModel._check_ordering withholds that one term from the check, since FormattedNameManager annotates the expression onto every queryset the model builds and so makes the ordering valid by the time the query runs. Every other term in the same Meta.ordering is still checked as usual, so a genuinely stale field name is still reported.

The manager is what makes that suppression safe, so the suppression asks about the manager. Meta.ordering applies to every queryset, not only the ones a viewset builds, so the annotation has to be there too — otherwise withholding the check would trade a startup error for a FieldError at query time in a management command, a data migration, or an admin page. A model whose default manager doesn't annotate formatted_name is therefore left to models.E015, which is right about it. vueda_core.E019 reports the same model with a hint aimed at the manager rather than at the ordering.

Replacing the default manager ​

Django uses the first manager declared on a model as its default, so a model that declares its own objects shadows FormattedNameManager and loses the annotation. Inherit from it rather than from models.Manager when a VUEDA model needs a manager of its own:

python
from vueda.core.models import FormattedNameManager, VuedaModel


class DeliveryManager(FormattedNameManager):
    def get_queryset(self):
        return super().get_queryset().filter(archived=False)


class Delivery(VuedaModel):
    formatted_name = None
    formatted_name_lookup_expression = "recipient__name"

    objects = DeliveryManager()

Build on super().get_queryset() rather than a fresh queryset, so the annotation survives. For a manager built with Manager.from_queryset(), pass FormattedNameManager as the base: FormattedNameManager.from_queryset(DeliveryQuerySet). A manager built with QuerySet.as_manager() has no base to pass. Replace DeliveryQuerySet.as_manager() with FormattedNameManager.from_queryset(DeliveryQuerySet)(), which builds the same manager on top of FormattedNameManager.

This only matters for models with a formatted_name_lookup_expression and no formatted_name column — the one strategy where the name has no column of its own. A model with the generated-field column, or one using get_formatted_name(), is unaffected either way.

A manager declared on an abstract base shadows the default manager just as readily as one declared on the model, and is the easier case to miss, since the model that names the lookup expression can be several classes away from the one that names the manager. VUEDAUserManager and SentItemManager sit on models that have no annotation to lose.

WARNING

A model whose default manager doesn't annotate formatted_name is back to the pre-manager behavior. The common case is a default manager that doesn't inherit FormattedNameManager. On such a model, filtering or ordering by formatted_name on a queryset from that manager raises FieldError: Cannot resolve keyword 'formatted_name', and so does every query when Meta.ordering names it. vueda_core.E019 reports the manager at startup, with a hint aimed at fixing it. It is a model check, so it reaches every installed model, registered or not. It reads the queryset the manager builds rather than the manager's class, so a manager of your own that annotates the path passes and a FormattedNameManager subclass that dropped the annotation does not. On a Meta.ordering that names formatted_name as a plain string, Django's own models.E015 reports the term as well, because the suppression above asks about the manager before withholding anything.

Managers other than the default ​

Every manager of the model needs the annotation, not only the default one. A second manager builds querysets of its own:

python
class Delivery(VuedaModel):
    formatted_name = None
    formatted_name_lookup_expression = "recipient__name"

    objects = DeliveryManager()                 # inherits FormattedNameManager
    unfulfilled = UnfulfilledDeliveryManager()  # inherits models.Manager

Delivery.unfulfilled.order_by("formatted_name") raises FieldError, and so does any filter on the name. A viewset built on Delivery.unfulfilled still works, because VuedaViewSet.get_queryset adds the annotation itself. The failure shows up later, in a report, a management command, or the shell. vueda_core.E019 reports each manager that does not annotate, by its attribute name, so one manage.py check run lists all of them. The fixes are the same as for the default manager, except Meta.default_manager_name: pointing it at another manager does not fix a second one.

Opting a manager out of the check ​

Some managers never use formatted_name. Examples are a manager that feeds a data export, or one that sends data to or receives data from another system's API. When the lookup expression crosses a relation, the annotation also adds a join that such a manager may not want. Set used_without_formatted_name = True on the manager's class to leave it out of vueda_core.E019:

python
from django.db import models

from vueda.core.models import FormattedNameManager, VuedaModel


class DeliveryExportManager(models.Manager):
    used_without_formatted_name = True


class Delivery(VuedaModel):
    formatted_name = None
    formatted_name_lookup_expression = "recipient__name"

    objects = FormattedNameManager()
    export = DeliveryExportManager()

Declare objects above the opted-out manager. Django makes the first manager that a model declares its default, ahead of the objects that VuedaModel provides. A model that declares only export would make export its default manager. Setting Meta.default_manager_name = "objects" also works, and then the order of the declarations does not matter.

The opt-out follows these rules:

  • Only the value True opts out. A missing attribute leaves the manager checked, and so does any other value, such as 1 or "yes".
  • Subclasses inherit the attribute. A subclass that sets used_without_formatted_name = False is checked again.
  • The opt-out has no effect on the default manager. Related managers, the admin, and dumpdata all build their querysets from the default manager, so it always needs the annotation. A default manager that sets the attribute is still reported, and the message says that the opt-out does not apply.
  • An opted-out manager still has to run with the model's Meta.ordering. Django applies Meta.ordering to the querysets of every manager. When it names formatted_name, every query from the opted-out manager raises FieldError, even Delivery.export.all(). vueda_core.E020 reports that manager. Give its get_queryset() an order_by() of its own that does not need formatted_name, or give it the annotation.

The opt-out changes only what the manager's own querysets carry. A viewset built on an opted-out manager still returns formatted_name, because VuedaViewSet.get_queryset adds the annotation itself.

A model that VUEDA never displays has a simpler option: inherit only Django's models.Model, and no formatted_name check runs on it. Data That VUEDA Does Not Display compares the two options.

Model._base_manager is not this manager and never carries the annotation. Django builds the base manager itself, as a plain models.Manager, unless Meta.base_manager_name names one — deliberately, since the base manager is what fetches related objects and a default manager may filter them out.

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. vueda_core.E017 reports a model that did neither. It is a model check rather than a registration check, so it reaches a model no serializer or viewset names. It asks both managers by compiling the ordering rather than by reading their classes, so a manager of your own that annotates the path passes and a FormattedNameManager subclass that dropped the annotation does not.

The Python-method strategy cannot be ordered by: the value is computed per object, so the database has no column to sort on, and sorting in Python would mean loading every row of the table. Ordering declared on a method-backed formatted_name triggers vueda_info.E005 at startup. Switch that model to formatted_name_lookup_expression if the value needs to be sortable. Where the value needs a join or an aggregate that no field path reaches, back it with a database view — a managed = False model related by OneToOneField — and point formatted_name_lookup_expression at a real column on that view (see Queryset annotations).

Ordering and filtering by a related model's formatted_name. A related model's formatted name is named the way any other related path is, and works to any depth:

python
class CartViewSet(VuedaViewSet):
    queryset = Cart.objects.all()
    serializer_class = CartSerializer
    ordering = ["customer__formatted_name"]
    ordering_fields = ["customer__formatted_name", "customer__user__formatted_name"]


class CartFilterSet(VuedaFilterSet):
    customer_name = filters.CharFilter(field_name="customer__formatted_name", lookup_expr="icontains")

    class Meta:
        model = Cart
        fields = []

When Customer reaches its formatted name through formatted_name_lookup_expression = "data__formatted_name", customer__formatted_name names no column the database knows: the annotation VuedaViewSet.get_queryset adds belongs to the Cart queryset, not to the Customer rows it joins. VuedaOrderingFilter rewrites the path to customer__data__formatted_name before the query runs, and FormattedNamePathFilterSetMixin does the same for a filter's field_name. Both VUEDA filterset bases — VuedaFilterSet and VuedaCompositePrimaryKeyFilterSet — already include that mixin, so CartFilterSet above needs nothing extra; a filterset built on django-filter's FilterSet directly has to mix it in itself, ahead of the FilterSet base (see The filterset half of the rewrite). For ordering, a client sends the dotted form of the declared path — ?o=customer.formatted_name — which is what model_ordering reports too. For filtering, customer_name has no __ in it to translate, so a client sends that same declared name, which is what model_filtering reports and a generated filter label is built from; a filter declared against a Meta.fields entry that names the relation directly (customer__formatted_name, with no custom attribute name of its own), or one declared by hand under a __-joined attribute name of its own, would instead get customer.formatted_name derived for it automatically (see Public filter names). Either way, the resolved path is server-side only and is not accepted as a query parameter.

Two shapes are refused rather than rewritten, left out of the metadata, and reported by vueda_info.E006: a related formatted_name that a get_formatted_name() method computes (no column exists behind it at any depth), and a path that reaches the related model through a reverse foreign key or many-to-many, which would join a row per related object and multiply the rows a list request returns.

WARNING

This applies to a viewset's ordering and ordering_fields, to ?o=, and to filters — not to a model's Meta.ordering, which applies to every queryset including ones no filter backend touches. ordering = ["customer__formatted_name"] in a model's Meta is still rejected by Django's models.E015; only the model's own un-prefixed formatted_name is withheld from that check.

Models that cannot inherit VuedaModel. Django's built-in Group, Permission, and ContentType do not inherit from VuedaModel, but VUEDA patches them in InfoConfig.ready() so they work correctly as expandable fields without any action on your part. Group and Permission receive formatted_name_lookup_expression = "name". ContentType receives a get_formatted_name() method that returns app_labeled_name (the "app_label | verbose_name" display string).

If you have a third-party model that cannot inherit VuedaModel but needs to participate in VUEDA's expand and formatted-name system, apply the same pattern in your own AppConfig.ready(): set formatted_name_lookup_expression to a field path string, or assign a get_formatted_name method that returns a string. Then add _has_formatted_name_field as a classmethod that returns truthy, and _get_formatted_name as an instance method that reads the annotated formatted_name attribute when present and falls back to get_formatted_name() or lookup_field. The FormattedNameBaseModel source is the reference implementation for both.

To name a lookup-expression formatted_name in such a model's Meta.ordering, assign FormattedNameBaseModel._check_ordering as well — without it, models.E015 reports the term — and give the model a FormattedNameManager (or a subclass) as its default manager, so the annotation the withheld check assumes is actually there on every queryset.

Serializer Contract ​

The Canonical Serializer defines the field schema that the metadata API exposes to the client. Every field the client can see, validate against, or submit comes from this serializer definition. Extend VuedaSerializer.

VuedaSerializer declares formatted_name and available_actions as base fields. Both are read-only. The serializer's Meta.fields list must include all fields that should appear in the metadata surface; if a field exists on the Django model but is not listed in the serializer's fields, it will not appear in model-info and the client will not know it exists. See Server-Client Metadata Contract for the full mapping from serializer definitions to metadata sections.

When the model uses the get_formatted_name() method pattern (the fourth strategy described above), the serializer must explicitly declare formatted_name = serializers.SerializerMethodField() and provide a corresponding get_formatted_name(self, obj) method. Without this declaration, formatted_name will be omitted from API responses and choice label rendering on the client will break.

A typical serializer definition extends the base Meta.fields list with the model's own fields:

python
from vueda.core.serializers import VuedaSerializer
from .models import Widget

class WidgetSerializer(VuedaSerializer):
    class Meta(VuedaSerializer.Meta):
        model = Widget
        fields = [
            "id",
            "name",
            "description",
            "status",
        ] + VuedaSerializer.Meta.fields

The serializer also serves as the source for expandable_fields metadata. If your model has foreign key or many-to-many relationships that should be expandable in the API, declare them in Meta.expandable_fields following the rest_flex_fields2 tuple syntax.

The NoExtraFieldsSerializerMixin (which VuedaSerializer includes) rejects unknown fields on input. Submitting a field name that is not in the serializer's declared fields will produce a validation error. This strictness is intentional; it prevents silent data loss from typos and keeps the serializer definition authoritative.

ViewSet Contract ​

The viewset defines the actions, filtering, ordering, and permission behavior for the model's API endpoints. Extend VuedaViewSet. It carries the history endpoint for every model that records history, so no history-specific base class exists. At minimum, set queryset, serializer_class, and the fields you want to support for ordering:

python
from vueda.core.viewsets import VuedaViewSet
from .models import Widget
from .serializers import WidgetSerializer
from .filtersets import WidgetFilterSet

class WidgetViewSet(VuedaViewSet):
    queryset = Widget.objects.all()
    serializer_class = WidgetSerializer
    filterset_class = WidgetFilterSet
    ordering_fields = ["name", "status"]

Set a default sort order with the viewset's ordering attribute (or the model's Meta.ordering), never with an order_by() on the queryset attribute or inside get_queryset. Model-info reads the declarations, not the code, so an order_by() applied in code sorts the rows without being described — and model_ordering.default then advertises a different order to every client. See Filtering and Ordering Semantics.

VuedaViewSet inherits from DRF's ModelViewSet and adds several framework behaviors. ListRowLevelViewSetMixin applies row-level permission filtering on list queries. NoExtraFieldsForViewSetMixin validates query parameters against the filterset and rejects unknown parameters. FlexFieldsMixin provides expand-aware serializer context. Together these mixins ensure that the viewset's behavior is consistent with what the metadata API advertises.

The viewset provides all five standard CRUDL actions by default: list, create, retrieve, update (including partial_update), and destroy. The destroy action supports both single-object deletion (via DELETE to the detail endpoint with a PK in the URL) and bulk deletion (via DELETE to the list endpoint with a {"pks": [...]} payload). Bulk destroy validates that all requested PKs exist before deleting any of them, and supports an optional dry-run mode via the Dry-Run header.

WARNING

VuedaViewSet does not wrap its own CRUDL handlers in transaction.atomic by default. If you need atomic write behavior, use AtomicModelViewSetMixin or manage transaction boundaries explicitly in your viewset. The web process's ATOMIC_REQUESTS setting provides request-level atomicity as a safety net, but explicit transaction control is appropriate when the viewset needs finer-grained boundaries.

Extra actions beyond the standard CRUDL set are defined using the @action decorator from vueda.core.decorators. Only add extra actions when you need distinct route or permission behavior that the standard actions do not cover. Extra actions appear in model-info's action list and are gated by get_allowed_extra_actions on the viewset.

Router and URL Wiring ​

Register the viewset with a VuedaRouter instance. The router generates URL patterns that follow VUEDA's conventions; including the app-label-qualified route names that the metadata API depends on for action resolution.

python
from vueda.core.routers import VuedaRouter
from .viewsets import WidgetViewSet

router = VuedaRouter()
router.register("widget", WidgetViewSet)

urlpatterns = router.urls

VuedaRouter extends DRF's SimpleRouter with two changes. It includes the app label in route names to prevent naming collisions between apps that happen to have models with the same name. It also maps DELETE on the list route to the viewset's destroy method, which is what enables the bulk-delete behavior described above.

The client requests every model endpoint at /routes/<app_label>/<model_name>/, with the model's Django _meta.app_label and _meta.model_name. The server must serve the viewset at that path unless the client project replaces the URL template with setCustomUrl. Register the viewset under the model name, as above, and mount the app's URL module at <app_label>/ under routes/.

Mount the app URL module inside the top-level routes/ path that also includes vueda.info.urls and other framework URL modules:

python
urlpatterns = [
    path("routes/", include([
        path("myapp/", include("myapp.urls")),
        path("", include("vueda.info.urls")),
        # ... other app and framework URLs
    ])),
]

A project generated from the VUEDA template already includes <python_package>.urls at "" under routes/. Add path("myapp/", include("myapp.urls")) to that module's urlpatterns instead of editing config/urls.py.

Model-Info Registration ​

Registration is the act that makes the model visible to the metadata API and, through it, to the client. Without registration, a model with a perfectly defined serializer, viewset, and router will simply not exist from the client's perspective. See Canonical Registration and Model Discovery for the full explanation of registration states and their implications.

Register the model in your app's AppConfig.ready() method by calling register with the canonical serializer and viewset:

python
from django.apps import AppConfig
from vueda.info import register

class MyAppConfig(AppConfig):
    name = "myapp"

    def ready(self):
        from .serializers import WidgetSerializer
        from .viewsets import WidgetViewSet

        register(WidgetSerializer, WidgetViewSet)

The imports are inside ready() deliberately. Registration resolves content types internally, which requires the Django app registry, content type framework, and all dependent models to be fully initialized. Performing registration at import time or module scope risks content-type resolution errors and import-ordering failures.

The register function accepts the serializer as the first argument and the viewset as the second. It can also be used as a decorator on the viewset class, with just the serializer as the argument. Both styles produce the same result; a fully registered model that appears in model-info with complete metadata: fields, actions, filters, ordering, and permissions.

Calling register_serializer instead of register produces a Serializer-Only Registration. The model will appear in model-info with field schema and permission metadata but without action, filter, or ordering metadata. This is appropriate for models that are referenced through expands but do not need their own CRUDL surface. It is not sufficient for a full CRUDL surface; the client cannot generate routes or forms for a model that lacks action metadata.

Client Route Wiring ​

On the client side, makeCRUDRoutes generates the two route records that handle all CRUDL navigation for all registered models: a detail route (/:app/:model/:action/:pk) and a list route (/:app/:model/:action/). Both routes share a guard chain that loads model-info before allowing navigation and verifies that the requested action is present in the computed allowlist. See Routing and View Resolution Model for the full explanation of how route gating and view resolution work.

In the project's router setup, call makeCRUDRoutes and add the returned routes to the router:

javascript
import { makeCRUDRoutes } from "@vueda/router/makeCrud.js";
import ViewActionRouter from "@vueda/views/ViewActionRouter.vue";

const crudRoutes = makeCRUDRoutes({
    component: ViewActionRouter,
    actionRedirect: { name: "not-found" },
    authRedirect: { name: "login" },
    vueApp: app,
    router,
    pinia,
});
router.addRoute(crudRoutes[0]);
router.addRoute(crudRoutes[1]);

The actionRedirect parameter is required. It specifies the route the guard should redirect to when a model or action is not found. If actionRedirect is falsy, makeCRUDRoutes throws at router build time; this is a hard error, not a runtime guard failure. The redirect target must not itself be gated by requireModelInfo, or you will produce a redirect loop.

The requireModelInfo guard computes its allowlist by intersecting server-advertised actions from model-info, any routeActions restrictions from client model-config, and workflow transition codes. This is the Route Admission check. The guard normalizes action names through a static action-map utility. The server uses retrieve and partial_update, while the client uses read and update in route paths. This normalization is automatic; you do not need to manually translate between naming conventions when defining routes.

Once routes are wired, the ViewActionRouter component handles runtime view resolution. It selects the concrete view component based on the action parameter: built-in CRUDL components for standard actions (list, create, read, update), or dynamically imported project-level components for custom actions. No per-model client code is needed for standard CRUDL surfaces.

After the baseline CRUDL surface is working, view behavior can be customized through model config without forking core components. See Configure CRUDL Views for the configuration API.

Grant Permissions ​

Model-info filters model_actions by the requesting user's permissions, and the client guard admits only listed actions. A user without grants sees "Action Not Found" on every route.

Grant myapp.list_widget, myapp.read_widget, myapp.create_widget, myapp.update_widget, and myapp.delete_widget to a group, then add your test user to that group. Use the group management page described in Manage Groups and Generate Group Migrations, or run this in python manage.py shell:

python
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission

group, _ = Group.objects.get_or_create(name="Widget Editors")
group.permissions.add(*Permission.objects.filter(
    content_type__app_label="myapp",
    codename__in=["list_widget", "read_widget", "create_widget", "update_widget", "delete_widget"],
))
get_user_model().objects.get(email="tester@example.com").groups.add(group)

Test with this user, not a superuser. A superuser passes every check, so it hides missing grants.

Verification Checklist ​

With all pieces in place, verify the surface end-to-end:

  • The model appears in the model-info list endpoint (GET /vueda.info/model_info/) and returns complete metadata from the detail endpoint (GET /vueda.info/model_info/{app_label}/{model}/), including model_fields, model_actions, model_filtering, and model_ordering.
  • The list endpoint returns paginated results and respects filter and ordering query parameters.
  • The detail endpoint returns a single object with an available_actions field reflecting the requesting user's permissions.
  • Create, update, and partial-update succeed with valid payloads and reject unknown fields with validation errors.
  • Single-object delete and bulk delete (via {"pks": [...]} payload) both succeed. Bulk delete with missing PKs returns a validation error identifying which PKs were not found.
  • Dry-run delete (with the Dry-Run: true header) returns 200 without deleting.
  • Client navigation to /:app/:model/list/ loads model-info, renders the list view, and displays data.
  • Client navigation to /:app/:model/read/:pk renders the read view for a specific object.
  • Client navigation to /:app/:model/create/ renders the create form. Submission redirects to the appropriate view.
  • Client navigation to /:app/:model/update/:pk renders the update form. Submission redirects to the appropriate view.
  • Navigating to an action the user lacks permission for produces an "Action Not Found" toast and redirects to the actionRedirect target.

Troubleshooting ​

Model does not appear in model-info. The most common cause is a missing register() call. Verify that the app's AppConfig.ready() method calls register with both the serializer and viewset. A register_serializer-only registration produces metadata without actions, which is not sufficient for a CRUDL surface.

The list view shows a 404 error. The client requested /routes/<app_label>/<model_name>/ and the server does not serve that path. Check that the router registers the viewset under the model name and that the project mounts the app's URL module at <app_label>/ under routes/. See Router and URL Wiring.

Model appears in model-info but client routes are blocked. The requireModelInfo guard blocks navigation when it cannot find the requested action in the allowlist. Check that the model-info response includes the expected actions in model_actions. If actions are missing, the requesting user may lack the necessary permissions; model_actions is permission-filtered per user.

Choice dropdowns show no labels or endpoint returns 500. This is typically a formatted_name configuration error. A system check (vueda_info.E001) catches the most common cause — formatted_name = None without an alternative — at startup, so the root cause should be visible in server output before any requests are served. If the server starts cleanly but choice labels are missing, verify that get_formatted_name() is a plain method (not a @property, which triggers vueda_info.E003) and that only one of formatted_name_lookup_expression or get_formatted_name() is provided (both at once triggers vueda_info.E002). If using get_formatted_name(), the serializer must also declare formatted_name = serializers.SerializerMethodField().

Client shows "Action Not Found" for a valid action. The client normalizes route action names through a static action map; for example, read maps to retrieve. If routeActions in client model-config is configured using client-side names (like read) instead of canonical server names (like retrieve), the guard filtering may exclude actions that should be present.

list endpoint returns 400 on invalid filter queries. NoExtraFieldsForViewSetMixin validates query parameters against the filterset class. A query parameter that does not match any declared filter or recognized framework parameter (pagination, ordering, expand, fields) will produce a field-keyed 400 response listing valid filters. Verify that the filterset declares filters for all parameters the client sends.

Duplicate registration error at startup. The canonical serializer is unique per model. If two apps attempt to register different serializers for the same model, the second registration raises a ValueError. Consolidate registration to a single app.

Bulk delete rejects valid PKs. Bulk destroy validates that every PK in the pks array exists in the queryset after row-level and object-level permission filtering. If the requesting user lacks delete permission for some of the objects, those objects are filtered out and the count mismatch triggers a validation error. The error message reports which PKs were "not found"; from the user's perspective they may not exist, even though the objects are in the database.

Relevant Implementation Surface ​