Segment schemas (the default segment type)

A patrol segment’s user-defined fields are shaped by a segment schema: one document per site (per tenant), applying to every patrol segment regardless of its patrol type.

The schema is owned by a SegmentType row. SegmentType is a pseudo-singleton — a real table that could hold many rows, but in practice exactly one row per tenant is allowed to exist, reserved under the value default. The model mirrors v2 EventType and PatrolType (same {json, ui} envelope, same meta-schema validation, same revision auditing); the only structural difference is that there is no create or delete surface.

Availability. These endpoints are gated on the per-tenant patrol_schemas preview feature. A tenant without it gets 404 from every route below — the surface is indistinguishable from one that was never deployed. Segment types have no permissions of their own: they use the patrol-type permissions. Reads require activity.view_patroltype or activity.view_patrol (anyone who can read patrols needs the segment schema to record segments); PUT requires activity.change_patroltype. activity.view_segmenttype and activity.change_segmenttype are not consulted.

Endpoints

GET /api/v1.0/activity/patrols/segments/types/default/            retrieve
PUT /api/v1.0/activity/patrols/segments/types/default/            replace the schema
GET /api/v1.0/activity/patrols/segments/types/default/schema/     the schema document

default is the only accepted path segment. Any other value — including the row’s own UUID — returns 404; the literal is baked into the URL pattern, so the rejection happens at the resolver.

POST and DELETE return 405. This table cannot gain or lose records through the API.

GET .../types/default/

Returns the record. By default the schema field is omitted:

{
  "id": "3f1c…",
  "value": "default",
  "display": "Default"
}

Pass ?include_schema=true to get the document inline:

{
  "id": "3f1c…",
  "value": "default",
  "display": "Default",
  "schema": {
    "json": {
      "$schema": "https://json-schema.org/draft/2020-12/schema",
      "type": "object",
      "properties": {},
      "required": [],
      "unevaluatedProperties": false
    },
    "ui": {"fields": {}, "headers": {}, "order": [], "sections": {}}
  }
}

A tenant that has never written a segment schema serves the canonical empty envelope shown above, never null — the same behaviour as PatrolType.schema with an unset schema. That envelope is the stored default, not something the API synthesizes on read, so it is ready to be edited as-is.

The response carries ETag and Last-Modified; both support conditional GET. The include_schema=true ETag is distinct from the default one and moves when the document changes.

PUT .../types/default/

Replaces the schema document. The body is validated against the shared meta-schema (JSON Schema draft 2020-12) plus the ui.sections / ui.headers / ui.order parent-reference contract.

{"schema": {"json": {...}, "ui": {...}}}
  • Omitting schema leaves the stored document untouched.

  • "schema": null is a 400, not a silent no-op.

  • value and display are reserved. A body may echo their current values — so a GET response can be PUT straight back — but any change is a 400 naming the reserved value. Nothing is written when that happens; a rejected identity change does not let an accompanying schema through.

  • Of the four envelope-root keys the shared meta-schema tolerates for EventType’s benefit, only auto-generate is accepted here. readonly, icon_id and image_url are rejected with a 400 under the schema key.

Every accepted write is recorded as a revision (SegmentTypeRevision), including the timestamp-only change a re-save of an identical document produces.

GET .../types/default/schema/

Returns the schema document itself (not the record wrapper), with the shared schema-endpoint semantics used by v2 event types:

Query param

Meaning

pre_render=true

Dereference $ref entries and render dynamic fragments inline.

s_format

Choice-field shape when pre_render=true: oneOf (default) or enum. Ignored otherwise; an unsupported value is a 400.

A 200 carries the bare document. A document-level problem (no schema, a broken envelope, corrupt storage) returns 422 with structured errors:

{"errors": [{
  "category": "validation",
  "code": "schema_structure_error",
  "message": "Schema missing 'json' or 'ui' top-level key.",
  "hints": [{"message": "A schema document must be a JSON object with top-level \"json\" and \"ui\" sections — update the segment type schema via the API.", "action_type": "fix", "details": {}}],
  "context": null
}]}

The hints name only the API on purpose: the Django admin row for segment schemas is a link out to the form builder, with no editable schema widget, so the validated API is the only write path.

The response carries an ETag (no Last-Modified) and honours If-None-Match with 304. It is a hash of the served document: shareable across users without pre_render; with pre_render=true it moves when the data behind a $ref changes and is per-user, so cache it per authenticated identity. Only a 200 carries an ETag; the 422 above has none. pre_render yields its own ETag, and so does s_format when pre_render=true.

How the default row comes to exist

Lazily, on first access, and by exactly one mechanism: SegmentTypeViewSet.get_object does a get_or_create. The first request to any of the routes above creates the row (and its added revision); every later request finds it. There is no seeding migration — every read and write of a segment schema goes through this viewset (the admin row is a link out to the form builder, which drives the same API), so a deploy-time fleet-wide write would buy nothing a tenant’s own first request does not.

Consequences worth knowing:

  • The row’s id is a fresh uuid4 per tenant. Ids are deliberately not shared across tenants here, unlike the PatrolType rows seeded by activity/migrations/0096 — so the shared-primary-key write hazard that applies to patrol types does not apply to segment types.

  • A tenant whose patrol_schemas preview feature is off never reaches the get_or_create: the gate runs first, so no row is created for a dark tenant.

  • The creation is idempotent and tenant-scoped. SegmentType.objects is tenant-scoped, so the read half only sees the caller’s own row, and the (das_tenant, value) unique constraint turns a concurrent double-create into a loser-raises that get_or_create retries as a read.

  • A tenant that has never called the API has no row at all. Nothing else reads SegmentType, so that is not observable anywhere.

Admin

The ACTIVITY section of /admin carries a link-only row, Patrol Types and Segments, which opens the form builder’s patrol-types page at /admin/form-builder/patrol-types. Like the existing “Event Types and Categories 2.0 (Beta)” row it is a proxy model with every write permission denied; the row is present only when PATROL_ENABLED is on.