feat: implement immutable form definitions

This commit is contained in:
2026-08-01 17:48:34 +02:00
parent 5825c2c8e4
commit 343a208894
25 changed files with 2072 additions and 20 deletions
+380
View File
@@ -0,0 +1,380 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any, Mapping, Sequence
import uuid
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.core.institutional import (
FormDefinition,
InstitutionalContextError,
InstitutionalReference,
)
from govoplan_forms.backend.db.models import FormDefinitionRevision
_PUBLICATION_TRANSITIONS: dict[str, frozenset[str]] = {
"draft": frozenset({"draft", "published", "retired"}),
"published": frozenset({"published", "retired"}),
"retired": frozenset(),
}
class FormDefinitionStoreError(ValueError):
pass
def definition_from_mapping(value: Mapping[str, object]) -> FormDefinition:
try:
return FormDefinition.from_mapping(value)
except InstitutionalContextError as exc:
raise FormDefinitionStoreError(str(exc)) from exc
def record_form_definition(
session: Session,
principal: object,
*,
definition: FormDefinition,
expected_revision: str | None = None,
) -> FormDefinition:
tenant_id = _principal_tenant(principal)
_validate_definition(definition, tenant_id=tenant_id)
payload = definition.to_dict()
replay = (
session.query(FormDefinitionRevision)
.filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.form_id == definition.reference.object_id,
FormDefinitionRevision.revision == definition.temporal.revision,
)
.one_or_none()
)
if replay is not None:
if replay.payload != payload:
raise FormDefinitionStoreError(
"A different Form definition already uses this revision."
)
return _definition_from_row(replay)
current = _current_row(
session,
tenant_id=tenant_id,
form_id=definition.reference.object_id,
lock=True,
)
if current is None:
if expected_revision is not None:
raise FormDefinitionStoreError(
"Form definition revision conflict: no current revision exists."
)
key_collision = (
session.query(FormDefinitionRevision.id)
.filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.form_key == definition.key,
FormDefinitionRevision.superseded_at.is_(None),
)
.first()
)
if key_collision is not None:
raise FormDefinitionStoreError(
"Form definition key is already in use in this tenant."
)
else:
if expected_revision != current.revision:
raise FormDefinitionStoreError(
"Form definition revision conflict: the expected revision is stale."
)
if definition.key != current.form_key:
raise FormDefinitionStoreError(
"A Form definition key cannot change across revisions."
)
if definition.publication_state not in _PUBLICATION_TRANSITIONS[
current.publication_state
]:
raise FormDefinitionStoreError(
f"Form publication transition {current.publication_state!r} to "
f"{definition.publication_state!r} is not allowed."
)
current.superseded_at = _recorded_at(definition)
row = FormDefinitionRevision(
tenant_id=tenant_id,
form_id=definition.reference.object_id,
form_key=definition.key,
revision=definition.temporal.revision,
previous_revision_id=current.id if current is not None else None,
publication_state=definition.publication_state,
title=definition.title,
recorded_at=_recorded_at(definition),
search_text=f"{definition.key} {definition.title} {definition.description or ''}".casefold(),
payload=payload,
changed_by=_principal_actor(principal),
)
session.add(row)
session.flush()
event_id = str(uuid.uuid4())
emit_platform_event(
session,
PlatformEvent(
event_id=event_id,
type="forms.definition.recorded",
module_id="forms",
payload={
"form_id": row.form_id,
"form_key": row.form_key,
"revision": row.revision,
"publication_state": row.publication_state,
"field_count": len(definition.fields),
},
occurred_at=row.recorded_at,
actor=EventActorRef(type="account", id=_principal_actor(principal)),
tenant=EventTenantRef(id=tenant_id),
resource=EventObjectRef(
type="form_definition",
id=row.form_id,
label=row.title,
),
classification="internal",
),
)
return _definition_from_row(row)
def get_form_definition(
session: Session,
principal: object,
*,
form_id: str,
revision: str | None = None,
) -> FormDefinition | None:
tenant_id = _principal_tenant(principal)
query = session.query(FormDefinitionRevision).filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.form_id == form_id,
)
if revision is None:
query = query.filter(FormDefinitionRevision.superseded_at.is_(None))
else:
query = query.filter(FormDefinitionRevision.revision == revision)
row = query.order_by(FormDefinitionRevision.recorded_at.desc()).first()
return _definition_from_row(row) if row is not None else None
def list_form_definitions(
session: Session,
principal: object,
*,
query: str = "",
publication_states: Sequence[str] | None = None,
offset: int = 0,
limit: int = 100,
) -> tuple[tuple[FormDefinition, ...], int]:
tenant_id = _principal_tenant(principal)
if offset < 0 or not 1 <= limit <= 200:
raise FormDefinitionStoreError(
"Form definition offset must be non-negative and limit between 1 and 200."
)
statement = session.query(FormDefinitionRevision).filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.superseded_at.is_(None),
)
if publication_states:
statement = statement.filter(
FormDefinitionRevision.publication_state.in_(tuple(publication_states))
)
clean_query = query.strip().casefold()
if clean_query:
statement = statement.filter(
FormDefinitionRevision.search_text.contains(clean_query)
)
total = int(statement.with_entities(func.count()).scalar() or 0)
rows = (
statement.order_by(
FormDefinitionRevision.form_key.asc(),
FormDefinitionRevision.recorded_at.desc(),
)
.offset(offset)
.limit(limit)
.all()
)
return tuple(_definition_from_row(row) for row in rows), total
def form_definition_history(
session: Session,
principal: object,
*,
form_id: str,
limit: int = 100,
) -> tuple[FormDefinition, ...]:
tenant_id = _principal_tenant(principal)
if not 1 <= limit <= 200:
raise FormDefinitionStoreError("Form history limit must be between 1 and 200.")
rows = (
session.query(FormDefinitionRevision)
.filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.form_id == form_id,
)
.order_by(FormDefinitionRevision.recorded_at.desc())
.limit(limit)
.all()
)
return tuple(_definition_from_row(row) for row in rows)
class SqlFormDefinitionProvider:
def get_form_definition(
self,
session: object,
principal: object,
*,
reference: InstitutionalReference,
effective_at: datetime | None = None,
) -> FormDefinition | None:
tenant_id = _principal_tenant(principal)
if (
reference.kind != "form"
or reference.owner_module != "forms"
or reference.tenant_id != tenant_id
or not reference.version
):
raise InstitutionalContextError(
"Form definition lookup requires an exact same-tenant Forms reference."
)
definition = get_form_definition(
_session(session),
principal,
form_id=reference.object_id,
revision=reference.version,
)
if definition is None or (
effective_at is not None
and not definition.temporal.effective_at(effective_at)
):
return None
return definition
def list_form_definitions(
self,
session: object,
principal: object,
*,
tenant_id: str,
query: str = "",
limit: int = 100,
) -> Sequence[FormDefinition]:
if tenant_id != _principal_tenant(principal):
raise InstitutionalContextError(
"Form definition catalogue lookup cannot cross tenants."
)
items, _ = list_form_definitions(
_session(session),
principal,
query=query,
publication_states=("published",),
limit=limit,
)
return items
def _current_row(
session: Session,
*,
tenant_id: str,
form_id: str,
lock: bool,
) -> FormDefinitionRevision | None:
query = session.query(FormDefinitionRevision).filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.form_id == form_id,
FormDefinitionRevision.superseded_at.is_(None),
)
if lock:
query = query.with_for_update()
return query.one_or_none()
def _definition_from_row(row: FormDefinitionRevision) -> FormDefinition:
payload: dict[str, Any] = dict(row.payload)
temporal = dict(payload.get("temporal") or {})
temporal["superseded_at"] = _datetime_text(row.superseded_at)
payload["temporal"] = temporal
return FormDefinition.from_mapping(payload)
def _validate_definition(definition: FormDefinition, *, tenant_id: str) -> None:
if definition.reference.owner_module != "forms":
raise FormDefinitionStoreError("Form definitions must be owned by Forms.")
if definition.reference.tenant_id != tenant_id:
raise FormDefinitionStoreError("Form definitions cannot cross tenants.")
if definition.temporal.superseded_at is not None:
raise FormDefinitionStoreError("Clients cannot set Form superseded_at.")
_recorded_at(definition)
if not str(definition.temporal.change_reason or "").strip():
raise FormDefinitionStoreError(
"A Form definition revision requires a change reason."
)
def _recorded_at(definition: FormDefinition) -> datetime:
if definition.temporal.recorded_at is None:
raise FormDefinitionStoreError(
"A Form definition revision requires recorded_at."
)
return definition.temporal.recorded_at
def _principal_tenant(principal: object) -> str:
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id:
raise InstitutionalContextError(
"Form definition operations require a tenant-bound principal."
)
return tenant_id
def _principal_actor(principal: object) -> str | None:
for name in ("account_id", "identity_id", "membership_id"):
value = str(getattr(principal, name, "") or "").strip()
if value:
return value
return None
def _session(value: object) -> Session:
if not hasattr(value, "query"):
raise InstitutionalContextError(
"Form definition provider requires a database session."
)
return value # type: ignore[return-value]
def _datetime_text(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.isoformat()
__all__ = [
"FormDefinitionStoreError",
"SqlFormDefinitionProvider",
"definition_from_mapping",
"form_definition_history",
"get_form_definition",
"list_form_definitions",
"record_form_definition",
]