Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

8.0 KiB

ViewSet and Action Annotation Patterns

@validated_request — the preferred decorator

@validated_request from insights/api/mixins.py combines request validation, query parameter validation, and @extend_schema in one decorator. It sets request.validated_data and request.validated_query_data automatically.

from insights.api.mixins import validated_request
from drf_spectacular.utils import OpenApiResponse

class TaskViewSet(viewsets.ModelViewSet):
    @validated_request(
        query_serializer=TaskListQuerySerializer,
        responses={
            200: OpenApiResponse(response=TaskSerializer, description="List of tasks"),
        },
        summary="List tasks",
        description="Get a list of tasks for the current project, optionally filtered by repository.",
    )
    def list(self, request, *args, **kwargs):
        repository = request.validated_query_data["repository"]
        # ... use validated data directly

Use @validated_request when:

  • The endpoint accepts a request body (request_serializer=)
  • The endpoint accepts query parameters (query_serializer=)
  • You want automatic validation before the method body runs

TypedRequest — typed validated_data

By default request.validated_data is dict[str, Any]. Use TypedRequest[T] from insights/api/mixins.py to tell the type checker the actual shape, especially with DataclassSerializer where validated_data returns a dataclass instance:

from insights.api.mixins import TypedRequest, validated_request

class RepoViewSet(viewsets.GenericViewSet):
    @validated_request(
        request_serializer=CreateRepoInputSerializer,
        responses={201: OpenApiResponse(response=RepoSerializer, description="Created repo")},
    )
    def create(self, request: TypedRequest[CreateRepoInput], **kwargs) -> Response:
        data = request.validated_data  # type checker knows this is CreateRepoInput
        repo = api.create_repo(data, team_id=self.team_id)
        return Response(RepoSerializer(repo).data, status=status.HTTP_201_CREATED)

Use TypedRequest[T] when the validated data is a typed object (dataclass, Pydantic model). For plain dict payloads, ValidatedRequest is fine.

@extend_schema — for endpoints where @validated_request doesn't fit

Use @extend_schema directly when you only need schema metadata without validation, or when the endpoint pattern doesn't match @validated_request.

from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParameter

class SentimentViewSet(viewsets.ViewSet):
    @extend_schema(
        request=SentimentRequestSerializer,
        responses={
            200: SentimentBatchResponseSerializer,
            400: OpenApiResponse(description="Invalid request data"),
        },
        tags=["LLM Analytics"],
        summary="Analyze sentiment",
        description="Run sentiment analysis on a batch of LLM generations.",
    )
    def create(self, request, **kwargs):
        serializer = SentimentRequestSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        # ...

Critical: decorate the right method

@extend_schema must go on the actual HTTP handler method, not on a helper or the class.

# Bad — decorator on APIView class has no effect
@extend_schema(request=MySerializer)
class MyView(APIView):
    def post(self, request):  # This method needs the decorator
        ...

# Good — decorator on the handler method
class MyView(APIView):
    @extend_schema(request=MySerializer, responses={201: MyResponseSerializer})
    def post(self, request):
        ...

For inherited methods (list, create, retrieve, etc.), use @extend_schema_view:

from drf_spectacular.utils import extend_schema_view, extend_schema

@extend_schema_view(
    list=extend_schema(description="List all feature flags for the project"),
    retrieve=extend_schema(description="Get a single feature flag by ID"),
)
class FeatureFlagViewSet(viewsets.ModelViewSet):
    serializer_class = FeatureFlagSerializer
    # ...

Custom @action methods

Every @action needs explicit schema annotation. Without it, drf-spectacular generates zero parameters.

# Bad — no schema, MCP tool gets z.object({})
@action(detail=False, methods=["post"], url_path="test_hog")
def test_hog(self, request, **kwargs):
    serializer = TestHogRequestSerializer(data=request.data)
    ...

# Good — schema declared
@extend_schema(
    request=TestHogRequestSerializer,
    responses={200: TestHogResponseSerializer},
    summary="Test Script evaluation code",
    description="Test Script evaluation code against sample events without saving.",
)
@action(detail=False, methods=["post"], url_path="test_hog")
def test_hog(self, request, **kwargs):
    ...

Note: @extend_schema goes above @action in the decorator stack.

Typed error responses

Generic OpenApiTypes.OBJECT tells downstream consumers nothing about the error shape.

from drf_spectacular.types import OpenApiTypes

# Bad — agents can't parse error structure
@extend_schema(
    responses={
        200: MySerializer,
        400: OpenApiTypes.OBJECT,
    },
)

# Good — error shape is documented
@extend_schema(
    responses={
        200: MySerializer,
        400: OpenApiResponse(description="Validation failed — returns field-level errors"),
        404: OpenApiResponse(description="Resource not found"),
    },
)

For endpoints with a consistent error body, create a reusable error serializer:

class ValidationErrorSerializer(serializers.Serializer):
    attr = serializers.CharField(help_text="Field that failed validation")
    code = serializers.CharField(help_text="Error code")
    detail = serializers.CharField(help_text="Human-readable error message")

Pagination on custom actions

Custom @action methods inherit the parent viewset's pagination by default, which may not be correct.

# If your custom action returns a non-paginated response:
@action(detail=False, methods=["get"], pagination_class=None, filter_backends=[])
def summary(self, request, **kwargs):
    ...

Request/response serializer splitting

When input and output shapes differ, use separate serializers:

@extend_schema(
    request=CreateExperimentSerializer,      # Only writable fields
    responses={201: ExperimentSerializer},    # Full object with computed fields
)
def create(self, request, *args, **kwargs):
    ...

drf-spectacular's COMPONENT_SPLIT_PATCH setting (enabled by default) handles the PATCH case automatically, creating separate schemas for PATCH vs POST since PATCH doesn't require all fields.

ENUM_NAME_OVERRIDES — fixing enum naming warnings

When multiple serializer fields share the same choice values (or the same field name is used across components with different choices), drf-spectacular emits warnings. These are fixed via ENUM_NAME_OVERRIDES in insights/settings/web.py.

The hash trap: overrides must produce the same hash that drf-spectacular computes at schema generation time. There are two different hash paths:

  1. ChoiceField (model-backed fields) — drf-spectacular injects x-spec-enum-id using list_hash([(value, label), ...]) where labels come from the Django Choices class. Override with a model class path: "MyEnum": "myapp.models.MyModel.MyChoices".

  2. Enum type hints (return types on SerializerMethodField) — no x-spec-enum-id is set, so postprocessing falls back to list_hash([(value, value), ...]). Override with an inline value list: "MyEnum": ["val1", "val2"]. For integer enums, use tuples: "MyEnum": [(1, 1), (8, 8)].

Using the wrong format means the override hash doesn't match and the warning persists even though it looks like it should work.

CI enforces zero warnings via spectacular --fail-on-warn.

Diagnostic tool: run python manage.py find_enum_collisions to find unresolved collisions — it shows the field name, hash, enum values, hash path (ChoiceField vs type-hint), which components use it, and suggests the override format to add.