---
url: "https://vueda.dev/v3/guides/create-crudl-surface.md"
title: "Create a CRUDL Surface for a New Model"
---

# Create a CRUDL Surface for a New Model

This guide walks through the end-to-end process of standing up a fully functional [CRUDL](/v3/reference/glossary.md#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](/v3/core-concepts/architecture-overview.md), start there; the server responsibility layers and convention-over-configuration principles it describes are the foundation for everything below.
This flow starts with [VuedaModel](/v3/reference/api/py/vueda.core.models/VuedaModel.md) and builds a complete [CRUDL](/v3/reference/glossary.md#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](/v3/guides/create-crudl-surface.md#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](/v3/core-concepts/field-and-expand-semantics.md#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](/v3/guides/create-crudl-surface.md#opting-a-manager-out-of-the-check); 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](/v3/core-concepts/filtering-and-ordering-semantics.md#nulls-placement-for-client-requested-ordering)).

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](/v3/core-concepts/filtering-and-ordering-semantics.md#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](/v3/guides/data-vueda-does-not-display.md) 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](/v3/core-concepts/filtering-and-ordering-semantics.md#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](/v3/core-concepts/filtering-and-ordering-semantics.md#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](/v3/core-concepts/filtering-and-ordering-semantics.md#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](/v3/reference/glossary.md#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](/v3/core-concepts/server-client-metadata-contract.md) 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](/v3/core-concepts/filtering-and-ordering-semantics.md#metadata-projection-for-ordering-and-filtering).

`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](/v3/core-concepts/canonical-registration-and-discovery.md) 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](/v3/reference/glossary.md#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](/v3/core-concepts/routing-and-view-resolution-model.md) 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](/v3/reference/glossary.md#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](/v3/guides/configure-crud-views.md) 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](/v3/guides/manage-groups.md), 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](/v3/guides/create-crudl-surface.md#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

- Python:
    - [VuedaModel](/v3/reference/api/py/vueda.core.models/VuedaModel.md)
    - [BaseModelMeta](/v3/reference/api/py/vueda.core.models/BaseModelMeta.md)
    - [VuedaSerializer](/v3/reference/api/py/vueda.core.serializers/VuedaSerializer.md)
    - [VuedaViewSet](/v3/reference/api/py/vueda.core.viewsets/VuedaViewSet.md)
    - [VuedaFilterSet](/v3/reference/api/py/vueda.core.filters/VuedaFilterSet.md)
    - [FormattedNamePathFilterSetMixin](/v3/reference/api/py/vueda.core.filters/FormattedNamePathFilterSetMixin.md)
    - [VuedaRouter](/v3/reference/api/py/vueda.core.routers/VuedaRouter.md)
    - [info.registration.register](/v3/reference/api/py/vueda.info.registration.md#register)
    - [VuedaListSerializer](/v3/reference/api/py/vueda.core.serializers/VuedaListSerializer.md)
    - [PrimaryKeyListSerializer](/v3/reference/api/py/vueda.core.serializers/PrimaryKeyListSerializer.md)
- REST:
    - [Get model info](/v3/reference/api/rest/vueda.info/vueda.info_model_info_retrieve.md)
    - [List field choices](/v3/reference/api/rest/vueda.info/vueda.info_model_info_choices_list.md)
    - [List filterset field choices](/v3/reference/api/rest/vueda.info/vueda.info_model_info_filter_choices_list.md)
- JavaScript:
    - [makeCRUDRoutes](/v3/reference/api/js/router/makeCrud/functions/makeCRUDRoutes.md)
    - [requireModelInfo](/v3/reference/api/js/router/guards/functions/requireModelInfo.md)
    - [setCrudComponents](/v3/reference/api/js/router/routerComponent/functions/setCrudComponents.md)
    - [stores/storeModelInfo](/v3/reference/api/js/stores/storeModelInfo.md)
    - [stores/storeModelConfig](/v3/reference/api/js/stores/storeModelConfig.md)
    - [getCRUDForTo](/v3/reference/api/js/router/getCrud/functions/getCRUDForTo.md)
- Vue.js Components:
    - [ViewActionRouter](/v3/reference/api/vue/ViewActionRouter.md)
