Skip to content

Viewsets

Full CRUD

ServiceViewSet

Bases: ServiceCreateMixin, ServiceUpdateMixin, ServiceDestroyMixin, SelectorListMixin, SelectorRetrieveMixin, ActionSerializerResolver, GenericViewSet

Router-compatible viewset wiring services and selectors.

Composes ServiceCreateMixin, ServiceUpdateMixin, ServiceDestroyMixin, SelectorListMixin, SelectorRetrieveMixin, and ActionSerializerResolver over GenericViewSet. See those classes for the configurable attributes.

SelectorViewSet

Bases: SelectorListMixin, SelectorRetrieveMixin, ActionSerializerResolver, GenericViewSet

Read-only viewset for list + retrieve.

Composes SelectorListMixin, SelectorRetrieveMixin, and ActionSerializerResolver over GenericViewSet.

Per-action mixins

ServiceCreateMixin

Bases: MutationFlowMixin, _ActionSpecsMixin

Provides the create action; reads its config from action_specs.

Set action_specs["create"] to a ServiceSpec. When the "create" key is absent the action raises MethodNotAllowed. A non-ServiceSpec entry (e.g. a SelectorSpec) raises ImproperlyConfigured.

ServiceUpdateMixin

Bases: MutationFlowMixin, _ActionSpecsMixin

Provides update (PUT) and partial_update (PATCH) actions.

Looks up the instance via spec.instance_selector_spec when set, falling back to DRF's get_object(). PUT reads its config from action_specs["update"]; PATCH reads action_specs["partial_update"] first and falls back to "update" — defining only "partial_update" yields a PATCH-only endpoint (PUT raises MethodNotAllowed). When neither key resolves, both actions raise MethodNotAllowed. A non-ServiceSpec entry raises ImproperlyConfigured.

ServiceDestroyMixin

Bases: MutationFlowMixin, _ActionSpecsMixin

Provides the destroy action.

Looks up the instance via spec.instance_selector_spec when set, falling back to DRF's get_object(). Reads its config from action_specs["destroy"]; when that key is absent the action raises MethodNotAllowed. A non-ServiceSpec entry raises ImproperlyConfigured.

SelectorListMixin

Bases: ListModelMixin, _ActionSpecsMixin

Compose with GenericViewSet.

When action_specs["list"] is a SelectorSpec with a non-None selector, get_queryset() invokes it instead of returning the configured queryset. The rest of DRF's list flow — filter backends, pagination, serialization — is unchanged.

action_specs["list"] = SelectorSpec(selector=None) or an absent "list" key both fall through to DRF's default get_queryset(). Any other entry type raises ImproperlyConfigured.

get_selector_kwargs

get_selector_kwargs() -> dict[str, Any]

Hook for additional kwargs available to the selector signature.

SelectorRetrieveMixin

Bases: RetrieveModelMixin, _ActionSpecsMixin

Compose with GenericViewSet.

When action_specs["retrieve"] is a SelectorSpec with a non-None selector, get_object() invokes it instead of falling through to DRF's standard lookup. The selector receives the URL kwargs plus the standard pool. Returning None or raising Model.DoesNotExist results in a 404 — or, when the spec sets allow_none=True, a 200 with a JSON null body (the nullable-resource contract; the output serializer is skipped).

action_specs["retrieve"] = SelectorSpec(selector=None) or an absent "retrieve" key both fall through to DRF's default get_object(). Any other entry type raises ImproperlyConfigured.

The selector applies wherever get_object() is called, including from update/destroy actions composed alongside this mixin. If you need an action-specific override, do it explicitly in your own get_object().

get_object

get_object() -> Any

Resolve the target row through the retrieve selector, if one is wired.

filter_backends do not apply here. DRF runs filter_queryset() inside its own get_object(), so taking that method over — as this mixin does, and as any hand-written get_object() override does — is what drops them. A tenant-scoping backend listed in DEFAULT_FILTER_BACKENDS therefore narrows the sibling list action and not this lookup, which is what makes the gap easy to miss. Scope the selector's own queryset, or declare the rule as SelectorSpec.filter_set — the sanctioned seam, applied by the dispatcher on both paths.

Action-serializer dispatch

ActionSerializerResolver

Bases: _ActionSpecsMixin

Resolve get_serializer_class() from action_specs.

Reads the active action's entry: a SelectorSpec supplies output_serializer, a ServiceSpec supplies output_selector_spec.output_serializer. Falls back to DRF's serializer_class, raising the usual AssertionError when neither is set.

Example:

class InvoiceViewSet(ActionSerializerResolver, GenericViewSet):
    action_specs = {
        "list": SelectorSpec(
            kind=SelectorKind.LIST, output_serializer=InvoiceListSerializer,
        ),
        "retrieve": SelectorSpec(
            kind=SelectorKind.RETRIEVE, output_serializer=InvoiceDetailSerializer,
        ),
    }

Custom actions

service_action

service_action(
    spec: ServiceSpec | PolymorphicServiceSpec,
    *,
    detail: bool = False,
    methods: list[str] | None = None,
    url_path: str | None = None,
    url_name: str | None = None,
    **action_kwargs: Any,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]

Wrap a viewset method as a service-backed custom action.

The decorated method's body is not executed — the decorator supplies the handler. The method exists so that @service_action can attach DRF @action metadata and pick up the action name from __name__.

Pass a ServiceSpec (or a PolymorphicServiceSpec for one action accepting several payload shapes) for the service wiring. detail, methods, url_path, url_name, and any extra **action_kwargs are forwarded to DRF's @action.

A plain ServiceSpec forwards its permission_classes into DRF's @action(permission_classes=...), so it is enforced on any viewset. A PolymorphicServiceSpec has no single list to forward — which list applies depends on the strategy, and under "discriminate" on the body — so its per-variant permission_classes are enforced by _ActionSpecsMixin.get_permissions, which reads the spec stashed on the handler. That makes the mixin a requirement, not a convenience: on a viewset without it, DRF's stock get_permissions would apply the view's defaults and every variant rule would go unchecked. The decorated class is unknown at decoration time, so the dependency is checked on the first request through the action and the request is refused rather than served unguarded. Compose ServiceViewSet (or any mixin from this package) and the check never fires.

selector_action

selector_action(
    spec: SelectorSpec[Any, Any],
    *,
    methods: list[str] | None = None,
    url_path: str | None = None,
    url_name: str | None = None,
    **action_kwargs: Any,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]

Wrap a viewset method as a selector-backed custom action.

The decorated method's body is not executed — the decorator supplies the handler. The method exists so the decorator can attach DRF @action metadata and take the action name from __name__.

spec.kind drives both the dispatch shape and the DRF URL shape, so there is no separate detail= to keep in sync. SelectorKind.LIST is a collection action (detail=False) whose selector returns an iterable, flowing through self.paginate_queryset / self.get_paginated_response when pagination is configured and serialized many=True otherwise; SelectorKind.RETRIEVE is a detail action (detail=True) whose selector returns a single object, or None / raises ObjectDoesNotExist for a 404. For a URL shape that doesn't match the response shape, fall back to DRF's plain @action and write the dispatch yourself.

Output serialization uses spec.output_serializer when set, falling back to self.get_serializer(...).