Add governed form handoffs
This commit is contained in:
+283
-13
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
@@ -9,17 +9,24 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_FORM_DEFINITIONS,
|
||||
CAPABILITY_SERVICE_DEFINITIONS,
|
||||
FormConditionExpression,
|
||||
FormDefinition,
|
||||
FormFieldDefinition,
|
||||
InstitutionalReference,
|
||||
ServiceBinding,
|
||||
ServiceDefinition,
|
||||
ServiceLaunchRequest,
|
||||
ServiceLaunchResult,
|
||||
TemporalRevision,
|
||||
)
|
||||
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||
from govoplan_forms.backend.service import SqlFormDefinitionProvider, record_form_definition
|
||||
from govoplan_forms.backend.service import (
|
||||
SqlFormDefinitionProvider,
|
||||
record_form_definition,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.db.models import (
|
||||
FormHandoffEffect,
|
||||
FormInstanceEvent,
|
||||
FormInstanceIdentity,
|
||||
FormInstanceRevision,
|
||||
@@ -29,6 +36,7 @@ from govoplan_forms_runtime.backend.service import (
|
||||
FormRuntimeService,
|
||||
FormsServiceLauncher,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
|
||||
@@ -41,16 +49,17 @@ class Principal:
|
||||
|
||||
|
||||
class Registry:
|
||||
def __init__(self, provider: object) -> None:
|
||||
self.provider = provider
|
||||
def __init__(self, provider: object, **capabilities: object) -> None:
|
||||
self.capabilities = {
|
||||
CAPABILITY_FORM_DEFINITIONS: provider,
|
||||
**capabilities,
|
||||
}
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == CAPABILITY_FORM_DEFINITIONS
|
||||
return name in self.capabilities
|
||||
|
||||
def require_capability(self, name: str) -> object:
|
||||
if name != CAPABILITY_FORM_DEFINITIONS:
|
||||
raise KeyError(name)
|
||||
return self.provider
|
||||
return self.capabilities[name]
|
||||
|
||||
|
||||
def form_definition(
|
||||
@@ -71,9 +80,7 @@ def form_definition(
|
||||
temporal=TemporalRevision(
|
||||
revision=revision,
|
||||
recorded_at=NOW + timedelta(minutes=int(revision) - 2),
|
||||
change_reason=(
|
||||
"Initial schema." if revision == "1" else "Revise schema."
|
||||
),
|
||||
change_reason=("Initial schema." if revision == "1" else "Revise schema."),
|
||||
),
|
||||
title="Permit form",
|
||||
fields=(
|
||||
@@ -105,6 +112,7 @@ class FormsRuntimeTests(unittest.TestCase):
|
||||
FormInstanceIdentity.__table__,
|
||||
FormInstanceRevision.__table__,
|
||||
FormInstanceEvent.__table__,
|
||||
FormHandoffEffect.__table__,
|
||||
):
|
||||
table.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
@@ -316,12 +324,16 @@ class FormsRuntimeTests(unittest.TestCase):
|
||||
request=request,
|
||||
)
|
||||
|
||||
self.assertEqual("form_submission", first.target_ref.kind if first.target_ref else None)
|
||||
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:
|
||||
def test_create_replay_is_actor_bound_and_survives_schema_supersession(
|
||||
self,
|
||||
) -> None:
|
||||
first = self.runtime.create_instance(
|
||||
self.session,
|
||||
self.principal,
|
||||
@@ -413,6 +425,264 @@ class FormsRuntimeTests(unittest.TestCase):
|
||||
instance_id="fixed-instance",
|
||||
)
|
||||
|
||||
def test_hidden_required_fields_are_not_required_or_persisted(self) -> None:
|
||||
conditional = replace(
|
||||
form_definition(form_id="conditional-form"),
|
||||
fields=(
|
||||
FormFieldDefinition(
|
||||
key="include_details",
|
||||
label="Include details",
|
||||
value_type="boolean",
|
||||
),
|
||||
FormFieldDefinition(
|
||||
key="details",
|
||||
label="Details",
|
||||
required=True,
|
||||
visibility_condition=FormConditionExpression(
|
||||
kind="predicate",
|
||||
field_key="include_details",
|
||||
operator="eq",
|
||||
value=True,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
stored = record_form_definition(
|
||||
self.session,
|
||||
self.principal,
|
||||
definition=conditional,
|
||||
)
|
||||
hidden = self.runtime.create_instance(
|
||||
self.session,
|
||||
self.principal,
|
||||
definition_ref=stored.reference,
|
||||
values={"include_details": False, "details": "stale secret"},
|
||||
idempotency_key="conditional-hidden",
|
||||
recorded_at=NOW,
|
||||
)
|
||||
self.assertNotIn("details", hidden.values)
|
||||
submitted = self.runtime.submit_instance(
|
||||
self.session,
|
||||
self.principal,
|
||||
instance_id=hidden.instance_id,
|
||||
expected_revision=1,
|
||||
values=hidden.values,
|
||||
attachment_refs=(),
|
||||
signature_refs=(),
|
||||
idempotency_key="conditional-submit",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
)
|
||||
self.assertEqual("submitted", submitted.status)
|
||||
|
||||
shown = self.runtime.create_instance(
|
||||
self.session,
|
||||
self.principal,
|
||||
definition_ref=stored.reference,
|
||||
values={"include_details": True},
|
||||
idempotency_key="conditional-shown",
|
||||
recorded_at=NOW,
|
||||
)
|
||||
with self.assertRaisesRegex(FormRuntimeError, "required"):
|
||||
self.runtime.submit_instance(
|
||||
self.session,
|
||||
self.principal,
|
||||
instance_id=shown.instance_id,
|
||||
expected_revision=1,
|
||||
values=shown.values,
|
||||
attachment_refs=(),
|
||||
signature_refs=(),
|
||||
idempotency_key="conditional-shown-submit",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
)
|
||||
|
||||
def test_native_handoff_is_durable_replay_safe_and_reconciles_unknown(self) -> None:
|
||||
form_binding = ServiceBinding(kind="form", reference="permit-form/1")
|
||||
case_binding = ServiceBinding(kind="case", reference="permit-case")
|
||||
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 intake targets.",
|
||||
),
|
||||
title="Apply for permit",
|
||||
audience=("resident",),
|
||||
bindings=(form_binding, case_binding),
|
||||
publication_state="published",
|
||||
)
|
||||
|
||||
class Services:
|
||||
def get_service_definition(
|
||||
self, session, principal, *, reference, effective_at=None
|
||||
):
|
||||
return service if reference == service.reference else None
|
||||
|
||||
def list_service_definitions(
|
||||
self, session, principal, *, tenant_id, query="", limit=100
|
||||
):
|
||||
return (service,)
|
||||
|
||||
class Cases:
|
||||
fail_unknown = False
|
||||
|
||||
def launch_service(self, session, principal, *, definition, request):
|
||||
if self.fail_unknown:
|
||||
raise ConnectionError("Provider response was lost.")
|
||||
return ServiceLaunchResult(
|
||||
service_ref=definition.reference,
|
||||
binding=request.binding,
|
||||
state="started",
|
||||
target_ref=InstitutionalReference(
|
||||
kind="case",
|
||||
owner_module="cases",
|
||||
object_id="case-1",
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
),
|
||||
href="/cases/case-1",
|
||||
replayed=request.idempotency_key.startswith("known"),
|
||||
metadata={"case_number": "CASE-1"},
|
||||
)
|
||||
|
||||
cases = Cases()
|
||||
registry = Registry(
|
||||
SqlFormDefinitionProvider(),
|
||||
**{
|
||||
CAPABILITY_SERVICE_DEFINITIONS: Services(),
|
||||
"cases.service_launcher": cases,
|
||||
},
|
||||
)
|
||||
runtime = FormRuntimeService(registry)
|
||||
draft = runtime.create_instance(
|
||||
self.session,
|
||||
self.principal,
|
||||
definition_ref=self.definition.reference,
|
||||
values={"name": "Ada"},
|
||||
idempotency_key="native-create",
|
||||
recorded_at=NOW,
|
||||
service_ref=service.reference,
|
||||
service_binding=form_binding,
|
||||
)
|
||||
submitted = runtime.submit_instance(
|
||||
self.session,
|
||||
self.principal,
|
||||
instance_id=draft.instance_id,
|
||||
expected_revision=1,
|
||||
values=draft.values,
|
||||
attachment_refs=(),
|
||||
signature_refs=(),
|
||||
idempotency_key="native-submit",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
)
|
||||
handoffs = FormHandoffService(registry)
|
||||
prepared = handoffs.prepare(
|
||||
self.session,
|
||||
self.principal,
|
||||
instance_id=submitted.instance_id,
|
||||
expected_revision=2,
|
||||
binding_kind="case",
|
||||
binding_reference="permit-case",
|
||||
idempotency_key="native-handoff",
|
||||
requested_at=NOW + timedelta(minutes=2),
|
||||
allow_all=True,
|
||||
)
|
||||
self.session.commit()
|
||||
self.assertEqual("requested", prepared.state)
|
||||
self.assertEqual(
|
||||
"requested",
|
||||
self.session.query(FormHandoffEffect).one().state,
|
||||
)
|
||||
accepted, revised = handoffs.execute(
|
||||
self.session,
|
||||
self.principal,
|
||||
effect_id=prepared.effect_id,
|
||||
executed_at=NOW + timedelta(minutes=2),
|
||||
allow_all=True,
|
||||
)
|
||||
self.assertEqual("accepted", accepted.state)
|
||||
self.assertEqual("handed_off", revised.status)
|
||||
replay = handoffs.prepare(
|
||||
self.session,
|
||||
self.principal,
|
||||
instance_id=submitted.instance_id,
|
||||
expected_revision=2,
|
||||
binding_kind="case",
|
||||
binding_reference="permit-case",
|
||||
idempotency_key="native-handoff",
|
||||
requested_at=NOW + timedelta(minutes=2),
|
||||
allow_all=True,
|
||||
)
|
||||
self.assertEqual("accepted", replay.state)
|
||||
|
||||
second = runtime.create_instance(
|
||||
self.session,
|
||||
self.principal,
|
||||
definition_ref=self.definition.reference,
|
||||
values={"name": "Grace"},
|
||||
idempotency_key="unknown-create",
|
||||
recorded_at=NOW,
|
||||
service_ref=service.reference,
|
||||
service_binding=form_binding,
|
||||
)
|
||||
second = runtime.submit_instance(
|
||||
self.session,
|
||||
self.principal,
|
||||
instance_id=second.instance_id,
|
||||
expected_revision=1,
|
||||
values=second.values,
|
||||
attachment_refs=(),
|
||||
signature_refs=(),
|
||||
idempotency_key="unknown-submit",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
)
|
||||
unknown = handoffs.prepare(
|
||||
self.session,
|
||||
self.principal,
|
||||
instance_id=second.instance_id,
|
||||
expected_revision=2,
|
||||
binding_kind="case",
|
||||
binding_reference=None,
|
||||
idempotency_key="unknown-handoff",
|
||||
requested_at=NOW + timedelta(minutes=2),
|
||||
allow_all=True,
|
||||
)
|
||||
self.session.commit()
|
||||
cases.fail_unknown = True
|
||||
unknown, _ = handoffs.execute(
|
||||
self.session,
|
||||
self.principal,
|
||||
effect_id=unknown.effect_id,
|
||||
executed_at=NOW + timedelta(minutes=2),
|
||||
allow_all=True,
|
||||
)
|
||||
self.assertEqual("outcome_unknown", unknown.state)
|
||||
with self.assertRaisesRegex(FormRuntimeError, "must be reconciled"):
|
||||
handoffs.execute(
|
||||
self.session,
|
||||
self.principal,
|
||||
effect_id=unknown.effect_id,
|
||||
executed_at=NOW + timedelta(minutes=3),
|
||||
allow_all=True,
|
||||
)
|
||||
cases.fail_unknown = False
|
||||
reconciled, revised = handoffs.execute(
|
||||
self.session,
|
||||
self.principal,
|
||||
effect_id=unknown.effect_id,
|
||||
executed_at=NOW + timedelta(minutes=4),
|
||||
allow_all=True,
|
||||
reconcile=True,
|
||||
)
|
||||
self.assertEqual("reconciled", reconciled.state)
|
||||
self.assertEqual("handed_off", revised.status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user