feat: implement definition-aware forms runtime
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# GovOPlaN Forms Runtime Codex Guide
|
||||
|
||||
## Documentation Contract
|
||||
|
||||
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||
- Keep feature content here; `govoplan-docs` projects it without importing Forms Runtime internals.
|
||||
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||
|
||||
## Scope
|
||||
|
||||
This repository owns the GovOPlaN Forms Runtime platform module seed.
|
||||
|
||||
@@ -4,13 +4,22 @@
|
||||
**Repository type:** module (platform).
|
||||
<!-- 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.
|
||||
|
||||
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
|
||||
- draft state
|
||||
@@ -31,13 +40,20 @@ Detailed boundary notes are in [docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md](docs/FORM
|
||||
|
||||
## Integrations
|
||||
|
||||
Expected optional integrations:
|
||||
Required integrations:
|
||||
|
||||
- access
|
||||
- forms
|
||||
|
||||
Optional integrations:
|
||||
|
||||
- files
|
||||
- approvals
|
||||
- workflow
|
||||
- workflow engine
|
||||
- portal
|
||||
- cases
|
||||
- policy
|
||||
- audit
|
||||
|
||||
## Development Install
|
||||
|
||||
@@ -48,11 +64,12 @@ cd /mnt/DATA/git/govoplan-core
|
||||
./.venv/bin/python -m pip install -e ../govoplan-forms-runtime
|
||||
```
|
||||
|
||||
Focused manifest verification:
|
||||
Focused verification:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-forms-runtime
|
||||
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
|
||||
PYTHONPATH=src:/mnt/DATA/git/govoplan-forms/src:/mnt/DATA/git/govoplan-core/src \
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
## Gitea Workflow
|
||||
|
||||
@@ -19,27 +19,68 @@ Runtime form submissions for validation, drafts, attachments, signatures, status
|
||||
- document storage
|
||||
- domain-specific adjudication
|
||||
|
||||
## Integration Candidates
|
||||
## Required Integrations
|
||||
|
||||
- access
|
||||
- forms
|
||||
|
||||
## Optional Integration Candidates
|
||||
|
||||
- files
|
||||
- approvals
|
||||
- workflow
|
||||
- workflow engine
|
||||
- portal
|
||||
- cases
|
||||
- policy
|
||||
- audit
|
||||
|
||||
## Seed State
|
||||
## Implemented State
|
||||
|
||||
The current repository state is intentionally small:
|
||||
- exact immutable Form-definition resolution through `forms.definitions`
|
||||
- tenant-bound instance identities and append-only revisions/status events
|
||||
- server-side type, option, constraint, required, attachment, signature, and
|
||||
optional policy validation
|
||||
- `started` launch sessions and definition-controlled draft persistence
|
||||
- final submission receipts, handoff references, replay safety, and OCC
|
||||
- actor-bound idempotency that permits an exact retry after definition
|
||||
supersession without exposing another participant's submission
|
||||
- owner-restricted participant access plus manager scopes
|
||||
- bounded list/detail/history/event APIs and accessible definition-driven WebUI
|
||||
- `forms_runtime.service_launcher` retaining exact Service and binding
|
||||
provenance
|
||||
- migrations, uninstall guards, tenant summaries, events, recovery notes, and
|
||||
tenant/replay/stale-write/validation/handoff tests
|
||||
|
||||
- module manifest and entry point
|
||||
- tenant-level permission definitions
|
||||
- manager and viewer role templates
|
||||
- documentation topic describing the module boundary
|
||||
- Gitea issue workflow templates
|
||||
- manifest contract test
|
||||
## Security And Policy
|
||||
|
||||
No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear.
|
||||
Authenticated accounts receive only the participant role by default. It permits
|
||||
access to their own instances; tenant-wide reads and review/handoff transitions
|
||||
require manager scopes. Event payloads exclude submitted values. Exact
|
||||
definition lookup, publication state, tenant, current authorization, and
|
||||
optional policy references are re-evaluated for each consequential operation.
|
||||
Definition providers must return the requested owner, tenant, object, and exact
|
||||
revision; a mismatched provider response fails closed.
|
||||
Policy-referenced definitions fail closed when no compatible
|
||||
`forms_runtime.policy_evaluator` is active.
|
||||
|
||||
## First Implementation Slice
|
||||
Files and signature providers retain their own content and key custody. Runtime
|
||||
stores only same-tenant evidence references. Cases and Workflow Engine retain
|
||||
their own target state; Runtime stores only a permitted same-tenant handoff
|
||||
reference and status evidence.
|
||||
|
||||
Define submission, draft, validation, attachment, signature, status, and handoff contracts around existing form definitions.
|
||||
## Recovery And Operations
|
||||
|
||||
Database recovery restores identities, revisions, and events together. After
|
||||
restore, verify one current revision per instance, monotonically increasing
|
||||
revision history, matching event revisions, resolvable exact Form and Service
|
||||
references, and referenced evidence availability. A replay with the original
|
||||
idempotency key and request hash must return the original revision; a changed
|
||||
request must conflict. Failed handoffs leave the prior revision current.
|
||||
|
||||
Destructive retirement is blocked while state exists and requires a verified
|
||||
database snapshot plus an export or retention decision for referenced evidence.
|
||||
No local generated files are required, so API and worker nodes remain stateless.
|
||||
|
||||
Anonymous public intake, concrete file/signature upload adapters, conditional
|
||||
multi-page layout, and automated target handoff execution remain product depth;
|
||||
the owner and security boundaries no longer depend on those additions.
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@govoplan/forms-runtime",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"description": "GovOPlaN Forms Runtime platform module seed.",
|
||||
"description": "Definition-aware form submissions and service launch for GovOPlaN.",
|
||||
"type": "module",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
+4
-3
@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-forms-runtime"
|
||||
version = "0.1.8"
|
||||
description = "GovOPlaN Forms Runtime platform module seed."
|
||||
version = "0.1.14"
|
||||
description = "Definition-aware form submissions and service launch for GovOPlaN."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.8",
|
||||
"govoplan-core>=0.1.14",
|
||||
"govoplan-access>=0.1.8",
|
||||
"govoplan-forms>=0.1.14",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -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 govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.institutional import CAPABILITY_FORM_DEFINITIONS
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_forms_runtime.backend.db import models as runtime_models
|
||||
from govoplan_forms_runtime.backend.service import (
|
||||
CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,
|
||||
CAPABILITY_FORMS_RUNTIME_REGISTRY,
|
||||
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER,
|
||||
FormRuntimeService,
|
||||
FormsServiceLauncher,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "forms_runtime"
|
||||
MODULE_NAME = "Forms Runtime"
|
||||
MODULE_VERSION = "0.1.8"
|
||||
MODULE_VERSION = "0.1.14"
|
||||
PARTICIPATE_SCOPE = "forms_runtime:submission:participate"
|
||||
READ_SCOPE = "forms_runtime:workspace:read"
|
||||
WRITE_SCOPE = "forms_runtime:workspace:write"
|
||||
ADMIN_SCOPE = "forms_runtime:workspace:admin"
|
||||
OPTIONAL_DEPENDENCIES = (
|
||||
"forms",
|
||||
"files",
|
||||
"approvals",
|
||||
"workflow_engine",
|
||||
"portal",
|
||||
"cases",
|
||||
"policy",
|
||||
"audit",
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +63,7 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="Forms Runtime",
|
||||
category=MODULE_NAME,
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
@@ -33,66 +72,255 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(READ_SCOPE, "View forms runtime workspace", "Read forms runtime records, configuration, and workflow context."),
|
||||
_permission(WRITE_SCOPE, "Manage forms runtime workspace", "Create and update forms runtime records and workflow state."),
|
||||
_permission(ADMIN_SCOPE, "Administer forms runtime workspace", "Configure forms runtime policies, templates, and tenant-level administration."),
|
||||
_permission(
|
||||
PARTICIPATE_SCOPE,
|
||||
"Complete assigned forms",
|
||||
"Start, read, save, and submit the acting account's own Form instances.",
|
||||
),
|
||||
_permission(
|
||||
READ_SCOPE,
|
||||
"View form submissions",
|
||||
"Read tenant Form instances, immutable history, and status evidence.",
|
||||
),
|
||||
_permission(
|
||||
WRITE_SCOPE,
|
||||
"Manage form submissions",
|
||||
"Review, transition, and hand off tenant Form submissions.",
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE,
|
||||
"Administer Forms Runtime",
|
||||
"Administer Forms Runtime policy, recovery, and retirement.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="forms_runtime_participant",
|
||||
name="Forms participant",
|
||||
description="Complete the acting account's own Forms.",
|
||||
permissions=(PARTICIPATE_SCOPE,),
|
||||
default_authenticated=True,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="forms_runtime_manager",
|
||||
name="Forms Runtime manager",
|
||||
description="Manage forms runtime records and workflow state.",
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
||||
description="Review, transition, and hand off Form submissions.",
|
||||
permissions=(PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="forms_runtime_viewer",
|
||||
name="Forms Runtime viewer",
|
||||
description="Read forms runtime records and workflow context.",
|
||||
description="Read Form submissions and their immutable history.",
|
||||
permissions=(READ_SCOPE,),
|
||||
),
|
||||
)
|
||||
|
||||
DOCUMENTATION = (
|
||||
DocumentationTopic(
|
||||
id=f"{MODULE_ID}.module-boundary",
|
||||
title=f"{MODULE_NAME} module boundary",
|
||||
summary="Runtime form submissions for validation, drafts, attachments, signatures, status tracking, and handoff to domain modules.",
|
||||
body=(
|
||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
||||
"database models, migrations, and WebUI routes are introduced."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin",),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
order=100,
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Repository domain boundary",
|
||||
href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"seed": True,
|
||||
"domain_objects": ['form submissions', 'draft state', 'runtime validation results', 'attachment references', 'signature state', 'handoff status'],
|
||||
"first_slice": "Define submission, draft, validation, attachment, signature, status, and handoff contracts around existing form definitions.",
|
||||
},
|
||||
),
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
from govoplan_forms_runtime.backend.router import create_router
|
||||
|
||||
return create_router(context.registry)
|
||||
|
||||
|
||||
def _registry(context: ModuleContext) -> FormRuntimeService:
|
||||
return FormRuntimeService(context.registry)
|
||||
|
||||
|
||||
def _service_launcher(context: ModuleContext) -> FormsServiceLauncher:
|
||||
return FormsServiceLauncher(context.registry)
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
current = session.query(runtime_models.FormInstanceRevision).filter(
|
||||
runtime_models.FormInstanceRevision.tenant_id == tenant_id,
|
||||
runtime_models.FormInstanceRevision.superseded_at.is_(None),
|
||||
)
|
||||
return {
|
||||
"form_instances": current.count(),
|
||||
"open_form_instances": current.filter(
|
||||
runtime_models.FormInstanceRevision.status.in_(
|
||||
("started", "draft", "submitted", "validated", "needs_review")
|
||||
)
|
||||
).count(),
|
||||
}
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("access",),
|
||||
dependencies=("access", "forms"),
|
||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_FORM_DEFINITIONS,
|
||||
),
|
||||
optional_capabilities=(CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
documentation=DOCUMENTATION,
|
||||
route_factory=_router,
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/forms-runtime",
|
||||
label="Forms",
|
||||
icon="form",
|
||||
required_any=(PARTICIPATE_SCOPE, READ_SCOPE),
|
||||
order=37,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/forms-runtime-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/forms-runtime",
|
||||
component="FormsRuntimePage",
|
||||
required_any=(PARTICIPATE_SCOPE, READ_SCOPE),
|
||||
order=37,
|
||||
),
|
||||
FrontendRoute(
|
||||
path="/forms-runtime/:instanceId",
|
||||
component="FormInstancePage",
|
||||
required_any=(PARTICIPATE_SCOPE, READ_SCOPE),
|
||||
order=38,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/forms-runtime",
|
||||
label="Forms",
|
||||
icon="form",
|
||||
required_any=(PARTICIPATE_SCOPE, READ_SCOPE),
|
||||
order=37,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="forms_runtime.navigation",
|
||||
module_id=MODULE_ID,
|
||||
kind="navigation",
|
||||
label="Forms navigation",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="forms_runtime.workspace",
|
||||
module_id=MODULE_ID,
|
||||
kind="route",
|
||||
label="Forms workspace",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
id="forms_runtime.instance",
|
||||
module_id=MODULE_ID,
|
||||
kind="route",
|
||||
label="Form instance",
|
||||
order=30,
|
||||
),
|
||||
),
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="forms_runtime.registry", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="forms_runtime.service_launcher", version="0.1.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
name="forms.definitions",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_FORMS_RUNTIME_REGISTRY: _registry,
|
||||
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: _service_launcher,
|
||||
},
|
||||
capability_documentation={
|
||||
CAPABILITY_FORMS_RUNTIME_REGISTRY: CapabilityDocumentation(
|
||||
label="Forms Runtime registry",
|
||||
summary="Manages tenant-bound, revisioned Form instances and handoff evidence.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: CapabilityDocumentation(
|
||||
label="Form-bound Service launcher",
|
||||
summary="Starts a replay-safe Form instance from an exact published Service and Form revision.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
migration_after=("forms",),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
runtime_models.FormInstanceEvent,
|
||||
runtime_models.FormInstanceRevision,
|
||||
runtime_models.FormInstanceIdentity,
|
||||
label=MODULE_NAME,
|
||||
),
|
||||
retirement_notes="Destructive retirement removes submissions and immutable status evidence and requires a verified database and referenced-evidence recovery plan.",
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
runtime_models.FormInstanceIdentity,
|
||||
runtime_models.FormInstanceRevision,
|
||||
runtime_models.FormInstanceEvent,
|
||||
label=MODULE_NAME,
|
||||
),
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="forms_runtime.submissions",
|
||||
title="Complete and manage Forms",
|
||||
summary="Save permitted drafts, submit validated values, and retain exact definition and handoff evidence.",
|
||||
body=(
|
||||
"Every instance resolves one immutable published Form revision. Draft and final values are validated on the server; final submission also enforces attachment, signature, and policy requirements. "
|
||||
"Service launches retain the exact Service and binding. History, receipts, and handoffs are append-only, replay-safe, and optimistic-concurrency guarded."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "product_owner"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Forms Runtime security and recovery",
|
||||
href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="human_work_procedure",
|
||||
kind="runtime",
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
|
||||
test_ref="tests/test_forms_runtime.py",
|
||||
known_limits=(
|
||||
"Anonymous public intake, concrete Files/signature adapters, and automatic target handoff execution remain adapter depth; authenticated Portal entry is supported.",
|
||||
),
|
||||
supported_authority_modes=("native_authoritative",),
|
||||
owned_concepts=(
|
||||
"form instance",
|
||||
"form submission",
|
||||
"runtime validation",
|
||||
"submission receipt",
|
||||
"form handoff evidence",
|
||||
),
|
||||
non_owned_concepts=(
|
||||
"form definition",
|
||||
"file content",
|
||||
"case",
|
||||
"workflow definition",
|
||||
"signature key custody",
|
||||
),
|
||||
reference_packages=("product.service-to-decision",),
|
||||
migration_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
|
||||
recovery_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
|
||||
security_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
|
||||
operations_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
from govoplan_forms_runtime.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest
|
||||
from govoplan_forms_runtime.backend.manifest import (
|
||||
ADMIN_SCOPE,
|
||||
PARTICIPATE_SCOPE,
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
get_manifest,
|
||||
)
|
||||
|
||||
|
||||
class ManifestSeedTests(unittest.TestCase):
|
||||
def test_manifest_registers_seed_contract(self) -> None:
|
||||
class ManifestTests(unittest.TestCase):
|
||||
def test_manifest_registers_definition_aware_runtime(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
self.assertEqual(manifest.id, "forms_runtime")
|
||||
self.assertEqual(manifest.name, "Forms Runtime")
|
||||
self.assertEqual(manifest.dependencies, ("access",))
|
||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
||||
self.assertEqual({role.slug for role in manifest.role_templates}, {"forms_runtime_manager", "forms_runtime_viewer"})
|
||||
self.assertTrue(manifest.documentation)
|
||||
self.assertIsNone(manifest.route_factory)
|
||||
self.assertIsNone(manifest.migration_spec)
|
||||
self.assertIsNone(manifest.frontend)
|
||||
self.assertEqual(manifest.dependencies, ("access", "forms"))
|
||||
self.assertEqual(
|
||||
{permission.scope for permission in manifest.permissions},
|
||||
{PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
|
||||
)
|
||||
participant = next(
|
||||
item
|
||||
for item in manifest.role_templates
|
||||
if item.slug == "forms_runtime_participant"
|
||||
)
|
||||
self.assertTrue(participant.default_authenticated)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertIsNotNone(manifest.frontend)
|
||||
self.assertIn(
|
||||
"forms_runtime.service_launcher",
|
||||
manifest.capability_factories,
|
||||
)
|
||||
self.assertEqual(
|
||||
"@govoplan/forms-runtime-webui",
|
||||
manifest.frontend.package_name if manifest.frontend else None,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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