Add governed public form intake
This commit is contained in:
@@ -0,0 +1,808 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from typing import Literal
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_FORM_DEFINITIONS,
|
||||
EvidenceReference,
|
||||
FormDefinition,
|
||||
FormDefinitionProvider,
|
||||
InstitutionalReference,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.db.models import (
|
||||
FormIntakeProfile,
|
||||
FormIntakeSession,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.domain import FormInstance
|
||||
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
|
||||
|
||||
|
||||
IntakeMode = Literal["anonymous", "invitation"]
|
||||
INTAKE_MODES = frozenset({"anonymous", "invitation"})
|
||||
DEFAULT_DRAFT_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||
DEFAULT_INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60
|
||||
DEFAULT_RATE_LIMIT_PER_MINUTE = 60
|
||||
|
||||
|
||||
class FormIntakeError(FormRuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PublicIntakePrincipal:
|
||||
tenant_id: str
|
||||
account_id: str
|
||||
auth_method: str
|
||||
profile_id: str
|
||||
identity_id: str | None = None
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
del scope
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IntakeSessionResult:
|
||||
session_id: str
|
||||
mode: str
|
||||
status: str
|
||||
expires_at: datetime
|
||||
instance: FormInstance | None
|
||||
token: str | None = None
|
||||
replayed: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"mode": self.mode,
|
||||
"status": self.status,
|
||||
"expires_at": self.expires_at.isoformat(),
|
||||
"instance": self.instance.to_dict() if self.instance else None,
|
||||
"token": self.token,
|
||||
"replayed": self.replayed,
|
||||
}
|
||||
|
||||
|
||||
class FormIntakeService:
|
||||
def __init__(self, registry: object | None) -> None:
|
||||
self._registry = registry
|
||||
self._runtime = FormRuntimeService(registry)
|
||||
|
||||
def create_profile(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
definition_ref: InstitutionalReference,
|
||||
mode: IntakeMode,
|
||||
custodian_ref: str,
|
||||
draft_ttl_seconds: int = DEFAULT_DRAFT_TTL_SECONDS,
|
||||
invitation_ttl_seconds: int = DEFAULT_INVITATION_TTL_SECONDS,
|
||||
rate_limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE,
|
||||
metadata: Mapping[str, object] | None = None,
|
||||
recorded_at: datetime,
|
||||
) -> FormIntakeProfile:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
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}.")
|
||||
if not custodian_ref.strip():
|
||||
raise FormIntakeError("A public 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."
|
||||
)
|
||||
if not 60 <= invitation_ttl_seconds <= 90 * 24 * 60 * 60:
|
||||
raise FormIntakeError(
|
||||
"Invitation expiry must be between one minute and ninety days."
|
||||
)
|
||||
if not 1 <= rate_limit_per_minute <= 10_000:
|
||||
raise FormIntakeError(
|
||||
"Public intake rate limit must be between 1 and 10000."
|
||||
)
|
||||
definition = self._definition(
|
||||
session,
|
||||
principal,
|
||||
reference=definition_ref,
|
||||
effective_at=recorded_at,
|
||||
)
|
||||
if definition.publication_state != "published":
|
||||
raise FormIntakeError("Only a published Form can receive public intake.")
|
||||
profile = FormIntakeProfile(
|
||||
tenant_id=tenant_id,
|
||||
profile_id=str(uuid.uuid4()),
|
||||
public_id=secrets.token_urlsafe(24),
|
||||
definition_id=definition.reference.object_id,
|
||||
definition_revision=str(definition.reference.version),
|
||||
mode=mode,
|
||||
enabled=True,
|
||||
revision=1,
|
||||
custodian_ref=custodian_ref.strip(),
|
||||
draft_ttl_seconds=draft_ttl_seconds,
|
||||
invitation_ttl_seconds=invitation_ttl_seconds,
|
||||
rate_limit_per_minute=rate_limit_per_minute,
|
||||
rate_window_started_at=None,
|
||||
rate_window_count=0,
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
details={
|
||||
"identity_claim_allowed": False,
|
||||
"pseudonymous": False,
|
||||
**dict(metadata or {}),
|
||||
},
|
||||
)
|
||||
session.add(profile)
|
||||
session.flush()
|
||||
return profile
|
||||
|
||||
def list_profiles(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
) -> tuple[FormIntakeProfile, ...]:
|
||||
return tuple(
|
||||
session.query(FormIntakeProfile)
|
||||
.filter(FormIntakeProfile.tenant_id == _principal_tenant(principal))
|
||||
.order_by(
|
||||
FormIntakeProfile.definition_id.asc(),
|
||||
FormIntakeProfile.mode.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
def list_available_definitions(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 200,
|
||||
) -> tuple[FormDefinition, ...]:
|
||||
provider = _capability(self._registry, CAPABILITY_FORM_DEFINITIONS)
|
||||
if not isinstance(provider, FormDefinitionProvider):
|
||||
raise FormIntakeError("The Forms definition provider is unavailable.")
|
||||
definitions = provider.list_form_definitions(
|
||||
session,
|
||||
principal,
|
||||
tenant_id=_principal_tenant(principal),
|
||||
query=query.strip(),
|
||||
limit=min(max(limit, 1), 200),
|
||||
)
|
||||
return tuple(
|
||||
item
|
||||
for item in definitions
|
||||
if item.publication_state == "published" and item.reference.version
|
||||
)
|
||||
|
||||
def set_profile_enabled(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
profile_id: str,
|
||||
expected_revision: int,
|
||||
enabled: bool,
|
||||
) -> FormIntakeProfile:
|
||||
profile = self._profile_for_admin(
|
||||
session,
|
||||
principal,
|
||||
profile_id=profile_id,
|
||||
lock=True,
|
||||
)
|
||||
if profile.revision != expected_revision:
|
||||
raise FormIntakeError(
|
||||
"Intake profile revision conflict: the expected revision is stale."
|
||||
)
|
||||
profile.enabled = bool(enabled)
|
||||
profile.revision += 1
|
||||
profile.updated_by = _principal_actor(principal)
|
||||
session.add(profile)
|
||||
session.flush()
|
||||
return profile
|
||||
|
||||
def issue_invitation(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
profile_id: str,
|
||||
idempotency_key: str,
|
||||
recorded_at: datetime,
|
||||
expires_at: datetime | None = None,
|
||||
metadata: Mapping[str, object] | None = None,
|
||||
) -> IntakeSessionResult:
|
||||
profile = self._profile_for_admin(
|
||||
session,
|
||||
principal,
|
||||
profile_id=profile_id,
|
||||
lock=True,
|
||||
)
|
||||
if profile.mode != "invitation" or not profile.enabled:
|
||||
raise FormIntakeError("This intake profile cannot issue invitations.")
|
||||
_require_aware(recorded_at, "Invitation recorded_at")
|
||||
effective_expiry = expires_at or (
|
||||
recorded_at + timedelta(seconds=profile.invitation_ttl_seconds)
|
||||
)
|
||||
_require_aware(effective_expiry, "Invitation expires_at")
|
||||
if effective_expiry <= recorded_at:
|
||||
raise FormIntakeError("Invitation expiry must be in the future.")
|
||||
request = {
|
||||
"operation": "issue_invitation",
|
||||
"profile_id": profile.profile_id,
|
||||
"expires_at": effective_expiry.isoformat(),
|
||||
"metadata": dict(metadata or {}),
|
||||
}
|
||||
replay = self._session_replay(
|
||||
session,
|
||||
tenant_id=profile.tenant_id,
|
||||
idempotency_key=idempotency_key,
|
||||
request_sha256=_hash(request),
|
||||
)
|
||||
if replay is not None:
|
||||
return self._result(session, replay, token=None, replayed=True)
|
||||
token = secrets.token_urlsafe(32)
|
||||
intake_session = self._new_session(
|
||||
profile,
|
||||
token=token,
|
||||
idempotency_key=idempotency_key,
|
||||
request_sha256=_hash(request),
|
||||
expires_at=effective_expiry,
|
||||
created_by=_principal_actor(principal),
|
||||
metadata=metadata,
|
||||
)
|
||||
session.add(intake_session)
|
||||
session.flush()
|
||||
return self._result(session, intake_session, token=token)
|
||||
|
||||
def start_anonymous(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
public_id: str,
|
||||
values: Mapping[str, object],
|
||||
idempotency_key: str,
|
||||
recorded_at: datetime,
|
||||
) -> IntakeSessionResult:
|
||||
profile = self._public_profile(
|
||||
session,
|
||||
public_id=public_id,
|
||||
expected_mode="anonymous",
|
||||
now=recorded_at,
|
||||
)
|
||||
request = {
|
||||
"operation": "anonymous_start",
|
||||
"profile_id": profile.profile_id,
|
||||
"values": dict(values),
|
||||
"recorded_at": recorded_at.isoformat(),
|
||||
}
|
||||
request_sha256 = _hash(request)
|
||||
replay = self._session_replay(
|
||||
session,
|
||||
tenant_id=profile.tenant_id,
|
||||
idempotency_key=f"anonymous:{profile.profile_id}:{idempotency_key}",
|
||||
request_sha256=request_sha256,
|
||||
)
|
||||
if replay is not None:
|
||||
return self._result(session, replay, token=None, replayed=True)
|
||||
token = secrets.token_urlsafe(32)
|
||||
intake_session = self._new_session(
|
||||
profile,
|
||||
token=token,
|
||||
idempotency_key=f"anonymous:{profile.profile_id}:{idempotency_key}",
|
||||
request_sha256=request_sha256,
|
||||
expires_at=recorded_at + timedelta(seconds=profile.draft_ttl_seconds),
|
||||
created_by="public",
|
||||
)
|
||||
session.add(intake_session)
|
||||
session.flush()
|
||||
instance = self._start_instance(
|
||||
session,
|
||||
profile,
|
||||
intake_session,
|
||||
values=values,
|
||||
recorded_at=recorded_at,
|
||||
)
|
||||
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, token=token, instance=instance)
|
||||
|
||||
def start_invitation(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
values: Mapping[str, object],
|
||||
recorded_at: datetime,
|
||||
) -> IntakeSessionResult:
|
||||
intake_session, profile = self._public_session(
|
||||
session,
|
||||
token=token,
|
||||
now=recorded_at,
|
||||
lock=True,
|
||||
)
|
||||
if intake_session.mode != "invitation":
|
||||
raise _public_unavailable()
|
||||
if intake_session.instance_id:
|
||||
return self._result(session, intake_session, replayed=True)
|
||||
instance = self._start_instance(
|
||||
session,
|
||||
profile,
|
||||
intake_session,
|
||||
values=values,
|
||||
recorded_at=recorded_at,
|
||||
)
|
||||
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 get_public_instance(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
now: datetime,
|
||||
) -> tuple[FormInstance, FormDefinition]:
|
||||
intake_session, profile = self._public_session(
|
||||
session,
|
||||
token=token,
|
||||
now=now,
|
||||
lock=True,
|
||||
)
|
||||
instance = self._instance(session, intake_session)
|
||||
definition = self._definition(
|
||||
session,
|
||||
_public_principal(intake_session, profile),
|
||||
reference=_profile_definition_ref(profile),
|
||||
effective_at=instance.recorded_at,
|
||||
)
|
||||
return instance, definition
|
||||
|
||||
def update_public_draft(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
expected_revision: int,
|
||||
values: Mapping[str, object],
|
||||
attachment_refs: Sequence[EvidenceReference],
|
||||
signature_refs: Sequence[EvidenceReference],
|
||||
idempotency_key: str,
|
||||
recorded_at: datetime,
|
||||
change_reason: str,
|
||||
) -> FormInstance:
|
||||
intake_session, profile = self._public_session(
|
||||
session,
|
||||
token=token,
|
||||
now=recorded_at,
|
||||
lock=True,
|
||||
)
|
||||
principal = _public_principal(intake_session, profile)
|
||||
return self._runtime.update_draft(
|
||||
session,
|
||||
principal,
|
||||
instance_id=_session_instance_id(intake_session),
|
||||
expected_revision=expected_revision,
|
||||
values=values,
|
||||
attachment_refs=attachment_refs,
|
||||
signature_refs=signature_refs,
|
||||
idempotency_key=f"public:{intake_session.session_id}:{idempotency_key}",
|
||||
recorded_at=recorded_at,
|
||||
change_reason=change_reason,
|
||||
)
|
||||
|
||||
def submit_public(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
expected_revision: int,
|
||||
values: Mapping[str, object],
|
||||
attachment_refs: Sequence[EvidenceReference],
|
||||
signature_refs: Sequence[EvidenceReference],
|
||||
idempotency_key: str,
|
||||
recorded_at: datetime,
|
||||
) -> FormInstance:
|
||||
intake_session, profile = self._public_session(
|
||||
session,
|
||||
token=token,
|
||||
now=recorded_at,
|
||||
lock=True,
|
||||
allow_submitted=True,
|
||||
)
|
||||
principal = _public_principal(intake_session, profile)
|
||||
instance = self._runtime.submit_instance(
|
||||
session,
|
||||
principal,
|
||||
instance_id=_session_instance_id(intake_session),
|
||||
expected_revision=expected_revision,
|
||||
values=values,
|
||||
attachment_refs=attachment_refs,
|
||||
signature_refs=signature_refs,
|
||||
idempotency_key=f"public:{intake_session.session_id}:{idempotency_key}",
|
||||
recorded_at=recorded_at,
|
||||
)
|
||||
intake_session.status = "submitted"
|
||||
intake_session.submitted_at = recorded_at
|
||||
session.add(intake_session)
|
||||
session.flush()
|
||||
return instance
|
||||
|
||||
def session_context(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
now: datetime,
|
||||
) -> tuple[FormIntakeSession, FormIntakeProfile, PublicIntakePrincipal]:
|
||||
intake_session, profile = self._public_session(
|
||||
session,
|
||||
token=token,
|
||||
now=now,
|
||||
lock=True,
|
||||
)
|
||||
return intake_session, profile, _public_principal(intake_session, profile)
|
||||
|
||||
def _start_instance(
|
||||
self,
|
||||
session: Session,
|
||||
profile: FormIntakeProfile,
|
||||
intake_session: FormIntakeSession,
|
||||
*,
|
||||
values: Mapping[str, object],
|
||||
recorded_at: datetime,
|
||||
) -> FormInstance:
|
||||
principal = _public_principal(intake_session, profile)
|
||||
return self._runtime.create_instance(
|
||||
session,
|
||||
principal,
|
||||
definition_ref=_profile_definition_ref(profile),
|
||||
values=values,
|
||||
idempotency_key=f"public-start:{intake_session.session_id}",
|
||||
recorded_at=recorded_at,
|
||||
metadata={
|
||||
"intake": {
|
||||
"profile_id": profile.profile_id,
|
||||
"mode": profile.mode,
|
||||
"channel": "public_web",
|
||||
"authenticated_identity_claimed": False,
|
||||
"authority_basis": "invitation"
|
||||
if profile.mode == "invitation"
|
||||
else "self_submission",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def _definition(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
reference: InstitutionalReference,
|
||||
effective_at: datetime,
|
||||
) -> FormDefinition:
|
||||
provider = _capability(self._registry, CAPABILITY_FORM_DEFINITIONS)
|
||||
if not isinstance(provider, FormDefinitionProvider):
|
||||
raise FormIntakeError("The Forms definition provider is unavailable.")
|
||||
definition = provider.get_form_definition(
|
||||
session,
|
||||
principal,
|
||||
reference=reference,
|
||||
effective_at=effective_at,
|
||||
)
|
||||
if definition is None or definition.reference != reference:
|
||||
raise FormIntakeError("The exact Form definition is unavailable.")
|
||||
return definition
|
||||
|
||||
def _profile_for_admin(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
profile_id: str,
|
||||
lock: bool,
|
||||
) -> FormIntakeProfile:
|
||||
query = session.query(FormIntakeProfile).filter(
|
||||
FormIntakeProfile.tenant_id == _principal_tenant(principal),
|
||||
FormIntakeProfile.profile_id == profile_id,
|
||||
)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
profile = query.one_or_none()
|
||||
if profile is None:
|
||||
raise LookupError("Form intake profile not found.")
|
||||
return profile
|
||||
|
||||
def _public_profile(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
public_id: str,
|
||||
expected_mode: str,
|
||||
now: datetime,
|
||||
) -> FormIntakeProfile:
|
||||
_require_aware(now, "Public intake time")
|
||||
profile = (
|
||||
session.query(FormIntakeProfile)
|
||||
.filter(FormIntakeProfile.public_id == public_id)
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if profile is None or not profile.enabled or profile.mode != expected_mode:
|
||||
raise _public_unavailable()
|
||||
_consume_rate_limit(profile, now=now)
|
||||
session.add(profile)
|
||||
return profile
|
||||
|
||||
def _public_session(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
now: datetime,
|
||||
lock: bool,
|
||||
allow_submitted: bool = False,
|
||||
) -> tuple[FormIntakeSession, FormIntakeProfile]:
|
||||
_require_aware(now, "Public intake time")
|
||||
query = session.query(FormIntakeSession).filter(
|
||||
FormIntakeSession.token_sha256 == _token_hash(token)
|
||||
)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
intake_session = query.one_or_none()
|
||||
if intake_session is None:
|
||||
raise _public_unavailable()
|
||||
profile_query = session.query(FormIntakeProfile).filter(
|
||||
FormIntakeProfile.id == intake_session.profile_id
|
||||
)
|
||||
if lock:
|
||||
profile_query = profile_query.with_for_update()
|
||||
profile = profile_query.one_or_none()
|
||||
allowed_states = {"issued", "active"}
|
||||
if allow_submitted:
|
||||
allowed_states.add("submitted")
|
||||
if (
|
||||
profile is None
|
||||
or not profile.enabled
|
||||
or intake_session.status not in allowed_states
|
||||
or intake_session.revoked_at is not None
|
||||
or _aware(intake_session.expires_at) <= now
|
||||
):
|
||||
raise _public_unavailable()
|
||||
_consume_rate_limit(profile, now=now)
|
||||
session.add(profile)
|
||||
return intake_session, profile
|
||||
|
||||
def _session_replay(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
idempotency_key: str,
|
||||
request_sha256: str,
|
||||
) -> FormIntakeSession | None:
|
||||
existing = (
|
||||
session.query(FormIntakeSession)
|
||||
.filter(
|
||||
FormIntakeSession.tenant_id == tenant_id,
|
||||
FormIntakeSession.idempotency_key == idempotency_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None and existing.request_sha256 != request_sha256:
|
||||
raise FormIntakeError(
|
||||
"Public intake idempotency conflict: this key was used for another request."
|
||||
)
|
||||
return existing
|
||||
|
||||
def _new_session(
|
||||
self,
|
||||
profile: FormIntakeProfile,
|
||||
*,
|
||||
token: str,
|
||||
idempotency_key: str,
|
||||
request_sha256: str,
|
||||
expires_at: datetime,
|
||||
created_by: str,
|
||||
metadata: Mapping[str, object] | None = None,
|
||||
) -> FormIntakeSession:
|
||||
session_id = str(uuid.uuid4())
|
||||
return FormIntakeSession(
|
||||
tenant_id=profile.tenant_id,
|
||||
session_id=session_id,
|
||||
profile_id=profile.id,
|
||||
token_sha256=_token_hash(token),
|
||||
mode=profile.mode,
|
||||
status="issued",
|
||||
instance_id=None,
|
||||
actor_id=f"form-public:{session_id}",
|
||||
idempotency_key=idempotency_key,
|
||||
request_sha256=request_sha256,
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
details=dict(metadata or {}),
|
||||
)
|
||||
|
||||
def _instance(
|
||||
self,
|
||||
session: Session,
|
||||
intake_session: FormIntakeSession,
|
||||
) -> FormInstance:
|
||||
profile = session.get(FormIntakeProfile, intake_session.profile_id)
|
||||
if profile is None:
|
||||
raise _public_unavailable()
|
||||
instance = self._runtime.get_instance(
|
||||
session,
|
||||
_public_principal(intake_session, profile),
|
||||
instance_id=_session_instance_id(intake_session),
|
||||
)
|
||||
if instance is None:
|
||||
raise _public_unavailable()
|
||||
return instance
|
||||
|
||||
def _result(
|
||||
self,
|
||||
session: Session,
|
||||
intake_session: FormIntakeSession,
|
||||
*,
|
||||
token: str | None = None,
|
||||
instance: FormInstance | None = None,
|
||||
replayed: bool = False,
|
||||
) -> IntakeSessionResult:
|
||||
if instance is None and intake_session.instance_id:
|
||||
instance = self._instance(session, intake_session)
|
||||
return IntakeSessionResult(
|
||||
session_id=intake_session.session_id,
|
||||
mode=intake_session.mode,
|
||||
status=intake_session.status,
|
||||
expires_at=_aware(intake_session.expires_at),
|
||||
instance=instance,
|
||||
token=token,
|
||||
replayed=replayed,
|
||||
)
|
||||
|
||||
|
||||
def profile_payload(profile: FormIntakeProfile) -> dict[str, object]:
|
||||
return {
|
||||
"profile_id": profile.profile_id,
|
||||
"public_id": profile.public_id,
|
||||
"definition_ref": {
|
||||
"kind": "form",
|
||||
"owner_module": "forms",
|
||||
"object_id": profile.definition_id,
|
||||
"tenant_id": profile.tenant_id,
|
||||
"version": profile.definition_revision,
|
||||
},
|
||||
"mode": profile.mode,
|
||||
"enabled": profile.enabled,
|
||||
"revision": profile.revision,
|
||||
"draft_ttl_seconds": profile.draft_ttl_seconds,
|
||||
"invitation_ttl_seconds": profile.invitation_ttl_seconds,
|
||||
"rate_limit_per_minute": profile.rate_limit_per_minute,
|
||||
"metadata": dict(profile.details),
|
||||
}
|
||||
|
||||
|
||||
def _consume_rate_limit(profile: FormIntakeProfile, *, now: datetime) -> None:
|
||||
window = (
|
||||
_aware(profile.rate_window_started_at)
|
||||
if profile.rate_window_started_at is not None
|
||||
else None
|
||||
)
|
||||
if window is None or now - window >= timedelta(minutes=1):
|
||||
profile.rate_window_started_at = now
|
||||
profile.rate_window_count = 1
|
||||
return
|
||||
if profile.rate_window_count >= profile.rate_limit_per_minute:
|
||||
raise FormIntakeError("Public Form intake is temporarily rate limited.")
|
||||
profile.rate_window_count += 1
|
||||
|
||||
|
||||
def _public_principal(
|
||||
intake_session: FormIntakeSession,
|
||||
profile: FormIntakeProfile,
|
||||
) -> PublicIntakePrincipal:
|
||||
return PublicIntakePrincipal(
|
||||
tenant_id=intake_session.tenant_id,
|
||||
account_id=intake_session.actor_id,
|
||||
auth_method=f"form_public_{intake_session.mode}",
|
||||
profile_id=profile.profile_id,
|
||||
)
|
||||
|
||||
|
||||
def _profile_definition_ref(profile: FormIntakeProfile) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind="form",
|
||||
owner_module="forms",
|
||||
object_id=profile.definition_id,
|
||||
tenant_id=profile.tenant_id,
|
||||
version=profile.definition_revision,
|
||||
)
|
||||
|
||||
|
||||
def _session_instance_id(intake_session: FormIntakeSession) -> str:
|
||||
if not intake_session.instance_id:
|
||||
raise _public_unavailable()
|
||||
return intake_session.instance_id
|
||||
|
||||
|
||||
def _public_unavailable() -> FormIntakeError:
|
||||
return FormIntakeError("Public Form intake is unavailable.")
|
||||
|
||||
|
||||
def _principal_tenant(principal: object) -> str:
|
||||
value = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not value:
|
||||
raise FormIntakeError("Forms Runtime requires a tenant-bound principal.")
|
||||
return value
|
||||
|
||||
|
||||
def _principal_actor(principal: object) -> str:
|
||||
for name in ("account_id", "identity_id", "membership_id"):
|
||||
value = str(getattr(principal, name, "") or "").strip()
|
||||
if value:
|
||||
return value
|
||||
raise FormIntakeError("Forms Runtime requires an acting identity.")
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(name):
|
||||
return None
|
||||
if hasattr(registry, "require_capability"):
|
||||
return registry.require_capability(name)
|
||||
if hasattr(registry, "capability"):
|
||||
return registry.capability(name)
|
||||
return None
|
||||
|
||||
|
||||
def _token_hash(token: str) -> str:
|
||||
clean = str(token or "").strip()
|
||||
if len(clean) < 32:
|
||||
raise _public_unavailable()
|
||||
return hashlib.sha256(clean.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _hash(value: Mapping[str, object]) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest()
|
||||
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_DRAFT_TTL_SECONDS",
|
||||
"DEFAULT_INVITATION_TTL_SECONDS",
|
||||
"DEFAULT_RATE_LIMIT_PER_MINUTE",
|
||||
"FormIntakeError",
|
||||
"FormIntakeService",
|
||||
"IntakeSessionResult",
|
||||
"PublicIntakePrincipal",
|
||||
"profile_payload",
|
||||
]
|
||||
Reference in New Issue
Block a user