diff --git a/AGENTS.md b/AGENTS.md index 3ae69b8..2e4d296 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # GovOPlaN Forms Runtime Codex Guide +## Documentation Contract + +- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior. +- Keep feature content here; `govoplan-docs` projects it without importing Forms Runtime internals. +- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes. + ## Scope This repository owns the GovOPlaN Forms Runtime platform module seed. diff --git a/README.md b/README.md index 1bb952b..10821ff 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,22 @@ **Repository type:** module (platform). -`govoplan-forms-runtime` is the GovOPlaN platform module seed for runtime form submissions for validation, drafts, attachments, signatures, status tracking, and handoff to domain modules. +`govoplan-forms-runtime` owns definition-aware runtime form submissions, +including validation, permitted drafts, attachment/signature references, status +history, receipts, and handoff evidence. Its runtime module ID is `forms_runtime`; the repository and Python distribution retain the hyphenated `govoplan-forms-runtime` name. -This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation. +The module persists tenant-bound immutable revisions and events, exposes bounded +owner/manager APIs and WebUI routes, and provides both +`forms_runtime.registry` and `forms_runtime.service_launcher`. -## Initial Ownership +Portal invokes the launcher only after re-fetching and re-evaluating an exact +published Service revision. A Form binding uses `/`; the +runtime resolves that exact immutable definition through `forms.definitions`, +validates launch values, and retains both Service and binding provenance. + +## Ownership - form submissions - draft state @@ -31,13 +40,20 @@ Detailed boundary notes are in [docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md](docs/FORM ## Integrations -Expected optional integrations: +Required integrations: +- access - forms + +Optional integrations: + - files - approvals -- workflow +- workflow engine - portal +- cases +- policy +- audit ## Development Install @@ -48,11 +64,12 @@ cd /mnt/DATA/git/govoplan-core ./.venv/bin/python -m pip install -e ../govoplan-forms-runtime ``` -Focused manifest verification: +Focused verification: ```bash cd /mnt/DATA/git/govoplan-forms-runtime -PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests +PYTHONPATH=src:/mnt/DATA/git/govoplan-forms/src:/mnt/DATA/git/govoplan-core/src \ + /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests ``` ## Gitea Workflow diff --git a/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md b/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md index 8b688eb..138b6f5 100644 --- a/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md +++ b/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md @@ -19,27 +19,68 @@ Runtime form submissions for validation, drafts, attachments, signatures, status - document storage - domain-specific adjudication -## Integration Candidates +## Required Integrations +- access - forms + +## Optional Integration Candidates + - files - approvals -- workflow +- workflow engine - portal +- cases +- policy +- audit -## Seed State +## Implemented State -The current repository state is intentionally small: +- exact immutable Form-definition resolution through `forms.definitions` +- tenant-bound instance identities and append-only revisions/status events +- server-side type, option, constraint, required, attachment, signature, and + optional policy validation +- `started` launch sessions and definition-controlled draft persistence +- final submission receipts, handoff references, replay safety, and OCC +- actor-bound idempotency that permits an exact retry after definition + supersession without exposing another participant's submission +- owner-restricted participant access plus manager scopes +- bounded list/detail/history/event APIs and accessible definition-driven WebUI +- `forms_runtime.service_launcher` retaining exact Service and binding + provenance +- migrations, uninstall guards, tenant summaries, events, recovery notes, and + tenant/replay/stale-write/validation/handoff tests -- module manifest and entry point -- tenant-level permission definitions -- manager and viewer role templates -- documentation topic describing the module boundary -- Gitea issue workflow templates -- manifest contract test +## Security And Policy -No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear. +Authenticated accounts receive only the participant role by default. It permits +access to their own instances; tenant-wide reads and review/handoff transitions +require manager scopes. Event payloads exclude submitted values. Exact +definition lookup, publication state, tenant, current authorization, and +optional policy references are re-evaluated for each consequential operation. +Definition providers must return the requested owner, tenant, object, and exact +revision; a mismatched provider response fails closed. +Policy-referenced definitions fail closed when no compatible +`forms_runtime.policy_evaluator` is active. -## First Implementation Slice +Files and signature providers retain their own content and key custody. Runtime +stores only same-tenant evidence references. Cases and Workflow Engine retain +their own target state; Runtime stores only a permitted same-tenant handoff +reference and status evidence. -Define submission, draft, validation, attachment, signature, status, and handoff contracts around existing form definitions. +## Recovery And Operations + +Database recovery restores identities, revisions, and events together. After +restore, verify one current revision per instance, monotonically increasing +revision history, matching event revisions, resolvable exact Form and Service +references, and referenced evidence availability. A replay with the original +idempotency key and request hash must return the original revision; a changed +request must conflict. Failed handoffs leave the prior revision current. + +Destructive retirement is blocked while state exists and requires a verified +database snapshot plus an export or retention decision for referenced evidence. +No local generated files are required, so API and worker nodes remain stateless. + +Anonymous public intake, concrete file/signature upload adapters, conditional +multi-page layout, and automated target handoff execution remain product depth; +the owner and security boundaries no longer depend on those additions. diff --git a/package.json b/package.json index c635c0b..1d505f6 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@govoplan/forms-runtime", - "version": "0.1.8", + "version": "0.1.14", "private": true, - "description": "GovOPlaN Forms Runtime platform module seed.", + "description": "Definition-aware form submissions and service launch for GovOPlaN.", "type": "module", "peerDependencies": {} } diff --git a/pyproject.toml b/pyproject.toml index cc685ef..9fd9c0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-forms-runtime" -version = "0.1.8" -description = "GovOPlaN Forms Runtime platform module seed." +version = "0.1.14" +description = "Definition-aware form submissions and service launch for GovOPlaN." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.8", + "govoplan-core>=0.1.14", "govoplan-access>=0.1.8", + "govoplan-forms>=0.1.14", ] [tool.setuptools.packages.find] diff --git a/src/govoplan_forms_runtime/backend/db/__init__.py b/src/govoplan_forms_runtime/backend/db/__init__.py new file mode 100644 index 0000000..31601d1 --- /dev/null +++ b/src/govoplan_forms_runtime/backend/db/__init__.py @@ -0,0 +1 @@ +"""Forms Runtime database models.""" diff --git a/src/govoplan_forms_runtime/backend/db/models.py b/src/govoplan_forms_runtime/backend/db/models.py new file mode 100644 index 0000000..e179f58 --- /dev/null +++ b/src/govoplan_forms_runtime/backend/db/models.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +import uuid + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, String, 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 FormInstanceIdentity(Base, TimestampMixin): + __tablename__ = "form_instance_identities" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "instance_id", + name="uq_form_instance_identity", + ), + Index( + "ix_form_instance_owner", + "tenant_id", + "created_by", + "definition_id", + ), + ) + + 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) + instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + definition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + definition_revision: Mapped[str] = mapped_column( + String(255), nullable=False, index=True + ) + created_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + + +class FormInstanceRevision(Base, TimestampMixin): + __tablename__ = "form_instance_revisions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "instance_id", + "revision", + name="uq_form_instance_revision", + ), + Index( + "ix_form_instance_current", + "tenant_id", + "instance_id", + "superseded_at", + ), + Index( + "ix_form_instance_catalog", + "tenant_id", + "status", + "recorded_at", + ), + ) + + 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) + instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + identity_id: Mapped[str] = mapped_column( + ForeignKey("form_instance_identities.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + previous_revision_id: Mapped[str | None] = mapped_column( + ForeignKey("form_instance_revisions.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + status: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + 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 + ) + snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + changed_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + + +class FormInstanceEvent(Base, TimestampMixin): + __tablename__ = "form_instance_events" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "event_id", + name="uq_form_instance_event", + ), + UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_form_instance_idempotency", + ), + Index( + "ix_form_instance_event_history", + "tenant_id", + "instance_id", + "occurred_at", + ), + ) + + 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) + instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + instance_revision: Mapped[int] = mapped_column(Integer, nullable=False) + event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +__all__ = [ + "FormInstanceEvent", + "FormInstanceIdentity", + "FormInstanceRevision", +] diff --git a/src/govoplan_forms_runtime/backend/domain.py b/src/govoplan_forms_runtime/backend/domain.py new file mode 100644 index 0000000..e615d92 --- /dev/null +++ b/src/govoplan_forms_runtime/backend/domain.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from datetime import datetime +from typing import Mapping + +from govoplan_core.core.institutional import ( + EvidenceReference, + InstitutionalContextError, + InstitutionalReference, + ServiceBinding, +) + + +FORM_INSTANCE_STATUSES = frozenset( + { + "started", + "draft", + "submitted", + "validated", + "needs_review", + "accepted", + "rejected", + "handed_off", + "archived", + } +) + + +@dataclass(frozen=True, slots=True) +class FormInstance: + tenant_id: str + instance_id: str + revision: int + status: str + definition_ref: InstitutionalReference + values: Mapping[str, object] + validation_results: tuple[Mapping[str, object], ...] + recorded_at: datetime + change_reason: str + created_by: str + changed_by: str + attachment_refs: tuple[EvidenceReference, ...] = () + signature_refs: tuple[EvidenceReference, ...] = () + handoff_refs: tuple[InstitutionalReference, ...] = () + service_ref: InstitutionalReference | None = None + service_binding: ServiceBinding | None = None + receipt_id: str | None = None + replayed: bool = False + metadata: Mapping[str, object] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.tenant_id or not self.instance_id: + raise InstitutionalContextError( + "A Form instance requires tenant and instance identities." + ) + if self.revision < 1: + raise InstitutionalContextError( + "A Form instance revision must be positive." + ) + if self.status not in FORM_INSTANCE_STATUSES: + raise InstitutionalContextError( + f"Unsupported Form instance status: {self.status!r}." + ) + if ( + self.definition_ref.kind != "form" + or self.definition_ref.owner_module != "forms" + or self.definition_ref.tenant_id != self.tenant_id + or not self.definition_ref.version + ): + raise InstitutionalContextError( + "A Form instance requires an exact same-tenant Forms definition." + ) + if self.service_ref is not None and ( + self.service_ref.kind != "service" + or self.service_ref.tenant_id != self.tenant_id + or not self.service_ref.version + ): + raise InstitutionalContextError( + "Form instance Service provenance must be exact and same-tenant." + ) + for item in (*self.attachment_refs, *self.signature_refs): + if item.tenant_id != self.tenant_id: + raise InstitutionalContextError( + "Form instance evidence cannot cross tenants." + ) + for item in self.handoff_refs: + if item.tenant_id != self.tenant_id: + raise InstitutionalContextError( + "Form instance handoff references cannot cross tenants." + ) + + @property + def reference(self) -> InstitutionalReference: + return InstitutionalReference( + kind="form_submission", + owner_module="forms_runtime", + object_id=self.instance_id, + tenant_id=self.tenant_id, + version=str(self.revision), + valid_at=self.recorded_at, + ) + + def with_replay(self) -> "FormInstance": + return replace(self, replayed=True) + + def to_dict(self, *, include_values: bool = True) -> dict[str, object]: + return { + "reference": self.reference.to_dict(), + "tenant_id": self.tenant_id, + "instance_id": self.instance_id, + "revision": self.revision, + "status": self.status, + "definition_ref": self.definition_ref.to_dict(disclose_label=True), + "values": dict(self.values) if include_values else {}, + "validation_results": [dict(item) for item in self.validation_results], + "attachment_refs": [ + item.to_dict(include_inspection=False) for item in self.attachment_refs + ], + "signature_refs": [ + item.to_dict(include_inspection=False) for item in self.signature_refs + ], + "handoff_refs": [item.to_dict() for item in self.handoff_refs], + "service_ref": ( + self.service_ref.to_dict() if self.service_ref is not None else None + ), + "service_binding": ( + self.service_binding.to_dict() + if self.service_binding is not None + else None + ), + "receipt_id": self.receipt_id, + "recorded_at": self.recorded_at.isoformat(), + "change_reason": self.change_reason, + "created_by": self.created_by, + "changed_by": self.changed_by, + "replayed": self.replayed, + "metadata": dict(self.metadata), + } + + +__all__ = ["FORM_INSTANCE_STATUSES", "FormInstance"] diff --git a/src/govoplan_forms_runtime/backend/manifest.py b/src/govoplan_forms_runtime/backend/manifest.py index 019aed6..3af865d 100644 --- a/src/govoplan_forms_runtime/backend/manifest.py +++ b/src/govoplan_forms_runtime/backend/manifest.py @@ -1,20 +1,59 @@ from __future__ import annotations -from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER -from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate +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, + ModuleInterfaceRequirement, + 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_runtime.backend.db import models as runtime_models +from govoplan_forms_runtime.backend.service import ( + CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR, + CAPABILITY_FORMS_RUNTIME_REGISTRY, + CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER, + FormRuntimeService, + FormsServiceLauncher, +) + MODULE_ID = "forms_runtime" MODULE_NAME = "Forms Runtime" -MODULE_VERSION = "0.1.8" +MODULE_VERSION = "0.1.14" +PARTICIPATE_SCOPE = "forms_runtime:submission:participate" READ_SCOPE = "forms_runtime:workspace:read" WRITE_SCOPE = "forms_runtime:workspace:write" ADMIN_SCOPE = "forms_runtime:workspace:admin" OPTIONAL_DEPENDENCIES = ( - "forms", "files", "approvals", "workflow_engine", "portal", + "cases", + "policy", + "audit", ) @@ -24,7 +63,7 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio scope=scope, label=label, description=description, - category="Forms Runtime", + category=MODULE_NAME, level="tenant", module_id=module_id, resource=resource, @@ -33,66 +72,255 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio PERMISSIONS = ( - _permission(READ_SCOPE, "View forms runtime workspace", "Read forms runtime records, configuration, and workflow context."), - _permission(WRITE_SCOPE, "Manage forms runtime workspace", "Create and update forms runtime records and workflow state."), - _permission(ADMIN_SCOPE, "Administer forms runtime workspace", "Configure forms runtime policies, templates, and tenant-level administration."), + _permission( + PARTICIPATE_SCOPE, + "Complete assigned forms", + "Start, read, save, and submit the acting account's own Form instances.", + ), + _permission( + READ_SCOPE, + "View form submissions", + "Read tenant Form instances, immutable history, and status evidence.", + ), + _permission( + WRITE_SCOPE, + "Manage form submissions", + "Review, transition, and hand off tenant Form submissions.", + ), + _permission( + ADMIN_SCOPE, + "Administer Forms Runtime", + "Administer Forms Runtime policy, recovery, and retirement.", + ), ) ROLE_TEMPLATES = ( + RoleTemplate( + slug="forms_runtime_participant", + name="Forms participant", + description="Complete the acting account's own Forms.", + permissions=(PARTICIPATE_SCOPE,), + default_authenticated=True, + ), RoleTemplate( slug="forms_runtime_manager", name="Forms Runtime manager", - description="Manage forms runtime records and workflow state.", - permissions=(READ_SCOPE, WRITE_SCOPE), + description="Review, transition, and hand off Form submissions.", + permissions=(PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE), ), RoleTemplate( slug="forms_runtime_viewer", name="Forms Runtime viewer", - description="Read forms runtime records and workflow context.", + description="Read Form submissions and their immutable history.", permissions=(READ_SCOPE,), ), ) -DOCUMENTATION = ( - DocumentationTopic( - id=f"{MODULE_ID}.module-boundary", - title=f"{MODULE_NAME} module boundary", - summary="Runtime form submissions for validation, drafts, attachments, signatures, status tracking, and handoff to domain modules.", - body=( - "This repository is currently a platform module seed. It registers the domain boundary, " - "permission surface, role templates, and documentation metadata before runtime APIs, " - "database models, migrations, and WebUI routes are introduced." - ), - layer="available", - documentation_types=("admin",), - audience=("operator", "module_admin", "product_owner"), - order=100, - related_modules=OPTIONAL_DEPENDENCIES, - links=( - DocumentationLink( - label="Repository domain boundary", - href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md", - kind="repository", - ), - ), - metadata={ - "seed": True, - "domain_objects": ['form submissions', 'draft state', 'runtime validation results', 'attachment references', 'signature state', 'handoff status'], - "first_slice": "Define submission, draft, validation, attachment, signature, status, and handoff contracts around existing form definitions.", - }, - ), -) + +def _router(context: ModuleContext): + from govoplan_forms_runtime.backend.router import create_router + + return create_router(context.registry) + + +def _registry(context: ModuleContext) -> FormRuntimeService: + return FormRuntimeService(context.registry) + + +def _service_launcher(context: ModuleContext) -> FormsServiceLauncher: + return FormsServiceLauncher(context.registry) + + +def _tenant_summary(session, tenant_id: str) -> dict[str, int]: + current = session.query(runtime_models.FormInstanceRevision).filter( + runtime_models.FormInstanceRevision.tenant_id == tenant_id, + runtime_models.FormInstanceRevision.superseded_at.is_(None), + ) + return { + "form_instances": current.count(), + "open_form_instances": current.filter( + runtime_models.FormInstanceRevision.status.in_( + ("started", "draft", "submitted", "validated", "needs_review") + ) + ).count(), + } + manifest = ModuleManifest( id=MODULE_ID, name=MODULE_NAME, version=MODULE_VERSION, - dependencies=("access",), + dependencies=("access", "forms"), optional_dependencies=OPTIONAL_DEPENDENCIES, - required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), + required_capabilities=( + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_FORM_DEFINITIONS, + ), + optional_capabilities=(CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, - documentation=DOCUMENTATION, + route_factory=_router, + nav_items=( + NavItem( + path="/forms-runtime", + label="Forms", + icon="form", + required_any=(PARTICIPATE_SCOPE, READ_SCOPE), + order=37, + ), + ), + frontend=FrontendModule( + module_id=MODULE_ID, + package_name="@govoplan/forms-runtime-webui", + routes=( + FrontendRoute( + path="/forms-runtime", + component="FormsRuntimePage", + required_any=(PARTICIPATE_SCOPE, READ_SCOPE), + order=37, + ), + FrontendRoute( + path="/forms-runtime/:instanceId", + component="FormInstancePage", + required_any=(PARTICIPATE_SCOPE, READ_SCOPE), + order=38, + ), + ), + nav_items=( + NavItem( + path="/forms-runtime", + label="Forms", + icon="form", + required_any=(PARTICIPATE_SCOPE, READ_SCOPE), + order=37, + ), + ), + view_surfaces=( + ViewSurface( + id="forms_runtime.navigation", + module_id=MODULE_ID, + kind="navigation", + label="Forms navigation", + order=10, + ), + ViewSurface( + id="forms_runtime.workspace", + module_id=MODULE_ID, + kind="route", + label="Forms workspace", + order=20, + ), + ViewSurface( + id="forms_runtime.instance", + module_id=MODULE_ID, + kind="route", + label="Form instance", + order=30, + ), + ), + ), + provides_interfaces=( + ModuleInterfaceProvider(name="forms_runtime.registry", version="0.1.0"), + ModuleInterfaceProvider(name="forms_runtime.service_launcher", version="0.1.0"), + ), + requires_interfaces=( + ModuleInterfaceRequirement( + name="forms.definitions", + version_min="0.1.0", + version_max_exclusive="0.2.0", + ), + ), + capability_factories={ + CAPABILITY_FORMS_RUNTIME_REGISTRY: _registry, + CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: _service_launcher, + }, + capability_documentation={ + CAPABILITY_FORMS_RUNTIME_REGISTRY: CapabilityDocumentation( + label="Forms Runtime registry", + summary="Manages tenant-bound, revisioned Form instances and handoff evidence.", + contract_version="0.1.0", + ), + CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: CapabilityDocumentation( + label="Form-bound Service launcher", + summary="Starts a replay-safe Form instance from an exact published Service and Form revision.", + contract_version="0.1.0", + ), + }, + migration_spec=MigrationSpec( + module_id=MODULE_ID, + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + migration_after=("forms",), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider( + runtime_models.FormInstanceEvent, + runtime_models.FormInstanceRevision, + runtime_models.FormInstanceIdentity, + label=MODULE_NAME, + ), + retirement_notes="Destructive retirement removes submissions and immutable status evidence and requires a verified database and referenced-evidence recovery plan.", + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + runtime_models.FormInstanceIdentity, + runtime_models.FormInstanceRevision, + runtime_models.FormInstanceEvent, + label=MODULE_NAME, + ), + ), + tenant_summary_providers=(_tenant_summary,), + documentation=( + DocumentationTopic( + id="forms_runtime.submissions", + title="Complete and manage Forms", + summary="Save permitted drafts, submit validated values, and retain exact definition and handoff evidence.", + body=( + "Every instance resolves one immutable published Form revision. Draft and final values are validated on the server; final submission also enforces attachment, signature, and policy requirements. " + "Service launches retain the exact Service and binding. History, receipts, and handoffs are append-only, replay-safe, and optimistic-concurrency guarded." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "product_owner"), + links=( + DocumentationLink( + label="Forms Runtime security and recovery", + href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md", + kind="repository", + ), + ), + ), + ), + architecture=declared_module_architecture( + layer="human_work_procedure", + kind="runtime", + maturity="vertical_slice", + documentation_ref="docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md", + test_ref="tests/test_forms_runtime.py", + known_limits=( + "Anonymous public intake, concrete Files/signature adapters, and automatic target handoff execution remain adapter depth; authenticated Portal entry is supported.", + ), + supported_authority_modes=("native_authoritative",), + owned_concepts=( + "form instance", + "form submission", + "runtime validation", + "submission receipt", + "form handoff evidence", + ), + non_owned_concepts=( + "form definition", + "file content", + "case", + "workflow definition", + "signature key custody", + ), + reference_packages=("product.service-to-decision",), + migration_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",), + recovery_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",), + security_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",), + operations_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",), + ), ) diff --git a/src/govoplan_forms_runtime/backend/migrations/__init__.py b/src/govoplan_forms_runtime/backend/migrations/__init__.py new file mode 100644 index 0000000..7d480df --- /dev/null +++ b/src/govoplan_forms_runtime/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Forms Runtime Alembic revisions.""" diff --git a/src/govoplan_forms_runtime/backend/migrations/versions/__init__.py b/src/govoplan_forms_runtime/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..4ccdcaa --- /dev/null +++ b/src/govoplan_forms_runtime/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Forms Runtime migration versions.""" diff --git a/src/govoplan_forms_runtime/backend/migrations/versions/f2a3b4c5d6e7_v0114_forms_runtime.py b/src/govoplan_forms_runtime/backend/migrations/versions/f2a3b4c5d6e7_v0114_forms_runtime.py new file mode 100644 index 0000000..52eb180 --- /dev/null +++ b/src/govoplan_forms_runtime/backend/migrations/versions/f2a3b4c5d6e7_v0114_forms_runtime.py @@ -0,0 +1,175 @@ +"""v0.1.14 definition-aware Forms Runtime. + +Revision ID: f2a3b4c5d6e7 +Revises: None +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "f2a3b4c5d6e7" +down_revision = None +branch_labels = None +depends_on = "e1f2a3b4c5d6" + + +def upgrade() -> None: + op.create_table( + "form_instance_identities", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("instance_id", sa.String(length=255), nullable=False), + sa.Column("definition_id", sa.String(length=255), nullable=False), + sa.Column("definition_revision", sa.String(length=255), nullable=False), + sa.Column("created_by", sa.String(length=255), nullable=False), + 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_instance_identities")), + sa.UniqueConstraint( + "tenant_id", + "instance_id", + name="uq_form_instance_identity", + ), + ) + for column in ( + "tenant_id", + "instance_id", + "definition_id", + "definition_revision", + "created_by", + ): + op.create_index( + op.f(f"ix_form_instance_identities_{column}"), + "form_instance_identities", + [column], + unique=False, + ) + op.create_index( + "ix_form_instance_owner", + "form_instance_identities", + ["tenant_id", "created_by", "definition_id"], + unique=False, + ) + + op.create_table( + "form_instance_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("instance_id", sa.String(length=255), nullable=False), + sa.Column("identity_id", sa.String(length=36), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("previous_revision_id", sa.String(length=36), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("snapshot", sa.JSON(), nullable=False), + sa.Column("changed_by", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["identity_id"], + ["form_instance_identities.id"], + name=op.f("fk_form_instance_revisions_identity_id_form_instance_identities"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["previous_revision_id"], + ["form_instance_revisions.id"], + name=op.f("fk_form_instance_revisions_previous_revision_id_form_instance_revisions"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_form_instance_revisions")), + sa.UniqueConstraint( + "tenant_id", + "instance_id", + "revision", + name="uq_form_instance_revision", + ), + ) + for column in ( + "tenant_id", + "instance_id", + "identity_id", + "previous_revision_id", + "status", + "recorded_at", + "superseded_at", + "changed_by", + ): + op.create_index( + op.f(f"ix_form_instance_revisions_{column}"), + "form_instance_revisions", + [column], + unique=False, + ) + op.create_index( + "ix_form_instance_current", + "form_instance_revisions", + ["tenant_id", "instance_id", "superseded_at"], + unique=False, + ) + op.create_index( + "ix_form_instance_catalog", + "form_instance_revisions", + ["tenant_id", "status", "recorded_at"], + unique=False, + ) + + op.create_table( + "form_instance_events", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("instance_id", sa.String(length=255), nullable=False), + sa.Column("instance_revision", sa.Integer(), nullable=False), + sa.Column("event_id", sa.String(length=36), nullable=False), + sa.Column("event_type", sa.String(length=120), nullable=False), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("actor_id", sa.String(length=255), nullable=False), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("request_sha256", sa.String(length=64), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + 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_instance_events")), + sa.UniqueConstraint( + "tenant_id", + "event_id", + name="uq_form_instance_event", + ), + sa.UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_form_instance_idempotency", + ), + ) + for column in ( + "tenant_id", + "instance_id", + "event_id", + "event_type", + "status", + "occurred_at", + "actor_id", + ): + op.create_index( + op.f(f"ix_form_instance_events_{column}"), + "form_instance_events", + [column], + unique=False, + ) + op.create_index( + "ix_form_instance_event_history", + "form_instance_events", + ["tenant_id", "instance_id", "occurred_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_table("form_instance_events") + op.drop_table("form_instance_revisions") + op.drop_table("form_instance_identities") diff --git a/src/govoplan_forms_runtime/backend/router.py b/src/govoplan_forms_runtime/backend/router.py new file mode 100644 index 0000000..3955d16 --- /dev/null +++ b/src/govoplan_forms_runtime/backend/router.py @@ -0,0 +1,339 @@ +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 ( + EvidenceReference, + InstitutionalContextError, + InstitutionalReference, +) +from govoplan_core.db.session import get_session +from govoplan_forms_runtime.backend.manifest import ( + ADMIN_SCOPE, + PARTICIPATE_SCOPE, + READ_SCOPE, + WRITE_SCOPE, +) +from govoplan_forms_runtime.backend.schemas import ( + FormDraftUpdateRequest, + FormHandoffRequest, + FormInstanceCreateRequest, + FormInstanceEventsResponse, + FormInstanceHistoryResponse, + FormInstanceListResponse, + FormSubmitRequest, + FormTransitionRequest, +) +from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService + + +def create_router(registry: object | None) -> APIRouter: + router = APIRouter(prefix="/forms-runtime", tags=["forms-runtime"]) + runtime = FormRuntimeService(registry) + + @router.get("/instances", response_model=FormInstanceListResponse) + def api_list_instances( + instance_status: list[str] | None = Query(default=None, alias="status"), + definition_id: str | None = Query(default=None, max_length=255), + 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), + ) -> FormInstanceListResponse: + _require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE) + try: + items, total = runtime.list_instances( + session, + principal, + statuses=instance_status, + definition_id=definition_id, + offset=offset, + limit=limit, + allow_all=has_scope(principal, READ_SCOPE), + ) + except FormRuntimeError as exc: + raise _error(exc) from exc + return FormInstanceListResponse( + instances=[item.to_dict(include_values=False) for item in items], + total=total, + offset=offset, + limit=limit, + ) + + @router.post( + "/instances", + response_model=dict[str, object], + status_code=status.HTTP_201_CREATED, + ) + def api_create_instance( + payload: FormInstanceCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, object]: + _require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE) + try: + item = runtime.create_instance( + session, + principal, + definition_ref=InstitutionalReference.from_mapping( + payload.definition_ref + ), + values=payload.values, + attachment_refs=_evidence(payload.attachment_refs), + signature_refs=_evidence(payload.signature_refs), + idempotency_key=payload.idempotency_key, + recorded_at=payload.recorded_at, + instance_id=payload.instance_id, + metadata=payload.metadata, + ) + session.commit() + except (FormRuntimeError, InstitutionalContextError, PermissionError) as exc: + session.rollback() + raise _error(exc) from exc + return item.to_dict() + + @router.get("/instances/{instance_id}", response_model=dict[str, object]) + def api_get_instance( + instance_id: str, + revision: int | None = Query(default=None, ge=1), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, object]: + _require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE) + try: + item = runtime.get_instance( + session, + principal, + instance_id=instance_id, + revision=revision, + allow_all=has_scope(principal, READ_SCOPE), + ) + except PermissionError as exc: + raise _error(exc) from exc + if item is None: + raise HTTPException(status_code=404, detail="Form instance not found") + return item.to_dict() + + @router.get( + "/instances/{instance_id}/definition", + response_model=dict[str, object], + ) + def api_get_instance_definition( + instance_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, object]: + _require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE) + try: + item = runtime.get_instance_definition( + session, + principal, + instance_id=instance_id, + allow_all=has_scope(principal, READ_SCOPE), + ) + except PermissionError as exc: + raise _error(exc) from exc + if item is None: + raise HTTPException(status_code=404, detail="Form instance not found") + return item.to_dict() + + @router.patch("/instances/{instance_id}", response_model=dict[str, object]) + def api_update_draft( + instance_id: str, + payload: FormDraftUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, object]: + _require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE) + try: + item = runtime.update_draft( + session, + principal, + instance_id=instance_id, + expected_revision=payload.expected_revision, + values=payload.values, + attachment_refs=_evidence(payload.attachment_refs), + signature_refs=_evidence(payload.signature_refs), + idempotency_key=payload.idempotency_key, + recorded_at=payload.recorded_at, + change_reason=payload.change_reason, + allow_all=has_scope(principal, WRITE_SCOPE), + ) + session.commit() + except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc: + session.rollback() + raise _error(exc) from exc + return item.to_dict() + + @router.post( + "/instances/{instance_id}/submit", + response_model=dict[str, object], + ) + def api_submit_instance( + instance_id: str, + payload: FormSubmitRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, object]: + _require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE) + try: + item = runtime.submit_instance( + session, + principal, + instance_id=instance_id, + expected_revision=payload.expected_revision, + values=payload.values, + attachment_refs=_evidence(payload.attachment_refs), + signature_refs=_evidence(payload.signature_refs), + idempotency_key=payload.idempotency_key, + recorded_at=payload.recorded_at, + allow_all=has_scope(principal, WRITE_SCOPE), + ) + session.commit() + except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc: + session.rollback() + raise _error(exc) from exc + return item.to_dict() + + @router.post( + "/instances/{instance_id}/transition", + response_model=dict[str, object], + ) + def api_transition_instance( + instance_id: str, + payload: FormTransitionRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, object]: + _require_any(principal, WRITE_SCOPE, ADMIN_SCOPE) + try: + item = runtime.transition_instance( + session, + principal, + instance_id=instance_id, + expected_revision=payload.expected_revision, + status=payload.status, + idempotency_key=payload.idempotency_key, + recorded_at=payload.recorded_at, + change_reason=payload.change_reason, + allow_all=True, + ) + session.commit() + except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc: + session.rollback() + raise _error(exc) from exc + return item.to_dict() + + @router.post( + "/instances/{instance_id}/handoffs", + response_model=dict[str, object], + ) + def api_handoff_instance( + instance_id: str, + payload: FormHandoffRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> dict[str, object]: + _require_any(principal, WRITE_SCOPE, ADMIN_SCOPE) + try: + item = runtime.handoff_instance( + session, + principal, + instance_id=instance_id, + expected_revision=payload.expected_revision, + target_ref=InstitutionalReference.from_mapping(payload.target_ref), + idempotency_key=payload.idempotency_key, + recorded_at=payload.recorded_at, + change_reason=payload.change_reason, + allow_all=True, + ) + session.commit() + except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc: + session.rollback() + raise _error(exc) from exc + return item.to_dict() + + @router.get( + "/instances/{instance_id}/history", + response_model=FormInstanceHistoryResponse, + ) + def api_instance_history( + instance_id: str, + limit: int = Query(default=100, ge=1, le=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> FormInstanceHistoryResponse: + _require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE) + try: + items = runtime.history( + session, + principal, + instance_id=instance_id, + limit=limit, + allow_all=has_scope(principal, READ_SCOPE), + ) + except PermissionError as exc: + raise _error(exc) from exc + if not items: + raise HTTPException(status_code=404, detail="Form instance not found") + return FormInstanceHistoryResponse( + revisions=[item.to_dict() for item in items] + ) + + @router.get( + "/instances/{instance_id}/events", + response_model=FormInstanceEventsResponse, + ) + def api_instance_events( + instance_id: str, + limit: int = Query(default=200, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> FormInstanceEventsResponse: + _require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE) + try: + items = runtime.events( + session, + principal, + instance_id=instance_id, + limit=limit, + allow_all=has_scope(principal, READ_SCOPE), + ) + except PermissionError as exc: + raise _error(exc) from exc + return FormInstanceEventsResponse(events=[dict(item) for item in items]) + + return router + + +def _evidence(values: list[dict[str, object]]) -> tuple[EvidenceReference, ...]: + return tuple(EvidenceReference.from_mapping(item) for item in values) + + +def _require_any(principal: ApiPrincipal, *scopes: str) -> None: + if not any(has_scope(principal, scope) for scope in scopes): + raise HTTPException( + status_code=403, + detail=f"Missing one of the scopes: {', '.join(scopes)}", + ) + + +def _error(exc: Exception) -> HTTPException: + message = str(exc) + lowered = message.casefold() + if isinstance(exc, LookupError): + code = 404 + elif isinstance(exc, PermissionError): + code = 403 + elif any(word in lowered for word in ("conflict", "stale", "already")): + code = 409 + elif "validation" in lowered: + code = 422 + else: + code = 400 + return HTTPException(status_code=code, detail=message) + + +__all__ = ["create_router"] diff --git a/src/govoplan_forms_runtime/backend/schemas.py b/src/govoplan_forms_runtime/backend/schemas.py new file mode 100644 index 0000000..51f87ae --- /dev/null +++ b/src/govoplan_forms_runtime/backend/schemas.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class FormInstanceCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + definition_ref: dict[str, Any] + values: dict[str, Any] = Field(default_factory=dict) + attachment_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=1000) + signature_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=100) + idempotency_key: str = Field(min_length=1, max_length=255) + recorded_at: datetime + instance_id: str | None = Field(default=None, min_length=1, max_length=255) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class FormDraftUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + values: dict[str, Any] + attachment_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=1000) + signature_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=100) + idempotency_key: str = Field(min_length=1, max_length=255) + recorded_at: datetime + change_reason: str = Field(min_length=1, max_length=1000) + + +class FormSubmitRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + values: dict[str, Any] + attachment_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=1000) + signature_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=100) + idempotency_key: str = Field(min_length=1, max_length=255) + recorded_at: datetime + + +class FormTransitionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + status: Literal[ + "validated", + "needs_review", + "accepted", + "rejected", + "archived", + ] + idempotency_key: str = Field(min_length=1, max_length=255) + recorded_at: datetime + change_reason: str = Field(min_length=1, max_length=1000) + + +class FormHandoffRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + target_ref: dict[str, Any] + idempotency_key: str = Field(min_length=1, max_length=255) + recorded_at: datetime + change_reason: str = Field(min_length=1, max_length=1000) + + +class FormInstanceListResponse(BaseModel): + instances: list[dict[str, Any]] + total: int + offset: int + limit: int + + +class FormInstanceHistoryResponse(BaseModel): + revisions: list[dict[str, Any]] + + +class FormInstanceEventsResponse(BaseModel): + events: list[dict[str, Any]] + + +__all__ = [ + "FormDraftUpdateRequest", + "FormHandoffRequest", + "FormInstanceCreateRequest", + "FormInstanceEventsResponse", + "FormInstanceHistoryResponse", + "FormInstanceListResponse", + "FormSubmitRequest", + "FormTransitionRequest", +] diff --git a/src/govoplan_forms_runtime/backend/service.py b/src/govoplan_forms_runtime/backend/service.py new file mode 100644 index 0000000..ed7004b --- /dev/null +++ b/src/govoplan_forms_runtime/backend/service.py @@ -0,0 +1,1386 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import UTC, date, datetime +import hashlib +import json +import re +from typing import Protocol, runtime_checkable +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 ( + CAPABILITY_FORM_DEFINITIONS, + EvidenceReference, + FormDefinition, + FormDefinitionProvider, + FormFieldDefinition, + InstitutionalContextError, + InstitutionalReference, + ServiceBinding, + ServiceDefinition, + ServiceLaunchRequest, + ServiceLaunchResult, +) +from govoplan_forms_runtime.backend.db.models import ( + FormInstanceEvent, + FormInstanceIdentity, + FormInstanceRevision, +) +from govoplan_forms_runtime.backend.domain import FormInstance + + +CAPABILITY_FORMS_RUNTIME_REGISTRY = "forms_runtime.registry" +CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER = "forms_runtime.service_launcher" +CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR = "forms_runtime.policy_evaluator" + +_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") +_STATUS_TRANSITIONS: dict[str, frozenset[str]] = { + "started": frozenset({"submitted", "archived"}), + "draft": frozenset({"submitted", "archived"}), + "submitted": frozenset( + {"validated", "needs_review", "accepted", "rejected", "handed_off", "archived"} + ), + "validated": frozenset( + {"needs_review", "accepted", "rejected", "handed_off", "archived"} + ), + "needs_review": frozenset({"accepted", "rejected", "handed_off", "archived"}), + "accepted": frozenset({"handed_off", "archived"}), + "rejected": frozenset({"archived"}), + "handed_off": frozenset({"archived"}), + "archived": frozenset(), +} + + +class FormRuntimeError(ValueError): + pass + + +@runtime_checkable +class FormRuntimePolicyEvaluator(Protocol): + def evaluate_form_access( + self, + session: object, + principal: object, + *, + definition: FormDefinition, + action: str, + instance: FormInstance | None, + ) -> bool: ... + + +class FormRuntimeService: + def __init__(self, registry: object | None) -> None: + self._registry = registry + + def create_instance( + self, + session: Session, + principal: object, + *, + definition_ref: InstitutionalReference, + values: Mapping[str, object], + attachment_refs: Sequence[EvidenceReference] = (), + signature_refs: Sequence[EvidenceReference] = (), + idempotency_key: str, + recorded_at: datetime, + instance_id: str | None = None, + service_ref: InstitutionalReference | None = None, + service_binding: ServiceBinding | None = None, + metadata: Mapping[str, object] | None = None, + ) -> FormInstance: + tenant_id = _principal_tenant(principal) + _require_aware(recorded_at, "Form instance recorded_at") + definition = self._definition( + session, + principal, + reference=definition_ref, + effective_at=recorded_at, + ) + supplied_values = _mapping_copy(values, "Form values") + clean_values = { + **{ + field.key: field.default_value + for field in definition.fields + if field.default_value is not None + }, + **supplied_values, + } + attachments = tuple(attachment_refs) + signatures = tuple(signature_refs) + diagnostics = validate_form_values( + definition, + clean_values, + attachment_refs=attachments, + signature_refs=signatures, + final=False, + ) + request = { + "operation": "create", + "definition_ref": definition_ref.to_dict(), + "values": clean_values, + "attachment_refs": [item.to_dict() for item in attachments], + "signature_refs": [item.to_dict() for item in signatures], + "service_ref": service_ref.to_dict() if service_ref else None, + "service_binding": service_binding.to_dict() if service_binding else None, + "instance_id": instance_id, + "recorded_at": recorded_at.isoformat(), + "metadata": dict(metadata or {}), + } + request_sha256 = _request_hash(request) + replay = _replay( + session, + principal, + tenant_id=tenant_id, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + ) + if replay is not None: + return replay + _require_startable(definition) + self._evaluate_policy( + session, + principal, + definition=definition, + action="start", + instance=None, + ) + + actor_id = _principal_actor(principal) + resolved_id = _identifier(instance_id or str(uuid.uuid4()), "Form instance id") + existing_identity = ( + session.query(FormInstanceIdentity.id) + .filter( + FormInstanceIdentity.tenant_id == tenant_id, + FormInstanceIdentity.instance_id == resolved_id, + ) + .first() + ) + if existing_identity is not None: + raise FormRuntimeError( + "Form instance conflict: this instance id is already in use." + ) + identity = FormInstanceIdentity( + tenant_id=tenant_id, + instance_id=resolved_id, + definition_id=definition_ref.object_id, + definition_revision=str(definition_ref.version), + created_by=actor_id, + ) + session.add(identity) + session.flush() + instance = FormInstance( + tenant_id=tenant_id, + instance_id=resolved_id, + revision=1, + status="draft" if definition.allow_drafts else "started", + definition_ref=definition_ref, + values=clean_values, + validation_results=diagnostics, + attachment_refs=attachments, + signature_refs=signatures, + service_ref=service_ref, + service_binding=service_binding, + recorded_at=recorded_at, + change_reason="Form instance started.", + created_by=actor_id, + changed_by=actor_id, + metadata=dict(metadata or {}), + ) + return _record_instance( + session, + principal, + identity=identity, + current=None, + instance=instance, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + operation="started", + ) + + def update_draft( + self, + session: Session, + principal: object, + *, + instance_id: str, + expected_revision: int, + values: Mapping[str, object], + attachment_refs: Sequence[EvidenceReference], + signature_refs: Sequence[EvidenceReference], + idempotency_key: str, + recorded_at: datetime, + change_reason: str, + allow_all: bool = False, + ) -> FormInstance: + current, identity = _current_instance( + session, + principal, + instance_id=instance_id, + lock=True, + allow_all=allow_all, + ) + definition = self._definition( + session, + principal, + reference=current.definition_ref, + effective_at=recorded_at, + ) + _require_published(definition) + if current.status != "draft" or not definition.allow_drafts: + raise FormRuntimeError( + "This Form instance does not allow intermediate draft updates." + ) + self._evaluate_policy( + session, + principal, + definition=definition, + action="save_draft", + instance=current, + ) + return self._revise( + session, + principal, + current=current, + identity=identity, + expected_revision=expected_revision, + status="draft", + values=values, + attachment_refs=attachment_refs, + signature_refs=signature_refs, + handoff_refs=current.handoff_refs, + idempotency_key=idempotency_key, + recorded_at=recorded_at, + change_reason=change_reason, + operation="draft_saved", + final_validation=False, + definition=definition, + ) + + def submit_instance( + self, + session: Session, + principal: object, + *, + instance_id: str, + expected_revision: int, + values: Mapping[str, object], + attachment_refs: Sequence[EvidenceReference], + signature_refs: Sequence[EvidenceReference], + idempotency_key: str, + recorded_at: datetime, + allow_all: bool = False, + ) -> FormInstance: + current, identity = _current_instance( + session, + principal, + instance_id=instance_id, + lock=True, + allow_all=allow_all, + ) + definition = self._definition( + session, + principal, + reference=current.definition_ref, + effective_at=recorded_at, + ) + _require_published(definition) + self._evaluate_policy( + session, + principal, + definition=definition, + action="submit", + instance=current, + ) + return self._revise( + session, + principal, + current=current, + identity=identity, + expected_revision=expected_revision, + status="submitted", + values=values, + attachment_refs=attachment_refs, + signature_refs=signature_refs, + handoff_refs=current.handoff_refs, + idempotency_key=idempotency_key, + recorded_at=recorded_at, + change_reason="Form submitted.", + operation="submitted", + final_validation=True, + definition=definition, + allowed_current_statuses=("started", "draft"), + ) + + def transition_instance( + self, + session: Session, + principal: object, + *, + instance_id: str, + expected_revision: int, + status: str, + idempotency_key: str, + recorded_at: datetime, + change_reason: str, + allow_all: bool = False, + ) -> FormInstance: + current, identity = _current_instance( + session, + principal, + instance_id=instance_id, + lock=True, + allow_all=allow_all, + ) + definition = self._definition( + session, + principal, + reference=current.definition_ref, + effective_at=recorded_at, + ) + self._evaluate_policy( + session, + principal, + definition=definition, + action=f"transition:{status}", + instance=current, + ) + return self._revise( + session, + principal, + current=current, + identity=identity, + expected_revision=expected_revision, + status=status, + values=current.values, + attachment_refs=current.attachment_refs, + signature_refs=current.signature_refs, + handoff_refs=current.handoff_refs, + idempotency_key=idempotency_key, + recorded_at=recorded_at, + change_reason=change_reason, + operation=f"status_{status}", + final_validation=status not in {"archived"}, + definition=definition, + enforce_status_transition=True, + ) + + def handoff_instance( + self, + session: Session, + principal: object, + *, + instance_id: str, + expected_revision: int, + target_ref: InstitutionalReference, + idempotency_key: str, + recorded_at: datetime, + change_reason: str, + allow_all: bool = False, + ) -> FormInstance: + current, identity = _current_instance( + session, + principal, + instance_id=instance_id, + lock=True, + allow_all=allow_all, + ) + definition = self._definition( + session, + principal, + reference=current.definition_ref, + effective_at=recorded_at, + ) + if target_ref.tenant_id != current.tenant_id: + raise FormRuntimeError("Form handoff cannot cross tenants.") + if target_ref.kind not in definition.handoff_kinds: + raise FormRuntimeError( + f"Form definition does not permit a {target_ref.kind!r} handoff." + ) + self._evaluate_policy( + session, + principal, + definition=definition, + action="handoff", + instance=current, + ) + handoffs = tuple( + dict.fromkeys((*current.handoff_refs, target_ref)) + ) + return self._revise( + session, + principal, + current=current, + identity=identity, + expected_revision=expected_revision, + status="handed_off", + values=current.values, + attachment_refs=current.attachment_refs, + signature_refs=current.signature_refs, + handoff_refs=handoffs, + idempotency_key=idempotency_key, + recorded_at=recorded_at, + change_reason=change_reason, + operation="handed_off", + final_validation=True, + definition=definition, + allowed_current_statuses=( + "submitted", + "validated", + "needs_review", + "accepted", + ), + ) + + def get_instance( + self, + session: Session, + principal: object, + *, + instance_id: str, + revision: int | None = None, + allow_all: bool = False, + ) -> FormInstance | None: + tenant_id = _principal_tenant(principal) + identity = ( + session.query(FormInstanceIdentity) + .filter( + FormInstanceIdentity.tenant_id == tenant_id, + FormInstanceIdentity.instance_id == instance_id, + ) + .one_or_none() + ) + if identity is None: + return None + _assert_access(identity, principal, allow_all=allow_all) + query = session.query(FormInstanceRevision).filter( + FormInstanceRevision.tenant_id == tenant_id, + FormInstanceRevision.instance_id == instance_id, + ) + if revision is None: + query = query.filter(FormInstanceRevision.superseded_at.is_(None)) + else: + query = query.filter(FormInstanceRevision.revision == revision) + row = query.order_by(FormInstanceRevision.revision.desc()).first() + return _instance_from_row(row) if row is not None else None + + def get_instance_definition( + self, + session: Session, + principal: object, + *, + instance_id: str, + allow_all: bool = False, + ) -> FormDefinition | None: + instance = self.get_instance( + session, + principal, + instance_id=instance_id, + allow_all=allow_all, + ) + if instance is None: + return None + return self._definition( + session, + principal, + reference=instance.definition_ref, + effective_at=instance.recorded_at, + ) + + def list_instances( + self, + session: Session, + principal: object, + *, + statuses: Sequence[str] | None = None, + definition_id: str | None = None, + offset: int = 0, + limit: int = 100, + allow_all: bool = False, + ) -> tuple[tuple[FormInstance, ...], int]: + tenant_id = _principal_tenant(principal) + if offset < 0 or not 1 <= limit <= 200: + raise FormRuntimeError( + "Form instance offset must be non-negative and limit between 1 and 200." + ) + statement = ( + session.query(FormInstanceRevision) + .join( + FormInstanceIdentity, + FormInstanceIdentity.id == FormInstanceRevision.identity_id, + ) + .filter( + FormInstanceRevision.tenant_id == tenant_id, + FormInstanceRevision.superseded_at.is_(None), + ) + ) + if not allow_all: + statement = statement.filter( + FormInstanceIdentity.created_by == _principal_actor(principal) + ) + if statuses: + statement = statement.filter(FormInstanceRevision.status.in_(tuple(statuses))) + if definition_id: + statement = statement.filter( + FormInstanceIdentity.definition_id == definition_id + ) + total = int(statement.with_entities(func.count()).scalar() or 0) + rows = ( + statement.order_by(FormInstanceRevision.recorded_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + return tuple(_instance_from_row(row) for row in rows), total + + def history( + self, + session: Session, + principal: object, + *, + instance_id: str, + limit: int = 100, + allow_all: bool = False, + ) -> tuple[FormInstance, ...]: + current = self.get_instance( + session, + principal, + instance_id=instance_id, + allow_all=allow_all, + ) + if current is None: + return () + rows = ( + session.query(FormInstanceRevision) + .filter( + FormInstanceRevision.tenant_id == current.tenant_id, + FormInstanceRevision.instance_id == instance_id, + ) + .order_by(FormInstanceRevision.revision.desc()) + .limit(max(1, min(limit, 200))) + .all() + ) + return tuple(_instance_from_row(row) for row in rows) + + def events( + self, + session: Session, + principal: object, + *, + instance_id: str, + limit: int = 200, + allow_all: bool = False, + ) -> tuple[Mapping[str, object], ...]: + current = self.get_instance( + session, + principal, + instance_id=instance_id, + allow_all=allow_all, + ) + if current is None: + return () + rows = ( + session.query(FormInstanceEvent) + .filter( + FormInstanceEvent.tenant_id == current.tenant_id, + FormInstanceEvent.instance_id == instance_id, + ) + .order_by(FormInstanceEvent.occurred_at.asc()) + .limit(max(1, min(limit, 500))) + .all() + ) + return tuple( + { + "event_id": row.event_id, + "event_type": row.event_type, + "instance_revision": row.instance_revision, + "status": row.status, + "occurred_at": _aware(row.occurred_at).isoformat(), + "actor_id": row.actor_id, + "payload": dict(row.payload), + } + for row in rows + ) + + def _revise( + self, + session: Session, + principal: object, + *, + current: FormInstance, + identity: FormInstanceIdentity, + expected_revision: int, + status: str, + values: Mapping[str, object], + attachment_refs: Sequence[EvidenceReference], + signature_refs: Sequence[EvidenceReference], + handoff_refs: Sequence[InstitutionalReference], + idempotency_key: str, + recorded_at: datetime, + change_reason: str, + operation: str, + final_validation: bool, + definition: FormDefinition, + allowed_current_statuses: Sequence[str] | None = None, + enforce_status_transition: bool = False, + ) -> FormInstance: + _require_aware(recorded_at, "Form instance recorded_at") + clean_reason = _text(change_reason, "Form instance change reason", 1000) + clean_values = _mapping_copy(values, "Form values") + attachments = tuple(attachment_refs) + signatures = tuple(signature_refs) + handoffs = tuple(handoff_refs) + diagnostics = validate_form_values( + definition, + clean_values, + attachment_refs=attachments, + signature_refs=signatures, + final=final_validation, + ) + request = { + "operation": operation, + "instance_id": current.instance_id, + "expected_revision": expected_revision, + "status": status, + "values": clean_values, + "attachment_refs": [item.to_dict() for item in attachments], + "signature_refs": [item.to_dict() for item in signatures], + "handoff_refs": [item.to_dict() for item in handoffs], + "recorded_at": recorded_at.isoformat(), + "change_reason": clean_reason, + } + request_sha256 = _request_hash(request) + replay = _replay( + session, + principal, + tenant_id=current.tenant_id, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + ) + if replay is not None: + return replay + if allowed_current_statuses is not None and current.status not in set( + allowed_current_statuses + ): + raise FormRuntimeError( + f"Form status {current.status!r} does not permit this operation." + ) + if enforce_status_transition and status not in _STATUS_TRANSITIONS.get( + current.status, frozenset() + ): + raise FormRuntimeError( + f"Form status transition {current.status!r} to {status!r} is not allowed." + ) + if current.revision != expected_revision: + raise FormRuntimeError( + "Form instance revision conflict: the expected revision is stale." + ) + actor_id = _principal_actor(principal) + instance = FormInstance( + tenant_id=current.tenant_id, + instance_id=current.instance_id, + revision=current.revision + 1, + status=status, + definition_ref=current.definition_ref, + values=clean_values, + validation_results=diagnostics, + attachment_refs=attachments, + signature_refs=signatures, + handoff_refs=handoffs, + service_ref=current.service_ref, + service_binding=current.service_binding, + receipt_id=( + str(uuid.uuid4()) if status == "submitted" else current.receipt_id + ), + recorded_at=recorded_at, + change_reason=clean_reason, + created_by=current.created_by, + changed_by=actor_id, + metadata=current.metadata, + ) + current_row = ( + session.query(FormInstanceRevision) + .filter( + FormInstanceRevision.tenant_id == current.tenant_id, + FormInstanceRevision.instance_id == current.instance_id, + FormInstanceRevision.revision == current.revision, + FormInstanceRevision.superseded_at.is_(None), + ) + .with_for_update() + .one_or_none() + ) + if current_row is None: + raise FormRuntimeError( + "Form instance revision conflict: the current revision changed." + ) + return _record_instance( + session, + principal, + identity=identity, + current=current_row, + instance=instance, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + operation=operation, + ) + + def _definition( + self, + session: Session, + principal: object, + *, + reference: InstitutionalReference, + effective_at: datetime, + ) -> FormDefinition: + provider = _capability(self._registry, CAPABILITY_FORM_DEFINITIONS) + if not isinstance(provider, FormDefinitionProvider): + raise FormRuntimeError( + "The configured Forms definition provider is unavailable or invalid." + ) + definition = provider.get_form_definition( + session, + principal, + reference=reference, + effective_at=effective_at, + ) + if definition is None: + raise FormRuntimeError("The exact Form definition was not found or effective.") + if not _same_exact_form_reference(definition.reference, reference): + raise FormRuntimeError( + "The Forms provider returned a different definition or revision." + ) + if not definition.temporal.effective_at(effective_at): + raise FormRuntimeError( + "The exact Form definition is not effective at the requested time." + ) + return definition + + def _evaluate_policy( + self, + session: Session, + principal: object, + *, + definition: FormDefinition, + action: str, + instance: FormInstance | None, + ) -> None: + if not definition.policy_refs: + return + evaluator = _capability( + self._registry, + CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR, + required=False, + ) + if not isinstance(evaluator, FormRuntimePolicyEvaluator): + raise PermissionError( + "This Form requires a policy evaluator that is not available." + ) + if not evaluator.evaluate_form_access( + session, + principal, + definition=definition, + action=action, + instance=instance, + ): + raise PermissionError("Form policy denied this operation.") + + +class FormsServiceLauncher: + def __init__(self, registry: object | None) -> None: + self._runtime = FormRuntimeService(registry) + + def launch_service( + self, + session: object, + principal: object, + *, + definition: ServiceDefinition, + request: ServiceLaunchRequest, + ) -> ServiceLaunchResult: + db = _session(session) + tenant_id = _principal_tenant(principal) + if ( + definition.reference != request.service_ref + or request.binding.kind != "form" + or request.binding not in definition.bindings + or definition.reference.tenant_id != tenant_id + or definition.publication_state != "published" + ): + raise FormRuntimeError( + "Form Service launch requires the exact published Service and binding." + ) + form_id, form_revision = parse_form_binding_reference( + request.binding.reference + ) + definition_ref = InstitutionalReference( + kind="form", + owner_module="forms", + object_id=form_id, + tenant_id=tenant_id, + version=form_revision, + valid_at=request.requested_at, + ) + instance = self._runtime.create_instance( + db, + principal, + definition_ref=definition_ref, + values=request.parameters, + idempotency_key=f"service-launch:{request.idempotency_key}", + recorded_at=request.requested_at, + service_ref=request.service_ref, + service_binding=request.binding, + metadata={"launch_source": "portal"}, + ) + return ServiceLaunchResult( + service_ref=request.service_ref, + binding=request.binding, + state="started", + target_ref=instance.reference, + href=f"/forms-runtime/{instance.instance_id}", + replayed=instance.replayed, + metadata={ + "form_instance_id": instance.instance_id, + "form_instance_revision": instance.revision, + "form_definition_id": form_id, + "form_definition_revision": form_revision, + "status": instance.status, + }, + ) + + +def parse_form_binding_reference(value: str) -> tuple[str, str]: + clean = str(value or "").strip() + if "/" not in clean: + raise FormRuntimeError( + "Form Service bindings must use the exact '/' format." + ) + form_id, revision = clean.rsplit("/", 1) + return ( + _identifier(form_id, "Form binding form id"), + _identifier(revision, "Form binding revision"), + ) + + +def validate_form_values( + definition: FormDefinition, + values: Mapping[str, object], + *, + attachment_refs: Sequence[EvidenceReference], + signature_refs: Sequence[EvidenceReference], + final: bool, +) -> tuple[Mapping[str, object], ...]: + fields = {item.key: item for item in definition.fields} + diagnostics: list[Mapping[str, object]] = [] + unknown = sorted(set(values) - set(fields)) + for key in unknown: + diagnostics.append( + _diagnostic(key, "error", "field.unknown", "This field is not part of the exact Form revision.") + ) + for field in definition.fields: + present = field.key in values and values[field.key] not in (None, "") + if field.required and not present: + diagnostics.append( + _diagnostic( + field.key, + "error" if final else "warning", + "field.required", + "A value is required before submission.", + ) + ) + if present: + diagnostics.extend(_validate_field_value(field, values[field.key])) + if len(attachment_refs) > definition.max_attachments: + diagnostics.append( + _diagnostic( + None, + "error", + "attachments.limit", + f"This Form permits at most {definition.max_attachments} attachments.", + ) + ) + if final and definition.signature_requirement == "required" and not signature_refs: + diagnostics.append( + _diagnostic( + None, + "error", + "signature.required", + "A signature is required before submission.", + ) + ) + errors = [item for item in diagnostics if item["severity"] == "error"] + if errors: + summary = "; ".join(str(item["message"]) for item in errors[:5]) + raise FormRuntimeError(f"Form values failed validation: {summary}") + return tuple(diagnostics) + + +def _validate_field_value( + field: FormFieldDefinition, + value: object, +) -> tuple[Mapping[str, object], ...]: + valid = True + if field.value_type in {"text", "multiline_text", "email", "choice"}: + valid = isinstance(value, str) + elif field.value_type == "integer": + valid = isinstance(value, int) and not isinstance(value, bool) + elif field.value_type == "number": + valid = isinstance(value, (int, float)) and not isinstance(value, bool) + elif field.value_type == "boolean": + valid = isinstance(value, bool) + elif field.value_type == "object": + valid = isinstance(value, Mapping) + elif field.value_type in {"list", "multi_choice"}: + valid = isinstance(value, Sequence) and not isinstance(value, (str, bytes)) + elif field.value_type == "date": + valid = _is_iso_date(value, include_time=False) + elif field.value_type == "datetime": + valid = _is_iso_date(value, include_time=True) + if not valid: + return ( + _diagnostic( + field.key, + "error", + "field.type", + f"The value must use the {field.value_type} type.", + ), + ) + diagnostics: list[Mapping[str, object]] = [] + if field.value_type == "email" and not _EMAIL_RE.fullmatch(str(value)): + diagnostics.append( + _diagnostic(field.key, "error", "field.email", "Enter a valid email address.") + ) + if field.value_type == "choice" and value not in field.options: + diagnostics.append( + _diagnostic(field.key, "error", "field.option", "Select one of the declared options.") + ) + if field.value_type == "multi_choice" and any( + item not in field.options for item in value # type: ignore[union-attr] + ): + diagnostics.append( + _diagnostic(field.key, "error", "field.option", "Every selected value must be a declared option.") + ) + constraints = field.constraints + if isinstance(value, str): + minimum = constraints.get("min_length") + maximum = constraints.get("max_length") + pattern = constraints.get("pattern") + if isinstance(minimum, int) and len(value) < minimum: + diagnostics.append(_diagnostic(field.key, "error", "field.min_length", f"Enter at least {minimum} characters.")) + if isinstance(maximum, int) and len(value) > maximum: + diagnostics.append(_diagnostic(field.key, "error", "field.max_length", f"Enter at most {maximum} characters.")) + if isinstance(pattern, str): + try: + matches = re.fullmatch(pattern, value) is not None + except re.error as exc: + raise FormRuntimeError( + f"Form definition field {field.key!r} has an invalid pattern." + ) from exc + if not matches: + diagnostics.append(_diagnostic(field.key, "error", "field.pattern", "The value does not match the required format.")) + if isinstance(value, (int, float)) and not isinstance(value, bool): + minimum = constraints.get("minimum") + maximum = constraints.get("maximum") + if isinstance(minimum, (int, float)) and value < minimum: + diagnostics.append(_diagnostic(field.key, "error", "field.minimum", f"Enter a value of at least {minimum}.")) + if isinstance(maximum, (int, float)) and value > maximum: + diagnostics.append(_diagnostic(field.key, "error", "field.maximum", f"Enter a value no greater than {maximum}.")) + return tuple(diagnostics) + + +def _diagnostic( + field: str | None, + severity: str, + code: str, + message: str, +) -> Mapping[str, object]: + return {"field": field, "severity": severity, "code": code, "message": message} + + +def _record_instance( + session: Session, + principal: object, + *, + identity: FormInstanceIdentity, + current: FormInstanceRevision | None, + instance: FormInstance, + idempotency_key: str, + request_sha256: str, + operation: str, +) -> FormInstance: + clean_key = _text(idempotency_key, "Form idempotency key", 255) + if current is not None: + current.superseded_at = instance.recorded_at + row = FormInstanceRevision( + tenant_id=instance.tenant_id, + instance_id=instance.instance_id, + identity_id=identity.id, + revision=instance.revision, + previous_revision_id=current.id if current is not None else None, + status=instance.status, + recorded_at=instance.recorded_at, + snapshot=instance.to_dict(), + changed_by=instance.changed_by, + ) + event_id = str(uuid.uuid4()) + event = FormInstanceEvent( + tenant_id=instance.tenant_id, + instance_id=instance.instance_id, + instance_revision=instance.revision, + event_id=event_id, + event_type=f"forms_runtime.instance.{operation}", + status=instance.status, + occurred_at=instance.recorded_at, + actor_id=_principal_actor(principal), + idempotency_key=clean_key, + request_sha256=request_sha256, + payload={ + "definition_id": instance.definition_ref.object_id, + "definition_revision": instance.definition_ref.version, + "status": instance.status, + "revision": instance.revision, + "validation_warning_count": sum( + item.get("severity") == "warning" + for item in instance.validation_results + ), + "attachment_count": len(instance.attachment_refs), + "signature_count": len(instance.signature_refs), + "handoff_count": len(instance.handoff_refs), + "receipt_id": instance.receipt_id, + }, + ) + session.add_all((row, event)) + session.flush() + emit_platform_event( + session, + PlatformEvent( + event_id=event_id, + type=event.event_type, + module_id="forms_runtime", + payload=dict(event.payload), + occurred_at=instance.recorded_at, + actor=EventActorRef(type="account", id=_principal_actor(principal)), + tenant=EventTenantRef(id=instance.tenant_id), + resource=EventObjectRef( + type="form_submission", + id=instance.instance_id, + label=instance.definition_ref.label, + ), + classification="confidential", + ), + ) + return _instance_from_row(row) + + +def _current_instance( + session: Session, + principal: object, + *, + instance_id: str, + lock: bool, + allow_all: bool, +) -> tuple[FormInstance, FormInstanceIdentity]: + tenant_id = _principal_tenant(principal) + identity = ( + session.query(FormInstanceIdentity) + .filter( + FormInstanceIdentity.tenant_id == tenant_id, + FormInstanceIdentity.instance_id == instance_id, + ) + .one_or_none() + ) + if identity is None: + raise LookupError("Form instance not found.") + _assert_access(identity, principal, allow_all=allow_all) + query = session.query(FormInstanceRevision).filter( + FormInstanceRevision.tenant_id == tenant_id, + FormInstanceRevision.instance_id == instance_id, + FormInstanceRevision.superseded_at.is_(None), + ) + if lock: + query = query.with_for_update() + row = query.one_or_none() + if row is None: + raise LookupError("Form instance has no current revision.") + return _instance_from_row(row), identity + + +def _replay( + session: Session, + principal: object, + *, + tenant_id: str, + idempotency_key: str, + request_sha256: str, +) -> FormInstance | None: + clean_key = _text(idempotency_key, "Form idempotency key", 255) + event = ( + session.query(FormInstanceEvent) + .filter( + FormInstanceEvent.tenant_id == tenant_id, + FormInstanceEvent.idempotency_key == clean_key, + ) + .one_or_none() + ) + if event is None: + return None + if event.actor_id != _principal_actor(principal): + raise FormRuntimeError( + "Form idempotency conflict: this key belongs to another actor." + ) + if event.request_sha256 != request_sha256: + raise FormRuntimeError( + "Form idempotency conflict: this key was used for another request." + ) + row = ( + session.query(FormInstanceRevision) + .filter( + FormInstanceRevision.tenant_id == tenant_id, + FormInstanceRevision.instance_id == event.instance_id, + FormInstanceRevision.revision == event.instance_revision, + ) + .one() + ) + return _instance_from_row(row).with_replay() + + +def _same_exact_form_reference( + actual: InstitutionalReference, + requested: InstitutionalReference, +) -> bool: + return ( + actual.kind == requested.kind == "form" + and actual.owner_module == requested.owner_module == "forms" + and actual.object_id == requested.object_id + and actual.tenant_id == requested.tenant_id + and actual.version == requested.version + ) + + +def _instance_from_row(row: FormInstanceRevision) -> FormInstance: + value = dict(row.snapshot) + definition_ref = InstitutionalReference.from_mapping( + _required_mapping(value, "definition_ref") + ) + service_ref_payload = value.get("service_ref") + binding_payload = value.get("service_binding") + return FormInstance( + tenant_id=str(value["tenant_id"]), + instance_id=str(value["instance_id"]), + revision=int(value["revision"]), + status=str(value["status"]), + definition_ref=definition_ref, + values=_required_mapping(value, "values"), + validation_results=tuple( + dict(item) for item in _mapping_list(value.get("validation_results")) + ), + attachment_refs=tuple( + EvidenceReference.from_mapping(item) + for item in _mapping_list(value.get("attachment_refs")) + ), + signature_refs=tuple( + EvidenceReference.from_mapping(item) + for item in _mapping_list(value.get("signature_refs")) + ), + handoff_refs=tuple( + InstitutionalReference.from_mapping(item) + for item in _mapping_list(value.get("handoff_refs")) + ), + service_ref=( + InstitutionalReference.from_mapping(service_ref_payload) + if isinstance(service_ref_payload, Mapping) + else None + ), + service_binding=( + ServiceBinding.from_mapping(binding_payload) + if isinstance(binding_payload, Mapping) + else None + ), + receipt_id=_optional_text(value.get("receipt_id")), + recorded_at=_aware(datetime.fromisoformat(str(value["recorded_at"]))), + change_reason=str(value["change_reason"]), + created_by=str(value["created_by"]), + changed_by=str(value["changed_by"]), + replayed=bool(value.get("replayed", False)), + metadata=dict(value.get("metadata") or {}), + ) + + +def _require_published(definition: FormDefinition) -> None: + if definition.publication_state != "published": + raise FormRuntimeError("Only a published Form definition can be used.") + + +def _require_startable(definition: FormDefinition) -> None: + _require_published(definition) + if definition.temporal.superseded_at is not None: + raise FormRuntimeError( + "A superseded Form definition cannot start a new instance." + ) + + +def _assert_access( + identity: FormInstanceIdentity, + principal: object, + *, + allow_all: bool, +) -> None: + if not allow_all and identity.created_by != _principal_actor(principal): + raise PermissionError("Form instance access is denied.") + + +def _principal_tenant(principal: object) -> str: + tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() + if not tenant_id: + raise InstitutionalContextError( + "Forms Runtime operations require a tenant-bound principal." + ) + return tenant_id + + +def _principal_actor(principal: object) -> str: + for name in ("account_id", "identity_id", "membership_id"): + value = str(getattr(principal, name, "") or "").strip() + if value: + return value + raise InstitutionalContextError( + "Forms Runtime operations require an acting identity." + ) + + +def _capability( + registry: object | None, + name: str, + *, + required: bool = True, +) -> object | None: + if registry is None or not hasattr(registry, "has_capability"): + if required: + raise FormRuntimeError(f"Required capability is unavailable: {name}") + return None + if not registry.has_capability(name): + if required: + raise FormRuntimeError(f"Required capability is unavailable: {name}") + return None + if hasattr(registry, "require_capability"): + return registry.require_capability(name) + if hasattr(registry, "capability"): + return registry.capability(name) + if required: + raise FormRuntimeError(f"Required capability cannot be resolved: {name}") + return None + + +def _session(value: object) -> Session: + if not hasattr(value, "query"): + raise FormRuntimeError("Forms Runtime requires a database session.") + return value # type: ignore[return-value] + + +def _request_hash(value: Mapping[str, object]) -> str: + try: + payload = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + default=_json_default, + ) + except (TypeError, ValueError) as exc: + raise FormRuntimeError("Form values must be JSON serializable.") from exc + if len(payload.encode("utf-8")) > 2_000_000: + raise FormRuntimeError("Form instance payload exceeds the 2 MB limit.") + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _json_default(value: object) -> object: + if isinstance(value, (date, datetime)): + return value.isoformat() + raise TypeError(f"Unsupported JSON value: {type(value).__name__}") + + +def _mapping_copy(value: Mapping[str, object], label: str) -> dict[str, object]: + if not isinstance(value, Mapping): + raise FormRuntimeError(f"{label} must be an object.") + return {str(key): item for key, item in value.items()} + + +def _mapping_list(value: object | None) -> tuple[Mapping[str, object], ...]: + if value is None: + return () + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + raise FormRuntimeError("Expected a list of object payloads.") + if any(not isinstance(item, Mapping) for item in value): + raise FormRuntimeError("Expected object payloads in list.") + return tuple(value) # type: ignore[return-value] + + +def _required_mapping(value: Mapping[str, object], key: str) -> Mapping[str, object]: + item = value.get(key) + if not isinstance(item, Mapping): + raise FormRuntimeError(f"Form snapshot {key} must be an object.") + return item + + +def _identifier(value: str, label: str) -> str: + clean = str(value or "").strip() + if not clean or len(clean) > 255 or not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9_.:/-]*", clean + ): + raise FormRuntimeError(f"{label} is invalid.") + return clean + + +def _text(value: str, label: str, maximum: int) -> str: + clean = str(value or "").strip() + if not clean or len(clean) > maximum: + raise FormRuntimeError(f"{label} is required and limited to {maximum} characters.") + return clean + + +def _optional_text(value: object | None) -> str | None: + clean = str(value or "").strip() + return clean or None + + +def _require_aware(value: datetime, label: str) -> None: + if value.tzinfo is None or value.utcoffset() is None: + raise FormRuntimeError(f"{label} must include a timezone.") + + +def _aware(value: datetime) -> datetime: + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def _is_iso_date(value: object, *, include_time: bool) -> bool: + if not isinstance(value, str): + return False + try: + if include_time: + parsed = datetime.fromisoformat(value) + return parsed.tzinfo is not None and parsed.utcoffset() is not None + date.fromisoformat(value) + return True + except ValueError: + return False + + +__all__ = [ + "CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR", + "CAPABILITY_FORMS_RUNTIME_REGISTRY", + "CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER", + "FormRuntimeError", + "FormRuntimePolicyEvaluator", + "FormRuntimeService", + "FormsServiceLauncher", + "parse_form_binding_reference", + "validate_form_values", +] diff --git a/tests/test_forms_runtime.py b/tests/test_forms_runtime.py new file mode 100644 index 0000000..49f854f --- /dev/null +++ b/tests/test_forms_runtime.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.institutional import ( + CAPABILITY_FORM_DEFINITIONS, + FormDefinition, + FormFieldDefinition, + InstitutionalReference, + ServiceBinding, + ServiceDefinition, + ServiceLaunchRequest, + TemporalRevision, +) +from govoplan_forms.backend.db.models import FormDefinitionRevision +from govoplan_forms.backend.service import SqlFormDefinitionProvider, record_form_definition +from govoplan_forms_runtime.backend.db.models import ( + FormInstanceEvent, + FormInstanceIdentity, + FormInstanceRevision, +) +from govoplan_forms_runtime.backend.service import ( + FormRuntimeError, + FormRuntimeService, + FormsServiceLauncher, +) + + +NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC) + + +@dataclass +class Principal: + tenant_id: str = "tenant-1" + account_id: str = "account-1" + + +class Registry: + def __init__(self, provider: object) -> None: + self.provider = provider + + def has_capability(self, name: str) -> bool: + return name == CAPABILITY_FORM_DEFINITIONS + + def require_capability(self, name: str) -> object: + if name != CAPABILITY_FORM_DEFINITIONS: + raise KeyError(name) + return self.provider + + +def form_definition( + *, + form_id: str = "permit-form", + revision: str = "1", + policy_refs: tuple[str, ...] = (), +) -> FormDefinition: + return FormDefinition( + reference=InstitutionalReference( + kind="form", + owner_module="forms", + object_id=form_id, + tenant_id="tenant-1", + version=revision, + ), + key=form_id, + temporal=TemporalRevision( + revision=revision, + recorded_at=NOW + timedelta(minutes=int(revision) - 2), + change_reason=( + "Initial schema." if revision == "1" else "Revise schema." + ), + ), + title="Permit form", + fields=( + FormFieldDefinition( + key="name", + label="Name", + required=True, + constraints={"min_length": 2}, + ), + FormFieldDefinition( + key="delivery", + label="Delivery", + value_type="choice", + options=("portal", "mail"), + ), + ), + publication_state="published", + allow_drafts=True, + handoff_kinds=("case",), + policy_refs=policy_refs, + ) + + +class FormsRuntimeTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + for table in ( + FormDefinitionRevision.__table__, + FormInstanceIdentity.__table__, + FormInstanceRevision.__table__, + FormInstanceEvent.__table__, + ): + table.create(self.engine) + self.session = Session(self.engine) + self.principal = Principal() + self.definition = record_form_definition( + self.session, + self.principal, + definition=form_definition(), + ) + self.registry = Registry(SqlFormDefinitionProvider()) + self.runtime = FormRuntimeService(self.registry) + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_draft_submit_occ_replay_and_status_history(self) -> None: + draft = self.runtime.create_instance( + self.session, + self.principal, + definition_ref=self.definition.reference, + values={}, + idempotency_key="create-1", + recorded_at=NOW, + ) + self.assertEqual("draft", draft.status) + self.assertEqual("warning", draft.validation_results[0]["severity"]) + + saved = self.runtime.update_draft( + self.session, + self.principal, + instance_id=draft.instance_id, + expected_revision=1, + values={"name": "Ada", "delivery": "portal"}, + attachment_refs=(), + signature_refs=(), + idempotency_key="save-1", + recorded_at=NOW + timedelta(minutes=1), + change_reason="Complete required values.", + ) + submitted = self.runtime.submit_instance( + self.session, + self.principal, + instance_id=draft.instance_id, + expected_revision=2, + values=saved.values, + attachment_refs=(), + signature_refs=(), + idempotency_key="submit-1", + recorded_at=NOW + timedelta(minutes=2), + ) + replay = self.runtime.submit_instance( + self.session, + self.principal, + instance_id=draft.instance_id, + expected_revision=2, + values=saved.values, + attachment_refs=(), + signature_refs=(), + idempotency_key="submit-1", + recorded_at=NOW + timedelta(minutes=2), + ) + + self.assertEqual("submitted", submitted.status) + self.assertIsNotNone(submitted.receipt_id) + self.assertTrue(replay.replayed) + self.assertEqual( + [3, 2, 1], + [ + item.revision + for item in self.runtime.history( + self.session, + self.principal, + instance_id=draft.instance_id, + ) + ], + ) + with self.assertRaisesRegex(FormRuntimeError, "stale"): + self.runtime.transition_instance( + self.session, + self.principal, + instance_id=draft.instance_id, + expected_revision=2, + status="validated", + idempotency_key="transition-stale", + recorded_at=NOW + timedelta(minutes=3), + change_reason="Review complete.", + ) + + def test_validation_policy_tenant_and_handoff_fail_closed(self) -> None: + draft = self.runtime.create_instance( + self.session, + self.principal, + definition_ref=self.definition.reference, + values={}, + idempotency_key="create-2", + recorded_at=NOW, + ) + with self.assertRaisesRegex(FormRuntimeError, "failed validation"): + self.runtime.submit_instance( + self.session, + self.principal, + instance_id=draft.instance_id, + expected_revision=1, + values={"name": "A"}, + attachment_refs=(), + signature_refs=(), + idempotency_key="invalid-submit", + recorded_at=NOW + timedelta(minutes=1), + ) + with self.assertRaisesRegex(PermissionError, "denied"): + self.runtime.get_instance( + self.session, + Principal(account_id="account-2"), + instance_id=draft.instance_id, + ) + + submitted = self.runtime.submit_instance( + self.session, + self.principal, + instance_id=draft.instance_id, + expected_revision=1, + values={"name": "Ada"}, + attachment_refs=(), + signature_refs=(), + idempotency_key="valid-submit", + recorded_at=NOW + timedelta(minutes=2), + ) + with self.assertRaisesRegex(FormRuntimeError, "cross tenants"): + self.runtime.handoff_instance( + self.session, + self.principal, + instance_id=submitted.instance_id, + expected_revision=2, + target_ref=InstitutionalReference( + kind="case", + owner_module="cases", + object_id="case-1", + tenant_id="tenant-2", + ), + idempotency_key="handoff-invalid", + recorded_at=NOW + timedelta(minutes=3), + change_reason="Create case.", + ) + current = self.runtime.get_instance( + self.session, + self.principal, + instance_id=draft.instance_id, + ) + self.assertEqual(2, current.revision if current else None) + + protected = record_form_definition( + self.session, + self.principal, + definition=form_definition( + form_id="protected-form", + policy_refs=("policy:protected-intake",), + ), + ) + with self.assertRaisesRegex(PermissionError, "policy evaluator"): + self.runtime.create_instance( + self.session, + self.principal, + definition_ref=protected.reference, + values={"name": "Ada"}, + idempotency_key="protected-start", + recorded_at=NOW + timedelta(minutes=4), + ) + + def test_service_launcher_retains_exact_service_form_and_replay(self) -> None: + binding = ServiceBinding(kind="form", reference="permit-form/1") + service = ServiceDefinition( + reference=InstitutionalReference( + kind="service", + owner_module="services", + object_id="permit-service", + tenant_id="tenant-1", + version="4", + ), + key="permit-service", + temporal=TemporalRevision( + revision="4", + recorded_at=NOW - timedelta(minutes=2), + change_reason="Publish form entry.", + ), + title="Apply for permit", + audience=("resident",), + bindings=(binding,), + publication_state="published", + ) + request = ServiceLaunchRequest( + service_ref=service.reference, + binding=binding, + idempotency_key="portal-1", + requested_at=NOW, + parameters={}, + ) + launcher = FormsServiceLauncher(self.registry) + first = launcher.launch_service( + self.session, + self.principal, + definition=service, + request=request, + ) + replay = launcher.launch_service( + self.session, + self.principal, + definition=service, + request=request, + ) + + self.assertEqual("form_submission", first.target_ref.kind if first.target_ref else None) + self.assertEqual("1", first.metadata["form_definition_revision"]) + self.assertTrue(replay.replayed) + self.assertEqual(first.target_ref.object_id, replay.target_ref.object_id) + + def test_create_replay_is_actor_bound_and_survives_schema_supersession(self) -> None: + first = self.runtime.create_instance( + self.session, + self.principal, + definition_ref=self.definition.reference, + values={"name": "Ada"}, + idempotency_key="create-replay", + recorded_at=NOW, + ) + record_form_definition( + self.session, + self.principal, + definition=form_definition(revision="2"), + expected_revision="1", + ) + + replay = self.runtime.create_instance( + self.session, + self.principal, + definition_ref=self.definition.reference, + values={"name": "Ada"}, + idempotency_key="create-replay", + recorded_at=NOW, + ) + self.assertTrue(replay.replayed) + self.assertEqual(first.instance_id, replay.instance_id) + + with self.assertRaisesRegex(FormRuntimeError, "another actor"): + self.runtime.create_instance( + self.session, + Principal(account_id="account-2"), + definition_ref=self.definition.reference, + values={"name": "Ada"}, + idempotency_key="create-replay", + recorded_at=NOW, + ) + + def test_runtime_rejects_a_provider_returning_another_exact_revision(self) -> None: + class MismatchedProvider: + def get_form_definition( + self, + session, + principal, + *, + reference, + effective_at=None, + ): + return form_definition(revision="2") + + def list_form_definitions( + self, + session, + principal, + *, + tenant_id, + query="", + limit=100, + ): + return () + + runtime = FormRuntimeService(Registry(MismatchedProvider())) + with self.assertRaisesRegex(FormRuntimeError, "different definition"): + runtime.create_instance( + self.session, + self.principal, + definition_ref=self.definition.reference, + values={"name": "Ada"}, + idempotency_key="mismatched-provider", + recorded_at=NOW, + ) + + def test_client_supplied_instance_id_cannot_replace_existing_state(self) -> None: + self.runtime.create_instance( + self.session, + self.principal, + definition_ref=self.definition.reference, + values={"name": "Ada"}, + idempotency_key="fixed-instance-first", + recorded_at=NOW, + instance_id="fixed-instance", + ) + with self.assertRaisesRegex(FormRuntimeError, "instance id is already"): + self.runtime.create_instance( + self.session, + self.principal, + definition_ref=self.definition.reference, + values={"name": "Grace"}, + idempotency_key="fixed-instance-second", + recorded_at=NOW, + instance_id="fixed-instance", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest.py b/tests/test_manifest.py index ff09b8e..6f93936 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -2,22 +2,42 @@ from __future__ import annotations import unittest -from govoplan_forms_runtime.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest +from govoplan_forms_runtime.backend.manifest import ( + ADMIN_SCOPE, + PARTICIPATE_SCOPE, + READ_SCOPE, + WRITE_SCOPE, + get_manifest, +) -class ManifestSeedTests(unittest.TestCase): - def test_manifest_registers_seed_contract(self) -> None: +class ManifestTests(unittest.TestCase): + def test_manifest_registers_definition_aware_runtime(self) -> None: manifest = get_manifest() self.assertEqual(manifest.id, "forms_runtime") - self.assertEqual(manifest.name, "Forms Runtime") - self.assertEqual(manifest.dependencies, ("access",)) - self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}) - self.assertEqual({role.slug for role in manifest.role_templates}, {"forms_runtime_manager", "forms_runtime_viewer"}) - self.assertTrue(manifest.documentation) - self.assertIsNone(manifest.route_factory) - self.assertIsNone(manifest.migration_spec) - self.assertIsNone(manifest.frontend) + self.assertEqual(manifest.dependencies, ("access", "forms")) + self.assertEqual( + {permission.scope for permission in manifest.permissions}, + {PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}, + ) + participant = next( + item + for item in manifest.role_templates + if item.slug == "forms_runtime_participant" + ) + self.assertTrue(participant.default_authenticated) + self.assertIsNotNone(manifest.route_factory) + self.assertIsNotNone(manifest.migration_spec) + self.assertIsNotNone(manifest.frontend) + self.assertIn( + "forms_runtime.service_launcher", + manifest.capability_factories, + ) + self.assertEqual( + "@govoplan/forms-runtime-webui", + manifest.frontend.package_name if manifest.frontend else None, + ) if __name__ == "__main__": diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..b0b4dfe --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, inspect + +from govoplan_core.db.migrations import migrate_database +from govoplan_forms.backend.manifest import get_manifest as get_forms_manifest +from govoplan_forms_runtime.backend.manifest import get_manifest as get_runtime_manifest + + +class FormsRuntimeMigrationTests(unittest.TestCase): + def test_fresh_migration_creates_runtime_store_and_head(self) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-forms-runtime-migration-") as directory: + url = f"sqlite:///{Path(directory) / 'forms-runtime.db'}" + migrate_database( + database_url=url, + enabled_modules=("forms", "forms_runtime"), + manifest_factories=(get_forms_manifest, get_runtime_manifest), + ) + engine = create_engine(url) + try: + self.assertTrue( + { + "form_definition_revisions", + "form_instance_identities", + "form_instance_revisions", + "form_instance_events", + }.issubset(inspect(engine).get_table_names()) + ) + with engine.connect() as connection: + self.assertIn( + "f2a3b4c5d6e7", + set(MigrationContext.configure(connection).get_current_heads()), + ) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..212a9bc --- /dev/null +++ b/webui/package.json @@ -0,0 +1,28 @@ +{ + "name": "@govoplan/forms-runtime-webui", + "version": "0.1.14", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/forms-runtime.css": "./src/styles/forms-runtime.css" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/formsRuntime.ts b/webui/src/api/formsRuntime.ts new file mode 100644 index 0000000..9606502 --- /dev/null +++ b/webui/src/api/formsRuntime.ts @@ -0,0 +1,171 @@ +import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui"; + + +export type InstitutionalReference = { + kind: string; + owner_module: string; + object_id: string; + tenant_id: string; + version?: string | null; + valid_at?: string | null; + label?: string | null; +}; + +export type EvidenceReference = { + kind: string; + owner_module: string; + evidence_id: string; + tenant_id: string; + version?: string | null; +}; + +export type FormFieldDefinition = { + key: string; + label: string; + value_type: "text" | "multiline_text" | "integer" | "number" | "boolean" | "date" | "datetime" | "email" | "choice" | "multi_choice" | "object" | "list"; + required: boolean; + help_text?: string | null; + options: string[]; + constraints: Record; + default_value?: unknown; +}; + +export type FormDefinition = { + reference: InstitutionalReference; + key: string; + temporal: { revision: string; recorded_at?: string | null }; + title: string; + description?: string | null; + fields: FormFieldDefinition[]; + publication_state: "draft" | "published" | "retired"; + allow_drafts: boolean; + max_attachments: number; + signature_requirement: "none" | "optional" | "required"; + policy_refs: string[]; + handoff_kinds: string[]; +}; + +export type ValidationResult = { + field?: string | null; + severity: "warning" | "error"; + code: string; + message: string; +}; + +export type FormInstance = { + reference: InstitutionalReference; + tenant_id: string; + instance_id: string; + revision: number; + status: string; + definition_ref: InstitutionalReference; + values: Record; + validation_results: ValidationResult[]; + attachment_refs: EvidenceReference[]; + signature_refs: EvidenceReference[]; + handoff_refs: InstitutionalReference[]; + service_ref?: InstitutionalReference | null; + receipt_id?: string | null; + recorded_at: string; + change_reason: string; + created_by: string; + changed_by: string; + replayed: boolean; +}; + +export type FormInstanceEvent = { + event_id: string; + event_type: string; + instance_revision: number; + status: string; + occurred_at: string; + actor_id: string; + payload: Record; +}; + +export function listFormInstances( + settings: ApiSettings, + options: { statuses?: string[]; definitionId?: string; offset?: number; limit?: number } = {}, + signal?: AbortSignal +): Promise<{ instances: FormInstance[]; total: number; offset: number; limit: number }> { + return apiFetch(settings, apiPath("/api/v1/forms-runtime/instances", { + status: options.statuses, + definition_id: options.definitionId, + offset: options.offset ?? 0, + limit: options.limit ?? 100 + }), { signal }); +} + +export function getFormInstance( + settings: ApiSettings, + instanceId: string, + signal?: AbortSignal +): Promise { + return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}`, { signal }); +} + +export function getFormDefinition( + settings: ApiSettings, + instanceId: string, + signal?: AbortSignal +): Promise { + return apiFetch( + settings, + `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/definition`, + { signal } + ); +} + +export function getFormInstanceHistory( + settings: ApiSettings, + instanceId: string, + signal?: AbortSignal +): Promise<{ revisions: FormInstance[] }> { + return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/history`, { signal }); +} + +export function getFormInstanceEvents( + settings: ApiSettings, + instanceId: string, + signal?: AbortSignal +): Promise<{ events: FormInstanceEvent[] }> { + return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/events`, { signal }); +} + +export function saveFormDraft( + settings: ApiSettings, + instance: FormInstance, + values: Record, + changeReason: string +): Promise { + return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}`, { + method: "PATCH", + body: JSON.stringify({ + expected_revision: instance.revision, + values, + attachment_refs: instance.attachment_refs, + signature_refs: instance.signature_refs, + idempotency_key: crypto.randomUUID(), + recorded_at: new Date().toISOString(), + change_reason: changeReason + }) + }); +} + +export function submitFormInstance( + settings: ApiSettings, + instance: FormInstance, + values: Record +): Promise { + return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/submit`, { + method: "POST", + body: JSON.stringify({ + expected_revision: instance.revision, + values, + attachment_refs: instance.attachment_refs, + signature_refs: instance.signature_refs, + idempotency_key: crypto.randomUUID(), + recorded_at: new Date().toISOString() + }) + }); +} diff --git a/webui/src/features/forms/FormInstancePage.tsx b/webui/src/features/forms/FormInstancePage.tsx new file mode 100644 index 0000000..c44db9d --- /dev/null +++ b/webui/src/features/forms/FormInstancePage.tsx @@ -0,0 +1,384 @@ +import { ArrowLeft, Save, Send } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useParams } from "react-router"; +import { + Button, + DismissibleAlert, + LoadingIndicator, + PageScrollViewport, + StatusBadge, + ToggleSwitch, + useGuardedNavigate, + type PlatformRouteContext +} from "@govoplan/core-webui"; +import { + getFormDefinition, + getFormInstance, + getFormInstanceEvents, + getFormInstanceHistory, + saveFormDraft, + submitFormInstance, + type FormDefinition, + type FormFieldDefinition, + type FormInstance, + type FormInstanceEvent, + type ValidationResult +} from "../../api/formsRuntime"; + + +export default function FormInstancePage({ settings }: PlatformRouteContext) { + const { instanceId = "" } = useParams(); + const navigate = useGuardedNavigate(); + const [instance, setInstance] = useState(null); + const [definition, setDefinition] = useState(null); + const [history, setHistory] = useState([]); + const [events, setEvents] = useState([]); + const [values, setValues] = useState>({}); + const [changeReason, setChangeReason] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + const load = useCallback(async (signal?: AbortSignal) => { + setLoading(true); + setError(""); + try { + const nextInstance = await getFormInstance(settings, instanceId, signal); + const [nextDefinition, nextHistory, nextEvents] = await Promise.all([ + getFormDefinition(settings, instanceId, signal), + getFormInstanceHistory(settings, instanceId, signal), + getFormInstanceEvents(settings, instanceId, signal) + ]); + setInstance(nextInstance); + setDefinition(nextDefinition); + setHistory(nextHistory.revisions); + setEvents(nextEvents.events); + setValues(nextInstance.values); + setChangeReason(""); + } finally { + setLoading(false); + } + }, [instanceId, settings]); + + useEffect(() => { + const controller = new AbortController(); + load(controller.signal).catch((reason) => { + if ((reason as Error).name !== "AbortError") { + setError(reason instanceof Error ? reason.message : "The Form could not be loaded."); + } + }); + return () => controller.abort(); + }, [load]); + + const editable = instance?.status === "started" || instance?.status === "draft"; + const canSave = instance?.status === "draft" && definition?.allow_drafts; + const changed = useMemo( + () => Boolean(instance && JSON.stringify(values) !== JSON.stringify(instance.values)), + [instance, values] + ); + const diagnostics = useMemo(() => { + const grouped = new Map(); + for (const item of instance?.validation_results ?? []) { + const key = item.field ?? ""; + grouped.set(key, [...(grouped.get(key) ?? []), item]); + } + return grouped; + }, [instance]); + + async function save() { + if (!instance || !canSave || !changed || !changeReason.trim()) return; + setSaving(true); + setError(""); + try { + await saveFormDraft(settings, instance, values, changeReason.trim()); + await load(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "The draft could not be saved."); + } finally { + setSaving(false); + } + } + + async function submit() { + if (!instance || !editable) return; + setSaving(true); + setError(""); + try { + await submitFormInstance(settings, instance, values); + await load(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "The Form could not be submitted."); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+ + {definition && {definition.title}} + {instance && } +
+ + {error && + + {error} + + } + {loading && } + {!loading && instance && definition && +
+
+
+

{definition.title}

+ {definition.description &&

{definition.description}

} +
+
+ {definition.fields.map((field) => + setValues((current) => { + const next = { ...current }; + if (value === undefined || value === "") delete next[field.key]; + else next[field.key] = value; + return next; + })} + /> + )} +
+ {(instance.attachment_refs.length > 0 || instance.signature_refs.length > 0) && +
+ {instance.attachment_refs.length} attachments + {instance.signature_refs.length} signatures +
+ } + {editable && +
+ {canSave && + + } + {canSave && + + } + +
+ } + {!editable && instance.receipt_id && +
+ Submission receipt + {instance.receipt_id} +
+ } +
+ +
+ } +
+
+
+ ); +} + +function FormField({ + field, + value, + disabled, + diagnostics, + onChange +}: { + field: FormFieldDefinition; + value: unknown; + disabled: boolean; + diagnostics: ValidationResult[]; + onChange: (value: unknown) => void; +}) { + const describedBy = diagnostics.length > 0 ? `form-field-${field.key}-messages` : undefined; + if (field.value_type === "boolean") { + return ( +
+ + +
+ ); + } + return ( + + ); +} + +function renderInput( + field: FormFieldDefinition, + value: unknown, + disabled: boolean, + describedBy: string | undefined, + onChange: (value: unknown) => void +) { + const common = { disabled, required: field.required, "aria-describedby": describedBy }; + if (field.value_type === "multiline_text" || field.value_type === "object" || field.value_type === "list") { + return ( +