826 lines
28 KiB
Python
826 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import re
|
|
from typing import Any, Mapping, Sequence
|
|
import uuid
|
|
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.core.events import (
|
|
EventActorRef,
|
|
EventObjectRef,
|
|
EventTenantRef,
|
|
PlatformEvent,
|
|
emit_platform_event,
|
|
)
|
|
from govoplan_core.core.institutional import (
|
|
FormDefinition,
|
|
FormConditionExpression,
|
|
FormFieldDefinition,
|
|
InstitutionalContextError,
|
|
InstitutionalReference,
|
|
)
|
|
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
|
|
|
|
|
_PUBLICATION_TRANSITIONS: dict[str, frozenset[str]] = {
|
|
"draft": frozenset({"draft", "published", "retired"}),
|
|
"published": frozenset({"published", "retired"}),
|
|
"retired": frozenset(),
|
|
}
|
|
|
|
|
|
class FormDefinitionStoreError(ValueError):
|
|
pass
|
|
|
|
|
|
def definition_from_mapping(value: Mapping[str, object]) -> FormDefinition:
|
|
try:
|
|
return FormDefinition.from_mapping(value)
|
|
except InstitutionalContextError as exc:
|
|
raise FormDefinitionStoreError(str(exc)) from exc
|
|
|
|
|
|
def record_form_definition(
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
definition: FormDefinition,
|
|
expected_revision: str | None = None,
|
|
) -> FormDefinition:
|
|
tenant_id = _principal_tenant(principal)
|
|
_validate_definition(definition, tenant_id=tenant_id)
|
|
payload = definition.to_dict()
|
|
replay = (
|
|
session.query(FormDefinitionRevision)
|
|
.filter(
|
|
FormDefinitionRevision.tenant_id == tenant_id,
|
|
FormDefinitionRevision.form_id == definition.reference.object_id,
|
|
FormDefinitionRevision.revision == definition.temporal.revision,
|
|
)
|
|
.one_or_none()
|
|
)
|
|
if replay is not None:
|
|
if replay.payload != payload:
|
|
raise FormDefinitionStoreError(
|
|
"A different Form definition already uses this revision."
|
|
)
|
|
return _definition_from_row(replay)
|
|
|
|
current = _current_row(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
form_id=definition.reference.object_id,
|
|
lock=True,
|
|
)
|
|
if current is None:
|
|
if expected_revision is not None:
|
|
raise FormDefinitionStoreError(
|
|
"Form definition revision conflict: no current revision exists."
|
|
)
|
|
key_collision = (
|
|
session.query(FormDefinitionRevision.id)
|
|
.filter(
|
|
FormDefinitionRevision.tenant_id == tenant_id,
|
|
FormDefinitionRevision.form_key == definition.key,
|
|
FormDefinitionRevision.superseded_at.is_(None),
|
|
)
|
|
.first()
|
|
)
|
|
if key_collision is not None:
|
|
raise FormDefinitionStoreError(
|
|
"Form definition key is already in use in this tenant."
|
|
)
|
|
else:
|
|
if expected_revision != current.revision:
|
|
raise FormDefinitionStoreError(
|
|
"Form definition revision conflict: the expected revision is stale."
|
|
)
|
|
if definition.key != current.form_key:
|
|
raise FormDefinitionStoreError(
|
|
"A Form definition key cannot change across revisions."
|
|
)
|
|
if (
|
|
definition.publication_state
|
|
not in _PUBLICATION_TRANSITIONS[current.publication_state]
|
|
):
|
|
raise FormDefinitionStoreError(
|
|
f"Form publication transition {current.publication_state!r} to "
|
|
f"{definition.publication_state!r} is not allowed."
|
|
)
|
|
current.superseded_at = _recorded_at(definition)
|
|
|
|
row = FormDefinitionRevision(
|
|
tenant_id=tenant_id,
|
|
form_id=definition.reference.object_id,
|
|
form_key=definition.key,
|
|
revision=definition.temporal.revision,
|
|
previous_revision_id=current.id if current is not None else None,
|
|
publication_state=definition.publication_state,
|
|
title=definition.title,
|
|
recorded_at=_recorded_at(definition),
|
|
search_text=f"{definition.key} {definition.title} {definition.description or ''}".casefold(),
|
|
payload=payload,
|
|
changed_by=_principal_actor(principal),
|
|
)
|
|
session.add(row)
|
|
session.flush()
|
|
event_id = str(uuid.uuid4())
|
|
emit_platform_event(
|
|
session,
|
|
PlatformEvent(
|
|
event_id=event_id,
|
|
type="forms.definition.recorded",
|
|
module_id="forms",
|
|
payload={
|
|
"form_id": row.form_id,
|
|
"form_key": row.form_key,
|
|
"revision": row.revision,
|
|
"publication_state": row.publication_state,
|
|
"field_count": len(definition.fields),
|
|
},
|
|
occurred_at=row.recorded_at,
|
|
actor=EventActorRef(type="account", id=_principal_actor(principal)),
|
|
tenant=EventTenantRef(id=tenant_id),
|
|
resource=EventObjectRef(
|
|
type="form_definition",
|
|
id=row.form_id,
|
|
label=row.title,
|
|
),
|
|
classification="internal",
|
|
),
|
|
)
|
|
return _definition_from_row(row)
|
|
|
|
|
|
def form_definition_diagnostics(
|
|
definition: FormDefinition,
|
|
) -> tuple[Mapping[str, object], ...]:
|
|
"""Return deterministic, non-blocking authoring diagnostics.
|
|
|
|
Structural errors are rejected by ``_validate_definition``. Diagnostics are
|
|
reserved for useful publication quality feedback such as untranslated text.
|
|
"""
|
|
|
|
diagnostics: list[Mapping[str, object]] = []
|
|
field_by_key = {item.key: item for item in definition.fields}
|
|
page_keys = {item.key for item in definition.pages}
|
|
section_keys = {item.key for page in definition.pages for item in page.sections}
|
|
for localization in definition.localizations:
|
|
locale = localization.locale
|
|
if not localization.title:
|
|
diagnostics.append(
|
|
_definition_diagnostic(
|
|
"warning",
|
|
"translation.title_missing",
|
|
f"{locale} does not translate the Form title.",
|
|
locale=locale,
|
|
)
|
|
)
|
|
for field in definition.fields:
|
|
if field.key not in localization.field_labels:
|
|
diagnostics.append(
|
|
_definition_diagnostic(
|
|
"warning",
|
|
"translation.field_label_missing",
|
|
f"{locale} does not translate field {field.key!r}.",
|
|
locale=locale,
|
|
subject=field.key,
|
|
)
|
|
)
|
|
if field.help_text and field.key not in localization.field_help_texts:
|
|
diagnostics.append(
|
|
_definition_diagnostic(
|
|
"warning",
|
|
"translation.field_help_missing",
|
|
f"{locale} does not translate help for field {field.key!r}.",
|
|
locale=locale,
|
|
subject=field.key,
|
|
)
|
|
)
|
|
translated_options = localization.option_labels.get(field.key, {})
|
|
for option in field.options:
|
|
if option not in translated_options:
|
|
diagnostics.append(
|
|
_definition_diagnostic(
|
|
"warning",
|
|
"translation.option_missing",
|
|
f"{locale} does not translate option {option!r} of field {field.key!r}.",
|
|
locale=locale,
|
|
subject=f"{field.key}:{option}",
|
|
)
|
|
)
|
|
for key in sorted(page_keys - set(localization.page_titles)):
|
|
diagnostics.append(
|
|
_definition_diagnostic(
|
|
"warning",
|
|
"translation.page_title_missing",
|
|
f"{locale} does not translate page {key!r}.",
|
|
locale=locale,
|
|
subject=key,
|
|
)
|
|
)
|
|
for key in sorted(section_keys - set(localization.section_titles)):
|
|
diagnostics.append(
|
|
_definition_diagnostic(
|
|
"warning",
|
|
"translation.section_title_missing",
|
|
f"{locale} does not translate section {key!r}.",
|
|
locale=locale,
|
|
subject=key,
|
|
)
|
|
)
|
|
# These sets are validated structurally. Keeping the lookup here makes
|
|
# diagnostics stable if future compatible readers retain unknown keys.
|
|
_ = field_by_key
|
|
return tuple(diagnostics)
|
|
|
|
|
|
def export_form_definition_fragment(
|
|
definition: FormDefinition,
|
|
*,
|
|
exported_at: datetime,
|
|
exported_by: str | None,
|
|
) -> dict[str, object]:
|
|
if exported_at.tzinfo is None or exported_at.utcoffset() is None:
|
|
raise FormDefinitionStoreError("Package exported_at must include a timezone.")
|
|
definition_payload = definition.to_dict()
|
|
digest = _payload_sha256(definition_payload)
|
|
return {
|
|
"kind": "govoplan.forms.definition",
|
|
"contract_version": "0.1.0",
|
|
"definition": definition_payload,
|
|
"definition_sha256": digest,
|
|
"provenance": {
|
|
"owner_module": "forms",
|
|
"tenant_id": definition.reference.tenant_id,
|
|
"form_id": definition.reference.object_id,
|
|
"revision": definition.reference.version,
|
|
"exported_at": exported_at.isoformat(),
|
|
"exported_by": exported_by,
|
|
},
|
|
}
|
|
|
|
|
|
def assess_form_definition_fragment(
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
fragment: Mapping[str, object],
|
|
) -> dict[str, object]:
|
|
source = _definition_from_fragment(fragment)
|
|
tenant_id = _principal_tenant(principal)
|
|
current = get_form_definition(
|
|
session,
|
|
principal,
|
|
form_id=source.reference.object_id,
|
|
)
|
|
key_collision = (
|
|
session.query(FormDefinitionRevision.form_id)
|
|
.filter(
|
|
FormDefinitionRevision.tenant_id == tenant_id,
|
|
FormDefinitionRevision.form_key == source.key,
|
|
FormDefinitionRevision.superseded_at.is_(None),
|
|
)
|
|
.first()
|
|
)
|
|
same_tenant = source.reference.tenant_id == tenant_id
|
|
if (
|
|
same_tenant
|
|
and current is not None
|
|
and current.reference.version == source.reference.version
|
|
):
|
|
outcome = (
|
|
"replay" if current.to_dict() == source.to_dict() else "revision_conflict"
|
|
)
|
|
elif current is not None:
|
|
outcome = "new_revision_required"
|
|
elif key_collision is not None:
|
|
outcome = "key_conflict"
|
|
else:
|
|
outcome = "create"
|
|
return {
|
|
"outcome": outcome,
|
|
"portable": True,
|
|
"same_tenant": same_tenant,
|
|
"source": {
|
|
"tenant_id": source.reference.tenant_id,
|
|
"form_id": source.reference.object_id,
|
|
"key": source.key,
|
|
"revision": source.reference.version,
|
|
},
|
|
"current_revision": current.reference.version if current else None,
|
|
"requires_remap": not same_tenant,
|
|
}
|
|
|
|
|
|
def import_form_definition_fragment(
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
fragment: Mapping[str, object],
|
|
target_form_id: str | None,
|
|
target_key: str | None,
|
|
expected_revision: str | None,
|
|
change_reason: str,
|
|
recorded_at: datetime,
|
|
) -> FormDefinition:
|
|
source = _definition_from_fragment(fragment)
|
|
tenant_id = _principal_tenant(principal)
|
|
if recorded_at.tzinfo is None or recorded_at.utcoffset() is None:
|
|
raise FormDefinitionStoreError(
|
|
"Package import recorded_at must include a timezone."
|
|
)
|
|
clean_reason = str(change_reason or "").strip()
|
|
if not clean_reason or len(clean_reason) > 1000:
|
|
raise FormDefinitionStoreError(
|
|
"Package import requires a change reason of at most 1000 characters."
|
|
)
|
|
resolved_id = str(target_form_id or source.reference.object_id).strip()
|
|
resolved_key = str(target_key or source.key).strip()
|
|
current = get_form_definition(session, principal, form_id=resolved_id)
|
|
revision = str(uuid.uuid4())
|
|
payload = source.to_dict()
|
|
payload["reference"] = {
|
|
**dict(payload["reference"]),
|
|
"object_id": resolved_id,
|
|
"tenant_id": tenant_id,
|
|
"version": revision,
|
|
}
|
|
payload["key"] = current.key if current is not None else resolved_key
|
|
payload["temporal"] = {
|
|
"revision": revision,
|
|
"valid_from": recorded_at.isoformat(),
|
|
"valid_to": None,
|
|
"recorded_at": recorded_at.isoformat(),
|
|
"superseded_at": None,
|
|
"change_reason": clean_reason,
|
|
}
|
|
payload["publication_state"] = "draft"
|
|
metadata = dict(source.metadata)
|
|
metadata["package_import"] = {
|
|
"source_tenant_id": source.reference.tenant_id,
|
|
"source_form_id": source.reference.object_id,
|
|
"source_revision": source.reference.version,
|
|
"source_sha256": str(fragment.get("definition_sha256") or ""),
|
|
}
|
|
payload["metadata"] = metadata
|
|
imported = definition_from_mapping(payload)
|
|
return record_form_definition(
|
|
session,
|
|
principal,
|
|
definition=imported,
|
|
expected_revision=expected_revision,
|
|
)
|
|
|
|
|
|
def get_form_definition(
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
form_id: str,
|
|
revision: str | None = None,
|
|
) -> FormDefinition | None:
|
|
tenant_id = _principal_tenant(principal)
|
|
query = session.query(FormDefinitionRevision).filter(
|
|
FormDefinitionRevision.tenant_id == tenant_id,
|
|
FormDefinitionRevision.form_id == form_id,
|
|
)
|
|
if revision is None:
|
|
query = query.filter(FormDefinitionRevision.superseded_at.is_(None))
|
|
else:
|
|
query = query.filter(FormDefinitionRevision.revision == revision)
|
|
row = query.order_by(FormDefinitionRevision.recorded_at.desc()).first()
|
|
return _definition_from_row(row) if row is not None else None
|
|
|
|
|
|
def list_form_definitions(
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
query: str = "",
|
|
publication_states: Sequence[str] | None = None,
|
|
offset: int = 0,
|
|
limit: int = 100,
|
|
) -> tuple[tuple[FormDefinition, ...], int]:
|
|
tenant_id = _principal_tenant(principal)
|
|
if offset < 0 or not 1 <= limit <= 200:
|
|
raise FormDefinitionStoreError(
|
|
"Form definition offset must be non-negative and limit between 1 and 200."
|
|
)
|
|
statement = session.query(FormDefinitionRevision).filter(
|
|
FormDefinitionRevision.tenant_id == tenant_id,
|
|
FormDefinitionRevision.superseded_at.is_(None),
|
|
)
|
|
if publication_states:
|
|
statement = statement.filter(
|
|
FormDefinitionRevision.publication_state.in_(tuple(publication_states))
|
|
)
|
|
clean_query = query.strip().casefold()
|
|
if clean_query:
|
|
statement = statement.filter(
|
|
FormDefinitionRevision.search_text.contains(clean_query)
|
|
)
|
|
total = int(statement.with_entities(func.count()).scalar() or 0)
|
|
rows = (
|
|
statement.order_by(
|
|
FormDefinitionRevision.form_key.asc(),
|
|
FormDefinitionRevision.recorded_at.desc(),
|
|
)
|
|
.offset(offset)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
return tuple(_definition_from_row(row) for row in rows), total
|
|
|
|
|
|
def form_definition_history(
|
|
session: Session,
|
|
principal: object,
|
|
*,
|
|
form_id: str,
|
|
limit: int = 100,
|
|
) -> tuple[FormDefinition, ...]:
|
|
tenant_id = _principal_tenant(principal)
|
|
if not 1 <= limit <= 200:
|
|
raise FormDefinitionStoreError("Form history limit must be between 1 and 200.")
|
|
rows = (
|
|
session.query(FormDefinitionRevision)
|
|
.filter(
|
|
FormDefinitionRevision.tenant_id == tenant_id,
|
|
FormDefinitionRevision.form_id == form_id,
|
|
)
|
|
.order_by(FormDefinitionRevision.recorded_at.desc())
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
return tuple(_definition_from_row(row) for row in rows)
|
|
|
|
|
|
class SqlFormDefinitionProvider:
|
|
def get_form_definition(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
reference: InstitutionalReference,
|
|
effective_at: datetime | None = None,
|
|
) -> FormDefinition | None:
|
|
tenant_id = _principal_tenant(principal)
|
|
if (
|
|
reference.kind != "form"
|
|
or reference.owner_module != "forms"
|
|
or reference.tenant_id != tenant_id
|
|
or not reference.version
|
|
):
|
|
raise InstitutionalContextError(
|
|
"Form definition lookup requires an exact same-tenant Forms reference."
|
|
)
|
|
definition = get_form_definition(
|
|
_session(session),
|
|
principal,
|
|
form_id=reference.object_id,
|
|
revision=reference.version,
|
|
)
|
|
if definition is None or (
|
|
effective_at is not None
|
|
and not definition.temporal.effective_at(effective_at)
|
|
):
|
|
return None
|
|
return definition
|
|
|
|
def list_form_definitions(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
tenant_id: str,
|
|
query: str = "",
|
|
limit: int = 100,
|
|
) -> Sequence[FormDefinition]:
|
|
if tenant_id != _principal_tenant(principal):
|
|
raise InstitutionalContextError(
|
|
"Form definition catalogue lookup cannot cross tenants."
|
|
)
|
|
items, _ = list_form_definitions(
|
|
_session(session),
|
|
principal,
|
|
query=query,
|
|
publication_states=("published",),
|
|
limit=limit,
|
|
)
|
|
return items
|
|
|
|
|
|
def _current_row(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
form_id: str,
|
|
lock: bool,
|
|
) -> FormDefinitionRevision | None:
|
|
query = session.query(FormDefinitionRevision).filter(
|
|
FormDefinitionRevision.tenant_id == tenant_id,
|
|
FormDefinitionRevision.form_id == form_id,
|
|
FormDefinitionRevision.superseded_at.is_(None),
|
|
)
|
|
if lock:
|
|
query = query.with_for_update()
|
|
return query.one_or_none()
|
|
|
|
|
|
def _definition_from_row(row: FormDefinitionRevision) -> FormDefinition:
|
|
payload: dict[str, Any] = dict(row.payload)
|
|
temporal = dict(payload.get("temporal") or {})
|
|
temporal["superseded_at"] = _datetime_text(row.superseded_at)
|
|
payload["temporal"] = temporal
|
|
return FormDefinition.from_mapping(payload)
|
|
|
|
|
|
def _validate_definition(definition: FormDefinition, *, tenant_id: str) -> None:
|
|
if definition.reference.owner_module != "forms":
|
|
raise FormDefinitionStoreError("Form definitions must be owned by Forms.")
|
|
if definition.reference.tenant_id != tenant_id:
|
|
raise FormDefinitionStoreError("Form definitions cannot cross tenants.")
|
|
if definition.temporal.superseded_at is not None:
|
|
raise FormDefinitionStoreError("Clients cannot set Form superseded_at.")
|
|
_recorded_at(definition)
|
|
if not str(definition.temporal.change_reason or "").strip():
|
|
raise FormDefinitionStoreError(
|
|
"A Form definition revision requires a change reason."
|
|
)
|
|
_validate_form_composition(definition)
|
|
|
|
|
|
def _validate_form_composition(definition: FormDefinition) -> None:
|
|
fields = {item.key: item for item in definition.fields}
|
|
if definition.pages:
|
|
field_occurrences = [
|
|
field_key
|
|
for page in definition.pages
|
|
for section in page.sections
|
|
for field_key in section.field_keys
|
|
]
|
|
unknown = sorted(set(field_occurrences) - set(fields))
|
|
if unknown:
|
|
raise FormDefinitionStoreError(
|
|
f"Form pages reference unknown fields: {', '.join(unknown)}."
|
|
)
|
|
duplicates = sorted(
|
|
key for key in set(field_occurrences) if field_occurrences.count(key) > 1
|
|
)
|
|
if duplicates:
|
|
raise FormDefinitionStoreError(
|
|
f"Form pages place fields more than once: {', '.join(duplicates)}."
|
|
)
|
|
missing = sorted(set(fields) - set(field_occurrences))
|
|
if missing:
|
|
raise FormDefinitionStoreError(
|
|
f"Form pages do not place fields: {', '.join(missing)}."
|
|
)
|
|
conditions: list[tuple[str, FormConditionExpression]] = []
|
|
for field in definition.fields:
|
|
if field.visibility_condition is not None:
|
|
conditions.append((f"field:{field.key}", field.visibility_condition))
|
|
for page in definition.pages:
|
|
if page.visibility_condition is not None:
|
|
conditions.append((f"page:{page.key}", page.visibility_condition))
|
|
for section in page.sections:
|
|
if section.visibility_condition is not None:
|
|
conditions.append(
|
|
(f"section:{section.key}", section.visibility_condition)
|
|
)
|
|
for subject, condition in conditions:
|
|
_validate_condition(condition, fields=fields, subject=subject)
|
|
graph = {
|
|
field.key: set(field.visibility_condition.referenced_fields)
|
|
if field.visibility_condition is not None
|
|
else set()
|
|
for field in definition.fields
|
|
}
|
|
_reject_condition_cycles(graph)
|
|
page_keys = {item.key for item in definition.pages}
|
|
section_keys = {item.key for page in definition.pages for item in page.sections}
|
|
for localization in definition.localizations:
|
|
unknown_fields = (
|
|
set(localization.field_labels)
|
|
| set(localization.field_help_texts)
|
|
| set(localization.option_labels)
|
|
) - set(fields)
|
|
if unknown_fields:
|
|
raise FormDefinitionStoreError(
|
|
f"Form localization {localization.locale!r} references unknown fields: "
|
|
f"{', '.join(sorted(unknown_fields))}."
|
|
)
|
|
if set(localization.page_titles) - page_keys:
|
|
raise FormDefinitionStoreError(
|
|
f"Form localization {localization.locale!r} references unknown pages."
|
|
)
|
|
if set(localization.section_titles) - section_keys:
|
|
raise FormDefinitionStoreError(
|
|
f"Form localization {localization.locale!r} references unknown sections."
|
|
)
|
|
for field_key, labels in localization.option_labels.items():
|
|
field = fields[field_key]
|
|
if set(labels) - set(field.options):
|
|
raise FormDefinitionStoreError(
|
|
f"Form localization {localization.locale!r} translates unknown "
|
|
f"options for field {field_key!r}."
|
|
)
|
|
|
|
|
|
def _validate_condition(
|
|
condition: FormConditionExpression,
|
|
*,
|
|
fields: Mapping[str, FormFieldDefinition],
|
|
subject: str,
|
|
) -> None:
|
|
if condition.kind != "predicate":
|
|
for child in condition.conditions:
|
|
_validate_condition(child, fields=fields, subject=subject)
|
|
return
|
|
field_key = str(condition.field_key)
|
|
field = fields.get(field_key)
|
|
if field is None:
|
|
raise FormDefinitionStoreError(
|
|
f"Form condition on {subject} references unknown field {field_key!r}."
|
|
)
|
|
operator = str(condition.operator)
|
|
if operator in {"lt", "lte", "gt", "gte"} and field.value_type not in {
|
|
"integer",
|
|
"number",
|
|
"date",
|
|
"datetime",
|
|
}:
|
|
raise FormDefinitionStoreError(
|
|
f"Form condition operator {operator!r} is incompatible with "
|
|
f"field {field_key!r} ({field.value_type})."
|
|
)
|
|
if operator == "contains" and field.value_type not in {
|
|
"text",
|
|
"multiline_text",
|
|
"email",
|
|
"multi_choice",
|
|
"list",
|
|
}:
|
|
raise FormDefinitionStoreError(
|
|
f"Form condition operator 'contains' is incompatible with field {field_key!r}."
|
|
)
|
|
if operator in {"in", "not_in"} and (
|
|
not isinstance(condition.value, Sequence)
|
|
or isinstance(condition.value, (str, bytes))
|
|
):
|
|
raise FormDefinitionStoreError(
|
|
f"Form condition operator {operator!r} requires a list value."
|
|
)
|
|
if operator not in {"is_empty", "is_not_empty", "in", "not_in"}:
|
|
if not _condition_value_matches(field, condition.value):
|
|
raise FormDefinitionStoreError(
|
|
f"Form condition value is incompatible with field {field_key!r} "
|
|
f"({field.value_type})."
|
|
)
|
|
|
|
|
|
def _condition_value_matches(field: FormFieldDefinition, value: object) -> bool:
|
|
if value is None:
|
|
return True
|
|
if field.value_type == "boolean":
|
|
return isinstance(value, bool)
|
|
if field.value_type == "integer":
|
|
return isinstance(value, int) and not isinstance(value, bool)
|
|
if field.value_type == "number":
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
if field.value_type in {"object"}:
|
|
return isinstance(value, Mapping)
|
|
if field.value_type in {"list", "multi_choice"}:
|
|
return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
|
|
return isinstance(value, str)
|
|
|
|
|
|
def _reject_condition_cycles(graph: Mapping[str, set[str]]) -> None:
|
|
visiting: set[str] = set()
|
|
visited: set[str] = set()
|
|
|
|
def visit(key: str, path: tuple[str, ...]) -> None:
|
|
if key in visiting:
|
|
cycle = " -> ".join((*path, key))
|
|
raise FormDefinitionStoreError(
|
|
f"Form visibility conditions contain a dependency cycle: {cycle}."
|
|
)
|
|
if key in visited:
|
|
return
|
|
visiting.add(key)
|
|
for dependency in sorted(graph.get(key, set())):
|
|
if dependency in graph:
|
|
visit(dependency, (*path, key))
|
|
visiting.remove(key)
|
|
visited.add(key)
|
|
|
|
for key in sorted(graph):
|
|
visit(key, ())
|
|
|
|
|
|
def _definition_from_fragment(fragment: Mapping[str, object]) -> FormDefinition:
|
|
if fragment.get("kind") != "govoplan.forms.definition":
|
|
raise FormDefinitionStoreError("Unsupported Forms package fragment kind.")
|
|
if fragment.get("contract_version") != "0.1.0":
|
|
raise FormDefinitionStoreError("Unsupported Forms package contract version.")
|
|
payload = fragment.get("definition")
|
|
if not isinstance(payload, Mapping):
|
|
raise FormDefinitionStoreError("Forms package definition must be an object.")
|
|
expected = str(fragment.get("definition_sha256") or "")
|
|
if not re.fullmatch(r"[0-9a-f]{64}", expected) or not _constant_time_equal(
|
|
expected,
|
|
_payload_sha256(payload),
|
|
):
|
|
raise FormDefinitionStoreError("Forms package definition digest is invalid.")
|
|
return definition_from_mapping(payload)
|
|
|
|
|
|
def _payload_sha256(value: Mapping[str, object]) -> str:
|
|
encoded = json.dumps(
|
|
value,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=True,
|
|
).encode("utf-8")
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
def _constant_time_equal(left: str, right: str) -> bool:
|
|
return hmac.compare_digest(left, right)
|
|
|
|
|
|
def _definition_diagnostic(
|
|
severity: str,
|
|
code: str,
|
|
message: str,
|
|
*,
|
|
locale: str | None = None,
|
|
subject: str | None = None,
|
|
) -> Mapping[str, object]:
|
|
return {
|
|
"severity": severity,
|
|
"code": code,
|
|
"message": message,
|
|
"locale": locale,
|
|
"subject": subject,
|
|
}
|
|
|
|
|
|
def _recorded_at(definition: FormDefinition) -> datetime:
|
|
if definition.temporal.recorded_at is None:
|
|
raise FormDefinitionStoreError(
|
|
"A Form definition revision requires recorded_at."
|
|
)
|
|
return definition.temporal.recorded_at
|
|
|
|
|
|
def _principal_tenant(principal: object) -> str:
|
|
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
|
if not tenant_id:
|
|
raise InstitutionalContextError(
|
|
"Form definition operations require a tenant-bound principal."
|
|
)
|
|
return tenant_id
|
|
|
|
|
|
def _principal_actor(principal: object) -> str | None:
|
|
for name in ("account_id", "identity_id", "membership_id"):
|
|
value = str(getattr(principal, name, "") or "").strip()
|
|
if value:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _session(value: object) -> Session:
|
|
if not hasattr(value, "query"):
|
|
raise InstitutionalContextError(
|
|
"Form definition provider requires a database session."
|
|
)
|
|
return value # type: ignore[return-value]
|
|
|
|
|
|
def _datetime_text(value: datetime | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=UTC)
|
|
return value.isoformat()
|
|
|
|
|
|
__all__ = [
|
|
"FormDefinitionStoreError",
|
|
"SqlFormDefinitionProvider",
|
|
"definition_from_mapping",
|
|
"form_definition_history",
|
|
"get_form_definition",
|
|
"list_form_definitions",
|
|
"record_form_definition",
|
|
]
|