Add governed form handoffs
This commit is contained in:
@@ -21,6 +21,7 @@ from govoplan_core.core.events import (
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_FORM_DEFINITIONS,
|
||||
EvidenceReference,
|
||||
FormConditionExpression,
|
||||
FormDefinition,
|
||||
FormDefinitionProvider,
|
||||
FormFieldDefinition,
|
||||
@@ -107,14 +108,17 @@ class FormRuntimeService:
|
||||
effective_at=recorded_at,
|
||||
)
|
||||
supplied_values = _mapping_copy(values, "Form values")
|
||||
clean_values = {
|
||||
**{
|
||||
field.key: field.default_value
|
||||
for field in definition.fields
|
||||
if field.default_value is not None
|
||||
clean_values = normalize_form_values(
|
||||
definition,
|
||||
{
|
||||
**{
|
||||
field.key: field.default_value
|
||||
for field in definition.fields
|
||||
if field.default_value is not None
|
||||
},
|
||||
**supplied_values,
|
||||
},
|
||||
**supplied_values,
|
||||
}
|
||||
)
|
||||
attachments = tuple(attachment_refs)
|
||||
signatures = tuple(signature_refs)
|
||||
diagnostics = validate_form_values(
|
||||
@@ -402,6 +406,10 @@ class FormRuntimeService:
|
||||
)
|
||||
if target_ref.tenant_id != current.tenant_id:
|
||||
raise FormRuntimeError("Form handoff cannot cross tenants.")
|
||||
if target_ref.kind in {"case", "workflow"}:
|
||||
raise FormRuntimeError(
|
||||
"Case and Workflow handoffs must use the governed native handoff endpoint."
|
||||
)
|
||||
if target_ref.kind not in definition.handoff_kinds:
|
||||
raise FormRuntimeError(
|
||||
f"Form definition does not permit a {target_ref.kind!r} handoff."
|
||||
@@ -413,9 +421,7 @@ class FormRuntimeService:
|
||||
action="handoff",
|
||||
instance=current,
|
||||
)
|
||||
handoffs = tuple(
|
||||
dict.fromkeys((*current.handoff_refs, target_ref))
|
||||
)
|
||||
handoffs = tuple(dict.fromkeys((*current.handoff_refs, target_ref)))
|
||||
return self._revise(
|
||||
session,
|
||||
principal,
|
||||
@@ -528,7 +534,9 @@ class FormRuntimeService:
|
||||
FormInstanceIdentity.created_by == _principal_actor(principal)
|
||||
)
|
||||
if statuses:
|
||||
statement = statement.filter(FormInstanceRevision.status.in_(tuple(statuses)))
|
||||
statement = statement.filter(
|
||||
FormInstanceRevision.status.in_(tuple(statuses))
|
||||
)
|
||||
if definition_id:
|
||||
statement = statement.filter(
|
||||
FormInstanceIdentity.definition_id == definition_id
|
||||
@@ -635,7 +643,10 @@ class FormRuntimeService:
|
||||
) -> FormInstance:
|
||||
_require_aware(recorded_at, "Form instance recorded_at")
|
||||
clean_reason = _text(change_reason, "Form instance change reason", 1000)
|
||||
clean_values = _mapping_copy(values, "Form values")
|
||||
clean_values = normalize_form_values(
|
||||
definition,
|
||||
_mapping_copy(values, "Form values"),
|
||||
)
|
||||
attachments = tuple(attachment_refs)
|
||||
signatures = tuple(signature_refs)
|
||||
handoffs = tuple(handoff_refs)
|
||||
@@ -753,7 +764,9 @@ class FormRuntimeService:
|
||||
effective_at=effective_at,
|
||||
)
|
||||
if definition is None:
|
||||
raise FormRuntimeError("The exact Form definition was not found or effective.")
|
||||
raise FormRuntimeError(
|
||||
"The exact Form definition was not found or effective."
|
||||
)
|
||||
if not _same_exact_form_reference(definition.reference, reference):
|
||||
raise FormRuntimeError(
|
||||
"The Forms provider returned a different definition or revision."
|
||||
@@ -818,9 +831,7 @@ class FormsServiceLauncher:
|
||||
raise FormRuntimeError(
|
||||
"Form Service launch requires the exact published Service and binding."
|
||||
)
|
||||
form_id, form_revision = parse_form_binding_reference(
|
||||
request.binding.reference
|
||||
)
|
||||
form_id, form_revision = parse_form_binding_reference(request.binding.reference)
|
||||
definition_ref = InstitutionalReference(
|
||||
kind="form",
|
||||
owner_module="forms",
|
||||
@@ -883,9 +894,17 @@ def validate_form_values(
|
||||
unknown = sorted(set(values) - set(fields))
|
||||
for key in unknown:
|
||||
diagnostics.append(
|
||||
_diagnostic(key, "error", "field.unknown", "This field is not part of the exact Form revision.")
|
||||
_diagnostic(
|
||||
key,
|
||||
"error",
|
||||
"field.unknown",
|
||||
"This field is not part of the exact Form revision.",
|
||||
)
|
||||
)
|
||||
visible_fields = visible_form_field_keys(definition, values)
|
||||
for field in definition.fields:
|
||||
if field.key not in visible_fields:
|
||||
continue
|
||||
present = field.key in values and values[field.key] not in (None, "")
|
||||
if field.required and not present:
|
||||
diagnostics.append(
|
||||
@@ -923,6 +942,117 @@ def validate_form_values(
|
||||
return tuple(diagnostics)
|
||||
|
||||
|
||||
def normalize_form_values(
|
||||
definition: FormDefinition,
|
||||
values: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Drop declared fields hidden by authoritative conditions.
|
||||
|
||||
Unknown fields remain in the payload so regular validation reports them
|
||||
instead of silently accepting misspelled or retired keys.
|
||||
"""
|
||||
|
||||
result = _mapping_copy(values, "Form values")
|
||||
declared = {item.key for item in definition.fields}
|
||||
for _ in range(len(definition.fields) + 1):
|
||||
visible = visible_form_field_keys(definition, result)
|
||||
next_result = {
|
||||
key: value
|
||||
for key, value in result.items()
|
||||
if key not in declared or key in visible
|
||||
}
|
||||
if next_result == result:
|
||||
return next_result
|
||||
result = next_result
|
||||
raise FormRuntimeError(
|
||||
"Form visibility conditions did not converge; verify the published definition."
|
||||
)
|
||||
|
||||
|
||||
def visible_form_field_keys(
|
||||
definition: FormDefinition,
|
||||
values: Mapping[str, object],
|
||||
) -> frozenset[str]:
|
||||
visible = {
|
||||
item.key
|
||||
for item in definition.fields
|
||||
if item.visibility_condition is None
|
||||
or evaluate_form_condition(item.visibility_condition, values)
|
||||
}
|
||||
if not definition.pages:
|
||||
return frozenset(visible)
|
||||
placed_visible: set[str] = set()
|
||||
for page in definition.pages:
|
||||
if page.visibility_condition is not None and not evaluate_form_condition(
|
||||
page.visibility_condition,
|
||||
values,
|
||||
):
|
||||
continue
|
||||
for section in page.sections:
|
||||
if section.visibility_condition is not None and not evaluate_form_condition(
|
||||
section.visibility_condition, values
|
||||
):
|
||||
continue
|
||||
placed_visible.update(section.field_keys)
|
||||
return frozenset(visible & placed_visible)
|
||||
|
||||
|
||||
def evaluate_form_condition(
|
||||
condition: FormConditionExpression,
|
||||
values: Mapping[str, object],
|
||||
) -> bool:
|
||||
if condition.kind == "all":
|
||||
return all(
|
||||
evaluate_form_condition(item, values) for item in condition.conditions
|
||||
)
|
||||
if condition.kind == "any":
|
||||
return any(
|
||||
evaluate_form_condition(item, values) for item in condition.conditions
|
||||
)
|
||||
if condition.kind == "not":
|
||||
return not evaluate_form_condition(condition.conditions[0], values)
|
||||
actual = values.get(str(condition.field_key))
|
||||
expected = condition.value
|
||||
operator = condition.operator
|
||||
if operator == "eq":
|
||||
return actual == expected
|
||||
if operator == "neq":
|
||||
return actual != expected
|
||||
if operator == "is_empty":
|
||||
return _empty_value(actual)
|
||||
if operator == "is_not_empty":
|
||||
return not _empty_value(actual)
|
||||
if operator == "in":
|
||||
return actual in expected # type: ignore[operator]
|
||||
if operator == "not_in":
|
||||
return actual not in expected # type: ignore[operator]
|
||||
if operator == "contains":
|
||||
try:
|
||||
return expected in actual # type: ignore[operator]
|
||||
except TypeError:
|
||||
return False
|
||||
try:
|
||||
if operator == "lt":
|
||||
return actual < expected # type: ignore[operator]
|
||||
if operator == "lte":
|
||||
return actual <= expected # type: ignore[operator]
|
||||
if operator == "gt":
|
||||
return actual > expected # type: ignore[operator]
|
||||
if operator == "gte":
|
||||
return actual >= expected # type: ignore[operator]
|
||||
except TypeError:
|
||||
return False
|
||||
raise FormRuntimeError(f"Unsupported Form condition operator: {operator!r}.")
|
||||
|
||||
|
||||
def _empty_value(value: object) -> bool:
|
||||
if value is None or value == "":
|
||||
return True
|
||||
if isinstance(value, (Mapping, Sequence)) and not isinstance(value, (str, bytes)):
|
||||
return len(value) == 0
|
||||
return False
|
||||
|
||||
|
||||
def _validate_field_value(
|
||||
field: FormFieldDefinition,
|
||||
value: object,
|
||||
@@ -956,17 +1086,30 @@ def _validate_field_value(
|
||||
diagnostics: list[Mapping[str, object]] = []
|
||||
if field.value_type == "email" and not _EMAIL_RE.fullmatch(str(value)):
|
||||
diagnostics.append(
|
||||
_diagnostic(field.key, "error", "field.email", "Enter a valid email address.")
|
||||
_diagnostic(
|
||||
field.key, "error", "field.email", "Enter a valid email address."
|
||||
)
|
||||
)
|
||||
if field.value_type == "choice" and value not in field.options:
|
||||
diagnostics.append(
|
||||
_diagnostic(field.key, "error", "field.option", "Select one of the declared options.")
|
||||
_diagnostic(
|
||||
field.key,
|
||||
"error",
|
||||
"field.option",
|
||||
"Select one of the declared options.",
|
||||
)
|
||||
)
|
||||
if field.value_type == "multi_choice" and any(
|
||||
item not in field.options for item in value # type: ignore[union-attr]
|
||||
item not in field.options
|
||||
for item in value # type: ignore[union-attr]
|
||||
):
|
||||
diagnostics.append(
|
||||
_diagnostic(field.key, "error", "field.option", "Every selected value must be a declared option.")
|
||||
_diagnostic(
|
||||
field.key,
|
||||
"error",
|
||||
"field.option",
|
||||
"Every selected value must be a declared option.",
|
||||
)
|
||||
)
|
||||
constraints = field.constraints
|
||||
if isinstance(value, str):
|
||||
@@ -974,9 +1117,23 @@ def _validate_field_value(
|
||||
maximum = constraints.get("max_length")
|
||||
pattern = constraints.get("pattern")
|
||||
if isinstance(minimum, int) and len(value) < minimum:
|
||||
diagnostics.append(_diagnostic(field.key, "error", "field.min_length", f"Enter at least {minimum} characters."))
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
field.key,
|
||||
"error",
|
||||
"field.min_length",
|
||||
f"Enter at least {minimum} characters.",
|
||||
)
|
||||
)
|
||||
if isinstance(maximum, int) and len(value) > maximum:
|
||||
diagnostics.append(_diagnostic(field.key, "error", "field.max_length", f"Enter at most {maximum} characters."))
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
field.key,
|
||||
"error",
|
||||
"field.max_length",
|
||||
f"Enter at most {maximum} characters.",
|
||||
)
|
||||
)
|
||||
if isinstance(pattern, str):
|
||||
try:
|
||||
matches = re.fullmatch(pattern, value) is not None
|
||||
@@ -985,14 +1142,35 @@ def _validate_field_value(
|
||||
f"Form definition field {field.key!r} has an invalid pattern."
|
||||
) from exc
|
||||
if not matches:
|
||||
diagnostics.append(_diagnostic(field.key, "error", "field.pattern", "The value does not match the required format."))
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
field.key,
|
||||
"error",
|
||||
"field.pattern",
|
||||
"The value does not match the required format.",
|
||||
)
|
||||
)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
minimum = constraints.get("minimum")
|
||||
maximum = constraints.get("maximum")
|
||||
if isinstance(minimum, (int, float)) and value < minimum:
|
||||
diagnostics.append(_diagnostic(field.key, "error", "field.minimum", f"Enter a value of at least {minimum}."))
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
field.key,
|
||||
"error",
|
||||
"field.minimum",
|
||||
f"Enter a value of at least {minimum}.",
|
||||
)
|
||||
)
|
||||
if isinstance(maximum, (int, float)) and value > maximum:
|
||||
diagnostics.append(_diagnostic(field.key, "error", "field.maximum", f"Enter a value no greater than {maximum}."))
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
field.key,
|
||||
"error",
|
||||
"field.maximum",
|
||||
f"Enter a value no greater than {maximum}.",
|
||||
)
|
||||
)
|
||||
return tuple(diagnostics)
|
||||
|
||||
|
||||
@@ -1332,8 +1510,10 @@ def _required_mapping(value: Mapping[str, object], key: str) -> Mapping[str, obj
|
||||
|
||||
def _identifier(value: str, label: str) -> str:
|
||||
clean = str(value or "").strip()
|
||||
if not clean or len(clean) > 255 or not re.fullmatch(
|
||||
r"[A-Za-z0-9][A-Za-z0-9_.:/-]*", clean
|
||||
if (
|
||||
not clean
|
||||
or len(clean) > 255
|
||||
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:/-]*", clean)
|
||||
):
|
||||
raise FormRuntimeError(f"{label} is invalid.")
|
||||
return clean
|
||||
@@ -1342,7 +1522,9 @@ def _identifier(value: str, label: str) -> str:
|
||||
def _text(value: str, label: str, maximum: int) -> str:
|
||||
clean = str(value or "").strip()
|
||||
if not clean or len(clean) > maximum:
|
||||
raise FormRuntimeError(f"{label} is required and limited to {maximum} characters.")
|
||||
raise FormRuntimeError(
|
||||
f"{label} is required and limited to {maximum} characters."
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
@@ -1381,6 +1563,9 @@ __all__ = [
|
||||
"FormRuntimePolicyEvaluator",
|
||||
"FormRuntimeService",
|
||||
"FormsServiceLauncher",
|
||||
"evaluate_form_condition",
|
||||
"normalize_form_values",
|
||||
"parse_form_binding_reference",
|
||||
"validate_form_values",
|
||||
"visible_form_field_keys",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user