feat: implement immutable form definitions
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Forms backend package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Forms database models."""
|
||||
@@ -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"]
|
||||
@@ -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."""
|
||||
+77
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user