diff --git a/README.md b/README.md index 9f48ac1..56ed0eb 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,13 @@ requirements, policy references, and permitted handoff kinds. Saving always creates an exact immutable revision; it never mutates a published schema in place. +The designer also supports multi-page/section layout, bounded conditional +visibility expressions, localized labels/help/options/page titles, fallback +locale, and accessibility metadata. Server validation rejects unknown fields, +incompatible operators, placement errors, and condition cycles. Verified +configuration-package fragments can be assessed and imported as a new local +draft with source provenance rather than silently becoming active. + Definition writes use optimistic concurrency. Replaying the same object and revision is safe only when the payload is identical. Published definitions may be retired but not silently returned to draft. diff --git a/docs/FORMS_BOUNDARY.md b/docs/FORMS_BOUNDARY.md index 554ec04..a7e2d76 100644 --- a/docs/FORMS_BOUNDARY.md +++ b/docs/FORMS_BOUNDARY.md @@ -81,6 +81,9 @@ boundary emits only schema identity, revision, publication state, and field count; field content remains in the owning database. The schema vocabulary covers scalar, choice, structured, attachment, signature, -policy-reference, and handoff constraints. Conditional page layout, -localization authoring, and package-fragment tooling remain product depth that -can deepen this owner without changing the runtime boundary. +policy-reference, and handoff constraints. Conditional page/section layout, +cycle-safe predicates, localization authoring, accessibility assessment, and +verified package-fragment assessment/import are implemented. Remaining depth is +concrete attachment/signature providers, richer authoring ergonomics, public +identity profiles, and target-produced accessibility evidence; those do not +change the runtime boundary. diff --git a/src/govoplan_forms/backend/manifest.py b/src/govoplan_forms/backend/manifest.py index da9d288..2d72274 100644 --- a/src/govoplan_forms/backend/manifest.py +++ b/src/govoplan_forms/backend/manifest.py @@ -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",), diff --git a/src/govoplan_forms/backend/router.py b/src/govoplan_forms/backend/router.py index 331e2f2..5c4e10c 100644 --- a/src/govoplan_forms/backend/router.py +++ b/src/govoplan_forms/backend/router.py @@ -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, diff --git a/src/govoplan_forms/backend/schemas.py b/src/govoplan_forms/backend/schemas.py index 6d07e06..7e0678e 100644 --- a/src/govoplan_forms/backend/schemas.py +++ b/src/govoplan_forms/backend/schemas.py @@ -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", ] diff --git a/src/govoplan_forms/backend/service.py b/src/govoplan_forms/backend/service.py index 86666fa..f95381e 100644 --- a/src/govoplan_forms/backend/service.py +++ b/src/govoplan_forms/backend/service.py @@ -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: diff --git a/tests/test_forms.py b/tests/test_forms.py index 5371a5c..cc18fde 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import UTC, datetime, timedelta import unittest @@ -9,7 +9,11 @@ from sqlalchemy.orm import Session from govoplan_core.core.institutional import ( FormDefinition, + FormConditionExpression, FormFieldDefinition, + FormLocalization, + FormPageDefinition, + FormSectionDefinition, InstitutionalReference, TemporalRevision, ) @@ -17,7 +21,11 @@ from govoplan_forms.backend.db.models import FormDefinitionRevision from govoplan_forms.backend.service import ( FormDefinitionStoreError, SqlFormDefinitionProvider, + assess_form_definition_fragment, + export_form_definition_fragment, + form_definition_diagnostics, form_definition_history, + import_form_definition_fragment, record_form_definition, ) @@ -162,6 +170,129 @@ class FormsTests(unittest.TestCase): tenant_id="tenant-1", ) + def test_pages_conditions_and_localization_are_validated(self) -> None: + item = definition() + composed = replace( + item, + fields=( + item.fields[0], + replace( + item.fields[1], + visibility_condition=FormConditionExpression( + kind="predicate", + field_key="name", + operator="is_not_empty", + ), + ), + ), + pages=( + FormPageDefinition( + key="application", + title="Application", + sections=( + FormSectionDefinition( + key="details", + title="Details", + field_keys=("name", "delivery"), + ), + ), + ), + ), + fallback_locale="de", + localizations=( + FormLocalization( + locale="de", + title="Antrag", + field_labels={"name": "Name", "delivery": "Zustellung"}, + option_labels={"delivery": {"portal": "Portal", "mail": "Post"}}, + page_titles={"application": "Antrag"}, + section_titles={"details": "Angaben"}, + ), + ), + ) + stored = record_form_definition( + self.session, + self.principal, + definition=composed, + ) + self.assertEqual("application", stored.pages[0].key) + self.assertEqual((), form_definition_diagnostics(stored)) + + cyclic = replace( + definition(revision="2"), + fields=( + replace( + item.fields[0], + visibility_condition=FormConditionExpression( + kind="predicate", + field_key="delivery", + operator="eq", + value="portal", + ), + ), + replace( + item.fields[1], + visibility_condition=FormConditionExpression( + kind="predicate", + field_key="name", + operator="is_not_empty", + ), + ), + ), + ) + with self.assertRaisesRegex(FormDefinitionStoreError, "dependency cycle"): + record_form_definition( + self.session, + self.principal, + definition=cyclic, + expected_revision="1", + ) + + def test_package_fragment_is_verified_assessed_and_imported_as_draft(self) -> None: + source = definition(tenant_id="tenant-source") + fragment = export_form_definition_fragment( + source, + exported_at=NOW, + exported_by="source-account", + ) + assessment = assess_form_definition_fragment( + self.session, + self.principal, + fragment=fragment, + ) + self.assertEqual("create", assessment["outcome"]) + self.assertTrue(assessment["requires_remap"]) + + imported = import_form_definition_fragment( + self.session, + self.principal, + fragment=fragment, + target_form_id="local-permit", + target_key="local-permit", + expected_revision=None, + change_reason="Import reviewed package.", + recorded_at=NOW, + ) + self.assertEqual("tenant-1", imported.reference.tenant_id) + self.assertEqual("draft", imported.publication_state) + self.assertEqual( + "tenant-source", + imported.metadata["package_import"]["source_tenant_id"], + ) + + tampered = dict(fragment) + tampered["definition"] = {**fragment["definition"], "title": "Tampered"} + with self.assertRaisesRegex(FormDefinitionStoreError, "digest"): + assess_form_definition_fragment( + self.session, + self.principal, + fragment=tampered, + ) + if __name__ == "__main__": unittest.main() + (assess_form_definition_fragment,) + (export_form_definition_fragment,) + (form_definition_diagnostics,) + (import_form_definition_fragment,) diff --git a/webui/src/api/forms.ts b/webui/src/api/forms.ts index c1fbbd9..2ce05fc 100644 --- a/webui/src/api/forms.ts +++ b/webui/src/api/forms.ts @@ -3,6 +3,11 @@ import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui"; export type FormValueType = "text" | "multiline_text" | "integer" | "number" | "boolean" | "date" | "datetime" | "email" | "choice" | "multi_choice" | "object" | "list"; +export type FormCondition = + | { kind: "predicate"; field_key: string; operator: "eq" | "neq" | "lt" | "lte" | "gt" | "gte" | "in" | "not_in" | "contains" | "is_empty" | "is_not_empty"; value?: unknown } + | { kind: "all" | "any"; conditions: FormCondition[] } + | { kind: "not"; conditions: [FormCondition] }; + export type FormFieldDefinition = { key: string; label: string; @@ -12,6 +17,35 @@ export type FormFieldDefinition = { options: string[]; constraints: Record; default_value?: unknown; + visibility_condition?: FormCondition | null; + accessibility?: Record; +}; + +export type FormSectionDefinition = { + key: string; + title: string; + description?: string | null; + field_keys: string[]; + visibility_condition?: FormCondition | null; +}; + +export type FormPageDefinition = { + key: string; + title: string; + description?: string | null; + sections: FormSectionDefinition[]; + visibility_condition?: FormCondition | null; +}; + +export type FormLocalization = { + locale: string; + title?: string | null; + description?: string | null; + field_labels: Record; + field_help_texts: Record; + option_labels: Record>; + page_titles: Record; + section_titles: Record; }; export type FormDefinition = { @@ -40,9 +74,21 @@ export type FormDefinition = { signature_requirement: "none" | "optional" | "required"; policy_refs: string[]; handoff_kinds: string[]; + pages?: FormPageDefinition[]; + fallback_locale?: string | null; + localizations?: FormLocalization[]; + accessibility?: Record; metadata: Record; }; +export type FormPackageFragment = { + kind: "govoplan.forms.definition"; + contract_version: "0.1.0"; + definition: FormDefinition; + definition_sha256: string; + provenance: Record; +}; + export function listFormDefinitions( settings: ApiSettings, options: { query?: string; states?: string[]; offset?: number; limit?: number } = {}, @@ -69,3 +115,39 @@ export function saveFormDefinition( }) }); } + +export function exportFormDefinitionPackage( + settings: ApiSettings, + formId: string, + revision: string +): Promise { + return apiFetch(settings, apiPath(`/api/v1/forms/definitions/${encodeURIComponent(formId)}/package`, { revision })); +} + +export function assessFormDefinitionPackage( + settings: ApiSettings, + fragment: FormPackageFragment +): Promise<{ outcome: string; requires_remap: boolean; current_revision?: string | null }> { + return apiFetch(settings, "/api/v1/forms/packages/assess", { + method: "POST", + body: JSON.stringify({ fragment }) + }); +} + +export function importFormDefinitionPackage( + settings: ApiSettings, + fragment: FormPackageFragment, + options: { targetFormId?: string; targetKey?: string; expectedRevision?: string; changeReason: string } +): Promise { + return apiFetch(settings, "/api/v1/forms/packages/import", { + method: "POST", + body: JSON.stringify({ + fragment, + target_form_id: options.targetFormId || null, + target_key: options.targetKey || null, + expected_revision: options.expectedRevision || null, + change_reason: options.changeReason, + recorded_at: new Date().toISOString() + }) + }); +} diff --git a/webui/src/features/forms/FormDefinitionDialog.tsx b/webui/src/features/forms/FormDefinitionDialog.tsx index 07d5701..c91e1e4 100644 --- a/webui/src/features/forms/FormDefinitionDialog.tsx +++ b/webui/src/features/forms/FormDefinitionDialog.tsx @@ -1,4 +1,4 @@ -import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react"; +import { ArrowDown, ArrowUp, Eye, Languages, Plus, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Button, @@ -12,7 +12,10 @@ import { import { saveFormDefinition, type FormDefinition, + type FormCondition, type FormFieldDefinition, + type FormLocalization, + type FormPageDefinition, type FormValueType } from "../../api/forms"; @@ -113,7 +116,10 @@ export default function FormDefinitionDialog({ function patchField(index: number, patch: Partial) { setDraft((current) => ({ ...current, - fields: current.fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field) + fields: current.fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field), + pages: patch.key && patch.key !== current.fields[index].key + ? remapPageField(current.pages ?? [], current.fields[index].key, patch.key) + : current.pages })); } @@ -195,11 +201,23 @@ export default function FormDefinitionDialog({ /> )} + +