feat: add governed assisted form intake
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from govoplan_core.core.institutional import EvidenceReference
|
||||
from govoplan_forms_runtime.backend.domain import FormInstance
|
||||
|
||||
|
||||
ASSISTED_CHANNELS = frozenset(
|
||||
{
|
||||
"counter",
|
||||
"telephone",
|
||||
"paper",
|
||||
"email",
|
||||
"mobile",
|
||||
"representative",
|
||||
"offline_import",
|
||||
}
|
||||
)
|
||||
ASSISTED_CONFIRMATION_METHODS = frozenset(
|
||||
{"spoken_readback", "written_preview", "accessible_copy", "unavailable"}
|
||||
)
|
||||
ASSISTED_CONFIRMATION_OUTCOMES = frozenset(
|
||||
{"confirmed", "corrected", "confirmation_unavailable"}
|
||||
)
|
||||
ASSISTED_SOURCE_KINDS = frozenset(
|
||||
{"person_statement", "representative_statement", "document", "system", "derived"}
|
||||
)
|
||||
ASSISTED_CONFIDENCE_LEVELS = frozenset({"stated", "verified", "uncertain"})
|
||||
|
||||
|
||||
def assisted_submission_payload_sha256(
|
||||
instance: FormInstance,
|
||||
*,
|
||||
values: Mapping[str, object],
|
||||
attachment_refs: Sequence[EvidenceReference],
|
||||
signature_refs: Sequence[EvidenceReference],
|
||||
) -> str:
|
||||
return _hash(
|
||||
{
|
||||
"tenant_id": instance.tenant_id,
|
||||
"instance_id": instance.instance_id,
|
||||
"instance_revision": instance.revision,
|
||||
"definition_ref": instance.definition_ref.to_dict(),
|
||||
"values": dict(values),
|
||||
"attachment_refs": [item.to_dict() for item in attachment_refs],
|
||||
"signature_refs": [item.to_dict() for item in signature_refs],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def confirmation_payload(value: object) -> dict[str, object]:
|
||||
return {
|
||||
"confirmation_id": str(getattr(value, "confirmation_id")),
|
||||
"instance_id": str(getattr(value, "instance_id")),
|
||||
"instance_revision": int(getattr(value, "instance_revision")),
|
||||
"outcome": str(getattr(value, "outcome")),
|
||||
"method": str(getattr(value, "method")),
|
||||
"confirmed_by_ref": str(getattr(value, "confirmed_by_ref")),
|
||||
"operator_actor_id": str(getattr(value, "operator_actor_id")),
|
||||
"confirmed_at": getattr(value, "confirmed_at").isoformat(),
|
||||
"payload_sha256": str(getattr(value, "payload_sha256")),
|
||||
"correction_note": getattr(value, "correction_note"),
|
||||
"metadata": dict(getattr(value, "details")),
|
||||
}
|
||||
|
||||
|
||||
def _hash(value: Mapping[str, object]) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ASSISTED_CHANNELS",
|
||||
"ASSISTED_CONFIDENCE_LEVELS",
|
||||
"ASSISTED_CONFIRMATION_METHODS",
|
||||
"ASSISTED_CONFIRMATION_OUTCOMES",
|
||||
"ASSISTED_SOURCE_KINDS",
|
||||
"assisted_submission_payload_sha256",
|
||||
"confirmation_payload",
|
||||
]
|
||||
@@ -291,6 +291,57 @@ class FormIntakeSession(Base, TimestampMixin):
|
||||
)
|
||||
|
||||
|
||||
class FormAssistedConfirmation(Base, TimestampMixin):
|
||||
__tablename__ = "form_assisted_confirmations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"confirmation_id",
|
||||
name="uq_form_assisted_confirmation",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_form_assisted_confirmation_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_form_assisted_confirmation_instance",
|
||||
"tenant_id",
|
||||
"instance_id",
|
||||
"instance_revision",
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
confirmation_id: Mapped[str] = mapped_column(
|
||||
String(36), nullable=False, index=True
|
||||
)
|
||||
intake_session_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("form_intake_sessions.id", ondelete="RESTRICT"),
|
||||
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)
|
||||
outcome: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
method: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
confirmed_by_ref: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
operator_actor_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True
|
||||
)
|
||||
confirmed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
payload_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
correction_note: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
details: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata", JSON, default=dict, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class FormAcknowledgement(Base, TimestampMixin):
|
||||
__tablename__ = "form_acknowledgements"
|
||||
__table_args__ = (
|
||||
@@ -335,6 +386,7 @@ class FormAcknowledgement(Base, TimestampMixin):
|
||||
|
||||
__all__ = [
|
||||
"FormAcknowledgement",
|
||||
"FormAssistedConfirmation",
|
||||
"FormInstanceEvent",
|
||||
"FormHandoffEffect",
|
||||
"FormIntakeProfile",
|
||||
|
||||
@@ -19,15 +19,28 @@ from govoplan_core.core.institutional import (
|
||||
InstitutionalReference,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.db.models import (
|
||||
FormAssistedConfirmation,
|
||||
FormIntakeProfile,
|
||||
FormIntakeSession,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.assisted import (
|
||||
ASSISTED_CHANNELS,
|
||||
ASSISTED_CONFIDENCE_LEVELS,
|
||||
ASSISTED_CONFIRMATION_METHODS,
|
||||
ASSISTED_CONFIRMATION_OUTCOMES,
|
||||
ASSISTED_SOURCE_KINDS,
|
||||
assisted_submission_payload_sha256,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.domain import FormInstance
|
||||
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
|
||||
from govoplan_forms_runtime.backend.service import (
|
||||
FormRuntimeError,
|
||||
FormRuntimeService,
|
||||
normalize_form_values,
|
||||
)
|
||||
|
||||
|
||||
IntakeMode = Literal["anonymous", "invitation"]
|
||||
INTAKE_MODES = frozenset({"anonymous", "invitation"})
|
||||
IntakeMode = Literal["anonymous", "invitation", "assisted"]
|
||||
INTAKE_MODES = frozenset({"anonymous", "invitation", "assisted"})
|
||||
DEFAULT_DRAFT_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||
DEFAULT_INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60
|
||||
DEFAULT_RATE_LIMIT_PER_MINUTE = 60
|
||||
@@ -95,9 +108,9 @@ class FormIntakeService:
|
||||
actor_id = _principal_actor(principal)
|
||||
_require_aware(recorded_at, "Intake profile recorded_at")
|
||||
if mode not in INTAKE_MODES:
|
||||
raise FormIntakeError(f"Unsupported public intake mode: {mode!r}.")
|
||||
raise FormIntakeError(f"Unsupported Form intake mode: {mode!r}.")
|
||||
if not custodian_ref.strip():
|
||||
raise FormIntakeError("A public intake profile requires a custodian.")
|
||||
raise FormIntakeError("A Form intake profile requires a custodian.")
|
||||
if not 60 <= draft_ttl_seconds <= 365 * 24 * 60 * 60:
|
||||
raise FormIntakeError(
|
||||
"Draft expiry must be between one minute and one year."
|
||||
@@ -117,7 +130,11 @@ class FormIntakeService:
|
||||
effective_at=recorded_at,
|
||||
)
|
||||
if definition.publication_state != "published":
|
||||
raise FormIntakeError("Only a published Form can receive public intake.")
|
||||
raise FormIntakeError("Only a published Form can receive configured intake.")
|
||||
if mode == "assisted" and not definition.allow_drafts:
|
||||
raise FormIntakeError(
|
||||
"Assisted intake requires a published Form that permits resumable drafts."
|
||||
)
|
||||
profile = FormIntakeProfile(
|
||||
tenant_id=tenant_id,
|
||||
profile_id=str(uuid.uuid4()),
|
||||
@@ -136,9 +153,10 @@ class FormIntakeService:
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
details={
|
||||
**dict(metadata or {}),
|
||||
"identity_claim_allowed": False,
|
||||
"pseudonymous": False,
|
||||
**dict(metadata or {}),
|
||||
"definition_title": definition.title,
|
||||
},
|
||||
)
|
||||
session.add(profile)
|
||||
@@ -351,6 +369,346 @@ class FormIntakeService:
|
||||
session.flush()
|
||||
return self._result(session, intake_session, instance=instance)
|
||||
|
||||
def list_assisted_profiles(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
) -> tuple[FormIntakeProfile, ...]:
|
||||
return tuple(
|
||||
session.query(FormIntakeProfile)
|
||||
.filter(
|
||||
FormIntakeProfile.tenant_id == _principal_tenant(principal),
|
||||
FormIntakeProfile.mode == "assisted",
|
||||
FormIntakeProfile.enabled.is_(True),
|
||||
)
|
||||
.order_by(FormIntakeProfile.definition_id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def start_assisted(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
profile_id: str,
|
||||
values: Mapping[str, object],
|
||||
channel: str,
|
||||
affected_party_ref: str,
|
||||
represented_party_ref: str | None,
|
||||
authority_basis: str,
|
||||
purpose: str,
|
||||
legal_basis_ref: str | None,
|
||||
consent_basis: str | None,
|
||||
notice_given: bool,
|
||||
responsible_function_ref: str,
|
||||
language: str,
|
||||
accessibility_needs: Sequence[str],
|
||||
field_sources: Mapping[str, Mapping[str, object]],
|
||||
idempotency_key: str,
|
||||
recorded_at: datetime,
|
||||
) -> IntakeSessionResult:
|
||||
_require_aware(recorded_at, "Assisted intake recorded_at")
|
||||
profile = self._profile_for_admin(
|
||||
session,
|
||||
principal,
|
||||
profile_id=profile_id,
|
||||
lock=True,
|
||||
)
|
||||
if profile.mode != "assisted" or not profile.enabled:
|
||||
raise FormIntakeError("This assisted intake profile is unavailable.")
|
||||
context = _assisted_context(
|
||||
principal,
|
||||
channel=channel,
|
||||
affected_party_ref=affected_party_ref,
|
||||
represented_party_ref=represented_party_ref,
|
||||
authority_basis=authority_basis,
|
||||
purpose=purpose,
|
||||
legal_basis_ref=legal_basis_ref,
|
||||
consent_basis=consent_basis,
|
||||
notice_given=notice_given,
|
||||
responsible_function_ref=responsible_function_ref,
|
||||
language=language,
|
||||
accessibility_needs=accessibility_needs,
|
||||
field_sources=field_sources,
|
||||
)
|
||||
context_sources = context.get("field_sources")
|
||||
if not isinstance(context_sources, Mapping):
|
||||
raise FormIntakeError("Assisted intake field provenance is invalid.")
|
||||
missing_sources = sorted(
|
||||
key
|
||||
for key, value in values.items()
|
||||
if value not in (None, "")
|
||||
and key not in context_sources
|
||||
)
|
||||
if missing_sources:
|
||||
raise FormIntakeError(
|
||||
"Assisted intake requires source provenance for every supplied field: "
|
||||
+ ", ".join(missing_sources[:10])
|
||||
)
|
||||
request = {
|
||||
"operation": "assisted_start",
|
||||
"profile_id": profile.profile_id,
|
||||
"values": dict(values),
|
||||
"context": context,
|
||||
"recorded_at": recorded_at.isoformat(),
|
||||
}
|
||||
internal_key = (
|
||||
f"assisted:{profile.profile_id}:"
|
||||
f"{_clean_text(idempotency_key, 'Assisted intake idempotency key', 255)}"
|
||||
)
|
||||
request_sha256 = _hash(request)
|
||||
replay = self._session_replay(
|
||||
session,
|
||||
tenant_id=profile.tenant_id,
|
||||
idempotency_key=internal_key,
|
||||
request_sha256=request_sha256,
|
||||
)
|
||||
if replay is not None:
|
||||
if replay.actor_id != _principal_actor(principal):
|
||||
raise FormIntakeError(
|
||||
"Assisted intake idempotency conflict: this key belongs to another operator."
|
||||
)
|
||||
instance = self._runtime.get_instance(
|
||||
session,
|
||||
principal,
|
||||
instance_id=_session_instance_id(replay),
|
||||
allow_all=True,
|
||||
)
|
||||
if instance is None:
|
||||
raise FormIntakeError("The assisted intake Form is unavailable.")
|
||||
return self._result(
|
||||
session,
|
||||
replay,
|
||||
instance=instance,
|
||||
replayed=True,
|
||||
)
|
||||
|
||||
intake_session = self._new_session(
|
||||
profile,
|
||||
token=secrets.token_urlsafe(32),
|
||||
idempotency_key=internal_key,
|
||||
request_sha256=request_sha256,
|
||||
expires_at=recorded_at + timedelta(seconds=profile.draft_ttl_seconds),
|
||||
created_by=_principal_actor(principal),
|
||||
actor_id=_principal_actor(principal),
|
||||
metadata={"assisted_context": context},
|
||||
)
|
||||
session.add(intake_session)
|
||||
session.flush()
|
||||
instance = self._runtime.create_instance(
|
||||
session,
|
||||
principal,
|
||||
definition_ref=_profile_definition_ref(profile),
|
||||
values=values,
|
||||
idempotency_key=f"assisted-start:{intake_session.session_id}",
|
||||
recorded_at=recorded_at,
|
||||
metadata={
|
||||
"intake": {
|
||||
"session_id": intake_session.session_id,
|
||||
"profile_id": profile.profile_id,
|
||||
"mode": "assisted",
|
||||
**context,
|
||||
}
|
||||
},
|
||||
)
|
||||
intake_session.instance_id = instance.instance_id
|
||||
intake_session.status = "active"
|
||||
intake_session.started_at = recorded_at
|
||||
session.add(intake_session)
|
||||
session.flush()
|
||||
return self._result(session, intake_session, instance=instance)
|
||||
|
||||
def record_assisted_confirmation(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
instance_id: str,
|
||||
expected_revision: int,
|
||||
values: Mapping[str, object],
|
||||
attachment_refs: Sequence[EvidenceReference],
|
||||
signature_refs: Sequence[EvidenceReference],
|
||||
outcome: str,
|
||||
method: str,
|
||||
confirmed_by_ref: str,
|
||||
confirmed_at: datetime,
|
||||
idempotency_key: str,
|
||||
field_sources: Mapping[str, Mapping[str, object]],
|
||||
correction_note: str | None = None,
|
||||
metadata: Mapping[str, object] | None = None,
|
||||
allow_all: bool = False,
|
||||
) -> FormAssistedConfirmation:
|
||||
_require_aware(confirmed_at, "Assisted confirmation confirmed_at")
|
||||
intake_session = self._assisted_session(
|
||||
session,
|
||||
principal,
|
||||
instance_id=instance_id,
|
||||
lock=True,
|
||||
allow_all=allow_all,
|
||||
)
|
||||
if outcome not in ASSISTED_CONFIRMATION_OUTCOMES:
|
||||
raise FormIntakeError(f"Unsupported assisted confirmation outcome: {outcome!r}.")
|
||||
if method not in ASSISTED_CONFIRMATION_METHODS:
|
||||
raise FormIntakeError(f"Unsupported assisted confirmation method: {method!r}.")
|
||||
if (outcome == "confirmation_unavailable") != (method == "unavailable"):
|
||||
raise FormIntakeError(
|
||||
"Unavailable assisted confirmation must use the unavailable method, and that method is reserved for the unavailable outcome."
|
||||
)
|
||||
if (
|
||||
intake_session.status != "active"
|
||||
or _aware(intake_session.expires_at) <= confirmed_at
|
||||
or (
|
||||
intake_session.started_at is not None
|
||||
and confirmed_at < _aware(intake_session.started_at)
|
||||
)
|
||||
):
|
||||
raise FormIntakeError("This assisted intake session is no longer active.")
|
||||
clean_note = str(correction_note or "").strip() or None
|
||||
if outcome in {"corrected", "confirmation_unavailable"} and not clean_note:
|
||||
raise FormIntakeError(
|
||||
"Corrected or unavailable confirmation requires a bounded note."
|
||||
)
|
||||
if clean_note and len(clean_note) > 1000:
|
||||
raise FormIntakeError("Assisted confirmation note is limited to 1000 characters.")
|
||||
instance = self._runtime.get_instance(
|
||||
session,
|
||||
principal,
|
||||
instance_id=instance_id,
|
||||
allow_all=allow_all,
|
||||
)
|
||||
if instance is None:
|
||||
raise LookupError("Assisted intake Form not found.")
|
||||
if instance.status not in {"started", "draft"}:
|
||||
raise FormIntakeError("Only an editable assisted intake can be confirmed.")
|
||||
if instance.revision != expected_revision:
|
||||
raise FormIntakeError(
|
||||
"Assisted confirmation revision conflict: the expected revision is stale."
|
||||
)
|
||||
definition = self._definition(
|
||||
session,
|
||||
principal,
|
||||
reference=instance.definition_ref,
|
||||
effective_at=confirmed_at,
|
||||
)
|
||||
clean_values = normalize_form_values(definition, values)
|
||||
attachments = tuple(attachment_refs)
|
||||
signatures = tuple(signature_refs)
|
||||
clean_sources = _clean_field_sources(field_sources)
|
||||
missing_sources = sorted(
|
||||
key
|
||||
for key, value in clean_values.items()
|
||||
if value not in (None, "") and key not in clean_sources
|
||||
)
|
||||
if missing_sources:
|
||||
raise FormIntakeError(
|
||||
"Assisted read-back requires source provenance for every populated field: "
|
||||
+ ", ".join(missing_sources[:10])
|
||||
)
|
||||
if (
|
||||
clean_values != dict(instance.values)
|
||||
or attachments != tuple(instance.attachment_refs)
|
||||
or signatures != tuple(instance.signature_refs)
|
||||
):
|
||||
raise FormIntakeError(
|
||||
"Save assisted corrections and evidence as a new draft revision before recording read-back confirmation."
|
||||
)
|
||||
payload_sha256 = assisted_submission_payload_sha256(
|
||||
instance,
|
||||
values=clean_values,
|
||||
attachment_refs=attachments,
|
||||
signature_refs=signatures,
|
||||
)
|
||||
internal_key = (
|
||||
f"assisted-confirmation:{intake_session.session_id}:"
|
||||
f"{_clean_text(idempotency_key, 'Assisted confirmation idempotency key', 255)}"
|
||||
)
|
||||
request = {
|
||||
"operation": "assisted_confirmation",
|
||||
"session_id": intake_session.session_id,
|
||||
"instance_id": instance.instance_id,
|
||||
"instance_revision": instance.revision,
|
||||
"outcome": outcome,
|
||||
"method": method,
|
||||
"confirmed_by_ref": _clean_text(
|
||||
confirmed_by_ref, "Assisted confirmation party", 255
|
||||
),
|
||||
"confirmed_at": confirmed_at.isoformat(),
|
||||
"payload_sha256": payload_sha256,
|
||||
"field_sources": clean_sources,
|
||||
"correction_note": clean_note,
|
||||
"metadata": dict(metadata or {}),
|
||||
}
|
||||
request_sha256 = _hash(request)
|
||||
existing = (
|
||||
session.query(FormAssistedConfirmation)
|
||||
.filter(
|
||||
FormAssistedConfirmation.tenant_id == instance.tenant_id,
|
||||
FormAssistedConfirmation.idempotency_key == internal_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.request_sha256 != request_sha256:
|
||||
raise FormIntakeError(
|
||||
"Assisted confirmation idempotency conflict: this key was used for another request."
|
||||
)
|
||||
return existing
|
||||
confirmation = FormAssistedConfirmation(
|
||||
tenant_id=instance.tenant_id,
|
||||
confirmation_id=str(uuid.uuid4()),
|
||||
intake_session_id=intake_session.id,
|
||||
instance_id=instance.instance_id,
|
||||
instance_revision=instance.revision,
|
||||
outcome=outcome,
|
||||
method=method,
|
||||
confirmed_by_ref=str(request["confirmed_by_ref"]),
|
||||
operator_actor_id=_principal_actor(principal),
|
||||
confirmed_at=confirmed_at,
|
||||
payload_sha256=payload_sha256,
|
||||
idempotency_key=internal_key,
|
||||
request_sha256=request_sha256,
|
||||
correction_note=clean_note,
|
||||
details={
|
||||
**dict(metadata or {}),
|
||||
"statement": "The shown values and managed evidence were read back or made available for review.",
|
||||
"field_sources": clean_sources,
|
||||
},
|
||||
)
|
||||
session.add(confirmation)
|
||||
intake_session.details = {
|
||||
**dict(intake_session.details),
|
||||
"current_field_sources": clean_sources,
|
||||
"latest_confirmation_id": confirmation.confirmation_id,
|
||||
}
|
||||
session.add(intake_session)
|
||||
session.flush()
|
||||
return confirmation
|
||||
|
||||
def list_assisted_confirmations(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
instance_id: str,
|
||||
allow_all: bool = False,
|
||||
) -> tuple[FormAssistedConfirmation, ...]:
|
||||
self._assisted_session(
|
||||
session,
|
||||
principal,
|
||||
instance_id=instance_id,
|
||||
lock=False,
|
||||
allow_all=allow_all,
|
||||
)
|
||||
return tuple(
|
||||
session.query(FormAssistedConfirmation)
|
||||
.filter(
|
||||
FormAssistedConfirmation.tenant_id == _principal_tenant(principal),
|
||||
FormAssistedConfirmation.instance_id == instance_id,
|
||||
)
|
||||
.order_by(FormAssistedConfirmation.confirmed_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_public_instance(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -549,6 +907,29 @@ class FormIntakeService:
|
||||
session.add(profile)
|
||||
return profile
|
||||
|
||||
def _assisted_session(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
instance_id: str,
|
||||
lock: bool,
|
||||
allow_all: bool,
|
||||
) -> FormIntakeSession:
|
||||
query = session.query(FormIntakeSession).filter(
|
||||
FormIntakeSession.tenant_id == _principal_tenant(principal),
|
||||
FormIntakeSession.instance_id == instance_id,
|
||||
FormIntakeSession.mode == "assisted",
|
||||
)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
intake_session = query.one_or_none()
|
||||
if intake_session is None:
|
||||
raise LookupError("Assisted intake session not found.")
|
||||
if not allow_all and intake_session.actor_id != _principal_actor(principal):
|
||||
raise PermissionError("Assisted intake access denied.")
|
||||
return intake_session
|
||||
|
||||
def _public_session(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -606,7 +987,7 @@ class FormIntakeService:
|
||||
)
|
||||
if existing is not None and existing.request_sha256 != request_sha256:
|
||||
raise FormIntakeError(
|
||||
"Public intake idempotency conflict: this key was used for another request."
|
||||
"Form intake idempotency conflict: this key was used for another request."
|
||||
)
|
||||
return existing
|
||||
|
||||
@@ -619,6 +1000,7 @@ class FormIntakeService:
|
||||
request_sha256: str,
|
||||
expires_at: datetime,
|
||||
created_by: str,
|
||||
actor_id: str | None = None,
|
||||
metadata: Mapping[str, object] | None = None,
|
||||
) -> FormIntakeSession:
|
||||
session_id = str(uuid.uuid4())
|
||||
@@ -630,7 +1012,7 @@ class FormIntakeService:
|
||||
mode=profile.mode,
|
||||
status="issued",
|
||||
instance_id=None,
|
||||
actor_id=f"form-public:{session_id}",
|
||||
actor_id=actor_id or f"form-public:{session_id}",
|
||||
idempotency_key=idempotency_key,
|
||||
request_sha256=request_sha256,
|
||||
expires_at=expires_at,
|
||||
@@ -725,6 +1107,93 @@ def _public_principal(
|
||||
)
|
||||
|
||||
|
||||
def _assisted_context(
|
||||
principal: object,
|
||||
*,
|
||||
channel: str,
|
||||
affected_party_ref: str,
|
||||
represented_party_ref: str | None,
|
||||
authority_basis: str,
|
||||
purpose: str,
|
||||
legal_basis_ref: str | None,
|
||||
consent_basis: str | None,
|
||||
notice_given: bool,
|
||||
responsible_function_ref: str,
|
||||
language: str,
|
||||
accessibility_needs: Sequence[str],
|
||||
field_sources: Mapping[str, Mapping[str, object]],
|
||||
) -> dict[str, object]:
|
||||
clean_channel = str(channel or "").strip()
|
||||
if clean_channel not in ASSISTED_CHANNELS:
|
||||
raise FormIntakeError(f"Unsupported assisted intake channel: {channel!r}.")
|
||||
clean_sources = _clean_field_sources(field_sources)
|
||||
if len(accessibility_needs) > 30:
|
||||
raise FormIntakeError("Assisted intake is limited to 30 accessibility needs.")
|
||||
clean_accessibility = []
|
||||
for item in accessibility_needs:
|
||||
value = _clean_text(item, "Accessibility need", 255)
|
||||
if value not in clean_accessibility:
|
||||
clean_accessibility.append(value)
|
||||
represented = _optional_text(
|
||||
represented_party_ref, "Represented party reference", 255
|
||||
)
|
||||
legal_basis = _optional_text(legal_basis_ref, "Legal basis reference", 255)
|
||||
consent = _optional_text(consent_basis, "Consent basis", 255)
|
||||
return {
|
||||
"channel": clean_channel,
|
||||
"affected_party_ref": _clean_text(
|
||||
affected_party_ref, "Affected party reference", 255
|
||||
),
|
||||
"represented_party_ref": represented,
|
||||
"authority_basis": _clean_text(authority_basis, "Authority basis", 255),
|
||||
"purpose": _clean_text(purpose, "Assisted intake purpose", 500),
|
||||
"legal_basis_ref": legal_basis,
|
||||
"consent_basis": consent,
|
||||
"notice_given": bool(notice_given),
|
||||
"responsible_function_ref": _clean_text(
|
||||
responsible_function_ref, "Responsible function reference", 255
|
||||
),
|
||||
"language": _clean_text(language, "Assisted intake language", 35),
|
||||
"accessibility_needs": clean_accessibility,
|
||||
"field_sources": clean_sources,
|
||||
"operator": {
|
||||
"actor_id": _principal_actor(principal),
|
||||
"auth_method": str(getattr(principal, "auth_method", "") or "authenticated"),
|
||||
"acting_for_account_id": getattr(principal, "acting_for_account_id", None),
|
||||
"acting_assignment_id": getattr(principal, "acting_assignment_id", None),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _clean_field_sources(
|
||||
field_sources: Mapping[str, Mapping[str, object]],
|
||||
) -> dict[str, dict[str, object]]:
|
||||
clean_sources: dict[str, dict[str, object]] = {}
|
||||
for field_key, source in field_sources.items():
|
||||
key = _clean_text(field_key, "Assisted field source key", 255)
|
||||
kind = str(source.get("source") or "").strip()
|
||||
confidence = str(source.get("confidence") or "").strip()
|
||||
if kind not in ASSISTED_SOURCE_KINDS:
|
||||
raise FormIntakeError(
|
||||
f"Unsupported source for assisted field {key!r}: {kind!r}."
|
||||
)
|
||||
if confidence not in ASSISTED_CONFIDENCE_LEVELS:
|
||||
raise FormIntakeError(
|
||||
f"Unsupported confidence for assisted field {key!r}: {confidence!r}."
|
||||
)
|
||||
declared_by_ref = str(source.get("declared_by_ref") or "").strip()
|
||||
if len(declared_by_ref) > 255:
|
||||
raise FormIntakeError(
|
||||
f"Declared-by reference for assisted field {key!r} is limited to 255 characters."
|
||||
)
|
||||
clean_sources[key] = {
|
||||
"source": kind,
|
||||
"confidence": confidence,
|
||||
"declared_by_ref": declared_by_ref or None,
|
||||
}
|
||||
return clean_sources
|
||||
|
||||
|
||||
def _profile_definition_ref(profile: FormIntakeProfile) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind="form",
|
||||
@@ -787,6 +1256,20 @@ def _hash(value: Mapping[str, object]) -> str:
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _clean_text(value: object, label: str, maximum: int) -> str:
|
||||
clean = str(value or "").strip()
|
||||
if not clean or len(clean) > maximum:
|
||||
raise FormIntakeError(f"{label} is required and limited to {maximum} characters.")
|
||||
return clean
|
||||
|
||||
|
||||
def _optional_text(value: object, label: str, maximum: int) -> str | None:
|
||||
clean = str(value or "").strip()
|
||||
if len(clean) > maximum:
|
||||
raise FormIntakeError(f"{label} is limited to {maximum} characters.")
|
||||
return clean or None
|
||||
|
||||
|
||||
def _require_aware(value: datetime, label: str) -> None:
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise FormIntakeError(f"{label} must include a timezone.")
|
||||
|
||||
@@ -57,6 +57,7 @@ MODULE_ID = "forms_runtime"
|
||||
MODULE_NAME = "Forms Runtime"
|
||||
MODULE_VERSION = "0.1.18"
|
||||
PARTICIPATE_SCOPE = "forms_runtime:submission:participate"
|
||||
ASSIST_SCOPE = "forms_runtime:submission:assist"
|
||||
READ_SCOPE = "forms_runtime:workspace:read"
|
||||
WRITE_SCOPE = "forms_runtime:workspace:write"
|
||||
ADMIN_SCOPE = "forms_runtime:workspace:admin"
|
||||
@@ -92,6 +93,11 @@ PERMISSIONS = (
|
||||
"Complete assigned forms",
|
||||
"Start, read, save, and submit the acting account's own Form instances.",
|
||||
),
|
||||
_permission(
|
||||
ASSIST_SCOPE,
|
||||
"Conduct assisted Form intake",
|
||||
"Start authenticated assisted sessions and record party read-back or correction evidence.",
|
||||
),
|
||||
_permission(
|
||||
READ_SCOPE,
|
||||
"View form submissions",
|
||||
@@ -117,11 +123,17 @@ ROLE_TEMPLATES = (
|
||||
permissions=(PARTICIPATE_SCOPE,),
|
||||
default_authenticated=True,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="forms_runtime_assistant",
|
||||
name="Forms intake assistant",
|
||||
description="Conduct purpose-bound assisted intake for another party.",
|
||||
permissions=(PARTICIPATE_SCOPE, ASSIST_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="forms_runtime_manager",
|
||||
name="Forms Runtime manager",
|
||||
description="Review, transition, and hand off Form submissions.",
|
||||
permissions=(PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE),
|
||||
permissions=(PARTICIPATE_SCOPE, ASSIST_SCOPE, READ_SCOPE, WRITE_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="forms_runtime_viewer",
|
||||
@@ -191,10 +203,33 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
)
|
||||
).count(),
|
||||
"public_intake_profiles": session.query(runtime_models.FormIntakeProfile)
|
||||
.filter(runtime_models.FormIntakeProfile.tenant_id == tenant_id)
|
||||
.filter(
|
||||
runtime_models.FormIntakeProfile.tenant_id == tenant_id,
|
||||
runtime_models.FormIntakeProfile.mode.in_(("anonymous", "invitation")),
|
||||
)
|
||||
.count(),
|
||||
"public_intake_sessions": session.query(runtime_models.FormIntakeSession)
|
||||
.filter(runtime_models.FormIntakeSession.tenant_id == tenant_id)
|
||||
.filter(
|
||||
runtime_models.FormIntakeSession.tenant_id == tenant_id,
|
||||
runtime_models.FormIntakeSession.mode.in_(("anonymous", "invitation")),
|
||||
)
|
||||
.count(),
|
||||
"assisted_intake_profiles": session.query(runtime_models.FormIntakeProfile)
|
||||
.filter(
|
||||
runtime_models.FormIntakeProfile.tenant_id == tenant_id,
|
||||
runtime_models.FormIntakeProfile.mode == "assisted",
|
||||
)
|
||||
.count(),
|
||||
"assisted_intake_sessions": session.query(runtime_models.FormIntakeSession)
|
||||
.filter(
|
||||
runtime_models.FormIntakeSession.tenant_id == tenant_id,
|
||||
runtime_models.FormIntakeSession.mode == "assisted",
|
||||
)
|
||||
.count(),
|
||||
"assisted_confirmations": session.query(
|
||||
runtime_models.FormAssistedConfirmation
|
||||
)
|
||||
.filter(runtime_models.FormAssistedConfirmation.tenant_id == tenant_id)
|
||||
.count(),
|
||||
"authenticated_acknowledgements": session.query(
|
||||
runtime_models.FormAcknowledgement
|
||||
@@ -315,6 +350,9 @@ manifest = ModuleManifest(
|
||||
ModuleInterfaceProvider(name="forms_runtime.registry", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="forms_runtime.service_launcher", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="forms_runtime.public_intake", version="1.0.0"),
|
||||
ModuleInterfaceProvider(
|
||||
name="forms_runtime.assisted_intake", version="1.0.0"
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name="forms_runtime.authenticated_acknowledgement",
|
||||
version="1.0.0",
|
||||
@@ -379,6 +417,7 @@ manifest = ModuleManifest(
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
runtime_models.FormAcknowledgement,
|
||||
runtime_models.FormAssistedConfirmation,
|
||||
runtime_models.FormIntakeSession,
|
||||
runtime_models.FormIntakeProfile,
|
||||
runtime_models.FormHandoffEffect,
|
||||
@@ -392,6 +431,7 @@ manifest = ModuleManifest(
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
runtime_models.FormAcknowledgement,
|
||||
runtime_models.FormAssistedConfirmation,
|
||||
runtime_models.FormIntakeSession,
|
||||
runtime_models.FormIntakeProfile,
|
||||
runtime_models.FormHandoffEffect,
|
||||
@@ -416,7 +456,7 @@ manifest = ModuleManifest(
|
||||
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. Native Case and Workflow handoffs persist intent before execution, use owner capabilities with stable provider keys, and require reconciliation after unknown outcomes. History, receipts, and handoffs are replay-safe and optimistic-concurrency guarded."
|
||||
" Invitation and explicitly enabled anonymous intake use hash-only expiring tokens, bounded rate limits, and isolated synthetic actors. Files-backed attachments use one-time purpose-bound grants, while authenticated acknowledgements bind an exact actor and payload digest without claiming advanced or qualified signature assurance. When Search is enabled, Forms Runtime contributes a rebuildable metadata-only projection; submitted values and evidence content are excluded, and every candidate receives a current workspace or participant access check."
|
||||
" Invitation and explicitly enabled anonymous intake use hash-only expiring tokens, bounded rate limits, and isolated synthetic actors. Authenticated assisted sessions retain channel, affected and represented parties, authority, purpose, notice, responsible function, language, accessibility needs, and field provenance without bypassing the exact Form rules. Submission requires immutable read-back evidence bound to the current revision, values, attachments, and signatures; any later draft edit invalidates it. Files-backed attachments use one-time purpose-bound grants, while authenticated acknowledgements bind an exact actor and payload digest without claiming advanced or qualified signature assurance. When Search is enabled, Forms Runtime contributes a rebuildable metadata-only projection; submitted values and evidence content are excluded, and every candidate receives a current workspace or participant access check."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -435,6 +475,8 @@ manifest = ModuleManifest(
|
||||
"forms_runtime.workspace",
|
||||
"forms_runtime.instance",
|
||||
"forms_runtime.public-intake",
|
||||
"forms_runtime.assisted-intake",
|
||||
"forms_runtime.assisted-confirmation",
|
||||
"forms_runtime.search.result",
|
||||
"forms_runtime.state.read-only",
|
||||
"forms_runtime.state.permission-blocked",
|
||||
@@ -444,6 +486,7 @@ manifest = ModuleManifest(
|
||||
"Validation messages expose field-level diagnostics without disclosing unrelated submissions.",
|
||||
"Handoff rows retain provider references and outcomes but do not bypass target-module authorization.",
|
||||
"Public intake tokens and Files upload tokens are retained only as cryptographic digests; anonymous submissions cannot later be claimed by an identity.",
|
||||
"Assisted session provenance names governed party/function references and purpose; operators should not duplicate names or evidence content in free-text references and notes.",
|
||||
],
|
||||
},
|
||||
),
|
||||
@@ -459,6 +502,7 @@ manifest = ModuleManifest(
|
||||
"stable idempotency key. Rejected effects may be retried. Unknown outcomes must be reconciled before retry to avoid a "
|
||||
"duplicate target. Administrative compensation records verified absence and never deletes a remote target. "
|
||||
"When Records is enabled, only immutable submitted revisions can be resolved for filing; current Forms Runtime access is rechecked and editable drafts fail closed."
|
||||
" Assisted intake begins with an authenticated, purpose-bound session. The operator records the channel, party and representation references, authority basis, notice, responsible function, language, accessibility support, and per-field sources. Read-back outcomes are append-only and bind the exact current payload. Corrections must first be saved as a new draft revision and confirmed again; an unavailable confirmation requires an explicit exception note."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -487,6 +531,8 @@ manifest = ModuleManifest(
|
||||
"forms_runtime.action.issue-invitation",
|
||||
"forms_runtime.action.upload-evidence",
|
||||
"forms_runtime.action.acknowledge",
|
||||
"forms_runtime.action.start-assisted-intake",
|
||||
"forms_runtime.action.confirm-assisted-readback",
|
||||
"records.action.file",
|
||||
],
|
||||
"consequence_classes": {
|
||||
@@ -498,6 +544,8 @@ manifest = ModuleManifest(
|
||||
"public_intake": "Starts an isolated, expiring invitation or explicitly enabled anonymous submission without granting general platform access.",
|
||||
"upload_evidence": "Issues a short-lived provider grant; the returned immutable evidence reference must pass owner verification again at submission.",
|
||||
"acknowledge": "Binds the acting account, statement version, exact Form revision, values, and attachments in an authenticated acknowledgement digest.",
|
||||
"start_assisted_intake": "Creates a resumable authenticated draft with explicit channel, party, authority, purpose, notice, function, accessibility, and source provenance.",
|
||||
"confirm_assisted_readback": "Creates immutable evidence for the exact current revision and payload; a later correction requires a new confirmation before submission.",
|
||||
"file_submission": "Resolves the exact immutable submission revision under current access and preserves only a digest-bound reference in Records.",
|
||||
},
|
||||
},
|
||||
@@ -511,6 +559,7 @@ manifest = ModuleManifest(
|
||||
test_ref="tests/test_forms_runtime.py",
|
||||
known_limits=(
|
||||
"External advanced or qualified signature providers, scheduled expiry cleanup, and target kinds beyond native Case/Workflow handoffs remain adapter depth. Public links intentionally cannot substitute the native authenticated acknowledgement profile.",
|
||||
"The assisted-intake API retains per-field provenance, while the first operator dialog applies one selected source/confidence profile to all populated values; mixed-source field editing remains UI depth.",
|
||||
),
|
||||
supported_authority_modes=("native_authoritative",),
|
||||
owned_concepts=(
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
"""Add immutable assisted-intake read-back confirmations.
|
||||
|
||||
Revision ID: c5f7a9b1d3e4
|
||||
Revises: b4e6f8a0c2d3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c5f7a9b1d3e4"
|
||||
down_revision = "b4e6f8a0c2d3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"form_assisted_confirmations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("confirmation_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("intake_session_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("outcome", sa.String(length=30), nullable=False),
|
||||
sa.Column("method", sa.String(length=30), nullable=False),
|
||||
sa.Column("confirmed_by_ref", sa.String(length=255), nullable=False),
|
||||
sa.Column("operator_actor_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("payload_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("correction_note", sa.String(length=1000), nullable=True),
|
||||
sa.Column("metadata", 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.ForeignKeyConstraint(
|
||||
["intake_session_id"],
|
||||
["form_intake_sessions.id"],
|
||||
name=op.f(
|
||||
"fk_form_assisted_confirmations_intake_session_id_form_intake_sessions"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_assisted_confirmations")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "confirmation_id", name="uq_form_assisted_confirmation"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_form_assisted_confirmation_idempotency",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"confirmation_id",
|
||||
"intake_session_id",
|
||||
"instance_id",
|
||||
"outcome",
|
||||
"method",
|
||||
"operator_actor_id",
|
||||
"confirmed_at",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_form_assisted_confirmations_{column}"),
|
||||
"form_assisted_confirmations",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_form_assisted_confirmation_instance",
|
||||
"form_assisted_confirmations",
|
||||
["tenant_id", "instance_id", "instance_revision"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("form_assisted_confirmations")
|
||||
@@ -14,11 +14,14 @@ from govoplan_core.core.institutional import (
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_forms_runtime.backend.manifest import (
|
||||
ADMIN_SCOPE,
|
||||
ASSIST_SCOPE,
|
||||
PARTICIPATE_SCOPE,
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.schemas import (
|
||||
AssistedFormConfirmationRequest,
|
||||
AssistedFormStartRequest,
|
||||
FormAcknowledgementRequest,
|
||||
FormDraftUpdateRequest,
|
||||
FormEvidenceGrantCreateRequest,
|
||||
@@ -37,6 +40,7 @@ from govoplan_forms_runtime.backend.schemas import (
|
||||
FormTransitionRequest,
|
||||
FormNativeHandoffRequest,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.assisted import confirmation_payload
|
||||
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
|
||||
from govoplan_forms_runtime.backend.intake import (
|
||||
FormIntakeError,
|
||||
@@ -190,6 +194,132 @@ def create_router(registry: object | None) -> APIRouter:
|
||||
raise _public_error(exc) from exc
|
||||
return result.to_dict()
|
||||
|
||||
@router.get("/assisted-intake/profiles", response_model=dict[str, object])
|
||||
def api_list_assisted_intake_profiles(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require_any(principal, ASSIST_SCOPE, WRITE_SCOPE)
|
||||
return {
|
||||
"profiles": [
|
||||
profile_payload(item)
|
||||
for item in intake.list_assisted_profiles(session, principal)
|
||||
]
|
||||
}
|
||||
|
||||
@router.post(
|
||||
"/assisted-intake/start",
|
||||
response_model=dict[str, object],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_start_assisted_intake(
|
||||
payload: AssistedFormStartRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require_any(principal, ASSIST_SCOPE, WRITE_SCOPE)
|
||||
try:
|
||||
result = intake.start_assisted(
|
||||
session,
|
||||
principal,
|
||||
profile_id=payload.profile_id,
|
||||
values=payload.values,
|
||||
channel=payload.channel,
|
||||
affected_party_ref=payload.affected_party_ref,
|
||||
represented_party_ref=payload.represented_party_ref,
|
||||
authority_basis=payload.authority_basis,
|
||||
purpose=payload.purpose,
|
||||
legal_basis_ref=payload.legal_basis_ref,
|
||||
consent_basis=payload.consent_basis,
|
||||
notice_given=payload.notice_given,
|
||||
responsible_function_ref=payload.responsible_function_ref,
|
||||
language=payload.language,
|
||||
accessibility_needs=payload.accessibility_needs,
|
||||
field_sources={
|
||||
key: value.model_dump()
|
||||
for key, value in payload.field_sources.items()
|
||||
},
|
||||
idempotency_key=payload.idempotency_key,
|
||||
recorded_at=payload.recorded_at,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
FormIntakeError,
|
||||
InstitutionalContextError,
|
||||
LookupError,
|
||||
PermissionError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result.to_dict()
|
||||
|
||||
@router.get(
|
||||
"/instances/{instance_id}/assisted-confirmations",
|
||||
response_model=dict[str, object],
|
||||
)
|
||||
def api_list_assisted_confirmations(
|
||||
instance_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require_any(principal, ASSIST_SCOPE, READ_SCOPE, WRITE_SCOPE)
|
||||
try:
|
||||
items = intake.list_assisted_confirmations(
|
||||
session,
|
||||
principal,
|
||||
instance_id=instance_id,
|
||||
allow_all=has_scope(principal, READ_SCOPE)
|
||||
or has_scope(principal, WRITE_SCOPE),
|
||||
)
|
||||
except (FormIntakeError, LookupError, PermissionError) as exc:
|
||||
raise _error(exc) from exc
|
||||
return {"confirmations": [confirmation_payload(item) for item in items]}
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/assisted-confirmations",
|
||||
response_model=dict[str, object],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_record_assisted_confirmation(
|
||||
instance_id: str,
|
||||
payload: AssistedFormConfirmationRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require_any(principal, ASSIST_SCOPE, WRITE_SCOPE)
|
||||
try:
|
||||
item = intake.record_assisted_confirmation(
|
||||
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),
|
||||
outcome=payload.outcome,
|
||||
method=payload.method,
|
||||
confirmed_by_ref=payload.confirmed_by_ref,
|
||||
confirmed_at=payload.confirmed_at,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
field_sources={
|
||||
key: value.model_dump()
|
||||
for key, value in payload.field_sources.items()
|
||||
},
|
||||
correction_note=payload.correction_note,
|
||||
metadata=payload.metadata,
|
||||
allow_all=has_scope(principal, WRITE_SCOPE),
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
FormRuntimeError,
|
||||
InstitutionalContextError,
|
||||
LookupError,
|
||||
PermissionError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return confirmation_payload(item)
|
||||
|
||||
@router.post(
|
||||
"/public/intake/start",
|
||||
response_model=dict[str, object],
|
||||
|
||||
@@ -93,7 +93,7 @@ class FormIntakeProfileCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
definition_ref: dict[str, Any]
|
||||
mode: Literal["anonymous", "invitation"]
|
||||
mode: Literal["anonymous", "invitation", "assisted"]
|
||||
draft_ttl_seconds: int = Field(default=2_592_000, ge=60, le=31_536_000)
|
||||
invitation_ttl_seconds: int = Field(default=1_209_600, ge=60, le=7_776_000)
|
||||
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
|
||||
@@ -125,6 +125,70 @@ class PublicFormStartRequest(BaseModel):
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class AssistedFieldSourceRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
source: Literal[
|
||||
"person_statement",
|
||||
"representative_statement",
|
||||
"document",
|
||||
"system",
|
||||
"derived",
|
||||
]
|
||||
confidence: Literal["stated", "verified", "uncertain"]
|
||||
declared_by_ref: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
|
||||
|
||||
class AssistedFormStartRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
profile_id: str = Field(min_length=1, max_length=255)
|
||||
values: dict[str, Any] = Field(default_factory=dict)
|
||||
channel: Literal[
|
||||
"counter",
|
||||
"telephone",
|
||||
"paper",
|
||||
"email",
|
||||
"mobile",
|
||||
"representative",
|
||||
"offline_import",
|
||||
]
|
||||
affected_party_ref: str = Field(min_length=1, max_length=255)
|
||||
represented_party_ref: str | None = Field(
|
||||
default=None, min_length=1, max_length=255
|
||||
)
|
||||
authority_basis: str = Field(min_length=1, max_length=255)
|
||||
purpose: str = Field(min_length=1, max_length=500)
|
||||
legal_basis_ref: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
consent_basis: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
notice_given: bool
|
||||
responsible_function_ref: str = Field(min_length=1, max_length=255)
|
||||
language: str = Field(min_length=1, max_length=35)
|
||||
accessibility_needs: list[str] = Field(default_factory=list, max_length=30)
|
||||
field_sources: dict[str, AssistedFieldSourceRequest] = Field(default_factory=dict)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class AssistedFormConfirmationRequest(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)
|
||||
outcome: Literal["confirmed", "corrected", "confirmation_unavailable"]
|
||||
method: Literal[
|
||||
"spoken_readback", "written_preview", "accessible_copy", "unavailable"
|
||||
]
|
||||
confirmed_by_ref: str = Field(min_length=1, max_length=255)
|
||||
confirmed_at: datetime
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
field_sources: dict[str, AssistedFieldSourceRequest]
|
||||
correction_note: str | None = Field(default=None, min_length=1, max_length=1000)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FormEvidenceGrantCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -166,6 +230,9 @@ class FormInstanceEventsResponse(BaseModel):
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AssistedFieldSourceRequest",
|
||||
"AssistedFormConfirmationRequest",
|
||||
"AssistedFormStartRequest",
|
||||
"FormAcknowledgementRequest",
|
||||
"FormDraftUpdateRequest",
|
||||
"FormEvidenceGrantCreateRequest",
|
||||
|
||||
@@ -34,10 +34,13 @@ from govoplan_core.core.institutional import (
|
||||
ServiceLaunchResult,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.db.models import (
|
||||
FormAssistedConfirmation,
|
||||
FormInstanceEvent,
|
||||
FormInstanceIdentity,
|
||||
FormInstanceRevision,
|
||||
FormIntakeSession,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.assisted import assisted_submission_payload_sha256
|
||||
from govoplan_forms_runtime.backend.domain import FormInstance
|
||||
from govoplan_forms_runtime.backend.evidence import FormEvidenceCoordinator
|
||||
|
||||
@@ -329,7 +332,7 @@ class FormRuntimeService:
|
||||
action="submit",
|
||||
instance=current,
|
||||
)
|
||||
return self._revise(
|
||||
submitted = self._revise(
|
||||
session,
|
||||
principal,
|
||||
current=current,
|
||||
@@ -348,6 +351,21 @@ class FormRuntimeService:
|
||||
definition=definition,
|
||||
allowed_current_statuses=("started", "draft"),
|
||||
)
|
||||
assisted_session = (
|
||||
session.query(FormIntakeSession)
|
||||
.filter(
|
||||
FormIntakeSession.tenant_id == submitted.tenant_id,
|
||||
FormIntakeSession.instance_id == submitted.instance_id,
|
||||
FormIntakeSession.mode == "assisted",
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if assisted_session is not None and assisted_session.status != "submitted":
|
||||
assisted_session.status = "submitted"
|
||||
assisted_session.submitted_at = recorded_at
|
||||
session.add(assisted_session)
|
||||
session.flush()
|
||||
return submitted
|
||||
|
||||
def transition_instance(
|
||||
self,
|
||||
@@ -821,6 +839,46 @@ class FormRuntimeService:
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
if operation == "submitted" and _is_assisted_instance(current):
|
||||
assisted_session = (
|
||||
session.query(FormIntakeSession)
|
||||
.filter(
|
||||
FormIntakeSession.tenant_id == current.tenant_id,
|
||||
FormIntakeSession.instance_id == current.instance_id,
|
||||
FormIntakeSession.mode == "assisted",
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if (
|
||||
assisted_session is None
|
||||
or assisted_session.status != "active"
|
||||
or _aware(assisted_session.expires_at) <= recorded_at
|
||||
):
|
||||
raise FormRuntimeError(
|
||||
"Assisted intake submission requires an active, unexpired session."
|
||||
)
|
||||
expected_confirmation_digest = assisted_submission_payload_sha256(
|
||||
current,
|
||||
values=clean_values,
|
||||
attachment_refs=attachments,
|
||||
signature_refs=signatures,
|
||||
)
|
||||
confirmation = (
|
||||
session.query(FormAssistedConfirmation)
|
||||
.filter(
|
||||
FormAssistedConfirmation.tenant_id == current.tenant_id,
|
||||
FormAssistedConfirmation.instance_id == current.instance_id,
|
||||
FormAssistedConfirmation.instance_revision == current.revision,
|
||||
FormAssistedConfirmation.payload_sha256
|
||||
== expected_confirmation_digest,
|
||||
)
|
||||
.order_by(FormAssistedConfirmation.confirmed_at.desc())
|
||||
.first()
|
||||
)
|
||||
if confirmation is None:
|
||||
raise FormRuntimeError(
|
||||
"Assisted intake submission requires a current read-back confirmation for the exact values and evidence."
|
||||
)
|
||||
if allowed_current_statuses is not None and current.status not in set(
|
||||
allowed_current_statuses
|
||||
):
|
||||
@@ -1123,6 +1181,11 @@ def validate_form_values(
|
||||
return tuple(diagnostics)
|
||||
|
||||
|
||||
def _is_assisted_instance(instance: FormInstance) -> bool:
|
||||
intake = instance.metadata.get("intake")
|
||||
return isinstance(intake, Mapping) and intake.get("mode") == "assisted"
|
||||
|
||||
|
||||
def normalize_form_values(
|
||||
definition: FormDefinition,
|
||||
values: Mapping[str, object],
|
||||
|
||||
Reference in New Issue
Block a user