Action configs¶
ActionConfig is a small frozen dataclass for declaring per-action
overrides on a viewset. It avoids the maintenance cost of writing one
if self.action == "list": ... branch per getter and keeps overrides
visible in a single mapping at the top of the class.
The dataclass¶
@dataclass(frozen=True)
class ActionConfig:
serializer_class: type | None = None
request_serializer_class: type | None = None
response_serializer_class: type | None = None
permission_classes: list | tuple | None = None
throttle_classes: list | tuple | None = None
parser_classes: list | tuple | None = None
renderer_classes: list | tuple | None = None
pagination_class: type | None = None
queryset: Any = None
Every field is optional and defaults to None. None means "fall through to the next layer". Set only the fields the action needs to override.
The dataclass is frozen so an instance is safe to share between viewsets. Build them at module level when they need to be reused.
Lookup order¶
The viewset getters consult three layers in order.
- The ActionConfig field for the current action.
- The parent's
get_*()method (DRF's chain). - The class attribute (
serializer_class,pagination_class, etc).
The first non-None value wins. The chain is the same for every getter, which means the override semantics are predictable across attributes.
The same chain applies to permission_classes, throttle_classes, parser_classes, renderer_classes, pagination_class, and queryset.
The queryset field¶
The queryset field is the only one that accepts more than a class reference. Three shapes are supported.
| Shape | Resolution |
|---|---|
| QuerySet | Returned via qs.all() |
| Manager | Returned via manager.all() |
| Callable | Called with self, expected to return a QuerySet |
The callable form is useful when the queryset depends on the current request.
The callable receives the viewset instance, so self.request,
self.action, and self.kwargs are all available.
Common patterns¶
Lighter list serializer¶
A list endpoint typically returns less detail per item than a retrieve endpoint. Configure both response shapes from a single declaration.
class ProductViewSet(AsyncModelViewSet):
serializer_class = ProductDetailSer
queryset = Product.objects.all()
action_configs = {
"list": ActionConfig(response_serializer_class=ProductListSer),
}
Admin-only destroy¶
The class-wide permission_classes still applies to every other
action. The destroy action gets its own list, replacing the default
during the dispatch loop.
Faster pagination on list¶
from restflow.pagination import FastPageNumberPagination
action_configs = {
"list": ActionConfig(pagination_class=FastPageNumberPagination),
}
PageNumberPagination runs an extra count(*) query to compute
totals; FastPageNumberPagination skips the count and trades total
counts for query speed. Use it on hot list endpoints.
Per-action queryset¶
action_configs = {
"list": ActionConfig(
queryset=lambda self: Product.objects.filter(owner=self.request.user),
),
"archive_list": ActionConfig(
queryset=Product.objects.filter(is_archived=True),
),
}
The list endpoint scopes results to the current user; a custom
archive_list action looks at archived rows.
Per-action throttle¶
Custom actions¶
Custom actions added through @action(...) participate in the same
lookup chain. The action name on the viewset is the method name (or
the url_name argument when supplied).
class ProductViewSet(AsyncModelViewSet):
serializer_class = ProductSer
queryset = Product.objects.all()
action_configs = {
"bulk_archive": ActionConfig(
request_serializer_class=BulkArchiveSer,
permission_classes=[IsAdminUser],
),
}
@action(detail=False, methods=["post"])
async def bulk_archive(self, request):
ser = await self.avalidated_serializer()
...
The ActionConfig entry under "bulk_archive" is consulted whenever
self.action == "bulk_archive", so request serializer and permission
overrides apply automatically.
ActionConfig vs @action overrides¶
DRF lets @action accept many of the same overrides directly:
The trade-off.
| Approach | Pros |
|---|---|
| ActionConfig | One place per viewset, every override visible. |
| @action | Override sits next to the action it modifies. |
The two mechanisms cooperate. ActionConfig wins when both are present,
because the action-config-aware getter checks the dict before
delegating to the parent (which reads the @action attribute).
For consistency, pick one approach per viewset. ActionConfig scales better as the override surface grows.
Worked example¶
A complete viewset showing several override patterns.
from rest_framework.decorators import action
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from restflow.pagination import FastPageNumberPagination
from restflow.views import (
ActionConfig,
AsyncModelViewSet,
)
class ProductViewSet(AsyncModelViewSet):
serializer_class = ProductDetailSer
queryset = Product.objects.all()
pagination_class = PageNumberPagination
permission_classes = [IsAuthenticated]
filter_backends = [RestflowFilterBackend]
filterset_class = ProductFilterSet
action_configs = {
"list": ActionConfig(
response_serializer_class=ProductListSer,
pagination_class=FastPageNumberPagination,
queryset=lambda self: Product.objects.filter(
owner=self.request.user,
),
),
"destroy": ActionConfig(permission_classes=[IsAdminUser]),
"archive": ActionConfig(
queryset=Product.objects.filter(is_archived=True),
permission_classes=[IsAdminUser],
),
}
@action(detail=False, methods=["get"])
async def archive(self, request):
return await self.apaginated_response(self.get_queryset())
The same class delivers four different policies on the same model without scattering conditionals across multiple methods.