feat: implement definition-aware forms runtime
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# GovOPlaN Forms Runtime Codex Guide
|
# 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
|
## Scope
|
||||||
|
|
||||||
This repository owns the GovOPlaN Forms Runtime platform module seed.
|
This repository owns the GovOPlaN Forms Runtime platform module seed.
|
||||||
|
|||||||
@@ -4,13 +4,22 @@
|
|||||||
**Repository type:** module (platform).
|
**Repository type:** module (platform).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
`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.
|
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 `<form-id>/<revision>`; the
|
||||||
|
runtime resolves that exact immutable definition through `forms.definitions`,
|
||||||
|
validates launch values, and retains both Service and binding provenance.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
- form submissions
|
- form submissions
|
||||||
- draft state
|
- draft state
|
||||||
@@ -31,13 +40,20 @@ Detailed boundary notes are in [docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md](docs/FORM
|
|||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
Expected optional integrations:
|
Required integrations:
|
||||||
|
|
||||||
|
- access
|
||||||
- forms
|
- forms
|
||||||
|
|
||||||
|
Optional integrations:
|
||||||
|
|
||||||
- files
|
- files
|
||||||
- approvals
|
- approvals
|
||||||
- workflow
|
- workflow engine
|
||||||
- portal
|
- portal
|
||||||
|
- cases
|
||||||
|
- policy
|
||||||
|
- audit
|
||||||
|
|
||||||
## Development Install
|
## Development Install
|
||||||
|
|
||||||
@@ -48,11 +64,12 @@ cd /mnt/DATA/git/govoplan-core
|
|||||||
./.venv/bin/python -m pip install -e ../govoplan-forms-runtime
|
./.venv/bin/python -m pip install -e ../govoplan-forms-runtime
|
||||||
```
|
```
|
||||||
|
|
||||||
Focused manifest verification:
|
Focused verification:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-forms-runtime
|
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
|
## Gitea Workflow
|
||||||
|
|||||||
@@ -19,27 +19,68 @@ Runtime form submissions for validation, drafts, attachments, signatures, status
|
|||||||
- document storage
|
- document storage
|
||||||
- domain-specific adjudication
|
- domain-specific adjudication
|
||||||
|
|
||||||
## Integration Candidates
|
## Required Integrations
|
||||||
|
|
||||||
|
- access
|
||||||
- forms
|
- forms
|
||||||
|
|
||||||
|
## Optional Integration Candidates
|
||||||
|
|
||||||
- files
|
- files
|
||||||
- approvals
|
- approvals
|
||||||
- workflow
|
- workflow engine
|
||||||
- portal
|
- 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
|
## Security And Policy
|
||||||
- tenant-level permission definitions
|
|
||||||
- manager and viewer role templates
|
|
||||||
- documentation topic describing the module boundary
|
|
||||||
- Gitea issue workflow templates
|
|
||||||
- manifest contract test
|
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/forms-runtime",
|
"name": "@govoplan/forms-runtime",
|
||||||
"version": "0.1.8",
|
"version": "0.1.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "GovOPlaN Forms Runtime platform module seed.",
|
"description": "Definition-aware form submissions and service launch for GovOPlaN.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"peerDependencies": {}
|
"peerDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-forms-runtime"
|
name = "govoplan-forms-runtime"
|
||||||
version = "0.1.8"
|
version = "0.1.14"
|
||||||
description = "GovOPlaN Forms Runtime platform module seed."
|
description = "Definition-aware form submissions and service launch for GovOPlaN."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.14",
|
||||||
"govoplan-access>=0.1.8",
|
"govoplan-access>=0.1.8",
|
||||||
|
"govoplan-forms>=0.1.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Forms Runtime database models."""
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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"]
|
||||||
@@ -1,20 +1,59 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from pathlib import Path
|
||||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
|
||||||
|
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_ID = "forms_runtime"
|
||||||
MODULE_NAME = "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"
|
READ_SCOPE = "forms_runtime:workspace:read"
|
||||||
WRITE_SCOPE = "forms_runtime:workspace:write"
|
WRITE_SCOPE = "forms_runtime:workspace:write"
|
||||||
ADMIN_SCOPE = "forms_runtime:workspace:admin"
|
ADMIN_SCOPE = "forms_runtime:workspace:admin"
|
||||||
OPTIONAL_DEPENDENCIES = (
|
OPTIONAL_DEPENDENCIES = (
|
||||||
"forms",
|
|
||||||
"files",
|
"files",
|
||||||
"approvals",
|
"approvals",
|
||||||
"workflow_engine",
|
"workflow_engine",
|
||||||
"portal",
|
"portal",
|
||||||
|
"cases",
|
||||||
|
"policy",
|
||||||
|
"audit",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -24,7 +63,7 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
scope=scope,
|
scope=scope,
|
||||||
label=label,
|
label=label,
|
||||||
description=description,
|
description=description,
|
||||||
category="Forms Runtime",
|
category=MODULE_NAME,
|
||||||
level="tenant",
|
level="tenant",
|
||||||
module_id=module_id,
|
module_id=module_id,
|
||||||
resource=resource,
|
resource=resource,
|
||||||
@@ -33,66 +72,255 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission(READ_SCOPE, "View forms runtime workspace", "Read forms runtime records, configuration, and workflow context."),
|
_permission(
|
||||||
_permission(WRITE_SCOPE, "Manage forms runtime workspace", "Create and update forms runtime records and workflow state."),
|
PARTICIPATE_SCOPE,
|
||||||
_permission(ADMIN_SCOPE, "Administer forms runtime workspace", "Configure forms runtime policies, templates, and tenant-level administration."),
|
"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 = (
|
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(
|
RoleTemplate(
|
||||||
slug="forms_runtime_manager",
|
slug="forms_runtime_manager",
|
||||||
name="Forms Runtime manager",
|
name="Forms Runtime manager",
|
||||||
description="Manage forms runtime records and workflow state.",
|
description="Review, transition, and hand off Form submissions.",
|
||||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
permissions=(PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="forms_runtime_viewer",
|
slug="forms_runtime_viewer",
|
||||||
name="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,),
|
permissions=(READ_SCOPE,),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
DOCUMENTATION = (
|
|
||||||
DocumentationTopic(
|
def _router(context: ModuleContext):
|
||||||
id=f"{MODULE_ID}.module-boundary",
|
from govoplan_forms_runtime.backend.router import create_router
|
||||||
title=f"{MODULE_NAME} module boundary",
|
|
||||||
summary="Runtime form submissions for validation, drafts, attachments, signatures, status tracking, and handoff to domain modules.",
|
return create_router(context.registry)
|
||||||
body=(
|
|
||||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
|
||||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
def _registry(context: ModuleContext) -> FormRuntimeService:
|
||||||
"database models, migrations, and WebUI routes are introduced."
|
return FormRuntimeService(context.registry)
|
||||||
),
|
|
||||||
layer="available",
|
|
||||||
documentation_types=("admin",),
|
def _service_launcher(context: ModuleContext) -> FormsServiceLauncher:
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
return FormsServiceLauncher(context.registry)
|
||||||
order=100,
|
|
||||||
related_modules=OPTIONAL_DEPENDENCIES,
|
|
||||||
links=(
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
DocumentationLink(
|
current = session.query(runtime_models.FormInstanceRevision).filter(
|
||||||
label="Repository domain boundary",
|
runtime_models.FormInstanceRevision.tenant_id == tenant_id,
|
||||||
href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
|
runtime_models.FormInstanceRevision.superseded_at.is_(None),
|
||||||
kind="repository",
|
)
|
||||||
),
|
return {
|
||||||
),
|
"form_instances": current.count(),
|
||||||
metadata={
|
"open_form_instances": current.filter(
|
||||||
"seed": True,
|
runtime_models.FormInstanceRevision.status.in_(
|
||||||
"domain_objects": ['form submissions', 'draft state', 'runtime validation results', 'attachment references', 'signature state', 'handoff status'],
|
("started", "draft", "submitted", "validated", "needs_review")
|
||||||
"first_slice": "Define submission, draft, validation, attachment, signature, status, and handoff contracts around existing form definitions.",
|
)
|
||||||
},
|
).count(),
|
||||||
),
|
}
|
||||||
)
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id=MODULE_ID,
|
id=MODULE_ID,
|
||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("access",),
|
dependencies=("access", "forms"),
|
||||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
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,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
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",),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Forms Runtime Alembic revisions."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Forms Runtime migration versions."""
|
||||||
+175
@@ -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")
|
||||||
@@ -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"]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
+31
-11
@@ -2,22 +2,42 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
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):
|
class ManifestTests(unittest.TestCase):
|
||||||
def test_manifest_registers_seed_contract(self) -> None:
|
def test_manifest_registers_definition_aware_runtime(self) -> None:
|
||||||
manifest = get_manifest()
|
manifest = get_manifest()
|
||||||
|
|
||||||
self.assertEqual(manifest.id, "forms_runtime")
|
self.assertEqual(manifest.id, "forms_runtime")
|
||||||
self.assertEqual(manifest.name, "Forms Runtime")
|
self.assertEqual(manifest.dependencies, ("access", "forms"))
|
||||||
self.assertEqual(manifest.dependencies, ("access",))
|
self.assertEqual(
|
||||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
{permission.scope for permission in manifest.permissions},
|
||||||
self.assertEqual({role.slug for role in manifest.role_templates}, {"forms_runtime_manager", "forms_runtime_viewer"})
|
{PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
|
||||||
self.assertTrue(manifest.documentation)
|
)
|
||||||
self.assertIsNone(manifest.route_factory)
|
participant = next(
|
||||||
self.assertIsNone(manifest.migration_spec)
|
item
|
||||||
self.assertIsNone(manifest.frontend)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<FormInstance> {
|
||||||
|
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormDefinition(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instanceId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<FormDefinition> {
|
||||||
|
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<string, unknown>,
|
||||||
|
changeReason: string
|
||||||
|
): Promise<FormInstance> {
|
||||||
|
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<string, unknown>
|
||||||
|
): Promise<FormInstance> {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<FormInstance | null>(null);
|
||||||
|
const [definition, setDefinition] = useState<FormDefinition | null>(null);
|
||||||
|
const [history, setHistory] = useState<FormInstance[]>([]);
|
||||||
|
const [events, setEvents] = useState<FormInstanceEvent[]>([]);
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
|
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<string, ValidationResult[]>();
|
||||||
|
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 (
|
||||||
|
<main className="forms-runtime-page">
|
||||||
|
<div className="form-instance-shell">
|
||||||
|
<div className="form-instance-toolbar">
|
||||||
|
<Button onClick={() => navigate("/forms-runtime")}>
|
||||||
|
<ArrowLeft size={16} aria-hidden="true" />
|
||||||
|
Forms
|
||||||
|
</Button>
|
||||||
|
{definition && <strong>{definition.title}</strong>}
|
||||||
|
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={humanize(instance.status)} />}
|
||||||
|
</div>
|
||||||
|
<PageScrollViewport className="form-instance-viewport">
|
||||||
|
{error &&
|
||||||
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
}
|
||||||
|
{loading && <LoadingIndicator label="Loading Form" />}
|
||||||
|
{!loading && instance && definition &&
|
||||||
|
<div className="form-instance-content">
|
||||||
|
<section className="form-instance-main">
|
||||||
|
<header>
|
||||||
|
<h1>{definition.title}</h1>
|
||||||
|
{definition.description && <p>{definition.description}</p>}
|
||||||
|
</header>
|
||||||
|
<div className="form-fields">
|
||||||
|
{definition.fields.map((field) =>
|
||||||
|
<FormField
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
value={values[field.key]}
|
||||||
|
disabled={!editable || saving}
|
||||||
|
diagnostics={diagnostics.get(field.key) ?? []}
|
||||||
|
onChange={(value) => setValues((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
if (value === undefined || value === "") delete next[field.key];
|
||||||
|
else next[field.key] = value;
|
||||||
|
return next;
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(instance.attachment_refs.length > 0 || instance.signature_refs.length > 0) &&
|
||||||
|
<div className="form-evidence-summary">
|
||||||
|
<span>{instance.attachment_refs.length} attachments</span>
|
||||||
|
<span>{instance.signature_refs.length} signatures</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{editable &&
|
||||||
|
<div className="form-instance-actions">
|
||||||
|
{canSave &&
|
||||||
|
<label className="form-change-reason">
|
||||||
|
<span>Change reason</span>
|
||||||
|
<input
|
||||||
|
value={changeReason}
|
||||||
|
onChange={(event) => setChangeReason(event.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
{canSave &&
|
||||||
|
<Button
|
||||||
|
onClick={() => void save()}
|
||||||
|
disabled={!changed || !changeReason.trim() || saving}>
|
||||||
|
<Save size={16} aria-hidden="true" />
|
||||||
|
Save draft
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
<Button variant="primary" onClick={() => void submit()} disabled={saving}>
|
||||||
|
<Send size={16} aria-hidden="true" />
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{!editable && instance.receipt_id &&
|
||||||
|
<div className="form-receipt">
|
||||||
|
<span>Submission receipt</span>
|
||||||
|
<code>{instance.receipt_id}</code>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
<aside className="form-instance-aside">
|
||||||
|
<section>
|
||||||
|
<h2>Status history</h2>
|
||||||
|
<ol>
|
||||||
|
{events.map((event) =>
|
||||||
|
<li key={event.event_id}>
|
||||||
|
<strong>{humanize(event.status)}</strong>
|
||||||
|
<span>{humanize(event.event_type)}</span>
|
||||||
|
<time>{formatDateTime(event.occurred_at)}</time>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Revisions</h2>
|
||||||
|
<ol>
|
||||||
|
{history.map((item) =>
|
||||||
|
<li key={item.revision}>
|
||||||
|
<strong>Revision {item.revision}</strong>
|
||||||
|
<span>{item.change_reason}</span>
|
||||||
|
<time>{formatDateTime(item.recorded_at)}</time>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="form-field form-field-toggle">
|
||||||
|
<ToggleSwitch
|
||||||
|
label={`${field.label}${field.required ? " (required)" : ""}`}
|
||||||
|
checked={Boolean(value)}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
help={field.help_text ?? undefined}
|
||||||
|
/>
|
||||||
|
<FieldMessages id={describedBy} diagnostics={diagnostics} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<label className="form-field">
|
||||||
|
<span>{field.label}{field.required && <b aria-hidden="true"> *</b>}</span>
|
||||||
|
{field.help_text && <small>{field.help_text}</small>}
|
||||||
|
{renderInput(field, value, disabled, describedBy, onChange)}
|
||||||
|
<FieldMessages id={describedBy} diagnostics={diagnostics} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<textarea
|
||||||
|
{...common}
|
||||||
|
rows={field.value_type === "multiline_text" ? 5 : 7}
|
||||||
|
value={structuredValue(value)}
|
||||||
|
onChange={(event) => onChange(field.value_type === "multiline_text" ? event.target.value : parseStructured(event.target.value))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field.value_type === "choice") {
|
||||||
|
return (
|
||||||
|
<select {...common} value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)}>
|
||||||
|
<option value="">Select</option>
|
||||||
|
{field.options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field.value_type === "multi_choice") {
|
||||||
|
const selected = Array.isArray(value) ? value.map(String) : [];
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
{...common}
|
||||||
|
multiple
|
||||||
|
value={selected}
|
||||||
|
onChange={(event) => onChange(Array.from(event.target.selectedOptions, (option) => option.value))}>
|
||||||
|
{field.options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const type = field.value_type === "integer" || field.value_type === "number"
|
||||||
|
? "number"
|
||||||
|
: field.value_type === "email"
|
||||||
|
? "email"
|
||||||
|
: field.value_type === "date"
|
||||||
|
? "date"
|
||||||
|
: field.value_type === "datetime"
|
||||||
|
? "datetime-local"
|
||||||
|
: "text";
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...common}
|
||||||
|
type={type}
|
||||||
|
step={field.value_type === "integer" ? 1 : field.value_type === "number" ? "any" : undefined}
|
||||||
|
min={numericConstraint(field.constraints.minimum)}
|
||||||
|
max={numericConstraint(field.constraints.maximum)}
|
||||||
|
minLength={numericConstraint(field.constraints.min_length)}
|
||||||
|
maxLength={numericConstraint(field.constraints.max_length)}
|
||||||
|
value={inputValue(field, value)}
|
||||||
|
onChange={(event) => onChange(inputChangeValue(field, event.target.value))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldMessages({ id, diagnostics }: { id?: string; diagnostics: ValidationResult[] }) {
|
||||||
|
if (diagnostics.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<span id={id} className="form-field-messages" aria-live="polite">
|
||||||
|
{diagnostics.map((item) => <small key={item.code} data-severity={item.severity}>{item.message}</small>)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputValue(field: FormFieldDefinition, value: unknown): string | number {
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
if (field.value_type === "datetime" && typeof value === "string") {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (!Number.isNaN(parsed.valueOf())) {
|
||||||
|
const local = new Date(parsed.getTime() - parsed.getTimezoneOffset() * 60_000);
|
||||||
|
return local.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof value === "number" || typeof value === "string" ? value : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputChangeValue(field: FormFieldDefinition, value: string): unknown {
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (field.value_type === "integer") return Number.parseInt(value, 10);
|
||||||
|
if (field.value_type === "number") return Number(value);
|
||||||
|
if (field.value_type === "datetime") return new Date(value).toISOString();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function structuredValue(value: unknown): string {
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStructured(value: string): unknown {
|
||||||
|
if (!value.trim()) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericConstraint(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DismissibleAlert,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
StatusBadge,
|
||||||
|
useGuardedNavigate,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
|
||||||
|
|
||||||
|
|
||||||
|
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
|
||||||
|
|
||||||
|
export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
||||||
|
const navigate = useGuardedNavigate();
|
||||||
|
const [items, setItems] = useState<FormInstance[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [status, setStatus] = useState("open");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback((signal?: AbortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
return listFormInstances(settings, {
|
||||||
|
statuses: status === "open" ? OPEN_STATUSES : status ? [status] : undefined,
|
||||||
|
limit: 200
|
||||||
|
}, signal).
|
||||||
|
then((result) => {
|
||||||
|
setItems(result.instances);
|
||||||
|
setTotal(result.total);
|
||||||
|
}).
|
||||||
|
finally(() => setLoading(false));
|
||||||
|
}, [settings, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
load(controller.signal).catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Forms could not be loaded.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="forms-runtime-page">
|
||||||
|
<div className="forms-runtime-shell">
|
||||||
|
<div className="forms-runtime-toolbar">
|
||||||
|
<Button onClick={() => void load()} disabled={loading}>
|
||||||
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<label>
|
||||||
|
<span>Status</span>
|
||||||
|
<select value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="submitted">Submitted</option>
|
||||||
|
<option value="needs_review">Needs review</option>
|
||||||
|
<option value="accepted">Accepted</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
|
<option value="handed_off">Handed off</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span className="forms-runtime-count">{total} forms</span>
|
||||||
|
</div>
|
||||||
|
<PageScrollViewport className="forms-runtime-list-viewport">
|
||||||
|
{error &&
|
||||||
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
}
|
||||||
|
{loading && <LoadingIndicator label="Loading forms" />}
|
||||||
|
{!loading && !error && items.length === 0 &&
|
||||||
|
<div className="forms-runtime-empty">No matching Forms.</div>
|
||||||
|
}
|
||||||
|
{!loading && items.length > 0 &&
|
||||||
|
<div className="forms-runtime-list" role="list">
|
||||||
|
{items.map((item) =>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
className="forms-runtime-row"
|
||||||
|
key={item.instance_id}
|
||||||
|
onClick={() => navigate(`/forms-runtime/${encodeURIComponent(item.instance_id)}`)}>
|
||||||
|
<span className="forms-runtime-row-main">
|
||||||
|
<strong>{item.definition_ref.label ?? humanize(item.definition_ref.object_id)}</strong>
|
||||||
|
<span>Revision {item.definition_ref.version ?? "-"}</span>
|
||||||
|
</span>
|
||||||
|
<span>{formatDateTime(item.recorded_at)}</span>
|
||||||
|
<StatusBadge status={isOpen(item.status) ? "active" : "inactive"} label={humanize(item.status)} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpen(status: string): boolean {
|
||||||
|
return OPEN_STATUSES.includes(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, formsRuntimeModule } from "./module";
|
||||||
|
export * from "./api/formsRuntime";
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import "./styles/forms-runtime.css";
|
||||||
|
|
||||||
|
|
||||||
|
const FormsRuntimePage = lazy(() => import("./features/forms/FormsRuntimePage"));
|
||||||
|
const FormInstancePage = lazy(() => import("./features/forms/FormInstancePage"));
|
||||||
|
const routeScopes = [
|
||||||
|
"forms_runtime:submission:participate",
|
||||||
|
"forms_runtime:workspace:read"
|
||||||
|
];
|
||||||
|
|
||||||
|
export const formsRuntimeModule: PlatformWebModule = {
|
||||||
|
id: "forms_runtime",
|
||||||
|
label: "Forms",
|
||||||
|
version: "0.1.14",
|
||||||
|
dependencies: ["access", "forms"],
|
||||||
|
optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "policy", "audit"],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/forms-runtime",
|
||||||
|
anyOf: routeScopes,
|
||||||
|
order: 37,
|
||||||
|
surfaceId: "forms_runtime.workspace",
|
||||||
|
render: (context) => createElement(FormsRuntimePage, context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/forms-runtime/:instanceId",
|
||||||
|
anyOf: routeScopes,
|
||||||
|
order: 38,
|
||||||
|
surfaceId: "forms_runtime.instance",
|
||||||
|
render: (context) => createElement(FormInstancePage, context)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
navItems: [
|
||||||
|
{
|
||||||
|
to: "/forms-runtime",
|
||||||
|
label: "Forms",
|
||||||
|
iconName: "form",
|
||||||
|
anyOf: routeScopes,
|
||||||
|
order: 37,
|
||||||
|
surfaceId: "forms_runtime.navigation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "forms_runtime.navigation", moduleId: "forms_runtime", kind: "navigation", label: "Forms navigation", order: 10 },
|
||||||
|
{ id: "forms_runtime.workspace", moduleId: "forms_runtime", kind: "route", label: "Forms workspace", order: 20 },
|
||||||
|
{ id: "forms_runtime.instance", moduleId: "forms_runtime", kind: "route", label: "Form instance", order: 30 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default formsRuntimeModule;
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
.forms-runtime-page,
|
||||||
|
.forms-runtime-shell,
|
||||||
|
.form-instance-shell {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-shell,
|
||||||
|
.form-instance-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-toolbar,
|
||||||
|
.form-instance-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-toolbar label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-toolbar label > span,
|
||||||
|
.forms-runtime-count {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-count,
|
||||||
|
.form-instance-toolbar .status-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-list-viewport,
|
||||||
|
.form-instance-viewport {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16px 18px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-list {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) minmax(170px, auto) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row:hover,
|
||||||
|
.forms-runtime-row:focus-visible {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row-main strong,
|
||||||
|
.forms-runtime-row-main span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row-main span,
|
||||||
|
.forms-runtime-row > span:not(.status-badge) {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-empty {
|
||||||
|
padding: 36px 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||||
|
gap: 28px;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-main,
|
||||||
|
.form-instance-aside {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-main > header {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-main h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-main > header p {
|
||||||
|
max-width: 70ch;
|
||||||
|
margin: 7px 0 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px 18px;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field:has(textarea),
|
||||||
|
.form-field:has(select[multiple]) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field > span:first-child {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field > span b {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field > small {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field select[multiple] {
|
||||||
|
min-height: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-toggle {
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-height: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-messages [data-severity="error"] {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-messages [data-severity="warning"] {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-evidence-summary,
|
||||||
|
.form-receipt {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 18px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-receipt {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-receipt code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-change-reason {
|
||||||
|
display: flex;
|
||||||
|
min-width: min(360px, 100%);
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-change-reason span {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-aside section + section {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-aside h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-aside ol {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-aside li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 10px 0 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-aside li span,
|
||||||
|
.form-instance-aside li time {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.forms-runtime-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-runtime-row > span:not(.forms-runtime-row-main, .status-badge) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field:has(textarea),
|
||||||
|
.form-field:has(select[multiple]) {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user