Add template and generated artifact contracts

This commit is contained in:
2026-08-02 12:37:58 +02:00
parent fe62fd4644
commit e4d2d10c7e
9 changed files with 411 additions and 2 deletions
+28
View File
@@ -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.
+39
View File
@@ -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: ...
+236
View File
@@ -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",
]
+1 -1
View File
@@ -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",
+76
View File
@@ -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()
+22
View File
@@ -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
+1
View File
@@ -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",
+4 -1
View File
@@ -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;
+4
View File
@@ -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))