In a Django REST Framework ModelViewSet, how do you add POST /projects/{pk}/archive/ with @action, and what do detail, methods and url_path control?
answer
- decorator on a viewset method
- detail: object or collection
- GET unless told otherwise
- route name gets hyphens
basics
~10 sDecorate a ViewSet method with @action(detail=True, methods=['post']). detail picks object versus collection URLs, methods defaults to GET only, url_path defaults to the method name, and the route is named <basename>-<method-name-with-hyphens>.
solid answer
~40 sI add `@action(detail=True, methods=["post"])` to a method named `archive`; the router then serves `POST projects/<pk>/archive/` under the name `project-archive`. `detail=True` puts the action after the lookup segment and is required; `detail=False` puts it directly under the prefix. `methods` defaults to `["get"]`. `url_path` defaults to the method name and `url_name` to the method name with underscores turned into hyphens. Inside the method I call `self.get_object()` myself — DRF does not fetch the object for a detail action — which gives the scoped lookup, the 404 and the object permission checks. Extra keyword arguments such as `permission_classes` override the ViewSet's attributes for this action only.
code
python · 31 linesfrom rest_framework import permissions, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Project
from .serializers import ProjectSerializer
class ProjectViewSet(viewsets.ModelViewSet):
queryset = Project.objects.all()
serializer_class = ProjectSerializer
@action(detail=True, methods=["post"],
permission_classes=[permissions.IsAdminUser])
def archive(self, request, pk=None):
project = self.get_object()
project.archive()
return Response(self.get_serializer(project).data)
@archive.mapping.delete
def unarchive(self, request, pk=None):
project = self.get_object()
project.unarchive()
return Response(self.get_serializer(project).data)
@action(detail=False, url_path="archived", url_name="archived")
def archived_projects(self, request):
queryset = self.filter_queryset(self.get_queryset()).filter(
archived_at__isnull=False
)
return Response(self.get_serializer(queryset, many=True).data)go deeper
Remember the decorator shape: @action(detail=True, methods=['post']) on a ViewSet method, and that the router builds the URL from the method name.
Explain every parameter and its default, the route name format, and why get_object() must be called inside a detail action.
Use per-action kwargs and .mapping deliberately, and push back when a pile of actions is really a separate resource with its own lifecycle.
Set API design rules for verbs on resources: when an action is acceptable, how it is named and permissioned, and when a new resource is required.
## What @action is for A DRF `ModelViewSet` covers the six standard actions. Real resources also have **operations that are not CRUD**: archiving a project, restoring it, listing only archived projects. The `@action` decorator from `rest_framework.decorators` marks an ordinary ViewSet method as a **routable extra action**. The router discovers such methods with `get_extra_actions()` and generates a URL pattern for each. ## The decorator's parameters `@action(methods=None, detail=None, url_path=None, url_name=None, **kwargs)`: | Parameter | Meaning | Default | |---|---|---| | `detail` | `True` for an action on one object (`projects/<pk>/archive/`), `False` for one on the collection (`projects/archived/`) | **required** — omitting it fails an assertion | | `methods` | HTTP methods the action answers | `["get"]` | | `url_path` | the URL segment | the method name, underscores kept | | `url_name` | the route-name suffix, combined as `<basename>-<url_name>` | the method name with underscores turned into hyphens | | `**kwargs` | attributes set on the view for this action only, such as `permission_classes` or `serializer_class` | none | Two details catch people out: - `url_path` and `url_name` differ by default. A method named `bulk_archive` is served at `bulk_archive/` but named `<basename>-bulk-archive`. - The `**kwargs` must name attributes the ViewSet class already has; the router passes them to `as_view()`, which raises `TypeError` for an unknown keyword. ## The archive action, step by step ```python from django.utils import timezone from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.response import Response from .models import Project from .serializers import ProjectSerializer class ProjectViewSet(viewsets.ModelViewSet): queryset = Project.objects.all() serializer_class = ProjectSerializer @action(detail=True, methods=["post"]) def archive(self, request, pk=None): project = self.get_object() if project.archived_at is not None: return Response( {"detail": "Project is already archived."}, status=status.HTTP_409_CONFLICT, ) project.archived_at = timezone.now() project.save(update_fields=["archived_at"]) return Response(self.get_serializer(project).data) ``` Registered as `router.register("projects", ProjectViewSet)`, this adds **`POST projects/<pk>/archive/`**, named **`project-archive`**. Inside the method: 1. `self.action` is `"archive"`, the method's name, so per-action branching elsewhere can recognise it. 2. `self.get_object()` must be called **explicitly**. It looks the project up through `get_queryset()`, returns 404 when it is absent, and runs object-level permission checks. DRF does not fetch the object for you just because `detail=True`. 3. The method returns a `Response` like any handler; `get_serializer()` still gives the ViewSet's serializer with its context. View-level checks — authentication, `has_permission()`, throttles — have already run in `initial()` before the method is called, exactly as for standard actions. ## Collection actions and extra methods A `detail=False` action receives no lookup keyword. A read-only "archived projects" listing looks like `@action(detail=False, url_path="archived", url_name="archived")`, serving `GET projects/archived/` as `project-archived`. When one URL should answer two methods with **different** code — say `POST` to archive and `DELETE` to un-archive — declare the first with `@action(detail=True, methods=["post"])` and add the second with the `.mapping` helper, `@archive.mapping.delete`, on a method with a different name. The mapping cannot reuse the decorated method's name. ## Rules the router enforces - An extra action may not reuse a standard action's name. Decorating a method called `list` or `destroy` with `@action` makes URL generation raise `ImproperlyConfigured`, because those names are already routes. - An extra action is routed only on the matching route template — detail actions after the lookup segment, list actions directly under the prefix. - The browsable API shows extra actions in an "Extra Actions" menu, using the display name derived from the method name unless you pass `name=`. ## Testing an extra action Test extra actions through their route names so a renamed method or a changed `url_name` is caught: - reverse `project-archive` with a project's pk, POST as an authorised user, and assert the response and the stored `archived_at`; - POST as an unauthorised user and assert 403, which proves the action's `permission_classes` or the ViewSet's permissions apply; - POST to a missing pk and assert 404, which proves `get_object()` is being called; - send GET to the same URL and assert 405, which proves `methods` is what you intended. ## Choosing between an action and a new resource An action is right for a **verb on an existing resource** — archive, publish, restore. If the operation has its own lifecycle or its own data (archive requests that need approval, with a history), it is usually a separate resource with its own ViewSet, not a growing list of actions on the project.
- In DRF, how do you reverse the URL of a ViewSet's extra action from inside the ViewSet?Use `self.reverse_action(url_name, args=...)`, for example `self.reverse_action("archive", args=[project.pk])`. It prefixes the ViewSet's `basename` to form `project-archive`, adds the current URL namespace if there is one, and passes the request so the result is absolute. The `url_name` argument must match the action's `url_name`, which is the hyphenated method name by default.
- Why does an @action with detail=True still need self.get_object() inside it?The router only puts the lookup keyword into the URL; it does not load anything. `get_object()` is what applies `get_queryset()` scoping, filter backends, the 404 for a missing or malformed id and the object-level permission checks. An action that reads `pk` and queries the model directly skips all of that.
- What happens if a DRF ViewSet decorates a method named destroy with @action?Generating the router's URLs raises `ImproperlyConfigured`, saying `@action` cannot be used on methods that are existing routes. The standard action names — list, create, retrieve, update, partial_update, destroy — are reserved for the router's built-in routes.
saying these in an interview costs you the question
- @action routes POST by default because actions change state.
- With detail=True, DRF loads the object and checks object permissions before calling the method.
- url_path and url_name are always the same string.
- An extra action skips authentication and view-level permission checks.
- You can name an extra action list to override the list route.