Skip to content

Viewsets and custom actions

The standard actions

ServiceViewSet serves list, retrieve, create, update, partial_update and destroy, each from its entry in action_specs. SelectorViewSet serves list and retrieve. A write with no entry answers 405, and Allow leaves it out. A read with no entry falls back to DRF's own behaviour (get_queryset(), get_object()), which needs rows and a serializer: on a viewset with no queryset or get_queryset(), or no serializer_class, get_serializer_class() or get_serializer(), that read answers 405 too, and Allow and the schema leave it out. A list, a retrieve or a get_object() of your own keeps the route yours.

So a create-only ServiceViewSet answers POST and nothing else. A router still mounts its detail route, where every method answers 405, Allow names OPTIONS alone and the schema has no path. For no detail route at all, declare ServiceCreateMixin over GenericViewSet.

get_queryset() answers the list spec's set on list alone, from the one dispatch the list presents; every other action, a custom @action included, reads the viewset's queryset. So a viewset under DRF's DjangoModelPermissions, which asks get_queryset() on every action, sets queryset. The schema needs none: a lookup is documented from the read its spec declares (see OpenAPI).

from django_spec_rest import ServiceViewSet


class InvoiceViewSet(ServiceViewSet):
    action_specs = {
        "list": LIST_SPEC,
        "retrieve": RETRIEVE_SPEC,
        "create": CREATE_SPEC,
        "update": UPDATE_SPEC,
        # "partial_update" is optional: PATCH falls back to "update",
        # validated partially. Declare it to give PATCH a spec of its own,
        # or alone for a PATCH-only route.
    }

An entry under list or retrieve must be a SelectorSpec, and under a write key a ServiceSpec. An entry under a key no action dispatches is refused at as_view(), since nothing would ask its permissions: a custom route takes its spec through a decorator.

A custom write: @service_action

from typing import Any

from django_spec_rest import ServiceViewSet, service_action


class InvoiceViewSet(ServiceViewSet):
    action_specs = {...}

    @service_action(SEND_SPEC, detail=True, methods=["post"])
    def send(self, request: Any, pk: Any = None) -> None:
        """Mark a draft as sent."""

The method's body never runs: the decorator supplies the handler, and the method gives DRF's @action its name. detail, methods, url_path, url_name and any other keyword go to DRF's @action. On a detail route the row is the spec's own target selector's, read from the route's pk, never get_object()'s. The answer is the spec's presenter's, under the action's success_status and through its response_finalizer where the decorator declares them, else the viewset's (200 by default): @service_action(SEND_SPEC, detail=True, methods=["post"], success_status=202). See Per-action answers.

A custom read: @selector_action

from django_spec_rest import SelectorViewSet, selector_action


class InvoiceViewSet(SelectorViewSet):
    @selector_action(OVERDUE_SPEC)
    def overdue(self, request: Any) -> None:
        """Invoices past their due date."""

The spec's kind sets the route's shape: a LIST spec is a collection action, paginated where the viewset paginates, and a RETRIEVE spec a detail action, answering 404 for a miss or null under allow_none.

Composing your own viewset

Each standard action is a mixin over GenericViewSet, so a viewset takes only the actions it serves:

from rest_framework.viewsets import GenericViewSet

from django_spec_rest import (
    ActionSerializerResolver,
    SelectorListMixin,
    ServiceCreateMixin,
)


class InboxViewSet(ServiceCreateMixin, SelectorListMixin, ActionSerializerResolver, GenericViewSet):
    action_specs = {"list": INBOX_SPEC, "create": POST_MESSAGE_SPEC}

ActionSerializerResolver answers get_serializer_class() per action: a write spec's input serializer where the viewset sets no serializer_class, which DRF's browsable API asks to draw its forms, and the viewset's serializer_class otherwise.

A standalone view

A single route needs no viewset:

from django_spec_rest import SelectorListView


class SentInvoiceListView(SelectorListView):
    spec = SENT_SPEC