AI
← All posts

· 10 min read · Engineering

Advanced Django — Part 5: Views and permissions

This is Part 5 of the Advanced Django series — the last one. Part 1 built the model layer, Part 2 covered custom querysets, Part 3 built the selectors and services that sit above the ORM, and Part 4 split serializers into input and output shapes.

Part 5 is where it all meets the HTTP boundary. This post covers three things: a CustomViewSet base class that everything else inherits from, an action-level permission decorator backed by a small RBAC, and the response envelope that keeps success and error shapes consistent across every endpoint.

By the end you should be able to see how one action ties the whole vertical together — parse input, run a service or selector, serialize output, return a consistent envelope. Every endpoint in the app has the same shape. That uniformity is what makes a large codebase legible.

Companion repo: the patterns in this series live in ademolaidowu/django-saas-foundation on GitHub — a working Django + DRF project you can clone, run, and read alongside the posts.

The CustomViewSet base

DRF ships with several viewset base classes — ViewSet, GenericViewSet, ModelViewSet. ModelViewSet gives you CRUD for free, but it also ties every action to a single serializer and queryset, which fights the input/output serializer split from Part 4 and the service layer from Part 3.

I use GenericViewSet and add a small layer on top. That layer owns two things: typed query-parameter access via self.params, and a default that every action must run inside an authenticated organization context.

# apps/core/viewset.py
from rest_framework import viewsets
 
from core.exceptions import APIServiceError
from core.query_params import QueryParamExtractor
 
 
class CustomViewSet(viewsets.GenericViewSet):
    """Every viewset in the app inherits from this. Adds:
 
    - `self.params` — a typed query-parameter extractor.
    - `requires_organization` — a class-level flag (default True) that
      ensures every request has `request.organization` set before the
      handler runs.
 
    Endpoints that shouldn't require an organization context (login,
    password reset, public catalogues) opt out by setting the flag to
    False.
    """
 
    requires_organization: bool = True
 
    @property
    def params(self) -> QueryParamExtractor:
        if not hasattr(self, "_qp_extractor"):
            self._qp_extractor = QueryParamExtractor(self.request)
        return self._qp_extractor
 
    def initial(self, request, *args, **kwargs):
        super().initial(request, *args, **kwargs)
        if self.requires_organization:
            self._ensure_organization_context(request)
 
    def _ensure_organization_context(self, request) -> None:
        # If the request isn't authenticated at all, permission classes
        # will surface that. Only check the org context when there's an
        # account.
        if getattr(request, "account", None) is None:
            return
        if getattr(request, "organization", None) is None:
            raise APIServiceError(error_code=AccountContextError0001())

The initial() override runs before every action. It's DRF's built-in hook for "run this before the handler" — permissions and throttles use the same seam. Doing the org check here means every action is guaranteed to see a real request.organization (or crash with a clean error code before the handler runs).

request.organization gets attached earlier by the auth class, which reads a token, resolves it to an account, and pins the account's organization onto the request. That's a separate topic — for this post the important thing is that by the time an action runs, request.organization exists.

self.params — typed query parameters

Query params come off the wire as strings. ?page=3&active_only=true&status=paid is three strings, not an int, a bool, and an enum. Every viewset that reads query params does the same casting and validation dance, and that's what self.params centralizes.

class QueryParamExtractor:
    def __init__(self, request):
        self.request = request
 
    def get(
        self,
        key: str,
        default=None,
        *,
        choices: list | None = None,
        required: bool = False,
        cast_type: type | None = None,
    ):
        value = self.request.query_params.get(key, default)
        if required and value is None:
            raise APIServiceError(error_code=QueryParamError0001(field=key))
        if choices and value not in choices:
            raise APIServiceError(error_code=QueryParamError0002(field=key, choices=choices))
        if cast_type and value is not None:
            value = self._cast(value, cast_type)
        return value
 
    def get_list(self, key: str, *, cast_type: type | None = None) -> list:
        # Handles `?key=a&key=b`, `?key[]=a`, and `?key=a,b` transparently.
        ...

Then in an action:

sort_by = self.params.get("sort", default="created_at", cast_type=str)
active_only = self.params.get("active_only", default=True, cast_type=bool)
action_kind = self.params.get(
    "action",
    required=True,
    cast_type=str,
    choices=["add", "remove"],
)
tags = self.params.get_list("tags", cast_type=str)

Every viewset uses the same signature to extract params. When a query param is missing that shouldn't be, or is the wrong type, the error is consistent (an APIServiceError with a domain code) and it never reaches the handler.

The shape of an action

Every action follows the same five-step recipe. Once you've read one, you've read them all.

# apps/orders/views/order.py
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
 
from access.decorators import require_permission
from core.viewset import CustomViewSet
 
from orders.selectors.order import OrderSelectors
from orders.services.order import OrderService
from orders.serializers.input.order import (
    OrderCreateInputSerializer,
    OrderUpdateInputSerializer,
)
from orders.serializers.output.order import (
    OrderMiniOutputSerializer,
    OrderOutputSerializer,
)
 
 
class OrderViewSet(CustomViewSet):
    """CRUD for orders in the current organization."""
 
    permission_classes = [IsAuthenticated]
 
    @require_permission("order:create")
    def create(self, request: Request, *args, **kwargs) -> Response:
        serializer = OrderCreateInputSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
 
        order = OrderService.create_order(
            organization=request.organization,
            data=serializer.validated_data,
            created_by=request.user,
        )
        return Response(
            {
                "message": "Order created successfully",
                "data": OrderOutputSerializer(order).data,
            },
            status=status.HTTP_201_CREATED,
        )
 
    @require_permission("order:read")
    def retrieve(self, request: Request, *args, **kwargs) -> Response:
        order = OrderSelectors.get_order_by_public_id(
            order_id=kwargs["order_id"],
            organization=request.organization,
            select_related=["customer"],
            prefetch_related=["line_items"],
        )
        return Response(
            {
                "message": "Order retrieved",
                "data": OrderOutputSerializer(order).data,
            },
            status=status.HTTP_200_OK,
        )
 
    @require_permission("order:update")
    def partial_update(self, request: Request, *args, **kwargs) -> Response:
        order = OrderSelectors.get_order_by_public_id(
            order_id=kwargs["order_id"],
            organization=request.organization,
        )
 
        serializer = OrderUpdateInputSerializer(data=request.data, partial=True)
        serializer.is_valid(raise_exception=True)
 
        order = OrderService.update_order(
            organization=request.organization,
            order=order,
            data=serializer.validated_data,
            updated_by=request.user,
        )
        return Response(
            {
                "message": "Order updated successfully",
                "data": OrderOutputSerializer(order).data,
            },
            status=status.HTTP_200_OK,
        )
 
    @require_permission("order:delete")
    def destroy(self, request: Request, *args, **kwargs) -> Response:
        order = OrderSelectors.get_order_by_public_id(
            order_id=kwargs["order_id"],
            organization=request.organization,
        )
        OrderService.delete_order(order=order, deleted_by=request.user)
        return Response(
            {"message": "Order deleted successfully"},
            status=status.HTTP_204_NO_CONTENT,
        )

The five steps that repeat in every action:

  1. Permission check. @require_permission("order:create") runs before the body of the action. If the account doesn't hold that permission, the handler is never entered.
  2. Input. Instantiate an input serializer, call is_valid(raise_exception=True). Any bad payload raises a ValidationError that the exception handler turns into a 400.
  3. Selector or service. Calls into the domain layer from Part 3. The action doesn't know how the query is filtered or how the write is atomic — the service and selector do.
  4. Output serializer. Pass the model instance to an output serializer from Part 4. The action doesn't shape the response by hand.
  5. Envelope response. Wrap the serialized data in the consistent {"message": ..., "data": ...} shape.

That's the pattern. Reading a hundred actions in a codebase teaches you nothing new after the fifth one — they all look like this. That's a feature.

Notice permission_classes = [IsAuthenticated]. That's just the "you must be logged in" gate. The real authorization is per-action, via the decorator.

Permissions with @require_permission

DRF's built-in permission classes are attached to the whole viewset. You get one decision for the entire set of actions. But real apps need different permissions for different actions on the same resource — "read" is a very different check from "delete", and lumping them into a single HasOrderPermission class quickly becomes a mess of if action == "create" branches.

I attach permissions at the action level, with a small decorator:

# apps/access/decorators.py
from functools import wraps
from typing import Callable
 
 
def require_permission(
    code: str | list[str],
    *,
    require_all: bool = True,
) -> Callable:
    """Ensure the requesting account holds a permission code.
 
    Single code:      @require_permission("order:create")
    All of many:      @require_permission(["order:update", "order:approve"])
    Any of many:      @require_permission(["order:update", "billing:approve"], require_all=False)
    """
    codes = [code] if isinstance(code, str) else list(code)
 
    def decorator(view_function: Callable) -> Callable:
        @wraps(view_function)
        def wrapper(self, request, *args, **kwargs):
            checker = PermissionChecker(
                account=request.account,
                organization=request.organization,
            )
            if require_all:
                for c in codes:
                    checker.require_permission(c)
            else:
                checker.require_any_permission(codes)
            return view_function(self, request, *args, **kwargs)
 
        return wrapper
 
    return decorator

The decorator instantiates a PermissionChecker from the request's account and organization, then runs the check. On failure it raises an APIServiceError with a permission-specific error code.

Permission codes are strings shaped as {domain}:{action} — order:create, order:read, enrollee:approve. That's a naming convention, not a language feature. It reads well and greps well, and every code that exists in the codebase is registered somewhere (see below).

Four layers of check, in order

PermissionChecker doesn't just look up a role assignment. It runs four checks and returns "allowed" only when all four pass:

  1. Platform — is this module or feature turned on globally? (Kill switch for the whole SaaS.)
  2. Subscription — is this module or feature included in the organization's current plan?
  3. Organization overrides — has this organization explicitly disabled the module or feature?
  4. User — does the user's account hold the specific permission code, via a role assignment?

Platform admins skip layers 2-4 — they have blanket access. Everyone else runs all four.

This looks like a lot of checks per request. In practice each layer is either an in-memory boolean check (module active, plan active) or a set-lookup on a permission-code set that gets pre-computed at token-authentication time. The bottom layer isn't a database query per action — the token auth already fetched every code the account holds and cached it on the account. Layer 4 is code in cached_codes. Cheap.

The full RBAC implementation deserves its own post — the token pre-fetch, the layered checker, the platform/plan/org gating machinery, and how permission codes are registered as data rather than hardcoded constants. For this post the pattern is enough: the decorator runs before the action, the check is a single call, and permission drift ("who could actually do this?") is testable because every code is a string that shows up in exactly one decorator.

The response envelope

Every successful response has the same shape:

{
  "message": "Order created successfully",
  "data": { ... }
}

message is a short human-readable summary for the client. data is the actual payload, shaped by the output serializer. Nothing else at the top level.

Pagination has a slightly extended shape (I'll show it below), but the principle is identical — the client always knows where to look for the payload.

Errors go through a custom exception handler and get the mirror shape:

{
  "error": {
    "code": "order_1400",
    "message": "Order cannot be cancelled after fulfilment",
    "request_id": "018f2c4b-...",
    "fields": { "shipping_address": ["This field is required."] }
  }
}

Every error carries a machine-readable code, a human-readable message, a request_id for correlation with logs, and (for validation errors) a fields dict mapping field names to messages. The client can display message, log code and request_id, and drill into fields for form errors.

The exception handler catches everything and normalizes:

# apps/core/exceptions/handlers.py
def custom_exception_handler(exc, context):
    response = exception_handler(exc, context)      # DRF's default
    if response is None:                             # unhandled — 500
        logger.exception("unhandled exception", exc_info=exc)
        return Response(
            _build_error_response(
                code="internal_error",
                message="An internal error occurred.",
                request_id=_get_request_id(context),
            ),
            status=500,
        )
    response.data = _build_error_response(
        code=_get_error_code(exc),
        message=_get_error_message(exc),
        request_id=_get_request_id(context),
        fields=_get_field_errors(exc),
    )
    return response

Wire it into DRF via REST_FRAMEWORK["EXCEPTION_HANDLER"] = "core.exceptions.handlers.custom_exception_handler" and every error your app produces — APIServiceError from services, ValidationError from serializers, Http404 from lookups, a plain uncaught KeyError — comes out in that envelope. The frontend has one error-handling function to write.

The error codes themselves (order_1400, access_2401, enrollee_0003) come from small classes registered per domain. Each code carries a default message, a numeric layer/subdomain identifier, and its HTTP status. Services raise them by class, the exception handler serializes them into the envelope. That whole system is its own post — for now, the point is that every error has a stable machine-readable identity.

Pagination

Pagination piggybacks on the envelope. The custom paginator wraps the same envelope shape with the extra list metadata:

{
  "count": 245,
  "page_size": 10,
  "current_page": 3,
  "next": "https://api.example.com/orders/?page=4",
  "previous": "https://api.example.com/orders/?page=2",
  "status": 200,
  "message": "Success",
  "success": true,
  "results": [ ... ]
}

Same top-level keys as errors have error, the list rows go in results. Every list endpoint uses the same paginator, so count, page_size, current_page, next, previous are always in the same place.

Usage in a list action:

@require_permission("order:read")
def list(self, request: Request, *args, **kwargs) -> Response:
    orders = OrderSelectors.list_orders_for_organization(
        organization=request.organization,
        search=self.params.get("search", cast_type=str),
        status=self.params.get("status", cast_type=str),
    )
    paginator = CustomPagination()
    page = paginator.paginate_queryset(orders, request)
    return paginator.get_paginated_response(
        OrderMiniOutputSerializer(page, many=True).data,
    )

The selector returns a QuerySet (from Part 3), the paginator slices it, the output serializer shapes it, the paginator envelopes it. Every list action is that shape.

What I've deferred

A handful of classes and helpers came up along the way that I didn't provide the code for — CustomPagination, PermissionChecker, CustomAPIRouter, APIServiceError and the error-code taxonomy behind it. Each one is meaty enough to be its own follow-up post; I'll write those separately.

Nothing in this post depends on my specific implementations. Everywhere I've named a custom class, you can drop in a standard replacement today and the vertical above still works. When a project's needs outgrow the standard tool, you'll evolve into something close to what I have — and by then the follow-ups will exist to compare notes.

Here's the "just use the regular approach" swap-in for each:

  • CustomPagination → DRF's PageNumberPagination or LimitOffsetPagination. The envelope shape is different from mine, but the list-action pattern doesn't change.
  • PermissionChecker and the 4-layer RBAC → a plain DRF BasePermission subclass with has_permission() doing a role or group lookup. If you like the @require_permission("code") decorator shape, you can still use it — just have it call your permission class internally instead of my checker.
  • CustomAPIRouter / custom_api_routes → DRF's DefaultRouter. Register viewsets, include in urls.py, done. The action bodies stay identical.
  • APIServiceError and hierarchical error codes → DRF's built-in exceptions (ValidationError, NotFound, PermissionDenied) plus a small custom exception handler that shapes them into a consistent envelope. Flatter than mine, but same discipline.

Build the standard version today and you can ship. Migrate to something richer when the standard tool starts leaking through — the "what does 'permission denied' actually mean here" ticket, the "we need error codes we can search on" ticket, the "we need to gate features by plan" ticket. That's what my custom versions solve, and that's what I'll walk through in the follow-up posts.

Closing the series

Zoom out. The vertical you have now, from top of the stack to bottom:

  • Views (this post): thin actions on CustomViewSet, decorated with @require_permission, following the five-step recipe.
  • Serializers (Part 4): input (plain Serializer) validates the payload; output (ModelSerializer) shapes the response.
  • Selectors and services (Part 3): read functions in selector classes, write functions in service classes, both keyword-only and organized by domain.
  • Managers and querysets (Part 2): custom querysets when they earn their keep; view-level scoping otherwise.
  • Models (Part 1): BaseModel with UUIDv7 public IDs, soft-delete, audit fields, dual managers, and BaseTextChoices.

Every layer has one job. Every layer talks to the one below it and the one above. Every layer is testable on its own. Every layer looks the same across every domain, so a new engineer joining the team can read one Order file and understand every Enrollee file.

That's what "advanced Django" actually means to me — not a specific library, framework, or trick. It's the discipline of picking a set of defaults, applying them uniformly, and then getting to build features without stopping to redesign the plumbing on every new endpoint.

If you've been reading the series and thought "I'd do X differently" at any point — the DMs are open, and this is the sort of feedback that makes future posts better. Thanks for reading.

Comments