-
Define the serializer in calendar_integration/serializers.py:
import django_virtual_models as v
from rest_framework import serializers
from calendar_integration.models import Calendar
from calendar_integration.virtual_models import CalendarSummaryVirtualModel
class CalendarSummarySerializer(serializers.ModelSerializer):
event_count = serializers.IntegerField(read_only=True)
last_event_end_time = serializers.DateTimeField(read_only=True)
class Meta:
model = Calendar
fields = ("id", "name", "calendar_type", "event_count", "last_event_end_time")
read_only_fields = fields
virtual_model = CalendarSummaryVirtualModel
Notes:
virtual_model = ... opts into django-virtual-models for queryset optimization.
- Read-only response →
read_only_fields = fields. Write endpoints declare writable fields explicitly.
- Validation on write goes in
validate() / validate_<field> methods, not in the view.
-
Add the virtual model in calendar_integration/virtual_models.py (only when the queryset would N+1):
import django_virtual_models as v
from calendar_integration.models import Calendar
class CalendarSummaryVirtualModel(v.VirtualModel):
class Meta:
model = Calendar
deferred_fields = ["description"]
-
Add the permission class in calendar_integration/permissions.py (if a new resource class):
from rest_framework.permissions import BasePermission
class CalendarSummaryPermission(BasePermission):
def has_permission(self, request, view) -> bool:
if not request.user.is_authenticated:
return False
return getattr(request.user, "organization_membership", None) is not None
For most cases, reuse an existing permission class — only add a new one when the resource class is genuinely new.
-
Add the filterset in calendar_integration/filtersets.py (optional):
import django_filters
from calendar_integration.models import Calendar
class CalendarSummaryFilterSet(django_filters.FilterSet):
calendar_type = django_filters.CharFilter(lookup_expr="iexact")
has_events = django_filters.BooleanFilter(method="filter_has_events")
class Meta:
model = Calendar
fields = ("calendar_type",)
def filter_has_events(self, queryset, name, value):
return queryset.filter(events__isnull=not value).distinct()
-
Add the viewset in calendar_integration/views.py:
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from calendar_integration.filtersets import CalendarSummaryFilterSet
from calendar_integration.models import Calendar
from calendar_integration.permissions import CalendarSummaryPermission
from calendar_integration.serializers import CalendarSummarySerializer
from common.utils.view_utils import VintaScheduleModelViewSet
@extend_schema(tags=["Calendar Summaries"])
class CalendarSummaryViewSet(VintaScheduleModelViewSet):
"""ViewSet exposing per-calendar event-count summaries."""
permission_classes = (CalendarSummaryPermission,)
queryset = Calendar.objects.all()
serializer_class = CalendarSummarySerializer
filterset_class = CalendarSummaryFilterSet
http_method_names = ("get", "head", "options")
def get_queryset(self):
user = self.request.user
if not user.is_authenticated:
return Calendar.objects.none()
org_id = user.organization_membership.organization_id
return Calendar.objects.filter_by_organization(org_id).with_summary()
@extend_schema(
parameters=[
OpenApiParameter(name="event_type", required=False, type=str, location="query"),
],
responses={200: CalendarSummarySerializer(many=True)},
)
@action(detail=False, methods=["get"], url_path="trending")
def trending(self, request):
qs = self.get_queryset().order_by("-event_count")[:10]
return Response(self.get_serializer(qs, many=True).data, status=status.HTTP_200_OK)
Notes:
VintaScheduleModelViewSet (from common/utils/view_utils.py) is the project base — provides the virtual-models hook + project-wide conventions. Use it.
get_queryset() hydrates the organization scope. The manager's filter_by_organization raises if missing — that's the safety net.
with_summary() is a queryset method on the manager, not inline annotation in the view.
http_method_names restricts allowed verbs.
@extend_schema provides OpenAPI metadata. Required for @action methods so drf-spectacular sees the param + response shape. Apply at the viewset level for tags / shared params.
-
Register the route in calendar_integration/routes.py:
from common.types import RouteDict
from .views import (
...,
CalendarSummaryViewSet,
)
routes: list[RouteDict] = [
...,
{
"regex": r"calendar-summaries",
"viewset": CalendarSummaryViewSet,
"basename": "CalendarSummaries",
},
]
The root URL conf (vinta_schedule_api/urls.py) imports per-app routes lists and registers each with the DefaultRouter. No edit needed at the project root.
-
Regenerate the OpenAPI schema:
docker compose run --rm api uv run python manage.py spectacular --color --file schema.yml
Pre-commit hook (backend-schema in .pre-commit-config.yaml) verifies the schema is up-to-date. Run it before committing or CI fails.
-
Tests in calendar_integration/tests/test_calendar_summary_endpoint.py:
- Authenticated user with org membership receives a 200 + serialized list.
- Anonymous user receives 401.
- User of org A receives only org A's data (cross-tenant isolation).
- Filterset works:
?calendar_type=PRIMARY filters correctly.
- Custom
@action: /calendar-summaries/trending/ returns the right shape.
- Virtual model prevents N+1: assert query count is bounded (use
django_assert_max_num_queries).
(Multi-tenancy bypass + DI bypass + complex-inline-queryset are covered upstream — see AGENTS.md → Multi-Tenancy and the reviewer agent's BLOCKER classes. Skill-specific pitfalls below.)