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.
5.0 KiB
Serializer Field Patterns
help_text on every field
help_text flows through the entire pipeline to become the OpenAPI field description,
the Zod .describe() string, and ultimately what an LLM reads when deciding how to fill a parameter.
# Bad — agent has no idea what this field expects
name = serializers.CharField()
# Good — clear, actionable description
name = serializers.CharField(
help_text="Human-readable name for the action. Used in the UI and API responses."
)
Guidelines for writing help_text:
- Describe the purpose, not just the type ("UUID of the parent dashboard" not "a UUID")
- Mention format constraints ("ISO 8601 datetime string", "comma-separated list")
- List valid values for constrained fields ("One of: active, archived, deleted")
- Note defaults if non-obvious ("Defaults to the current project's timezone")
- Be specific about what happens with null/empty ("Pass null to remove the filter")
ListField — always specify child=
A bare ListField() produces z.unknown() in the generated Zod schema.
# Bad — Orval generates z.array(z.unknown())
tags = serializers.ListField()
# Good — generates z.array(z.string())
tags = serializers.ListField(
child=serializers.CharField(),
help_text="Tags to apply to this resource. Each tag is a plain string.",
)
# Good — typed child for complex items
steps = serializers.ListField(
child=ActionStepSerializer(),
help_text="Ordered list of action steps. Each step defines a match condition.",
)
JSONField — use @extend_schema_field with a typed schema
Bare JSONField produces a generic object schema. The fix is a custom field class
with @extend_schema_field pointing to a Pydantic model or inline schema.
The canonical pattern is in insights/api/alert.py:
from drf_spectacular.utils import extend_schema_field
from pydantic import BaseModel
# 1. Define the schema (Pydantic model or OpenAPI-compatible class)
class AlertCondition(BaseModel):
type: str
threshold: float
operator: str
# 2. Create a custom field class with the decorator
@extend_schema_field(AlertCondition) # type: ignore[arg-type]
class AlertConditionField(serializers.JSONField):
pass
# 3. Use the custom field in the serializer
class AlertSerializer(serializers.ModelSerializer):
condition = AlertConditionField(
required=False,
allow_null=True,
help_text="Condition that triggers the alert. See AlertCondition schema.",
)
For simpler cases where a full Pydantic model is overkill, use OpenApiTypes:
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
@extend_schema_field(OpenApiTypes.OBJECT)
class MetadataField(serializers.JSONField):
"""At minimum, this tells Orval it's an object, not unknown."""
pass
SerializerMethodField — annotate the getter
Without @extend_schema_field, drf-spectacular can't infer the return type.
# Bad — return type unknown in OpenAPI
class TeamSerializer(serializers.ModelSerializer):
member_count = serializers.SerializerMethodField()
def get_member_count(self, obj):
return obj.members.count()
# Good — explicit return type
class TeamSerializer(serializers.ModelSerializer):
member_count = serializers.SerializerMethodField()
@extend_schema_field(serializers.IntegerField(help_text="Number of team members"))
def get_member_count(self, obj):
return obj.members.count()
For complex return types:
@extend_schema_field(serializers.ListField(child=serializers.CharField()))
def get_redirect_uris_list(self, instance) -> list[str]:
return instance.redirect_uris.split() if instance.redirect_uris else []
ChoiceField — explicit choices
# Bad — valid values not discoverable
status = serializers.CharField()
# Good — choices appear in OpenAPI enum
status = serializers.ChoiceField(
choices=["active", "archived", "deleted"],
help_text="Current status of the resource.",
)
Collision-prone field names. Generic names like format, type, status,
kind, level, mode, state, platform, provider already exist on multiple
components with different choices. Reusing one for a new ChoiceField means
drf-spectacular auto-names your enum (Format5eaEnum and similar), which fails CI
under --fail-on-warn. Either pick a more specific field name, or add a stable
name to ENUM_NAME_OVERRIDES in insights/settings/web.py when you introduce the
field. Run python manage.py find_enum_collisions after the change — it prints
a suggested override entry (pastable as-is for type-hint enum collisions; for
ChoiceField collisions you fill in the Choices/Enum class path).
DictField — typed values
# Bad — generic dict
properties = serializers.DictField()
# Good — typed values
properties = serializers.DictField(
child=serializers.CharField(),
help_text="Key-value pairs of event properties. Keys and values are strings.",
)