Add conditional localized form definitions
This commit is contained in:
@@ -69,7 +69,13 @@ manifest = ModuleManifest(
|
||||
name=MODULE_NAME,
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("access",),
|
||||
optional_dependencies=("forms_runtime", "portal", "workflow_engine", "cases", "policy"),
|
||||
optional_dependencies=(
|
||||
"forms_runtime",
|
||||
"portal",
|
||||
"workflow_engine",
|
||||
"cases",
|
||||
"policy",
|
||||
),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -78,9 +84,21 @@ manifest = ModuleManifest(
|
||||
ModuleInterfaceProvider(name="forms.definitions", version="0.1.0"),
|
||||
),
|
||||
permissions=(
|
||||
_permission(READ_SCOPE, "View form definitions", "Read reusable form definitions and exact revisions."),
|
||||
_permission(WRITE_SCOPE, "Manage form definitions", "Create and revise reusable form definitions."),
|
||||
_permission(ADMIN_SCOPE, "Publish form definitions", "Publish and retire form-definition revisions."),
|
||||
_permission(
|
||||
READ_SCOPE,
|
||||
"View form definitions",
|
||||
"Read reusable form definitions and exact revisions.",
|
||||
),
|
||||
_permission(
|
||||
WRITE_SCOPE,
|
||||
"Manage form definitions",
|
||||
"Create and revise reusable form definitions.",
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE,
|
||||
"Publish form definitions",
|
||||
"Publish and retire form-definition revisions.",
|
||||
),
|
||||
),
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
@@ -196,11 +214,16 @@ manifest = ModuleManifest(
|
||||
documentation_ref="docs/FORMS_BOUNDARY.md",
|
||||
test_ref="tests/test_forms.py",
|
||||
known_limits=(
|
||||
"Conditional multi-page layout, localization authoring, and package-fragment tooling remain product depth.",
|
||||
"Concrete attachment/signature providers, anonymous identity profiles, richer authoring ergonomics, and target-produced accessibility evidence remain product depth.",
|
||||
),
|
||||
supported_authority_modes=("native_authoritative",),
|
||||
owned_concepts=("form definition", "form schema", "form definition revision"),
|
||||
non_owned_concepts=("form submission", "file content", "case", "workflow instance"),
|
||||
non_owned_concepts=(
|
||||
"form submission",
|
||||
"file content",
|
||||
"case",
|
||||
"workflow instance",
|
||||
),
|
||||
reference_packages=("product.service-to-decision",),
|
||||
migration_docs=("docs/FORMS_BOUNDARY.md",),
|
||||
recovery_docs=("docs/FORMS_BOUNDARY.md",),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -11,13 +13,19 @@ from govoplan_forms.backend.schemas import (
|
||||
FormDefinitionHistoryResponse,
|
||||
FormDefinitionListResponse,
|
||||
FormDefinitionWriteRequest,
|
||||
FormPackageImportRequest,
|
||||
FormPackageRequest,
|
||||
)
|
||||
from govoplan_forms.backend.service import (
|
||||
FormDefinitionStoreError,
|
||||
definition_from_mapping,
|
||||
assess_form_definition_fragment,
|
||||
export_form_definition_fragment,
|
||||
form_definition_diagnostics,
|
||||
form_definition_history,
|
||||
get_form_definition,
|
||||
list_form_definitions,
|
||||
import_form_definition_fragment,
|
||||
record_form_definition,
|
||||
)
|
||||
|
||||
@@ -32,9 +40,11 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||
|
||||
def _error(exc: Exception) -> HTTPException:
|
||||
message = str(exc)
|
||||
code = 409 if any(
|
||||
word in message.casefold() for word in ("conflict", "already", "stale")
|
||||
) else 400
|
||||
code = (
|
||||
409
|
||||
if any(word in message.casefold() for word in ("conflict", "already", "stale"))
|
||||
else 400
|
||||
)
|
||||
return HTTPException(status_code=code, detail=message)
|
||||
|
||||
|
||||
@@ -117,6 +127,95 @@ def api_get_form_definition(
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.get("/definitions/{form_id}/diagnostics", response_model=dict[str, object])
|
||||
def api_form_definition_diagnostics(
|
||||
form_id: str,
|
||||
revision: str | None = Query(default=None, max_length=255),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, READ_SCOPE)
|
||||
item = get_form_definition(session, principal, form_id=form_id, revision=revision)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Form definition not found")
|
||||
diagnostics = form_definition_diagnostics(item)
|
||||
return {
|
||||
"diagnostics": [dict(value) for value in diagnostics],
|
||||
"error_count": sum(value.get("severity") == "error" for value in diagnostics),
|
||||
"warning_count": sum(
|
||||
value.get("severity") == "warning" for value in diagnostics
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/definitions/{form_id}/package", response_model=dict[str, object])
|
||||
def api_export_form_definition_package(
|
||||
form_id: str,
|
||||
revision: str | None = Query(default=None, max_length=255),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, READ_SCOPE)
|
||||
item = get_form_definition(session, principal, form_id=form_id, revision=revision)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Form definition not found")
|
||||
actor_id = next(
|
||||
(
|
||||
str(getattr(principal, name))
|
||||
for name in ("account_id", "identity_id", "membership_id")
|
||||
if getattr(principal, name, None)
|
||||
),
|
||||
None,
|
||||
)
|
||||
return export_form_definition_fragment(
|
||||
item,
|
||||
exported_at=datetime.now(UTC),
|
||||
exported_by=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/packages/assess", response_model=dict[str, object])
|
||||
def api_assess_form_definition_package(
|
||||
payload: FormPackageRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
try:
|
||||
return assess_form_definition_fragment(
|
||||
session,
|
||||
principal,
|
||||
fragment=payload.fragment,
|
||||
)
|
||||
except (FormDefinitionStoreError, InstitutionalContextError) as exc:
|
||||
raise _error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/packages/import", response_model=dict[str, object])
|
||||
def api_import_form_definition_package(
|
||||
payload: FormPackageImportRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
try:
|
||||
item = import_form_definition_fragment(
|
||||
session,
|
||||
principal,
|
||||
fragment=payload.fragment,
|
||||
target_form_id=payload.target_form_id,
|
||||
target_key=payload.target_key,
|
||||
expected_revision=payload.expected_revision,
|
||||
change_reason=payload.change_reason,
|
||||
recorded_at=payload.recorded_at,
|
||||
)
|
||||
session.commit()
|
||||
except (FormDefinitionStoreError, InstitutionalContextError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/definitions/{form_id}/history",
|
||||
response_model=FormDefinitionHistoryResponse,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -21,8 +22,24 @@ class FormDefinitionHistoryResponse(BaseModel):
|
||||
revisions: list[dict[str, Any]]
|
||||
|
||||
|
||||
class FormPackageRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
fragment: dict[str, Any]
|
||||
|
||||
|
||||
class FormPackageImportRequest(FormPackageRequest):
|
||||
target_form_id: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
target_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
expected_revision: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
change_reason: str = Field(min_length=1, max_length=1000)
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FormDefinitionHistoryResponse",
|
||||
"FormDefinitionListResponse",
|
||||
"FormDefinitionWriteRequest",
|
||||
"FormPackageImportRequest",
|
||||
"FormPackageRequest",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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
|
||||
|
||||
@@ -16,6 +20,8 @@ from govoplan_core.core.events import (
|
||||
)
|
||||
from govoplan_core.core.institutional import (
|
||||
FormDefinition,
|
||||
FormConditionExpression,
|
||||
FormFieldDefinition,
|
||||
InstitutionalContextError,
|
||||
InstitutionalReference,
|
||||
)
|
||||
@@ -99,9 +105,10 @@ def record_form_definition(
|
||||
raise FormDefinitionStoreError(
|
||||
"A Form definition key cannot change across revisions."
|
||||
)
|
||||
if definition.publication_state not in _PUBLICATION_TRANSITIONS[
|
||||
current.publication_state
|
||||
]:
|
||||
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."
|
||||
@@ -151,6 +158,227 @@ def record_form_definition(
|
||||
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,
|
||||
@@ -326,6 +554,223 @@ def _validate_definition(definition: FormDefinition, *, tenant_id: str) -> None:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user