From e4d2d10c7efc3be208ea95da3bde2117e883f3f6 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sun, 2 Aug 2026 12:37:58 +0200 Subject: [PATCH] Add template and generated artifact contracts --- docs/TEMPLATE_CAPABILITY_CONTRACT.md | 28 +++ src/govoplan_core/core/files.py | 39 ++++ src/govoplan_core/core/templates.py | 236 +++++++++++++++++++++ src/govoplan_core/settings.py | 2 +- tests/test_template_contract.py | 76 +++++++ webui/package-lock.json | 22 ++ webui/package.json | 1 + webui/scripts/test-module-permutations.mjs | 5 +- webui/vite.config.ts | 4 + 9 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 docs/TEMPLATE_CAPABILITY_CONTRACT.md create mode 100644 src/govoplan_core/core/templates.py create mode 100644 tests/test_template_contract.py diff --git a/docs/TEMPLATE_CAPABILITY_CONTRACT.md b/docs/TEMPLATE_CAPABILITY_CONTRACT.md new file mode 100644 index 0000000..e46f798 --- /dev/null +++ b/docs/TEMPLATE_CAPABILITY_CONTRACT.md @@ -0,0 +1,28 @@ +# Template And Generated Artifact Capability Contracts + +Core defines provider-neutral contracts for optional template libraries and +generated artifact storage. Core does not render templates or store generated +files itself. + +## Templates + +- `templates.catalog` lists typed, versioned template references and checks a + consumer's available fields, usage, and output format. +- `templates.renderer` accepts a `TemplateRenderRequest` containing pinned + input data and returns immutable render evidence plus an artifact reference. + +The DTOs contain identifiers, hashes, plain mappings, and scalar metadata. They +do not expose Template ORM models or require Campaign, Distribution Lists, +Addresses, Reporting, Forms, or Mail. + +## Generated Artifacts + +`files.artifact_store` accepts a `ManagedArtifactWriteRequest` and returns a +`ManagedArtifactRef`. Producers supply bytes, a safe filename/content type, +idempotency key, and non-secret provenance. Files owns path normalization, +authorization, versions, storage, and download behavior. + +Consumers must discover both contracts through the module registry and degrade +only the unavailable path. A template renderer may return a bounded download +when Files is absent. A caller must not infer successful external delivery from +successful rendering or artifact persistence. diff --git a/src/govoplan_core/core/files.py b/src/govoplan_core/core/files.py index 247045b..397ba6f 100644 --- a/src/govoplan_core/core/files.py +++ b/src/govoplan_core/core/files.py @@ -1,13 +1,52 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass, field from typing import Protocol, runtime_checkable from govoplan_core.core.access import ResourceAccessExplanationProvider CAPABILITY_FILES_ACCESS = "files.access" +CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store" + + +@dataclass(frozen=True, slots=True) +class ManagedArtifactWriteRequest: + filename: str + payload: bytes + content_type: str + folder: str = "Generated" + description: str | None = None + idempotency_key: str | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class ManagedArtifactRef: + file_asset_id: str + file_version_id: str + filename: str + display_path: str + content_type: str + size_bytes: int + sha256: str + provenance: Mapping[str, object] = field(default_factory=dict) @runtime_checkable class FileAccessProvider(ResourceAccessExplanationProvider, Protocol): """Resource-level access explanation provider for Files-owned resources.""" + + +@runtime_checkable +class ManagedArtifactStore(Protocol): + """Store generated module artifacts without exposing Files internals.""" + + def store_artifact( + self, + session: object, + principal: object, + *, + request: ManagedArtifactWriteRequest, + ) -> ManagedArtifactRef: ... diff --git a/src/govoplan_core/core/templates.py b/src/govoplan_core/core/templates.py new file mode 100644 index 0000000..2236309 --- /dev/null +++ b/src/govoplan_core/core/templates.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal, Protocol, runtime_checkable + + +CAPABILITY_TEMPLATE_CATALOG = "templates.catalog" +CAPABILITY_TEMPLATE_RENDERER = "templates.renderer" + +TemplateType = Literal[ + "label", + "label_sheet", + "envelope", + "serial_letter", + "form_letter", + "list_layout", + "email", + "generic", +] +TemplateOutputFormat = Literal["html", "text"] +TemplateRenderMode = Literal["preview", "final"] + + +class TemplateContractError(ValueError): + """Stable base error for provider-neutral template operations.""" + + +class TemplateNotFoundError(TemplateContractError): + pass + + +class TemplateCompatibilityError(TemplateContractError): + pass + + +class TemplateRenderError(TemplateContractError): + pass + + +@dataclass(frozen=True, slots=True) +class TemplateFieldRequirement: + path: str + value_type: Literal[ + "string", + "integer", + "number", + "boolean", + "date", + "datetime", + "object", + "array", + ] = "string" + label: str | None = None + required: bool = True + description: str | None = None + + +@dataclass(frozen=True, slots=True) +class TemplateOutputProfile: + id: str + label: str + output_format: TemplateOutputFormat + media_type: str + channel: str = "print" + capabilities: tuple[str, ...] = () + page: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class TemplateRevisionRef: + id: str + template_id: str + revision: int + definition_hash: str + template_type: TemplateType + usages: tuple[str, ...] + locale: str + required_fields: tuple[TemplateFieldRequirement, ...] + output_profiles: tuple[TemplateOutputProfile, ...] + published_at: datetime | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class TemplateRef: + id: str + tenant_id: str + name: str + slug: str + template_type: TemplateType + status: str + current_revision: int + current_revision_id: str + published_revision_id: str | None + description: str | None = None + scope_type: str = "tenant" + scope_id: str | None = None + read_only: bool = False + updated_at: datetime | None = None + revision: TemplateRevisionRef | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class TemplateCompatibility: + compatible: bool + template_id: str + revision_id: str + usage: str | None + output_format: str | None + missing_fields: tuple[str, ...] = () + incompatible_fields: tuple[str, ...] = () + diagnostics: tuple[Mapping[str, object], ...] = () + + +@dataclass(frozen=True, slots=True) +class TemplateRenderRequest: + template_id: str + revision: int | None = None + usage: str | None = None + locale: str | None = None + output_format: TemplateOutputFormat = "html" + profile_id: str | None = None + parameters: Mapping[str, object] = field(default_factory=dict) + items: tuple[Mapping[str, object], ...] = () + input_snapshot: Mapping[str, object] = field(default_factory=dict) + mode: TemplateRenderMode = "preview" + idempotency_key: str | None = None + persist_to_files: bool = False + + +@dataclass(frozen=True, slots=True) +class TemplateArtifactRef: + kind: Literal["managed_file", "bounded_download"] + filename: str + content_type: str + size_bytes: int + sha256: str + file_asset_id: str | None = None + file_version_id: str | None = None + download_path: str | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class TemplateRenderResult: + render_id: str + template_id: str + revision_id: str + revision: int + template_hash: str + input_hash: str + renderer_version: str + output_format: TemplateOutputFormat + content_type: str + filename: str + item_count: int + page_count: int + output_sha256: str + output_size_bytes: int + diagnostics: tuple[Mapping[str, object], ...] = () + artifact: TemplateArtifactRef | None = None + generated_at: datetime | None = None + payload: bytes | None = None + + +@runtime_checkable +class TemplateCatalogProvider(Protocol): + def list_templates( + self, + session: object, + principal: object, + *, + query: str = "", + usage: str | None = None, + template_type: str | None = None, + locale: str | None = None, + limit: int = 100, + ) -> Sequence[TemplateRef]: ... + + def get_template( + self, + session: object, + principal: object, + *, + template_id: str, + revision: int | None = None, + ) -> TemplateRef | None: ... + + def check_compatibility( + self, + session: object, + principal: object, + *, + template_id: str, + revision: int | None = None, + usage: str | None = None, + output_format: str | None = None, + available_fields: Mapping[str, str] | Sequence[str] = (), + ) -> TemplateCompatibility: ... + + +@runtime_checkable +class TemplateRendererProvider(Protocol): + def render( + self, + session: object, + principal: object, + *, + request: TemplateRenderRequest, + ) -> TemplateRenderResult: ... + + +__all__ = [ + "CAPABILITY_TEMPLATE_CATALOG", + "CAPABILITY_TEMPLATE_RENDERER", + "TemplateArtifactRef", + "TemplateCatalogProvider", + "TemplateCompatibility", + "TemplateCompatibilityError", + "TemplateContractError", + "TemplateFieldRequirement", + "TemplateNotFoundError", + "TemplateOutputFormat", + "TemplateOutputProfile", + "TemplateRef", + "TemplateRenderError", + "TemplateRenderMode", + "TemplateRenderRequest", + "TemplateRenderResult", + "TemplateRendererProvider", + "TemplateRevisionRef", + "TemplateType", +] diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py index 16566fa..fb62b18 100644 --- a/src/govoplan_core/settings.py +++ b/src/govoplan_core/settings.py @@ -107,7 +107,7 @@ class Settings(BaseSettings): default=( "tenancy,organizations,identity,idm,access,admin,dashboard,policy," "audit,campaigns,files,mail,calendar,poll,scheduling,connectors," - "datasources,dataflow,dist_lists,workflow_engine,workflow,views,search,risk_compliance," + "datasources,dataflow,dist_lists,templates,workflow_engine,workflow,views,search,risk_compliance," "postbox,notifications,docs,ops" ), alias="ENABLED_MODULES", diff --git a/tests/test_template_contract.py b/tests/test_template_contract.py new file mode 100644 index 0000000..e03cdf5 --- /dev/null +++ b/tests/test_template_contract.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.core.files import ( + CAPABILITY_FILES_ARTIFACT_STORE, + ManagedArtifactRef, + ManagedArtifactStore, + ManagedArtifactWriteRequest, +) +from govoplan_core.core.templates import ( + CAPABILITY_TEMPLATE_CATALOG, + CAPABILITY_TEMPLATE_RENDERER, + TemplateCatalogProvider, + TemplateRenderRequest, + TemplateRendererProvider, +) + + +class _Catalog: + def list_templates(self, session, principal, **kwargs): + del session, principal, kwargs + return () + + def get_template(self, session, principal, **kwargs): + del session, principal, kwargs + return None + + def check_compatibility(self, session, principal, **kwargs): + del session, principal, kwargs + return None + + +class _Renderer: + def render(self, session, principal, *, request): + del session, principal, request + return None + + +class _Store: + def store_artifact(self, session, principal, *, request): + del session, principal + return ManagedArtifactRef( + file_asset_id="file-1", + file_version_id="version-1", + filename=request.filename, + display_path=request.filename, + content_type=request.content_type, + size_bytes=len(request.payload), + sha256="0" * 64, + ) + + +class TemplateContractTests(unittest.TestCase): + def test_capability_names_and_runtime_protocols_are_stable(self) -> None: + self.assertEqual("templates.catalog", CAPABILITY_TEMPLATE_CATALOG) + self.assertEqual("templates.renderer", CAPABILITY_TEMPLATE_RENDERER) + self.assertEqual("files.artifact_store", CAPABILITY_FILES_ARTIFACT_STORE) + self.assertIsInstance(_Catalog(), TemplateCatalogProvider) + self.assertIsInstance(_Renderer(), TemplateRendererProvider) + self.assertIsInstance(_Store(), ManagedArtifactStore) + + def test_requests_do_not_expose_consumer_or_files_models(self) -> None: + render = TemplateRenderRequest(template_id="template-1") + artifact = ManagedArtifactWriteRequest( + filename="result.html", + payload=b"result", + content_type="text/html", + ) + self.assertEqual((), render.items) + self.assertEqual("preview", render.mode) + self.assertEqual("Generated", artifact.folder) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package-lock.json b/webui/package-lock.json index 2e86322..0a04768 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -38,6 +38,7 @@ "@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui", "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", "@govoplan/search-webui": "file:../../govoplan-search/webui", + "@govoplan/templates-webui": "file:../../govoplan-templates/webui", "@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui", "@govoplan/voting-webui": "file:../../govoplan-voting/webui", @@ -589,6 +590,23 @@ } } }, + "../../govoplan-templates/webui": { + "name": "@govoplan/templates-webui", + "version": "0.1.14", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9", + "typescript": "^5.7.2" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, "../../govoplan-tenancy/webui": { "name": "@govoplan/tenancy-webui", "version": "0.1.8", @@ -1525,6 +1543,10 @@ "resolved": "../../govoplan-search/webui", "link": true }, + "node_modules/@govoplan/templates-webui": { + "resolved": "../../govoplan-templates/webui", + "link": true + }, "node_modules/@govoplan/tenancy-webui": { "resolved": "../../govoplan-tenancy/webui", "link": true diff --git a/webui/package.json b/webui/package.json index f99d95b..4b4e2f8 100644 --- a/webui/package.json +++ b/webui/package.json @@ -79,6 +79,7 @@ "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", "@govoplan/search-webui": "file:../../govoplan-search/webui", "@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui", + "@govoplan/templates-webui": "file:../../govoplan-templates/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui", "@govoplan/voting-webui": "file:../../govoplan-voting/webui", "@govoplan/workflow-webui": "file:../../govoplan-workflow/webui", diff --git a/webui/scripts/test-module-permutations.mjs b/webui/scripts/test-module-permutations.mjs index 2a22476..81f73f3 100644 --- a/webui/scripts/test-module-permutations.mjs +++ b/webui/scripts/test-module-permutations.mjs @@ -33,6 +33,7 @@ const packageByModule = { scheduling: "@govoplan/scheduling-webui", search: "@govoplan/search-webui", tenancy: "@govoplan/tenancy-webui", + templates: "@govoplan/templates-webui", views: "@govoplan/views-webui", voting: "@govoplan/voting-webui", workflow: "@govoplan/workflow-webui" @@ -53,6 +54,8 @@ const cases = [ { name: "dataflow-with-datasources", modules: ["datasources", "dataflow"] }, { name: "distribution-lists-only", modules: ["dist_lists"] }, { name: "distribution-lists-with-providers", modules: ["addresses", "policy", "organizations", "idm", "dataflow", "dist_lists"] }, + { name: "templates-only", modules: ["templates"] }, + { name: "templates-with-files", modules: ["templates", "files"] }, { name: "workflow-only", modules: ["workflow"] }, { name: "workflow-with-dataflow", modules: ["datasources", "dataflow", "workflow"] }, { name: "views-only", modules: ["views"] }, @@ -82,7 +85,7 @@ const cases = [ { name: "docs-and-ops", modules: ["access", "docs", "ops"] }, { name: "approvals-only", modules: ["access", "approvals"] }, { name: "voting-only", modules: ["access", "voting"] }, - { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "workflow", "views", "organizations", "idm", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "risk_compliance", "search", "voting"] } + { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "risk_compliance", "search", "voting"] } ]; const npmExec = process.env.npm_execpath; diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 6111ba9..5bacf9f 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -25,6 +25,7 @@ const defaultWebModulePackages = [ "@govoplan/dataflow-webui", "@govoplan/datasources-webui", "@govoplan/dashboard-webui", + "@govoplan/dist-lists-webui", "@govoplan/docs-webui", "@govoplan/files-webui", "@govoplan/forms-webui", @@ -43,6 +44,7 @@ const defaultWebModulePackages = [ "@govoplan/scheduling-webui", "@govoplan/search-webui", "@govoplan/tenancy-webui", + "@govoplan/templates-webui", "@govoplan/views-webui", "@govoplan/voting-webui", "@govoplan/workflow-webui" @@ -235,6 +237,7 @@ export default defineConfig({ fileURLToPath(new URL('../../govoplan-dataflow/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-datasources/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)), + fileURLToPath(new URL('../../govoplan-dist-lists/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-forms/webui', import.meta.url)), @@ -252,6 +255,7 @@ export default defineConfig({ fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-search/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-tenancy/webui', import.meta.url)), + fileURLToPath(new URL('../../govoplan-templates/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-voting/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))