diff --git a/pyproject.toml b/pyproject.toml index 7a6836b..6957d8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-forms" -version = "0.1.19" +version = "0.1.20" description = "Immutable reusable form definitions for GovOPlaN." readme = "README.md" requires-python = ">=3.12" authors = [{ name = "GovOPlaN" }] -dependencies = ["govoplan-core>=0.1.18", "govoplan-access>=0.1.18"] +dependencies = ["govoplan-core>=0.1.35", "govoplan-access>=0.1.18"] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/govoplan_forms/__init__.py b/src/govoplan_forms/__init__.py index c84141b..03aab85 100644 --- a/src/govoplan_forms/__init__.py +++ b/src/govoplan_forms/__init__.py @@ -1,3 +1,3 @@ """GovOPlaN Forms module.""" -__version__ = "0.1.18" +__version__ = "0.1.20" diff --git a/src/govoplan_forms/backend/configuration_provider.py b/src/govoplan_forms/backend/configuration_provider.py new file mode 100644 index 0000000..2ef8e71 --- /dev/null +++ b/src/govoplan_forms/backend/configuration_provider.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +from govoplan_core.core.configuration_packages import ( + ConfigurationApplyResult, + ConfigurationDiagnostic, + ConfigurationExportResult, + ConfigurationExportSelection, + ConfigurationPackageFragment, + ConfigurationPlanItem, + ConfigurationPreflightContext, + ConfigurationPreflightResult, + ConfigurationProvider, + ConfigurationProviderDescription, +) +from govoplan_core.db.session import get_database +from govoplan_forms.backend.service import ( + FormDefinitionStoreError, + assess_form_definition_fragment, + export_form_definition_fragment, + get_form_definition, + import_form_definition_fragment, + list_form_definitions, +) + + +FORMS_CONFIGURATION_CAPABILITY = "forms.configuration" +_WRITE_SCOPES = frozenset( + { + "forms:definition:write", + "admin:settings:write", + "system:settings:write", + "system:governance:write", + } +) + + +@dataclass(frozen=True, slots=True) +class _ConfigurationPrincipal: + tenant_id: str + account_id: str | None = None + + +class SqlFormsConfigurationProvider(ConfigurationProvider): + module_id = "forms" + + def describe(self) -> ConfigurationProviderDescription: + return ConfigurationProviderDescription( + module_id=self.module_id, + fragment_types=("definition",), + schema_refs={ + "definition": "govoplan/forms/configuration/definition.v1", + }, + exported_scopes=("tenant",), + ) + + def preflight( + self, + fragment: ConfigurationPackageFragment, + context: ConfigurationPreflightContext, + ) -> ConfigurationPreflightResult: + with get_database().session() as session: + return _preflight_definition(session, fragment, context) + + def apply( + self, + fragment: ConfigurationPackageFragment, + supplied_data: Mapping[str, Any], + context: ConfigurationPreflightContext, + ) -> ConfigurationApplyResult: + del supplied_data + with get_database().session() as session: + result = _apply_definition(session, fragment, context) + if not any(item.severity == "blocker" for item in result.diagnostics): + session.commit() + return result + + def export( + self, + selection: ConfigurationExportSelection, + context: ConfigurationPreflightContext, + ) -> ConfigurationExportResult: + tenant_id = selection.tenant_id or context.tenant_id + if not tenant_id: + return ConfigurationExportResult(diagnostics=(_tenant_required(),)) + principal = _ConfigurationPrincipal( + tenant_id=tenant_id, + account_id=context.operator_user_id, + ) + selected_ids = { + item.removeprefix("form:") + for item in selection.object_refs + if item.startswith("form:") + } + with get_database().session() as session: + definitions, _total = list_form_definitions( + session, + principal, + limit=200, + ) + fragments = tuple( + ConfigurationPackageFragment( + module_id=self.module_id, + fragment_type="definition", + fragment_id=definition.reference.object_id, + payload={ + "fragment": export_form_definition_fragment( + definition, + exported_at=datetime.now(UTC), + exported_by=context.operator_user_id, + ), + "on_conflict": "new_revision", + }, + ) + for definition in definitions + if not selected_ids + or definition.reference.object_id in selected_ids + ) + return ConfigurationExportResult(fragments=fragments) + + def health( + self, + import_result: ConfigurationApplyResult, + context: ConfigurationPreflightContext, + ) -> tuple[ConfigurationDiagnostic, ...]: + del context + return tuple( + item for item in import_result.diagnostics if item.severity == "blocker" + ) + + +def _preflight_definition( + session: Any, + fragment: ConfigurationPackageFragment, + context: ConfigurationPreflightContext, +) -> ConfigurationPreflightResult: + if fragment.fragment_type != "definition": + return ConfigurationPreflightResult(diagnostics=(_unsupported(fragment),)) + if not context.tenant_id: + return ConfigurationPreflightResult( + diagnostics=(_tenant_required(fragment),), + plan=(_plan("blocked", fragment, "Select a target tenant."),), + ) + if not (_WRITE_SCOPES & context.operator_scopes): + return ConfigurationPreflightResult( + diagnostics=(_write_scope_required(fragment),), + plan=(_plan("blocked", fragment, "Forms write authority is missing."),), + ) + principal = _ConfigurationPrincipal( + tenant_id=context.tenant_id, + account_id=context.operator_user_id, + ) + try: + parsed = _definition_payload(fragment) + assessment = assess_form_definition_fragment( + session, + principal, + fragment=parsed["fragment"], + ) + except (FormDefinitionStoreError, ValueError) as exc: + return ConfigurationPreflightResult( + diagnostics=(_invalid(fragment, str(exc)),), + plan=(_plan("blocked", fragment, "Forms fragment is invalid."),), + ) + target_form_id = parsed["target_form_id"] or str( + assessment["source"]["form_id"] + ) + current = get_form_definition( + session, + principal, + form_id=target_form_id, + ) + if current is not None and _is_replay(current.metadata, parsed["fragment"]): + return ConfigurationPreflightResult( + plan=(_plan("noop", fragment, "The same source definition is already imported."),) + ) + if current is not None and parsed["on_conflict"] != "new_revision": + return ConfigurationPreflightResult( + diagnostics=(_conflict(fragment, target_form_id),), + plan=(_plan("blocked", fragment, "Existing definition is preserved by package policy."),), + ) + return ConfigurationPreflightResult( + plan=(_plan( + "update" if current is not None else "create", + fragment, + f"{'Create a new revision of' if current is not None else 'Create'} Form definition {target_form_id} as a draft.", + ),) + ) + + +def _apply_definition( + session: Any, + fragment: ConfigurationPackageFragment, + context: ConfigurationPreflightContext, +) -> ConfigurationApplyResult: + preflight = _preflight_definition(session, fragment, context) + if any(item.severity == "blocker" for item in preflight.diagnostics): + return ConfigurationApplyResult(diagnostics=preflight.diagnostics) + parsed = _definition_payload(fragment) + assert context.tenant_id is not None + principal = _ConfigurationPrincipal( + tenant_id=context.tenant_id, + account_id=context.operator_user_id, + ) + source = parsed["fragment"].get("provenance") + source_form_id = ( + str(source.get("form_id") or "").strip() + if isinstance(source, Mapping) + else "" + ) + target_form_id = parsed["target_form_id"] or source_form_id + current = get_form_definition(session, principal, form_id=target_form_id) + if current is not None and _is_replay(current.metadata, parsed["fragment"]): + return ConfigurationApplyResult() + imported = import_form_definition_fragment( + session, + principal, + fragment=parsed["fragment"], + target_form_id=target_form_id, + target_key=parsed["target_key"], + expected_revision=current.reference.version if current is not None else None, + change_reason=parsed["change_reason"], + recorded_at=datetime.now(UTC), + ) + reference = f"form:{imported.reference.object_id}:{imported.reference.version}" + key = fragment.fragment_id or imported.reference.object_id + if current is None: + return ConfigurationApplyResult(created_refs={key: reference}) + return ConfigurationApplyResult(updated_refs={key: reference}) + + +def _definition_payload(fragment: ConfigurationPackageFragment) -> dict[str, Any]: + allowed = { + "fragment", + "target_form_id", + "target_key", + "change_reason", + "on_conflict", + } + unknown = sorted(set(fragment.payload) - allowed) + if unknown: + raise FormDefinitionStoreError( + f"Forms configuration payload contains unsupported fields: {', '.join(unknown)}." + ) + source = fragment.payload.get("fragment") + if not isinstance(source, Mapping): + raise FormDefinitionStoreError( + "Forms configuration definition requires a fragment object." + ) + on_conflict = str(fragment.payload.get("on_conflict") or "preserve").strip().casefold() + if on_conflict not in {"preserve", "new_revision"}: + raise FormDefinitionStoreError( + "Forms configuration on_conflict must be preserve or new_revision." + ) + return { + "fragment": dict(source), + "target_form_id": _optional_text(fragment.payload.get("target_form_id")), + "target_key": _optional_text(fragment.payload.get("target_key")), + "change_reason": _optional_text(fragment.payload.get("change_reason")) + or "Imported through a reviewed configuration package.", + "on_conflict": on_conflict, + } + + +def _is_replay(metadata: Mapping[str, Any], source: Mapping[str, Any]) -> bool: + package_import = metadata.get("package_import") + if not isinstance(package_import, Mapping): + return False + return bool(source.get("definition_sha256")) and ( + str(package_import.get("source_sha256") or "") + == str(source.get("definition_sha256") or "") + ) + + +def _plan( + action: str, + fragment: ConfigurationPackageFragment, + summary: str, +) -> ConfigurationPlanItem: + return ConfigurationPlanItem( + action=action, # type: ignore[arg-type] + module_id=fragment.module_id, + fragment_type=fragment.fragment_type, + fragment_id=fragment.fragment_id, + summary=summary, + ) + + +def _tenant_required( + fragment: ConfigurationPackageFragment | None = None, +) -> ConfigurationDiagnostic: + return ConfigurationDiagnostic( + severity="blocker", + code="tenant_required", + message="Forms configuration import and export require a target tenant.", + module_id="forms", + object_ref=(fragment.fragment_id or fragment.fragment_type) if fragment else None, + resolution="Select a tenant before continuing.", + ) + + +def _write_scope_required(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic: + return ConfigurationDiagnostic( + severity="blocker", + code="forms_configuration_write_scope_required", + message="The operator may not import Form definitions for this tenant.", + module_id="forms", + object_ref=fragment.fragment_id or fragment.fragment_type, + resolution="Use an approved Forms designer or system configuration administrator.", + ) + + +def _conflict(fragment: ConfigurationPackageFragment, form_id: str) -> ConfigurationDiagnostic: + return ConfigurationDiagnostic( + severity="blocker", + code="forms_configuration_conflict", + message=f"Form definition {form_id!r} already exists and preserve is selected.", + module_id="forms", + object_ref=form_id, + resolution="Keep the local definition, choose a different target id, or review a package that explicitly creates a new revision.", + ) + + +def _invalid(fragment: ConfigurationPackageFragment, message: str) -> ConfigurationDiagnostic: + return ConfigurationDiagnostic( + severity="blocker", + code="forms_configuration_payload_invalid", + message=message, + module_id="forms", + object_ref=fragment.fragment_id or fragment.fragment_type, + resolution="Use a Forms definition fragment compatible with the installed provider schema.", + ) + + +def _unsupported(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic: + return ConfigurationDiagnostic( + severity="blocker", + code="fragment_type_unsupported", + message=f"Forms configuration does not support fragment type {fragment.fragment_type!r}.", + module_id="forms", + object_ref=fragment.fragment_id or fragment.fragment_type, + ) + + +def _optional_text(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +__all__ = [ + "FORMS_CONFIGURATION_CAPABILITY", + "SqlFormsConfigurationProvider", +] diff --git a/src/govoplan_forms/backend/manifest.py b/src/govoplan_forms/backend/manifest.py index ebdf08c..907d134 100644 --- a/src/govoplan_forms/backend/manifest.py +++ b/src/govoplan_forms/backend/manifest.py @@ -33,6 +33,7 @@ from govoplan_core.core.semantic_documentation import ( ) from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base +from govoplan_forms.backend.configuration_provider import FORMS_CONFIGURATION_CAPABILITY from govoplan_forms.backend.db import models as form_models from govoplan_forms.backend.dsar_provider import ( FORMS_DSAR_CAPABILITY, @@ -46,7 +47,7 @@ from govoplan_forms.backend.semantic_subjects import ( MODULE_ID = "forms" MODULE_NAME = "Forms" -MODULE_VERSION = "0.1.19" +MODULE_VERSION = "0.1.20" READ_SCOPE = "forms:definition:read" WRITE_SCOPE = "forms:definition:write" ADMIN_SCOPE = "forms:definition:admin" @@ -95,6 +96,12 @@ def _semantic_subjects( return FormsSemanticDocumentationSubjectProvider() +def _configuration_provider(_context: ModuleContext): + from govoplan_forms.backend.configuration_provider import SqlFormsConfigurationProvider + + return SqlFormsConfigurationProvider() + + manifest = ModuleManifest( id=MODULE_ID, name=MODULE_NAME, @@ -107,6 +114,7 @@ manifest = ModuleManifest( ), provides_interfaces=( ModuleInterfaceProvider(name="forms.definitions", version="0.1.0"), + ModuleInterfaceProvider(name=FORMS_CONFIGURATION_CAPABILITY, version="0.1.0"), ModuleInterfaceProvider(name=FORMS_DSAR_CAPABILITY, version="0.1.0"), ModuleInterfaceProvider( name=SEMANTIC_SUBJECT_CAPABILITY, @@ -204,6 +212,7 @@ manifest = ModuleManifest( ), capability_factories={ CAPABILITY_FORM_DEFINITIONS: _definitions, + FORMS_CONFIGURATION_CAPABILITY: _configuration_provider, FORMS_DSAR_CAPABILITY: _dsar_provider, SEMANTIC_SUBJECT_CAPABILITY: _semantic_subjects, }, @@ -213,6 +222,13 @@ manifest = ModuleManifest( summary="Resolves exact tenant-bound form schemas without exposing Forms tables.", contract_version="0.1.0", ), + FORMS_CONFIGURATION_CAPABILITY: CapabilityDocumentation( + label="Forms configuration-package provider", + summary="Preflights, imports, and exports immutable Form definition fragments as tenant-local drafts with source provenance.", + contract_version="0.1.0", + documentation_types=("admin",), + audience=("forms_designer", "system_admin", "operator"), + ), FORMS_DSAR_CAPABILITY: CapabilityDocumentation( label="Forms data-subject request provider", summary=( @@ -354,7 +370,8 @@ manifest = ModuleManifest( "become the exact runtime schema. Attachment, signature, policy, draft, and permitted-handoff settings are enforced " "by Forms Runtime when that module is present. Publishing makes a revision available for new instances; existing " "instances retain their prior exact revision. Retirement prevents future use without deleting definitions or submissions. " - "Package import always creates a local draft and retains source provenance; it never silently publishes an imported revision." + "Package import always creates a local draft and retains source provenance; it never silently publishes an imported revision. " + "The Forms configuration provider validates the digest-bound source fragment, target tenant, operator authority, local conflicts, and replay provenance during preflight. The conservative conflict policy preserves an existing definition unless the reviewed package explicitly requests a new revision. Reapplying the same source digest is a no-op. Export emits only definition configuration and provenance; runtime submissions and submitted values remain outside the package." ), layer="configured", documentation_types=("admin", "user"), @@ -367,6 +384,22 @@ manifest = ModuleManifest( kind="repository", ), ), + translations={ + "de": { + "title": "Felder und Lebenszyklusfolgen von Formulardefinitionen", + "summary": "Schema, Veröffentlichung, Lokalisierung, Richtlinien, Nachweise, Pakete und Übergaben unveränderlicher Formularrevisionen verstehen.", + "body": ( + "Der stabile Schlüssel kennzeichnet die Definition; jedes Speichern erzeugt eine neue unveränderliche Revision. " + "Feldschlüssel, Typen, Einschränkungen, Sichtbarkeitsbedingungen, Seiten, Abschnitte, Auswahlwerte, Hilfen, Übersetzungen und Barrierefreiheitshinweise bilden das exakte Laufzeitschema. " + "Anhangs-, Signatur-, Richtlinien-, Entwurfs- und Übergabevorgaben werden durch Forms Runtime erzwungen, sofern das Modul vorhanden ist. " + "Die Veröffentlichung stellt eine Revision für neue Instanzen bereit; bestehende Instanzen behalten ihre genaue frühere Revision. Die Stilllegung verhindert künftige Nutzung, ohne Definitionen oder Einreichungen zu löschen. " + "Ein Paketimport erzeugt immer einen lokalen Entwurf, bewahrt die Herkunft und veröffentlicht niemals stillschweigend. " + "Der Forms-Konfigurationsprovider prüft vorab das digest-gebundene Quellfragment, den Zielmandanten, die Berechtigung, lokale Konflikte und Wiederholungsnachweise. " + "Die vorsichtige Konfliktregel erhält eine vorhandene Definition, sofern das geprüfte Paket nicht ausdrücklich eine neue Revision verlangt. Dieselbe Quelldigest erneut anzuwenden ist ein Leerlauf. " + "Der Export enthält ausschließlich Definitionskonfiguration und Herkunft; Laufzeiteinreichungen und eingegebene Werte bleiben außerhalb des Pakets." + ), + } + }, metadata={ "seed": True, "help_contexts": [ @@ -387,6 +420,16 @@ manifest = ModuleManifest( "retire": "Stops future use while retaining definitions and exact runtime references.", "import_package": "Creates a local draft and retains package provenance without automatic publication.", }, + "limitations": [ + "Package import never publishes a Form revision automatically.", + "Runtime submissions and submitted values are never exported as definition configuration.", + "Generic package rollback requires the retained pre-apply database snapshot.", + ], + "operational_consequences": [ + "Preserve blocks a conflicting local definition; new_revision must be explicitly reviewed.", + "Reapplying an identical source digest is idempotent and creates no revision.", + "Imported definitions require normal Forms review and publication before runtime use.", + ], }, ), ), diff --git a/tests/test_configuration_provider.py b/tests/test_configuration_provider.py new file mode 100644 index 0000000..7f46d0a --- /dev/null +++ b/tests/test_configuration_provider.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.configuration_packages import ( + ConfigurationPackageFragment, + ConfigurationPreflightContext, + ConfigurationProvider, +) +from govoplan_core.core.institutional import ( + FormDefinition, + FormFieldDefinition, + FormLocalization, + FormPageDefinition, + FormSectionDefinition, + InstitutionalReference, + TemporalRevision, +) +from govoplan_forms.backend.configuration_provider import ( + FORMS_CONFIGURATION_CAPABILITY, + SqlFormsConfigurationProvider, + _apply_definition, + _preflight_definition, +) +from govoplan_forms.backend.db.models import FormDefinitionRevision +from govoplan_forms.backend.manifest import get_manifest +from govoplan_forms.backend.service import ( + export_form_definition_fragment, + get_form_definition, +) + + +@dataclass(frozen=True) +class Principal: + tenant_id: str + account_id: str = "operator-1" + + +def source_definition(title: str = "Resident parking permit") -> FormDefinition: + return FormDefinition( + reference=InstitutionalReference( + kind="form", + owner_module="forms", + object_id="resident-parking-permit-application", + tenant_id="reference-package", + version="3", + ), + key="resident-parking-permit-application", + temporal=TemporalRevision( + revision="3", + recorded_at=datetime(2026, 8, 22, tzinfo=UTC), + change_reason="Reference package revision.", + ), + title=title, + description="Apply for a resident parking permit through a digital or assisted channel.", + fields=( + FormFieldDefinition(key="applicant_name", label="Name", required=True, constraints={"min_length": 2, "max_length": 200}), + FormFieldDefinition(key="applicant_email", label="Email", required=True, constraints={"format": "email"}), + FormFieldDefinition(key="residence_address", label="Primary residence", required=True, constraints={"max_length": 500}), + FormFieldDefinition(key="licence_plate", label="Licence plate", required=True, constraints={"max_length": 20}), + ), + publication_state="published", + max_attachments=4, + policy_refs=("law:resident-parking-permit", "records:resident-parking-permit"), + handoff_kinds=("case", "workflow"), + pages=( + FormPageDefinition( + key="application", + title="Application", + sections=( + FormSectionDefinition( + key="applicant-and-vehicle", + title="Applicant and vehicle", + field_keys=("applicant_name", "applicant_email", "residence_address", "licence_plate"), + ), + ), + ), + ), + fallback_locale="de", + localizations=( + FormLocalization( + locale="de", + title="Anwohnerparkausweis beantragen", + description="Einen Anwohnerparkausweis digital oder mit Unterstützung beantragen.", + field_labels={ + "applicant_name": "Name", + "applicant_email": "E-Mail-Adresse", + "residence_address": "Hauptwohnsitz", + "licence_plate": "Kennzeichen", + }, + page_titles={"application": "Antrag"}, + section_titles={"applicant-and-vehicle": "Antragstellende Person und Fahrzeug"}, + ), + ), + ) + + +class FormsConfigurationProviderTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + FormDefinitionRevision.__table__.create(self.engine) + self.session = Session(self.engine) + self.context = ConfigurationPreflightContext( + tenant_id="tenant-1", + operator_user_id="operator-1", + operator_scopes=frozenset({"system:governance:write"}), + installed_modules={"forms": "0.1.20"}, + capabilities=frozenset({FORMS_CONFIGURATION_CAPABILITY}), + ) + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def fragment(self, definition: FormDefinition | None = None, *, on_conflict: str = "new_revision") -> ConfigurationPackageFragment: + return ConfigurationPackageFragment( + module_id="forms", + fragment_type="definition", + fragment_id="resident-parking-permit-application", + payload={ + "fragment": export_form_definition_fragment( + definition or source_definition(), + exported_at=datetime(2026, 8, 22, 12, tzinfo=UTC), + exported_by="package-author", + ), + "on_conflict": on_conflict, + "change_reason": "Install the reviewed resident parking permit reference form.", + }, + ) + + def test_provider_is_registered_and_runtime_checkable(self) -> None: + provider = get_manifest().capability_factories[FORMS_CONFIGURATION_CAPABILITY](None) # type: ignore[arg-type] + + self.assertIsInstance(provider, ConfigurationProvider) + self.assertIsInstance(provider, SqlFormsConfigurationProvider) + self.assertEqual(("definition",), provider.describe().fragment_types) + + def test_import_is_tenant_local_draft_and_same_source_replay_is_noop(self) -> None: + fragment = self.fragment() + + preflight = _preflight_definition(self.session, fragment, self.context) + applied = _apply_definition(self.session, fragment, self.context) + self.session.commit() + replay_preflight = _preflight_definition(self.session, fragment, self.context) + replay = _apply_definition(self.session, fragment, self.context) + + self.assertEqual("create", preflight.plan[0].action) + self.assertEqual(1, len(applied.created_refs)) + imported = get_form_definition( + self.session, + Principal("tenant-1"), + form_id="resident-parking-permit-application", + ) + assert imported is not None + self.assertEqual("tenant-1", imported.reference.tenant_id) + self.assertEqual("draft", imported.publication_state) + self.assertEqual( + "reference-package", + imported.metadata["package_import"]["source_tenant_id"], + ) + self.assertEqual("noop", replay_preflight.plan[0].action) + self.assertEqual({}, replay.created_refs) + self.assertEqual({}, replay.updated_refs) + + def test_preserve_reports_conflict_and_missing_authority_blocks(self) -> None: + first = self.fragment() + _apply_definition(self.session, first, self.context) + self.session.commit() + changed = self.fragment(source_definition("Changed reference"), on_conflict="preserve") + unauthorized = ConfigurationPreflightContext( + tenant_id="tenant-1", + operator_user_id="operator-2", + ) + + conflict = _preflight_definition(self.session, changed, self.context) + denied = _preflight_definition(self.session, changed, unauthorized) + + self.assertIn( + "forms_configuration_conflict", + {item.code for item in conflict.diagnostics}, + ) + self.assertIn( + "forms_configuration_write_scope_required", + {item.code for item in denied.diagnostics}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index fc44acd..34acce2 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/forms-webui", - "version": "0.1.19", + "version": "0.1.20", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/src/module.ts b/webui/src/module.ts index 58189de..c694a00 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -9,7 +9,7 @@ const FormsPage = lazy(() => import("./features/forms/FormsPage")); export const formsModule: PlatformWebModule = { id: "forms", label: "i18n:govoplan-forms.form_definitions", - version: "0.1.19", + version: "0.1.20", dependencies: ["access"], optionalDependencies: ["forms_runtime", "portal", "workflow_engine", "cases", "policy", "docs"], translations: generatedTranslations,