Critical Patterns
- ALWAYS separate serializers by operation: Read / Create / Update / Include
- ALWAYS use
filterset_class for complex filtering (not filterset_fields)
- ALWAYS validate unknown fields in write serializers (inherit
BaseWriteSerializer)
- ALWAYS use
select_related/prefetch_related in get_queryset() to avoid N+1
- ALWAYS handle
swagger_fake_view in get_queryset() for schema generation
- ALWAYS use
@extend_schema_field for OpenAPI docs on SerializerMethodField
- NEVER put business logic in serializers - use services/utils
- NEVER use auto-increment PKs - use UUIDv4 or UUIDv7
- NEVER use trailing slashes in URLs (
trailing_slash=False)
Note: swagger_fake_view is specific to drf-spectacular for OpenAPI schema generation.
Implementation Checklist
When implementing a new endpoint, review these patterns in order:
| # |
Pattern |
Reference |
Key Points |
| 1 |
Models |
api/models.py |
UUID PK, inserted_at/updated_at, JSONAPIMeta.resource_name |
| 2 |
ViewSets |
api/base_views.py, api/v1/views.py |
Inherit BaseRLSViewSet, get_queryset() with N+1 prevention |
| 3 |
Serializers |
api/v1/serializers.py |
Separate Read/Create/Update/Include, inherit BaseWriteSerializer |
| 4 |
Filters |
api/filters.py |
Use filterset_class, inherit base filter classes |
| 5 |
Permissions |
api/base_views.py |
required_permissions, set_required_permissions() |
| 6 |
Pagination |
api/pagination.py |
Custom pagination class if needed |
| 7 |
URL Routing |
api/v1/urls.py |
trailing_slash=False, kebab-case paths |
| 8 |
OpenAPI Schema |
api/v1/views.py |
@extend_schema_view with drf-spectacular |
| 9 |
Tests |
api/tests/test_views.py |
JSON:API content type, fixture patterns |
Full file paths: See references/file-locations.md
Decision Trees
Which Serializer?
GET list/retrieve โ <Model>Serializer
POST create โ <Model>CreateSerializer
PATCH update โ <Model>UpdateSerializer
?include=... โ <Model>IncludeSerializer
Which Base Serializer?
Read-only serializer โ BaseModelSerializerV1
Create with tenant_id โ RLSSerializer + BaseWriteSerializer (auto-injects tenant_id on create)
Update with validation โ BaseWriteSerializer (tenant_id already exists on object)
Non-model data โ BaseSerializerV1
Which Filter Base?
Direct FK to Provider โ BaseProviderFilter
FK via Scan โ BaseScanProviderFilter
No provider relation โ FilterSet
Which Base ViewSet?
RLS-protected model โ BaseRLSViewSet (most common)
Tenant operations โ BaseTenantViewset
User operations โ BaseUserViewset
No RLS required โ BaseViewSet (rare)
Resource Name Format?
Single word model โ plural lowercase (Provider โ providers)
Multi-word model โ plural lowercase kebab (ProviderGroup โ provider-groups)
Through/join model โ parent-child pattern (UserRoleRelationship โ user-roles)
Aggregation/overview โ descriptive kebab plural (ComplianceOverview โ compliance-overviews)
Serializer Patterns
Base Class Hierarchy
class ProviderSerializer(RLSSerializer):
class Meta:
model = Provider
fields = ["id", "provider", "uid", "alias", "connected", "inserted_at"]
class ProviderCreateSerializer(RLSSerializer, BaseWriteSerializer):
class Meta:
model = Provider
fields = ["provider", "uid", "alias"]
class ProviderIncludeSerializer(RLSSerializer):
class Meta:
model = Provider
fields = ["id", "alias"]
SerializerMethodField with OpenAPI
from drf_spectacular.utils import extend_schema_field
class ProviderSerializer(RLSSerializer):
connection = serializers.SerializerMethodField(read_only=True)
@extend_schema_field({
"type": "object",
"properties": {
"connected": {"type": "boolean"},
"last_checked_at": {"type": "string", "format": "date-time"},
},
})
def get_connection(self, obj):
return {
"connected": obj.connected,
"last_checked_at": obj.connection_last_checked_at,
}
Included Serializers (JSON:API)
class ScanSerializer(RLSSerializer):
included_serializers = {
"provider": "api.v1.serializers.ProviderIncludeSerializer",
}
Sensitive Data Masking
def to_representation(self, instance):
data = super().to_representation(instance)
fields_param = self.context.get("request").query_params.get("fields[my-model]", "")
if "api_key" in fields_param:
data["api_key"] = instance.api_key_decoded
else:
data["api_key"] = "****" if instance.api_key else None
return data
ViewSet Patterns
get_queryset() with N+1 Prevention
Always combine swagger_fake_view check with select_related/prefetch_related:
def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return Provider.objects.none()
return Provider.objects.select_related(
"tenant",
).prefetch_related(
"provider_groups",
Prefetch("tags", queryset=ProviderTag.objects.filter(tenant_id=self.request.tenant_id)),
)
Why swagger_fake_view? drf-spectacular introspects ViewSets to generate OpenAPI schemas. Without this check, it executes real queries and can fail without request context.
Action-Specific Serializers
def get_serializer_class(self):
if self.action == "create":
return ProviderCreateSerializer
elif self.action == "partial_update":
return ProviderUpdateSerializer
elif self.action in ["connection", "destroy"]:
return TaskSerializer
return ProviderSerializer
Dynamic Permissions per Action
class ProviderViewSet(BaseRLSViewSet):
required_permissions = [Permissions.MANAGE_PROVIDERS]
def set_required_permissions(self):
if self.action in ["list", "retrieve"]:
self.required_permissions = []
else:
self.required_permissions = [Permissions.MANAGE_PROVIDERS]
Cache Decorator
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
CACHE_DECORATOR = cache_control(
max_age=django_settings.CACHE_MAX_AGE,
stale_while_revalidate=django_settings.CACHE_STALE_WHILE_REVALIDATE,