Release Forms v0.1.20 configuration package provider
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN Forms module."""
|
||||
|
||||
__version__ = "0.1.18"
|
||||
__version__ = "0.1.20"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.",
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user