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
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Forms module."""
__version__ = "0.1.14"
+1
View File
@@ -0,0 +1 @@
"""Forms backend package."""
@@ -0,0 +1 @@
"""Forms database models."""
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import DateTime, Index, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class FormDefinitionRevision(Base, TimestampMixin):
__tablename__ = "form_definition_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"form_id",
"revision",
name="uq_form_definition_revision",
),
Index(
"ix_form_definition_current",
"tenant_id",
"form_id",
"superseded_at",
),
Index(
"ix_form_definition_catalog",
"tenant_id",
"publication_state",
"form_key",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
form_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
form_key: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
revision: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
previous_revision_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
publication_state: Mapped[str] = mapped_column(
String(30), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
search_text: Mapped[str] = mapped_column(Text, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
changed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
__all__ = ["FormDefinitionRevision"]
+214
View File
@@ -0,0 +1,214 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.institutional import CAPABILITY_FORM_DEFINITIONS
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_forms.backend.db import models as form_models
from govoplan_forms.backend.service import SqlFormDefinitionProvider
MODULE_ID = "forms"
MODULE_NAME = "Forms"
MODULE_VERSION = "0.1.14"
READ_SCOPE = "forms:definition:read"
WRITE_SCOPE = "forms:definition:write"
ADMIN_SCOPE = "forms:definition:admin"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category=MODULE_NAME,
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
def _router(_context: ModuleContext):
from govoplan_forms.backend.router import router
return router
def _definitions(_context: ModuleContext) -> SqlFormDefinitionProvider:
return SqlFormDefinitionProvider()
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("access",),
optional_dependencies=("forms_runtime", "portal", "workflow_engine", "cases", "policy"),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
provides_interfaces=(
ModuleInterfaceProvider(name="forms.definitions", version="0.1.0"),
),
permissions=(
_permission(READ_SCOPE, "View form definitions", "Read reusable form definitions and exact revisions."),
_permission(WRITE_SCOPE, "Manage form definitions", "Create and revise reusable form definitions."),
_permission(ADMIN_SCOPE, "Publish form definitions", "Publish and retire form-definition revisions."),
),
role_templates=(
RoleTemplate(
slug="forms_designer",
name="Forms designer",
description="Design and publish reusable form definitions.",
permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
),
RoleTemplate(
slug="forms_reader",
name="Forms reader",
description="Inspect reusable form definitions.",
permissions=(READ_SCOPE,),
),
),
route_factory=_router,
nav_items=(
NavItem(
path="/forms",
label="Form definitions",
icon="list-tree",
required_any=(READ_SCOPE,),
order=36,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/forms-webui",
routes=(
FrontendRoute(
path="/forms",
component="FormsPage",
required_any=(READ_SCOPE,),
order=36,
),
),
nav_items=(
NavItem(
path="/forms",
label="Form definitions",
icon="list-tree",
required_any=(READ_SCOPE,),
order=36,
),
),
view_surfaces=(
ViewSurface(
id="forms.navigation",
module_id=MODULE_ID,
kind="navigation",
label="Form definitions navigation",
order=10,
),
ViewSurface(
id="forms.catalogue",
module_id=MODULE_ID,
kind="route",
label="Form definition catalogue",
order=20,
),
),
),
capability_factories={CAPABILITY_FORM_DEFINITIONS: _definitions},
capability_documentation={
CAPABILITY_FORM_DEFINITIONS: CapabilityDocumentation(
label="Immutable form definitions",
summary="Resolves exact tenant-bound form schemas without exposing Forms tables.",
contract_version="0.1.0",
)
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
form_models.FormDefinitionRevision,
label=MODULE_NAME,
),
retirement_notes="Destructive retirement removes immutable form-definition history and requires a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
form_models.FormDefinitionRevision,
label=MODULE_NAME,
),
),
documentation=(
DocumentationTopic(
id="forms.definitions",
title="Reusable form definitions",
summary="Create immutable, versioned schemas consumed by Forms Runtime and institutional services.",
body=(
"Each revision fixes field types, options, constraints, draft, attachment, signature, policy, and handoff requirements. "
"Publishing is explicit; existing submissions continue to retain their exact revision."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
links=(
DocumentationLink(
label="Forms boundary and recovery",
href="govoplan-forms/docs/FORMS_BOUNDARY.md",
kind="repository",
),
),
),
),
architecture=declared_module_architecture(
layer="human_work_procedure",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/FORMS_BOUNDARY.md",
test_ref="tests/test_forms.py",
known_limits=(
"Conditional multi-page layout, localization authoring, and package-fragment tooling remain product depth.",
),
supported_authority_modes=("native_authoritative",),
owned_concepts=("form definition", "form schema", "form definition revision"),
non_owned_concepts=("form submission", "file content", "case", "workflow instance"),
reference_packages=("product.service-to-decision",),
migration_docs=("docs/FORMS_BOUNDARY.md",),
recovery_docs=("docs/FORMS_BOUNDARY.md",),
security_docs=("docs/FORMS_BOUNDARY.md",),
operations_docs=("docs/FORMS_BOUNDARY.md",),
),
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1 @@
"""Forms Alembic revisions."""
@@ -0,0 +1 @@
"""Forms migration versions."""
@@ -0,0 +1,77 @@
"""v0.1.14 immutable Forms definitions.
Revision ID: e1f2a3b4c5d6
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e1f2a3b4c5d6"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"form_definition_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("form_id", sa.String(length=255), nullable=False),
sa.Column("form_key", sa.String(length=255), nullable=False),
sa.Column("revision", sa.String(length=255), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("publication_state", sa.String(length=30), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("search_text", sa.Text(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_definition_revisions")),
sa.UniqueConstraint(
"tenant_id",
"form_id",
"revision",
name="uq_form_definition_revision",
),
)
for column in (
"tenant_id",
"form_id",
"form_key",
"revision",
"previous_revision_id",
"publication_state",
"recorded_at",
"superseded_at",
"changed_by",
):
op.create_index(
op.f(f"ix_form_definition_revisions_{column}"),
"form_definition_revisions",
[column],
unique=False,
)
op.create_index(
"ix_form_definition_current",
"form_definition_revisions",
["tenant_id", "form_id", "superseded_at"],
unique=False,
)
op.create_index(
"ix_form_definition_catalog",
"form_definition_revisions",
["tenant_id", "publication_state", "form_key"],
unique=False,
)
def downgrade() -> None:
op.drop_table("form_definition_revisions")
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.institutional import InstitutionalContextError
from govoplan_core.db.session import get_session
from govoplan_forms.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE
from govoplan_forms.backend.schemas import (
FormDefinitionHistoryResponse,
FormDefinitionListResponse,
FormDefinitionWriteRequest,
)
from govoplan_forms.backend.service import (
FormDefinitionStoreError,
definition_from_mapping,
form_definition_history,
get_form_definition,
list_form_definitions,
record_form_definition,
)
router = APIRouter(prefix="/forms", tags=["forms"])
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _error(exc: Exception) -> HTTPException:
message = str(exc)
code = 409 if any(
word in message.casefold() for word in ("conflict", "already", "stale")
) else 400
return HTTPException(status_code=code, detail=message)
@router.get("/definitions", response_model=FormDefinitionListResponse)
def api_list_form_definitions(
q: str = Query(default="", max_length=200),
publication_state: list[str] | None = Query(default=None),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> FormDefinitionListResponse:
_require(principal, READ_SCOPE)
try:
items, total = list_form_definitions(
session,
principal,
query=q,
publication_states=publication_state,
offset=offset,
limit=limit,
)
except FormDefinitionStoreError as exc:
raise _error(exc) from exc
return FormDefinitionListResponse(
definitions=[item.to_dict() for item in items],
total=total,
)
@router.put(
"/definitions/{form_id}",
response_model=dict[str, object],
status_code=status.HTTP_200_OK,
)
def api_record_form_definition(
form_id: str,
payload: FormDefinitionWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, WRITE_SCOPE)
try:
definition = definition_from_mapping(payload.definition)
if definition.reference.object_id != form_id:
raise FormDefinitionStoreError(
"Form definition path and payload IDs must match."
)
if definition.publication_state != "draft":
_require(principal, ADMIN_SCOPE)
stored = record_form_definition(
session,
principal,
definition=definition,
expected_revision=payload.expected_revision,
)
session.commit()
except (FormDefinitionStoreError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return stored.to_dict()
@router.get("/definitions/{form_id}", response_model=dict[str, object])
def api_get_form_definition(
form_id: str,
revision: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, READ_SCOPE)
item = get_form_definition(
session,
principal,
form_id=form_id,
revision=revision,
)
if item is None:
raise HTTPException(status_code=404, detail="Form definition not found")
return item.to_dict()
@router.get(
"/definitions/{form_id}/history",
response_model=FormDefinitionHistoryResponse,
)
def api_form_definition_history(
form_id: str,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> FormDefinitionHistoryResponse:
_require(principal, READ_SCOPE)
return FormDefinitionHistoryResponse(
revisions=[
item.to_dict()
for item in form_definition_history(
session,
principal,
form_id=form_id,
limit=limit,
)
]
)
__all__ = ["router"]
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class FormDefinitionWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
definition: dict[str, Any]
expected_revision: str | None = Field(default=None, min_length=1, max_length=255)
class FormDefinitionListResponse(BaseModel):
definitions: list[dict[str, Any]]
total: int
class FormDefinitionHistoryResponse(BaseModel):
revisions: list[dict[str, Any]]
__all__ = [
"FormDefinitionHistoryResponse",
"FormDefinitionListResponse",
"FormDefinitionWriteRequest",
]
+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",
]
+1
View File
@@ -0,0 +1 @@