Add template and generated artifact contracts
This commit is contained in:
@@ -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.
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Generated
+22
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user