Skip to content
Guide

Custom actions

Custom actions add endpoints next to the CRUD endpoints of a viewset. Use @on for actions on one object and @action for everything else.

Act on one object

blog/api.py
from ninja_aio import NinjaAIO, APIViewSet, on

from .models import Article

api = NinjaAIO(title="Blog API")


@api.viewset(model=Article)
class ArticleViewSet(APIViewSet):
    @on("publish", response={200: Article.read_schema})
    async def publish(self, request, obj):
        obj.is_published = True
        await obj.asave(update_fields=["is_published"])
        return await Article.amodel_dump(obj, schema=Article.read_schema)

This adds POST /api/articles/{id}/publish. The article is loaded for you and passed as obj. When it doesn't exist, the client gets 404 and your method doesn't run.

@on accepts the same options as @action, except detail. The default method is POST. The handler receives only request and obj. To read a request body, use @action(detail=True).

Add a collection endpoint

Python
from ninja_aio import action


@api.viewset(model=Article)
class ArticleViewSet(APIViewSet):
    @action(detail=False, url_path="stats")
    async def stats(self, request):
        published = await Article.objects.filter(is_published=True).acount()
        return {"published": published}

This adds GET /api/articles/stats. Collection handlers receive request and any parameters you declare.

Add a detail endpoint

With detail=True the path includes the primary key and your handler receives it as pk:

Python
from ninja import Schema


class RejectIn(Schema):
    reason: str


@api.viewset(model=Article)
class ArticleViewSet(APIViewSet):
    @action(detail=True, methods=["post"], url_path="reject")
    async def reject(self, request, pk: int, data: RejectIn):
        await Article.objects.filter(pk=pk).aupdate(is_published=False)
        return {"id": pk, "reason": data.reason}

This adds POST /api/articles/{id}/reject. Annotate pk with the type of the primary key, like pk: int. Without an annotation it arrives as a string. A parameter annotated with a Schema is read from the request body.

Unlike @on, @action(detail=True) doesn't pass the object to your method. Load it yourself, for example with await Article.aget(pk=pk). When the viewset has object checks, like PermissionViewSetMixin or your own aon_before_object_operation, the object is loaded and checked first, and a missing object returns 404.

Choose the path

Decorator Path
@action(detail=False) on stats /api/articles/stats
@action(detail=False) on top_rated /api/articles/top-rated
@action(detail=True) on reject /api/articles/{id}/reject
@action(detail=False, url_path="summary/today") /api/articles/summary/today
@on("publish") /api/articles/{id}/publish

Without url_path, @action uses the method name with underscores replaced by hyphens. @on uses its first argument. url_path is used exactly as you write it, with no slash added.

Return data and status codes

Return a dict or a schema instance to answer with 200. Return a Django Ninja Status to pick the code, and declare that code in response:

Python
from ninja import Status


@on("archive", response={202: Article.read_schema})
async def archive(self, request, obj):
    obj.is_published = False
    await obj.asave(update_fields=["is_published"])
    return Status(202, await Article.amodel_dump(obj, schema=Article.read_schema))

Without response, only 200 responses are allowed and the response isn't validated.

Like the CRUD endpoints, actions list 400, 401, 403 and, for detail actions, 404 in the OpenAPI schema, with the viewset error_schema. Codes you declare in response keep your schema.

Pick the HTTP methods

Pass lowercase names or HttpMethod members. One endpoint is added for each method, on the same path:

Python
from ninja_aio import HttpMethod


@action(detail=True, methods=[HttpMethod.GET, HttpMethod.POST], url_path="likes")
async def likes(self, request, pk: int):
    ...

HttpMethod has GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS. Any other name, including uppercase strings like "GET", raises ValueError.

Options

Option Default What it does
detail required True puts the primary key in the path. @action only
methods ["get"], ["post"] for @on HTTP methods to register
url_path Method name, action name for @on Path segment after the viewset path
url_name None Django URL name for reverse()
auth Viewset auth for the method Authentication. None makes the endpoint public
response Not set Response schemas, like {200: Article.read_schema}
summary Like "POST Publish Article" OpenAPI summary
description None OpenAPI description
tags Viewset tags OpenAPI tags
deprecated None Mark the endpoint as deprecated in OpenAPI
decorators None Decorators for the handler. The first one is the outermost
throttle Router and API throttles Throttles for the endpoint
include_in_schema True Show the endpoint in OpenAPI
openapi_extra None Extra OpenAPI data for the operation

An unknown option raises TypeError.

Write sync or async handlers

The handler can be def or async def, in any viewset. The hooks follow the handler: a def handler runs the sync hooks, an async def handler runs the async ones.

Python
@on("publish")
def publish(self, request, obj):
    obj.is_published = True
    obj.save(update_fields=["is_published"])
    return {"id": obj.pk, "is_published": obj.is_published}
Python
@on("publish")
async def publish(self, request, obj):
    obj.is_published = True
    await obj.asave(update_fields=["is_published"])
    return {"id": obj.pk, "is_published": obj.is_published}

Run hooks and permissions

The operation passed to hooks is the method name, like publish or stats.

Decorator Hooks that run
@action(detail=False) aon_before_operation
@action(detail=True) aon_before_operation, then, when the viewset has object checks, the object lookup and aon_before_object_operation
@on aon_before_operation, then the object lookup, then aon_before_object_operation

The sync names (on_before_operation, on_before_object_operation) run for def handlers. See Viewsets.

With PermissionViewSetMixin, every action checks has_permission(request, operation), and detail actions (@on and @action(detail=True)) also check has_object_permission(request, operation, obj):

Python
from ninja_aio.views.mixins import PermissionViewSetMixin


@api.viewset(model=Article)
class ArticleViewSet(PermissionViewSetMixin, APIViewSet):
    async def ahas_permission(self, request, operation):
        if operation == "publish":
            return request.auth.is_staff
        return True

A denied check answers 403. See Permissions.

Set authentication

Without auth, an action uses the viewset setting for its HTTP method:

Method Uses
GET, HEAD get_auth
POST post_auth
PATCH, PUT patch_auth
DELETE delete_auth
OPTIONS auth

When the method setting isn't set, auth is used. Pass auth=None to make a single action public.

Add actions to an APIView

APIView supports @action(detail=False). Every action uses the auth of the view:

Python
from ninja_aio import APIView, action


@api.view(prefix="/reports", tags=["Reports"])
class ReportsView(APIView):
    @action(detail=False, url_path="totals")
    async def totals(self, request):
        return {"articles": await Article.objects.acount()}

This adds GET /api/reports/totals. @action(detail=True) and @on raise ImproperlyConfigured on an APIView.

See also