Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69416faca9 | ||
|
|
ee6fbc784f | ||
|
|
dff2508698 | ||
|
|
80001bbe98 | ||
|
|
5de54ccd2e | ||
|
|
1c12359750 | ||
|
|
58be2482ec | ||
|
|
3856765520 | ||
|
|
cf4205f780 | ||
|
|
21ed6270a3 |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-templates"
|
name = "govoplan-templates"
|
||||||
version = "0.1.18"
|
version = "0.1.21"
|
||||||
description = "GovOPlaN typed template library and deterministic printable rendering."
|
description = "GovOPlaN typed template library and deterministic printable rendering."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN Templates module."""
|
"""GovOPlaN Templates module."""
|
||||||
|
|
||||||
__version__ = "0.1.18"
|
__version__ = "0.1.21"
|
||||||
|
|||||||
@@ -4,17 +4,30 @@ from collections.abc import Mapping, Sequence
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
from govoplan_core.auth import ApiPrincipal
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
from govoplan_core.core.templates import (
|
from govoplan_core.core.templates import (
|
||||||
TemplateCatalogProvider,
|
TemplateCatalogProvider,
|
||||||
TemplateCompatibility,
|
TemplateCompatibility,
|
||||||
|
TemplateContentDraftRequest,
|
||||||
|
TemplateContentLibraryProvider,
|
||||||
TemplateRef,
|
TemplateRef,
|
||||||
)
|
)
|
||||||
from govoplan_templates.backend.rendering import SqlTemplateRenderer
|
from govoplan_templates.backend.rendering import SqlTemplateRenderer
|
||||||
|
from govoplan_templates.backend.schemas import TemplateCreateRequest
|
||||||
from govoplan_templates.backend.service import (
|
from govoplan_templates.backend.service import (
|
||||||
READ_SCOPE,
|
READ_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
compatibility,
|
compatibility,
|
||||||
|
create_template,
|
||||||
get_template,
|
get_template,
|
||||||
get_template_revision,
|
get_template_revision,
|
||||||
list_templates,
|
list_templates,
|
||||||
@@ -108,6 +121,85 @@ class SqlTemplateCatalog(TemplateCatalogProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SqlTemplateContentLibrary(TemplateContentLibraryProvider):
|
||||||
|
def create_content_draft(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: TemplateContentDraftRequest,
|
||||||
|
) -> TemplateRef:
|
||||||
|
sql_session, api_principal = _context(session, principal)
|
||||||
|
_require_write(api_principal)
|
||||||
|
if request.template_type not in {"content_fragment", "email", "generic"}:
|
||||||
|
raise ValueError(
|
||||||
|
"Reusable content drafts must be a content fragment, email, or generic template."
|
||||||
|
)
|
||||||
|
item, revision = create_template(
|
||||||
|
sql_session,
|
||||||
|
api_principal,
|
||||||
|
TemplateCreateRequest(
|
||||||
|
name=request.name,
|
||||||
|
description=request.description,
|
||||||
|
scope_type=request.scope_type,
|
||||||
|
scope_id=request.scope_id,
|
||||||
|
template_type=request.template_type,
|
||||||
|
usages=list(request.usages),
|
||||||
|
locale=request.locale,
|
||||||
|
required_fields=[
|
||||||
|
{
|
||||||
|
"path": field.path,
|
||||||
|
"value_type": field.value_type,
|
||||||
|
"label": field.label,
|
||||||
|
"required": field.required,
|
||||||
|
"description": field.description,
|
||||||
|
}
|
||||||
|
for field in request.required_fields
|
||||||
|
],
|
||||||
|
output_profiles=[],
|
||||||
|
content_text=request.content_text,
|
||||||
|
content_html=request.content_html,
|
||||||
|
layout={},
|
||||||
|
metadata={
|
||||||
|
**dict(request.metadata),
|
||||||
|
"created_through": "templates.content_library",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
audit_from_principal(
|
||||||
|
sql_session,
|
||||||
|
api_principal,
|
||||||
|
action="templates.template.created",
|
||||||
|
object_type="template",
|
||||||
|
object_id=item.id,
|
||||||
|
details={
|
||||||
|
"revision": revision.revision,
|
||||||
|
"definition_hash": revision.definition_hash,
|
||||||
|
"template_type": revision.template_type,
|
||||||
|
"usages": list(revision.usages or []),
|
||||||
|
"required_fields": [
|
||||||
|
str(field.get("path") or "")
|
||||||
|
for field in revision.required_fields or []
|
||||||
|
if field.get("path")
|
||||||
|
],
|
||||||
|
"source": "content_library_capability",
|
||||||
|
},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
emit_platform_event(
|
||||||
|
sql_session,
|
||||||
|
PlatformEvent(
|
||||||
|
type="templates.template.created.v1",
|
||||||
|
module_id="templates",
|
||||||
|
actor=EventActorRef(type="account", id=api_principal.account_id),
|
||||||
|
tenant=EventTenantRef(id=api_principal.tenant_id),
|
||||||
|
resource=EventObjectRef(type="template", id=item.id),
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return template_ref(item, revision, read_only=False)
|
||||||
|
|
||||||
|
|
||||||
def catalog_capability(_context: ModuleContext) -> SqlTemplateCatalog:
|
def catalog_capability(_context: ModuleContext) -> SqlTemplateCatalog:
|
||||||
return SqlTemplateCatalog()
|
return SqlTemplateCatalog()
|
||||||
|
|
||||||
@@ -116,6 +208,10 @@ def renderer_capability(context: ModuleContext) -> SqlTemplateRenderer:
|
|||||||
return SqlTemplateRenderer(context.registry)
|
return SqlTemplateRenderer(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def content_library_capability(_context: ModuleContext) -> SqlTemplateContentLibrary:
|
||||||
|
return SqlTemplateContentLibrary()
|
||||||
|
|
||||||
|
|
||||||
def _context(session: object, principal: object) -> tuple[Session, ApiPrincipal]:
|
def _context(session: object, principal: object) -> tuple[Session, ApiPrincipal]:
|
||||||
if not isinstance(session, Session):
|
if not isinstance(session, Session):
|
||||||
raise TypeError("Template catalogue access requires a SQLAlchemy session.")
|
raise TypeError("Template catalogue access requires a SQLAlchemy session.")
|
||||||
@@ -137,6 +233,14 @@ def _require_read(principal: ApiPrincipal) -> None:
|
|||||||
raise PermissionError(f"Template catalogue access requires {READ_SCOPE}.")
|
raise PermissionError(f"Template catalogue access requires {READ_SCOPE}.")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_write(principal: ApiPrincipal) -> None:
|
||||||
|
if not any(
|
||||||
|
principal.has(scope)
|
||||||
|
for scope in (WRITE_SCOPE, "templates:template:admin")
|
||||||
|
):
|
||||||
|
raise PermissionError(f"Template draft creation requires {WRITE_SCOPE}.")
|
||||||
|
|
||||||
|
|
||||||
def _read_only(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -> bool:
|
def _read_only(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -> bool:
|
||||||
if principal.has("templates:template:admin") or scope_type == "tenant":
|
if principal.has("templates:template:admin") or scope_type == "tenant":
|
||||||
return False
|
return False
|
||||||
@@ -147,6 +251,8 @@ def _read_only(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SqlTemplateCatalog",
|
"SqlTemplateCatalog",
|
||||||
|
"SqlTemplateContentLibrary",
|
||||||
"catalog_capability",
|
"catalog_capability",
|
||||||
|
"content_library_capability",
|
||||||
"renderer_capability",
|
"renderer_capability",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
from govoplan_templates.backend.db.models import (
|
||||||
|
TemplateDefinition,
|
||||||
|
TemplateRender,
|
||||||
|
TemplateRevision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATES_DSAR_CAPABILITY = dsar_capability_name("templates")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
_CONFLICT = object()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Selectors:
|
||||||
|
account_id: str
|
||||||
|
template_id: str | None
|
||||||
|
revision_id: str | None
|
||||||
|
render_id: str | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def narrowed(self) -> bool:
|
||||||
|
return bool(self.template_id or self.revision_id or self.render_id)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplatesDsarProvider:
|
||||||
|
provider_id = "templates"
|
||||||
|
module_id = "templates"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _selectors(subject)
|
||||||
|
if selectors is None:
|
||||||
|
return ()
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
if not selectors.narrowed or selectors.template_id:
|
||||||
|
query = db.query(TemplateDefinition).filter(
|
||||||
|
TemplateDefinition.tenant_id == tenant_id,
|
||||||
|
or_(
|
||||||
|
TemplateDefinition.created_by_account_id == selectors.account_id,
|
||||||
|
TemplateDefinition.updated_by_account_id == selectors.account_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if selectors.template_id:
|
||||||
|
query = query.filter(TemplateDefinition.id == selectors.template_id)
|
||||||
|
records.extend(
|
||||||
|
_definition_record(row, selectors.account_id)
|
||||||
|
for row in _limited(
|
||||||
|
query,
|
||||||
|
TemplateDefinition.created_at,
|
||||||
|
TemplateDefinition.id,
|
||||||
|
label="definition attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not selectors.narrowed or selectors.template_id or selectors.revision_id:
|
||||||
|
query = db.query(TemplateRevision).filter(
|
||||||
|
TemplateRevision.tenant_id == tenant_id,
|
||||||
|
or_(
|
||||||
|
TemplateRevision.created_by_account_id == selectors.account_id,
|
||||||
|
TemplateRevision.published_by_account_id == selectors.account_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if selectors.template_id:
|
||||||
|
query = query.filter(
|
||||||
|
TemplateRevision.template_id == selectors.template_id
|
||||||
|
)
|
||||||
|
if selectors.revision_id:
|
||||||
|
query = query.filter(TemplateRevision.id == selectors.revision_id)
|
||||||
|
records.extend(
|
||||||
|
_revision_record(row, selectors.account_id)
|
||||||
|
for row in _limited(
|
||||||
|
query,
|
||||||
|
TemplateRevision.created_at,
|
||||||
|
TemplateRevision.id,
|
||||||
|
label="revision attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not selectors.narrowed or selectors.template_id or selectors.render_id:
|
||||||
|
query = db.query(TemplateRender).filter(
|
||||||
|
TemplateRender.tenant_id == tenant_id,
|
||||||
|
TemplateRender.created_by_account_id == selectors.account_id,
|
||||||
|
)
|
||||||
|
if selectors.template_id:
|
||||||
|
query = query.filter(
|
||||||
|
TemplateRender.template_id == selectors.template_id
|
||||||
|
)
|
||||||
|
if selectors.render_id:
|
||||||
|
query = query.filter(TemplateRender.id == selectors.render_id)
|
||||||
|
records.extend(
|
||||||
|
_render_record(row)
|
||||||
|
for row in _limited(
|
||||||
|
query,
|
||||||
|
TemplateRender.created_at,
|
||||||
|
TemplateRender.id,
|
||||||
|
label="render attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(records) > _MAX_RECORDS:
|
||||||
|
raise ValueError("Templates DSAR result limit exceeded; narrow selectors.")
|
||||||
|
return tuple(
|
||||||
|
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
|
||||||
|
)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _selectors(subject) is None:
|
||||||
|
raise ValueError("Templates DSAR subject selectors conflict.")
|
||||||
|
actions = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=(
|
||||||
|
f"templates:retain:{record.resource_type}:{record.resource_id}"
|
||||||
|
),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind="retain",
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=f"Retain {record.title}",
|
||||||
|
rationale=(
|
||||||
|
record.retention_reason
|
||||||
|
or "Template lifecycle attribution remains evidence."
|
||||||
|
),
|
||||||
|
executable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _selectors(subject) is None:
|
||||||
|
raise ValueError("Templates DSAR subject selectors conflict.")
|
||||||
|
results = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if action.executable or action.kind != "retain":
|
||||||
|
raise ValueError("Templates DSAR publishes retain actions only.")
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary="Template lifecycle attribution remains evidence.",
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
|
||||||
|
references = subject.external_references
|
||||||
|
values = {
|
||||||
|
"account_id": _coalesce(
|
||||||
|
subject.account_id,
|
||||||
|
references.get("templates.account"),
|
||||||
|
references.get("access.account"),
|
||||||
|
),
|
||||||
|
"template_id": _coalesce(
|
||||||
|
references.get("templates.template"),
|
||||||
|
references.get("templates.template_id"),
|
||||||
|
),
|
||||||
|
"revision_id": _coalesce(
|
||||||
|
references.get("templates.revision"),
|
||||||
|
references.get("templates.revision_id"),
|
||||||
|
),
|
||||||
|
"render_id": _coalesce(
|
||||||
|
references.get("templates.render"), references.get("templates.render_id")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if any(value is _CONFLICT for value in values.values()):
|
||||||
|
return None
|
||||||
|
account_id = _optional(values["account_id"])
|
||||||
|
if not account_id:
|
||||||
|
return None
|
||||||
|
return _Selectors(
|
||||||
|
account_id=account_id,
|
||||||
|
template_id=_optional(values["template_id"]),
|
||||||
|
revision_id=_optional(values["revision_id"]),
|
||||||
|
render_id=_optional(values["render_id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _definition_record(row: TemplateDefinition, account_id: str) -> DsarRecordRef:
|
||||||
|
activities = []
|
||||||
|
if row.created_by_account_id == account_id:
|
||||||
|
activities.append("created_template")
|
||||||
|
if row.updated_by_account_id == account_id:
|
||||||
|
activities.append("updated_template")
|
||||||
|
return _record(
|
||||||
|
resource_type="template_definition_actor_attribution",
|
||||||
|
resource_id=row.id,
|
||||||
|
title="Template-definition actor attribution",
|
||||||
|
data={
|
||||||
|
"template_id": row.id,
|
||||||
|
"template_type": row.template_type,
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"status": row.status,
|
||||||
|
"current_revision": row.current_revision,
|
||||||
|
"resource_revision": row.resource_revision,
|
||||||
|
"activities": activities,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
"updated_at": _iso(row.updated_at),
|
||||||
|
"retired_at": _iso(row.deleted_at),
|
||||||
|
},
|
||||||
|
observed_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _revision_record(row: TemplateRevision, account_id: str) -> DsarRecordRef:
|
||||||
|
activities = []
|
||||||
|
if row.created_by_account_id == account_id:
|
||||||
|
activities.append("created_template_revision")
|
||||||
|
if row.published_by_account_id == account_id:
|
||||||
|
activities.append("published_template_revision")
|
||||||
|
return _record(
|
||||||
|
resource_type="template_revision_actor_attribution",
|
||||||
|
resource_id=row.id,
|
||||||
|
title="Template-revision actor attribution",
|
||||||
|
data={
|
||||||
|
"template_id": row.template_id,
|
||||||
|
"revision_id": row.id,
|
||||||
|
"revision": row.revision,
|
||||||
|
"template_type": row.template_type,
|
||||||
|
"locale": row.locale,
|
||||||
|
"activities": activities,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
"published_at": _iso(row.published_at),
|
||||||
|
},
|
||||||
|
observed_at=row.published_at or row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_record(row: TemplateRender) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
resource_type="template_render_actor_attribution",
|
||||||
|
resource_id=row.id,
|
||||||
|
title="Template-render actor attribution",
|
||||||
|
data={
|
||||||
|
"template_id": row.template_id,
|
||||||
|
"revision_id": row.revision_id,
|
||||||
|
"render_id": row.id,
|
||||||
|
"revision": row.revision_number,
|
||||||
|
"mode": row.mode,
|
||||||
|
"usage": row.usage,
|
||||||
|
"output_format": row.output_format,
|
||||||
|
"content_type": row.content_type,
|
||||||
|
"item_count": row.item_count,
|
||||||
|
"page_count": row.page_count,
|
||||||
|
"output_size_bytes": row.output_size_bytes,
|
||||||
|
"activity": "rendered_template",
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
},
|
||||||
|
observed_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
title: str,
|
||||||
|
data: dict[str, object],
|
||||||
|
observed_at: datetime | None,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="templates",
|
||||||
|
module_id="templates",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
category="template_governance_attribution",
|
||||||
|
title=title,
|
||||||
|
data=data,
|
||||||
|
observed_at=_aware(observed_at),
|
||||||
|
immutable_evidence=True,
|
||||||
|
retention_reason=(
|
||||||
|
"Template definition, publication, and render attribution is retained "
|
||||||
|
"with immutable lifecycle evidence."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _limited(query, first, second, *, label: str):
|
||||||
|
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
|
||||||
|
if len(rows) > _MAX_RECORDS:
|
||||||
|
raise ValueError(f"Templates DSAR {label} limit exceeded; narrow selectors.")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _coalesce(*values: str | None) -> str | None | object:
|
||||||
|
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||||
|
if len(normalized) > 1:
|
||||||
|
return _CONFLICT
|
||||||
|
return next(iter(normalized), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
aware = _aware(value)
|
||||||
|
return aware.isoformat() if aware else None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Templates DSAR requires a SQLAlchemy Session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
_RESOURCE_TYPES = {
|
||||||
|
"template_definition_actor_attribution",
|
||||||
|
"template_revision_actor_attribution",
|
||||||
|
"template_render_actor_attribution",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "templates" or record.module_id != "templates":
|
||||||
|
raise ValueError("Templates DSAR cannot plan a foreign provider record.")
|
||||||
|
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
|
||||||
|
raise ValueError("Templates DSAR record identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "templates" or action.module_id != "templates":
|
||||||
|
raise ValueError("Templates DSAR cannot execute a foreign provider action.")
|
||||||
|
if not action.action_id.startswith("templates:retain:"):
|
||||||
|
raise ValueError("Templates DSAR action identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["TEMPLATES_DSAR_CAPABILITY", "TemplatesDsarProvider"]
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'templates.data-subject-requests': {'consequence_classes': {'exclude_template_payloads': 'Gibt '
|
||||||
|
'keinen '
|
||||||
|
'Template-Inhalt '
|
||||||
|
'zurück '
|
||||||
|
'oder '
|
||||||
|
'rendert '
|
||||||
|
'Inputs '
|
||||||
|
'und '
|
||||||
|
'Outputs.',
|
||||||
|
'export_template_attribution': 'Returns '
|
||||||
|
'minimiert '
|
||||||
|
'Autor, '
|
||||||
|
'Publikation '
|
||||||
|
'und '
|
||||||
|
'Render-Aktivität.'}},
|
||||||
|
'templates.reference.fields-and-consequences': {'consequence_classes': {'delete_template': 'Verhindert '
|
||||||
|
'zukünftige '
|
||||||
|
'Auswahl, '
|
||||||
|
'ohne '
|
||||||
|
'beibehaltene '
|
||||||
|
'Revisionen '
|
||||||
|
'neu '
|
||||||
|
'zu '
|
||||||
|
'schreiben '
|
||||||
|
'oder '
|
||||||
|
'Nachweise '
|
||||||
|
'zu '
|
||||||
|
'erbringen.',
|
||||||
|
'publish_revision': 'Macht '
|
||||||
|
'die '
|
||||||
|
'ausgewählte '
|
||||||
|
'unveränderliche '
|
||||||
|
'Revision '
|
||||||
|
'für '
|
||||||
|
'die '
|
||||||
|
'Endverbraucherproduktion '
|
||||||
|
'geeignet.',
|
||||||
|
'render_final': 'Erstellt '
|
||||||
|
'gespeicherte '
|
||||||
|
'Render-Nachweise '
|
||||||
|
'und kann '
|
||||||
|
'ein '
|
||||||
|
'Artefakt '
|
||||||
|
'durch '
|
||||||
|
'Dateien '
|
||||||
|
'beibehalten.',
|
||||||
|
'save_revision': 'Erstellt '
|
||||||
|
'eine '
|
||||||
|
'neue '
|
||||||
|
'unveränderliche '
|
||||||
|
'Template-Revision.'}}}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_templates.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.files import CAPABILITY_FILES_ARTIFACT_STORE
|
from govoplan_core.core.files import CAPABILITY_FILES_ARTIFACT_STORE
|
||||||
@@ -8,6 +11,8 @@ from govoplan_core.core.module_guards import (
|
|||||||
persistent_table_uninstall_guard,
|
persistent_table_uninstall_guard,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
@@ -18,21 +23,27 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
from govoplan_core.core.templates import (
|
from govoplan_core.core.templates import (
|
||||||
CAPABILITY_TEMPLATE_CATALOG,
|
CAPABILITY_TEMPLATE_CATALOG,
|
||||||
|
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||||
CAPABILITY_TEMPLATE_RENDERER,
|
CAPABILITY_TEMPLATE_RENDERER,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_templates.backend.db import models as template_models
|
from govoplan_templates.backend.db import models as template_models
|
||||||
|
from govoplan_templates.backend.dsar_provider import (
|
||||||
|
TEMPLATES_DSAR_CAPABILITY,
|
||||||
|
TemplatesDsarProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
MODULE_ID = "templates"
|
MODULE_ID = "templates"
|
||||||
MODULE_NAME = "Templates"
|
MODULE_NAME = "Templates"
|
||||||
MODULE_VERSION = "0.1.18"
|
MODULE_VERSION = "0.1.21"
|
||||||
|
|
||||||
READ_SCOPE = "templates:template:read"
|
READ_SCOPE = "templates:template:read"
|
||||||
WRITE_SCOPE = "templates:template:write"
|
WRITE_SCOPE = "templates:template:write"
|
||||||
@@ -56,11 +67,29 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission(READ_SCOPE, "View templates", "Read template definitions, revisions, and render evidence."),
|
_permission(
|
||||||
_permission(WRITE_SCOPE, "Manage templates", "Create and revise reusable templates."),
|
READ_SCOPE,
|
||||||
_permission(PUBLISH_SCOPE, "Publish templates", "Publish immutable template revisions for final output."),
|
"View templates",
|
||||||
_permission(RENDER_SCOPE, "Render templates", "Preview and render governed output from supplied snapshots."),
|
"Read template definitions, revisions, and render evidence.",
|
||||||
_permission(ADMIN_SCOPE, "Administer templates", "Manage all tenant, group, and user templates."),
|
),
|
||||||
|
_permission(
|
||||||
|
WRITE_SCOPE, "Manage templates", "Create and revise reusable templates."
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
PUBLISH_SCOPE,
|
||||||
|
"Publish templates",
|
||||||
|
"Publish immutable template revisions for final output.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
RENDER_SCOPE,
|
||||||
|
"Render templates",
|
||||||
|
"Preview and render governed output from supplied snapshots.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
"Administer templates",
|
||||||
|
"Manage all tenant, group, and user templates.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ROLE_TEMPLATES = (
|
ROLE_TEMPLATES = (
|
||||||
@@ -85,7 +114,25 @@ DOCUMENTATION = (
|
|||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
audience=("operator", "module_admin", "product_owner"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("templates",),
|
||||||
|
any_scopes=(READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Vorlagenbibliothek verwalten",
|
||||||
|
"summary": "Versionierte Vorlagen mit ausdrücklichen Verwendungen und erforderlichen Datenfeldern erstellen.",
|
||||||
|
"body": (
|
||||||
|
"Vorlagen sind wiederverwendbare, bereichsgebundene Definitionen. Jede Bearbeitung erzeugt eine unveränderliche Revision. "
|
||||||
|
"Veröffentlichen Sie die Revision, die Verbraucher für endgültige Ausgaben verwenden dürfen. Vor dem Rendern erläutert eine "
|
||||||
|
"Kompatibilitätsprüfung fehlende Felder, nicht unterstützte Verwendungen und nicht verfügbare Ausgabeformate."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"templates.page",
|
"templates.page",
|
||||||
@@ -95,6 +142,43 @@ DOCUMENTATION = (
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="templates.reusable-content",
|
||||||
|
title="Reusable content fragments and campaign parts",
|
||||||
|
summary="Create scoped, versioned content that authorized consumers can insert without copying a private library.",
|
||||||
|
body=(
|
||||||
|
"Content fragments retain text and HTML variants, usage constraints, locale, scope, revision, and publication state in Templates. "
|
||||||
|
"Campaign can load a fragment or complete email part through the optional content-library capability. Saving from Campaign creates "
|
||||||
|
"a draft with its declared required-field contract and never publishes it automatically. Consumers show missing required fields before "
|
||||||
|
"applying content. Inserting content changes only the current Campaign draft; existing Campaign versions "
|
||||||
|
"and Template revisions remain unchanged."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("operator", "module_admin", "campaign_author"),
|
||||||
|
related_modules=("campaigns",),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Wiederverwendbare Inhaltsbausteine und Kampagnenteile",
|
||||||
|
"summary": "Bereichsbezogene, versionierte Inhalte erstellen und in berechtigten Modulen verwenden.",
|
||||||
|
"body": (
|
||||||
|
"Inhaltsbausteine speichern Text- und HTML-Fassungen, Verwendungszwecke, Sprache, Geltungsbereich, Revision und "
|
||||||
|
"Veröffentlichungsstatus in Templates. Campaign kann Bausteine oder vollständige E-Mail-Teile über die optionale "
|
||||||
|
"Inhaltsbibliothek laden. Das Speichern aus Campaign legt einen Entwurf mit dem deklarierten Pflichtfeldvertrag an und "
|
||||||
|
"veröffentlicht ihn niemals automatisch. Fehlende Pflichtfelder werden vor dem Anwenden angezeigt. "
|
||||||
|
"Das Einfügen ändert nur den aktuellen Kampagnenentwurf; bestehende Kampagnenversionen und Template-Revisionen bleiben unverändert."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"templates.field.type",
|
||||||
|
"templates.field.usages",
|
||||||
|
"campaign.template.content-library",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="templates.printable-output",
|
id="templates.printable-output",
|
||||||
title="Printable template output",
|
title="Printable template output",
|
||||||
@@ -109,6 +193,18 @@ DOCUMENTATION = (
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
audience=("operator", "module_admin", "product_owner"),
|
||||||
related_modules=("files", "dist_lists", "campaigns", "audit"),
|
related_modules=("files", "dist_lists", "campaigns", "audit"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Druckfähige Vorlagenausgabe",
|
||||||
|
"summary": "Etiketten, Umschläge, Briefe und Listenlayouts aus eingefrorenen Eingabe-Snapshots rendern.",
|
||||||
|
"body": (
|
||||||
|
"Eine Vorschau darf eine Entwurfsrevision verwenden. Endgültige Ausgabe verlangt eine veröffentlichte Revision und einen "
|
||||||
|
"Idempotenzschlüssel. Ergebnisse legen Vorlagenhash, Eingabehash, Renderer-Version, Element-/Seitenanzahl, Diagnosen und "
|
||||||
|
"Ausgabe-Digest fest. Files speichert Artefakte, wenn die Fähigkeit verfügbar und berechtigt ist; andernfalls stellt Templates "
|
||||||
|
"einen begrenzten Download bereit. Drucken im Browser ist der unterstützte grundlegende Ausgabepfad."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
@@ -136,7 +232,26 @@ DOCUMENTATION = (
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
audience=("operator", "module_admin", "product_owner"),
|
||||||
related_modules=("files", "dist_lists", "campaigns", "audit", "policy"),
|
related_modules=("files", "dist_lists", "campaigns", "audit", "policy"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Vorlagenfelder und Folgen des Lebenszyklus",
|
||||||
|
"summary": (
|
||||||
|
"Semantik von Geltungsbereich, Verwendung, Datenvertrag, Veröffentlichung, Rendering und Löschung wiederverwendbarer Vorlagen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Die Sichtbarkeit bestimmt, in welchem Mandanten-, Gruppen- oder Benutzerbereich eine Vorlage auffindbar ist; geerbte "
|
||||||
|
"Vorlagen können schreibgeschützt sein. Verwendungen sind Fähigkeitskontexte, die begrenzen, wo eine Vorlage ausgewählt werden "
|
||||||
|
"darf. Pflichtfelder bilden den Kompatibilitätsvertrag, der vor dem Rendern gegen bereitgestellte Daten geprüft wird. Speichern "
|
||||||
|
"erzeugt eine neue unveränderliche Revision. Veröffentlichen markiert eine Revision für endgültige Ausgabe, ohne ältere "
|
||||||
|
"Revisionen oder Nachweise umzuschreiben. Die Vorschau validiert und rendert begrenzte Beispielausgabe; endgültiges Rendering "
|
||||||
|
"verlangt die veröffentlichte Revision und zeichnet Vorlagen-, Eingabe- und Ausgabehash sowie Renderer-Nachweise auf. Files "
|
||||||
|
"darf das Artefakt aufbewahren, wenn die optionale Fähigkeit verfügbar ist. Löschen entfernt die Vorlage aus zukünftiger "
|
||||||
|
"Auswahl, schreibt aber aufbewahrte Rendering-Nachweise nicht um."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"templates.field.type",
|
"templates.field.type",
|
||||||
@@ -176,15 +291,29 @@ def _renderer(context: ModuleContext):
|
|||||||
return renderer_capability(context)
|
return renderer_capability(context)
|
||||||
|
|
||||||
|
|
||||||
|
def _content_library(context: ModuleContext):
|
||||||
|
from govoplan_templates.backend.capabilities import content_library_capability
|
||||||
|
|
||||||
|
return content_library_capability(context)
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(_context: ModuleContext) -> TemplatesDsarProvider:
|
||||||
|
return TemplatesDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
return {
|
return {
|
||||||
"templates": session.query(template_models.TemplateDefinition).filter(
|
"templates": session.query(template_models.TemplateDefinition)
|
||||||
|
.filter(
|
||||||
template_models.TemplateDefinition.tenant_id == tenant_id,
|
template_models.TemplateDefinition.tenant_id == tenant_id,
|
||||||
template_models.TemplateDefinition.deleted_at.is_(None),
|
template_models.TemplateDefinition.deleted_at.is_(None),
|
||||||
).count(),
|
)
|
||||||
"template_renders": session.query(template_models.TemplateRender).filter(
|
.count(),
|
||||||
|
"template_renders": session.query(template_models.TemplateRender)
|
||||||
|
.filter(
|
||||||
template_models.TemplateRender.tenant_id == tenant_id,
|
template_models.TemplateRender.tenant_id == tenant_id,
|
||||||
).count(),
|
)
|
||||||
|
.count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -196,8 +325,17 @@ manifest = ModuleManifest(
|
|||||||
optional_dependencies=("files", "dist_lists", "campaigns", "audit"),
|
optional_dependencies=("files", "dist_lists", "campaigns", "audit"),
|
||||||
optional_capabilities=(CAPABILITY_FILES_ARTIFACT_STORE,),
|
optional_capabilities=(CAPABILITY_FILES_ARTIFACT_STORE,),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_TEMPLATE_CATALOG, version=MODULE_VERSION),
|
ModuleInterfaceProvider(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_TEMPLATE_RENDERER, version=MODULE_VERSION),
|
name=CAPABILITY_TEMPLATE_CATALOG, version=MODULE_VERSION
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_TEMPLATE_RENDERER, version=MODULE_VERSION
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(name=TEMPLATES_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -214,7 +352,13 @@ manifest = ModuleManifest(
|
|||||||
path="/templates",
|
path="/templates",
|
||||||
label=MODULE_NAME,
|
label=MODULE_NAME,
|
||||||
icon="layout-template",
|
icon="layout-template",
|
||||||
required_any=(READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, RENDER_SCOPE, ADMIN_SCOPE),
|
required_any=(
|
||||||
|
READ_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
PUBLISH_SCOPE,
|
||||||
|
RENDER_SCOPE,
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
),
|
||||||
order=75,
|
order=75,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -225,7 +369,13 @@ manifest = ModuleManifest(
|
|||||||
FrontendRoute(
|
FrontendRoute(
|
||||||
path="/templates",
|
path="/templates",
|
||||||
component="TemplatesPage",
|
component="TemplatesPage",
|
||||||
required_any=(READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, RENDER_SCOPE, ADMIN_SCOPE),
|
required_any=(
|
||||||
|
READ_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
PUBLISH_SCOPE,
|
||||||
|
RENDER_SCOPE,
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
),
|
||||||
order=75,
|
order=75,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -234,21 +384,74 @@ manifest = ModuleManifest(
|
|||||||
path="/templates",
|
path="/templates",
|
||||||
label=MODULE_NAME,
|
label=MODULE_NAME,
|
||||||
icon="layout-template",
|
icon="layout-template",
|
||||||
required_any=(READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, RENDER_SCOPE, ADMIN_SCOPE),
|
required_any=(
|
||||||
|
READ_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
PUBLISH_SCOPE,
|
||||||
|
RENDER_SCOPE,
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
),
|
||||||
order=75,
|
order=75,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="records-documents",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.records_documents",
|
||||||
|
icon="folder",
|
||||||
|
description="i18n:govoplan-core.product_area.records_documents_description",
|
||||||
|
surface_ids=("templates.nav.templates", "templates.route.templates"),
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
ViewSurface(id="templates.page", module_id=MODULE_ID, kind="route", label="Templates", order=75),
|
ViewSurface(
|
||||||
ViewSurface(id="templates.library", module_id=MODULE_ID, kind="section", label="Template library", order=10),
|
id="templates.page",
|
||||||
ViewSurface(id="templates.editor", module_id=MODULE_ID, kind="section", label="Template editor", order=20),
|
module_id=MODULE_ID,
|
||||||
ViewSurface(id="templates.preview", module_id=MODULE_ID, kind="section", label="Template preview and output", order=30),
|
kind="route",
|
||||||
|
label="Templates",
|
||||||
|
order=75,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="templates.library",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Template library",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="templates.editor",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Template editor",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="templates.preview",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Template preview and output",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
route_factory=_router,
|
route_factory=_router,
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_TEMPLATE_CATALOG: _catalog,
|
CAPABILITY_TEMPLATE_CATALOG: _catalog,
|
||||||
|
CAPABILITY_TEMPLATE_CONTENT_LIBRARY: _content_library,
|
||||||
CAPABILITY_TEMPLATE_RENDERER: _renderer,
|
CAPABILITY_TEMPLATE_RENDERER: _renderer,
|
||||||
|
TEMPLATES_DSAR_CAPABILITY: _dsar_provider,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
TEMPLATES_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Templates data-subject request provider",
|
||||||
|
summary=(
|
||||||
|
"Exports minimized template-author and render attribution without "
|
||||||
|
"template, input, or output payloads."
|
||||||
|
),
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
@@ -275,7 +478,53 @@ manifest = ModuleManifest(
|
|||||||
label=MODULE_NAME,
|
label=MODULE_NAME,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=DOCUMENTATION,
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="templates.data-subject-requests",
|
||||||
|
title="Template data-subject requests",
|
||||||
|
summary=(
|
||||||
|
"Export template-author and render activity without content or supplied data."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Templates correlates only an exact tenant account identifier and can "
|
||||||
|
"narrow an already verified search to one template, revision, or render. "
|
||||||
|
"It returns minimized definition, publication, and render lifecycle "
|
||||||
|
"metadata. Template text and HTML, required fields, layouts, metadata, "
|
||||||
|
"render input snapshots, filenames, diagnostics, artifact references, "
|
||||||
|
"output bytes, hashes, and idempotency keys are excluded. Rendered "
|
||||||
|
"business data belongs to the supplying module and is not inferred from "
|
||||||
|
"opaque Template payloads. Attribution remains retained with immutable "
|
||||||
|
"definition and render evidence."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("core", "files", "campaigns", "audit"),
|
||||||
|
order=90,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Betroffenenanfragen für Vorlagen",
|
||||||
|
"summary": "Aktivität von Vorlagenautoren und Renderläufen ohne Inhalt oder bereitgestellte Daten exportieren.",
|
||||||
|
"body": (
|
||||||
|
"Templates gleicht nur eine exakte mandantenbezogene Kontokennung ab und kann eine bereits verifizierte Suche auf eine "
|
||||||
|
"Vorlage, Revision oder einen Renderlauf begrenzen. Ausgegeben werden minimierte Lebenszyklusmetadaten für Definition, "
|
||||||
|
"Veröffentlichung und Rendering. Vorlagentext und -HTML, Pflichtfelder, Layouts, Metadaten, Eingabe-Snapshots, Dateinamen, "
|
||||||
|
"Diagnosen, Artefaktverweise, Ausgabebytes, Hashes und Idempotenzschlüssel sind ausgeschlossen. Gerenderte Fachdaten gehören "
|
||||||
|
"dem bereitstellenden Modul und werden nicht aus undurchsichtigen Vorlagennutzdaten abgeleitet. Die Zuordnung bleibt mit "
|
||||||
|
"unveränderlichen Definitions- und Rendering-Nachweisen erhalten."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"help_contexts": ["templates.page", "privacy.data-subject-requests"],
|
||||||
|
"consequence_classes": {
|
||||||
|
"export_template_attribution": "Returns minimized author, publication, and render activity.",
|
||||||
|
"exclude_template_payloads": "Does not return template content or render inputs and outputs.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
*DOCUMENTATION,
|
||||||
|
),
|
||||||
architecture=declared_module_architecture(
|
architecture=declared_module_architecture(
|
||||||
layer="content_records_evidence",
|
layer="content_records_evidence",
|
||||||
kind="domain",
|
kind="domain",
|
||||||
@@ -286,7 +535,11 @@ manifest = ModuleManifest(
|
|||||||
"The baseline emits safe deterministic HTML/text for browser or OS printing; PDF and printer delivery remain connector concerns.",
|
"The baseline emits safe deterministic HTML/text for browser or OS printing; PDF and printer delivery remain connector concerns.",
|
||||||
),
|
),
|
||||||
supported_authority_modes=("native_authoritative",),
|
supported_authority_modes=("native_authoritative",),
|
||||||
owned_concepts=("template definition", "template revision", "template render evidence"),
|
owned_concepts=(
|
||||||
|
"template definition",
|
||||||
|
"template revision",
|
||||||
|
"template render evidence",
|
||||||
|
),
|
||||||
non_owned_concepts=("recipient", "campaign", "file asset", "printer endpoint"),
|
non_owned_concepts=("recipient", "campaign", "file asset", "printer endpoint"),
|
||||||
recovery_docs=("docs/TEMPLATE_BOUNDARY.md",),
|
recovery_docs=("docs/TEMPLATE_BOUNDARY.md",),
|
||||||
security_docs=("docs/TEMPLATE_BOUNDARY.md",),
|
security_docs=("docs/TEMPLATE_BOUNDARY.md",),
|
||||||
@@ -295,5 +548,10 @@ manifest = ModuleManifest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ TemplateType = Literal[
|
|||||||
"form_letter",
|
"form_letter",
|
||||||
"list_layout",
|
"list_layout",
|
||||||
"email",
|
"email",
|
||||||
|
"content_fragment",
|
||||||
"generic",
|
"generic",
|
||||||
]
|
]
|
||||||
OutputFormat = Literal["html", "text"]
|
OutputFormat = Literal["html", "text"]
|
||||||
|
|||||||
@@ -444,6 +444,10 @@ def revision_ref(revision: TemplateRevision) -> TemplateRevisionRef:
|
|||||||
output_profiles=tuple(
|
output_profiles=tuple(
|
||||||
TemplateOutputProfile(**item) for item in revision.output_profiles
|
TemplateOutputProfile(**item) for item in revision.output_profiles
|
||||||
),
|
),
|
||||||
|
content_text=revision.content_text,
|
||||||
|
content_html=revision.content_html,
|
||||||
|
layout=dict(revision.layout or {}),
|
||||||
|
metadata=dict(revision.metadata_ or {}),
|
||||||
published_at=revision.published_at,
|
published_at=revision.published_at,
|
||||||
provenance={
|
provenance={
|
||||||
"module": "templates",
|
"module": "templates",
|
||||||
@@ -502,6 +506,8 @@ def _create_revision(
|
|||||||
|
|
||||||
|
|
||||||
def _default_output_profiles(template_type: str) -> list[dict[str, object]]:
|
def _default_output_profiles(template_type: str) -> list[dict[str, object]]:
|
||||||
|
if template_type == "content_fragment":
|
||||||
|
return []
|
||||||
media = "A4"
|
media = "A4"
|
||||||
if template_type == "envelope":
|
if template_type == "envelope":
|
||||||
media = "DL"
|
media = "DL"
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_templates.backend.db.models import (
|
||||||
|
TemplateDefinition,
|
||||||
|
TemplateRender,
|
||||||
|
TemplateRevision,
|
||||||
|
)
|
||||||
|
from govoplan_templates.backend.dsar_provider import (
|
||||||
|
TEMPLATES_DSAR_CAPABILITY,
|
||||||
|
TemplatesDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_templates.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 22, 14, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplatesDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.provider = TemplatesDsarProvider()
|
||||||
|
self._seed()
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _seed(self) -> None:
|
||||||
|
self.session.add(
|
||||||
|
TemplateDefinition(
|
||||||
|
id="template-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
name="Sensitive template title do not export",
|
||||||
|
slug="secret-slug-do-not-export",
|
||||||
|
description="description-do-not-export",
|
||||||
|
template_type="letter",
|
||||||
|
status="published",
|
||||||
|
current_revision_id="revision-1",
|
||||||
|
current_revision=1,
|
||||||
|
published_revision_id="revision-1",
|
||||||
|
resource_revision=2,
|
||||||
|
created_by_account_id="account-1",
|
||||||
|
updated_by_account_id="account-1",
|
||||||
|
metadata_={"secret": "definition-metadata-do-not-export"},
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add(
|
||||||
|
TemplateRevision(
|
||||||
|
id="revision-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
template_id="template-1",
|
||||||
|
revision=1,
|
||||||
|
definition_hash="definition-hash-do-not-export",
|
||||||
|
template_type="letter",
|
||||||
|
usages=["campaign"],
|
||||||
|
locale="de",
|
||||||
|
required_fields=[{"secret": "required-field-do-not-export"}],
|
||||||
|
output_profiles=[{"secret": "output-profile-do-not-export"}],
|
||||||
|
content_text="template-text-do-not-export",
|
||||||
|
content_html="template-html-do-not-export",
|
||||||
|
layout={"secret": "layout-do-not-export"},
|
||||||
|
metadata_={"secret": "revision-metadata-do-not-export"},
|
||||||
|
created_by_account_id="account-1",
|
||||||
|
published_at=NOW,
|
||||||
|
published_by_account_id="account-1",
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add(
|
||||||
|
TemplateRender(
|
||||||
|
id="render-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
template_id="template-1",
|
||||||
|
revision_id="revision-1",
|
||||||
|
revision_number=1,
|
||||||
|
mode="final",
|
||||||
|
usage="campaign",
|
||||||
|
output_format="html",
|
||||||
|
content_type="text/html",
|
||||||
|
filename="personal-filename-do-not-export.html",
|
||||||
|
idempotency_key="render-idempotency-do-not-export",
|
||||||
|
template_hash="template-hash-do-not-export",
|
||||||
|
input_hash="input-hash-do-not-export",
|
||||||
|
renderer_version="renderer-v1",
|
||||||
|
output_sha256="output-hash-do-not-export",
|
||||||
|
output_size_bytes=123,
|
||||||
|
item_count=2,
|
||||||
|
page_count=1,
|
||||||
|
diagnostics=[{"secret": "diagnostic-do-not-export"}],
|
||||||
|
input_snapshot={"person": "input-person-do-not-export"},
|
||||||
|
artifact_ref={"secret": "artifact-ref-do-not-export"},
|
||||||
|
payload=b"output-payload-do-not-export",
|
||||||
|
created_by_account_id="account-1",
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_is_minimized_and_narrowable(self) -> None:
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
)
|
||||||
|
self.assertEqual(3, len(records))
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
for excluded in (
|
||||||
|
"Sensitive template title do not export",
|
||||||
|
"secret-slug-do-not-export",
|
||||||
|
"definition-metadata-do-not-export",
|
||||||
|
"definition-hash-do-not-export",
|
||||||
|
"required-field-do-not-export",
|
||||||
|
"template-text-do-not-export",
|
||||||
|
"template-html-do-not-export",
|
||||||
|
"personal-filename-do-not-export",
|
||||||
|
"render-idempotency-do-not-export",
|
||||||
|
"template-hash-do-not-export",
|
||||||
|
"input-person-do-not-export",
|
||||||
|
"artifact-ref-do-not-export",
|
||||||
|
"output-payload-do-not-export",
|
||||||
|
):
|
||||||
|
self.assertNotIn(excluded, exported)
|
||||||
|
narrowed = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={"templates.render": "render-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"template_render_actor_attribution"},
|
||||||
|
{record.resource_type for record in narrowed},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_account_is_required_and_records_are_retained(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(email="author@example.test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
subject = DsarSubjectRef(account_id="account-1")
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session, tenant_id="tenant-1", subject=subject
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
self.assertTrue(all(action.kind == "retain" for action in actions))
|
||||||
|
|
||||||
|
def test_manifest_registers_provider_and_documentation(self) -> None:
|
||||||
|
self.assertIn(TEMPLATES_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
self.assertIn(
|
||||||
|
"templates.data-subject-requests",
|
||||||
|
{topic.id for topic in manifest.documentation},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -6,6 +6,14 @@ from govoplan_templates.backend.manifest import manifest
|
|||||||
|
|
||||||
|
|
||||||
class TemplatesInterfaceDocumentationContractTests(unittest.TestCase):
|
class TemplatesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
german = (topic.translations or {}).get("de", {})
|
||||||
|
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
|
||||||
|
self.assertTrue(
|
||||||
|
all(str(value).strip() for value in german.values()), topic.id
|
||||||
|
)
|
||||||
|
|
||||||
def test_route_and_surfaces_remain_declared(self) -> None:
|
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||||
frontend = manifest.frontend
|
frontend = manifest.frontend
|
||||||
self.assertIsNotNone(frontend)
|
self.assertIsNotNone(frontend)
|
||||||
@@ -27,10 +35,12 @@ class TemplatesInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
reference = topics["templates.reference.fields-and-consequences"]
|
reference = topics["templates.reference.fields-and-consequences"]
|
||||||
|
|
||||||
self.assertIn("templates.state.read-only", library.metadata["help_contexts"])
|
self.assertIn("templates.state.read-only", library.metadata["help_contexts"])
|
||||||
|
self.assertEqual("workflow", library.metadata["kind"])
|
||||||
self.assertIn("templates.action.render-final", output.metadata["help_contexts"])
|
self.assertIn("templates.action.render-final", output.metadata["help_contexts"])
|
||||||
self.assertIn("templates.field.usages", reference.metadata["help_contexts"])
|
self.assertIn("templates.field.usages", reference.metadata["help_contexts"])
|
||||||
self.assertIn("publish_revision", reference.metadata["consequence_classes"])
|
self.assertIn("publish_revision", reference.metadata["consequence_classes"])
|
||||||
self.assertIn("delete_template", reference.metadata["consequence_classes"])
|
self.assertIn("delete_template", reference.metadata["consequence_classes"])
|
||||||
|
self.assertEqual("reference", reference.metadata["kind"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+50
-1
@@ -2,20 +2,27 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal
|
from govoplan_core.auth import ApiPrincipal
|
||||||
from govoplan_core.core.access import PrincipalRef
|
from govoplan_core.core.access import PrincipalRef
|
||||||
from govoplan_core.core.files import ManagedArtifactRef
|
from govoplan_core.core.files import ManagedArtifactRef
|
||||||
from govoplan_core.core.templates import (
|
from govoplan_core.core.templates import (
|
||||||
CAPABILITY_TEMPLATE_CATALOG,
|
CAPABILITY_TEMPLATE_CATALOG,
|
||||||
|
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||||
CAPABILITY_TEMPLATE_RENDERER,
|
CAPABILITY_TEMPLATE_RENDERER,
|
||||||
TemplateCompatibilityError,
|
TemplateCompatibilityError,
|
||||||
|
TemplateContentDraftRequest,
|
||||||
|
TemplateFieldRequirement,
|
||||||
TemplateRenderError,
|
TemplateRenderError,
|
||||||
TemplateRenderRequest,
|
TemplateRenderRequest,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.db.session import configure_database, reset_database
|
from govoplan_core.db.session import configure_database, reset_database
|
||||||
from govoplan_templates.backend.capabilities import SqlTemplateCatalog
|
from govoplan_templates.backend.capabilities import (
|
||||||
|
SqlTemplateCatalog,
|
||||||
|
SqlTemplateContentLibrary,
|
||||||
|
)
|
||||||
from govoplan_templates.backend.db.models import (
|
from govoplan_templates.backend.db.models import (
|
||||||
TemplateDefinition,
|
TemplateDefinition,
|
||||||
TemplateRender,
|
TemplateRender,
|
||||||
@@ -158,10 +165,48 @@ class TemplateServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(1, len(refs))
|
self.assertEqual(1, len(refs))
|
||||||
self.assertEqual("serial_letter", refs[0].template_type)
|
self.assertEqual("serial_letter", refs[0].template_type)
|
||||||
self.assertEqual(("campaign.postal",), refs[0].revision.usages)
|
self.assertEqual(("campaign.postal",), refs[0].revision.usages)
|
||||||
|
self.assertIn("Dear", refs[0].revision.content_html)
|
||||||
self.assertEqual("postal.address", refs[0].revision.required_fields[1].path)
|
self.assertEqual("postal.address", refs[0].revision.required_fields[1].path)
|
||||||
self.assertNotEqual(first.definition_hash, second.definition_hash)
|
self.assertNotEqual(first.definition_hash, second.definition_hash)
|
||||||
self.assertEqual(2, second.revision)
|
self.assertEqual(2, second.revision)
|
||||||
|
|
||||||
|
def test_content_library_creates_unpublished_provider_owned_draft(self) -> None:
|
||||||
|
with self.database.session() as session, patch(
|
||||||
|
"govoplan_templates.backend.capabilities.audit_from_principal"
|
||||||
|
), patch("govoplan_templates.backend.capabilities.emit_platform_event"):
|
||||||
|
result = SqlTemplateContentLibrary().create_content_draft(
|
||||||
|
session,
|
||||||
|
principal(),
|
||||||
|
request=TemplateContentDraftRequest(
|
||||||
|
name="Closing paragraph",
|
||||||
|
template_type="content_fragment",
|
||||||
|
usages=("campaign.content",),
|
||||||
|
locale="de",
|
||||||
|
content_text="Mit freundlichen Grüßen",
|
||||||
|
required_fields=(
|
||||||
|
TemplateFieldRequirement(
|
||||||
|
path="local.display_name",
|
||||||
|
label="Display name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={"campaign_targets": ["text"]},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.assertEqual("draft", result.status)
|
||||||
|
self.assertEqual("content_fragment", result.template_type)
|
||||||
|
self.assertEqual("Mit freundlichen Grüßen", result.revision.content_text)
|
||||||
|
self.assertEqual(
|
||||||
|
"local.display_name",
|
||||||
|
result.revision.required_fields[0].path,
|
||||||
|
)
|
||||||
|
self.assertEqual(["text"], result.revision.metadata["campaign_targets"])
|
||||||
|
self.assertEqual(
|
||||||
|
"templates.content_library",
|
||||||
|
result.revision.metadata["created_through"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_frozen_postal_snapshot_renders_deterministic_letter_bundle(self) -> None:
|
def test_frozen_postal_snapshot_renders_deterministic_letter_bundle(self) -> None:
|
||||||
frozen = (
|
frozen = (
|
||||||
{"name": "Ada", "postal": {"address": "Street 1"}},
|
{"name": "Ada", "postal": {"address": "Street 1"}},
|
||||||
@@ -322,6 +367,10 @@ class TemplateManifestTests(unittest.TestCase):
|
|||||||
self.assertFalse(manifest.dependencies)
|
self.assertFalse(manifest.dependencies)
|
||||||
self.assertIn("files", manifest.optional_dependencies)
|
self.assertIn("files", manifest.optional_dependencies)
|
||||||
self.assertIn(CAPABILITY_TEMPLATE_CATALOG, manifest.capability_factories)
|
self.assertIn(CAPABILITY_TEMPLATE_CATALOG, manifest.capability_factories)
|
||||||
|
self.assertIn(
|
||||||
|
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||||
|
manifest.capability_factories,
|
||||||
|
)
|
||||||
self.assertIn(CAPABILITY_TEMPLATE_RENDERER, manifest.capability_factories)
|
self.assertIn(CAPABILITY_TEMPLATE_RENDERER, manifest.capability_factories)
|
||||||
self.assertEqual("@govoplan/templates-webui", manifest.frontend.package_name)
|
self.assertEqual("@govoplan/templates-webui", manifest.frontend.package_name)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/templates-webui",
|
"name": "@govoplan/templates-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.21",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type TemplateType =
|
|||||||
| "form_letter"
|
| "form_letter"
|
||||||
| "list_layout"
|
| "list_layout"
|
||||||
| "email"
|
| "email"
|
||||||
|
| "content_fragment"
|
||||||
| "generic";
|
| "generic";
|
||||||
|
|
||||||
export type TemplateFieldType =
|
export type TemplateFieldType =
|
||||||
|
|||||||
@@ -3,27 +3,35 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
FileCheck2,
|
FileCheck2,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
|
||||||
Save,
|
Save,
|
||||||
Send,
|
Send,
|
||||||
Trash2,
|
Trash2,
|
||||||
X
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { DialogSection, ActionToolbar,
|
||||||
ApiError,
|
ApiError,
|
||||||
ActionBlockerHint,
|
ActionBlockerHint,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
ContentSection,
|
||||||
Dialog,
|
Dialog,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
FilterBar,
|
||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
WorkspaceLayout,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
hasScope,
|
hasScope,
|
||||||
useUnsavedChanges,
|
useUnsavedChanges,
|
||||||
@@ -70,6 +78,7 @@ const TEMPLATE_TYPES: Array<{ value: TemplateType; label: string }> = [
|
|||||||
{ value: "form_letter", label: "Form letter" },
|
{ value: "form_letter", label: "Form letter" },
|
||||||
{ value: "list_layout", label: "List layout" },
|
{ value: "list_layout", label: "List layout" },
|
||||||
{ value: "email", label: "Email" },
|
{ value: "email", label: "Email" },
|
||||||
|
{ value: "content_fragment", label: "Content fragment" },
|
||||||
{ value: "generic", label: "Generic" }
|
{ value: "generic", label: "Generic" }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -292,20 +301,31 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="templates-page">
|
<WorkspaceFrame as="main" height="viewport" surface="plain" className="templates-page" label="Template workspace">
|
||||||
<div className="templates-shell">
|
<WorkspaceLayout
|
||||||
<aside className="templates-sidebar">
|
variant="split"
|
||||||
<div className="templates-sidebar-toolbar">
|
primarySize="compact"
|
||||||
<strong>Template library</strong>
|
surface="contained"
|
||||||
<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />
|
primaryScrollable={false}
|
||||||
</div>
|
contentScrollable={false}
|
||||||
<div className="templates-search"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></div>
|
primaryLabel="Template library"
|
||||||
<div className="templates-list">
|
contentLabel="Template workspace"
|
||||||
|
contentClassName="templates-workspace"
|
||||||
|
primary={<>
|
||||||
|
<WorkspaceActionBar
|
||||||
|
scope="collection-pane"
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void reload(selectedId), loading: loading || busy }}
|
||||||
|
contextActions={<strong>Template library</strong>}
|
||||||
|
createAction={<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />}
|
||||||
|
/>
|
||||||
|
<FilterBar surface="panel"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></FilterBar>
|
||||||
|
<SelectionList variant="navigation" label="Templates">
|
||||||
{visibleItems.map((item) => (
|
{visibleItems.map((item) => (
|
||||||
<button
|
<SelectionListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
selected={item.id === selectedId}
|
||||||
className={item.id === selectedId ? "is-selected" : ""}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.id === selectedId) return;
|
if (item.id === selectedId) return;
|
||||||
requestDiscard(() => {
|
requestDiscard(() => {
|
||||||
@@ -314,34 +334,42 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span><strong>{item.name}</strong><small>{typeLabel(item.template_type)} · revision {item.current_revision}</small></span>
|
<SelectionListItemContent title={item.name} description={`${typeLabel(item.template_type)} · revision ${item.current_revision}`} />
|
||||||
<StatusBadge status={item.status} label={item.status} />
|
<StatusBadge status={item.status} label={item.status} />
|
||||||
</button>
|
</SelectionListItem>
|
||||||
))}
|
))}
|
||||||
{!visibleItems.length && <p className="templates-empty">No matching templates.</p>}
|
{!visibleItems.length && <StatePanel size="compact" description="No matching templates." />}
|
||||||
</div>
|
</SelectionList>
|
||||||
</aside>
|
</>}
|
||||||
|
>
|
||||||
<section className="templates-workspace">
|
<WorkspaceActionBar
|
||||||
<header className="templates-workspace-toolbar">
|
scope="editor-pane"
|
||||||
<span className="templates-current-title">
|
variant="editor"
|
||||||
|
state={busy ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
|
className="templates-workspace-toolbar"
|
||||||
|
contextActions={<span className="templates-current-title">
|
||||||
<strong>{selected?.name ?? "Select a template"}</strong>
|
<strong>{selected?.name ?? "Select a template"}</strong>
|
||||||
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
|
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
|
||||||
</span>
|
</span>}
|
||||||
<div className="templates-toolbar-actions">
|
helpAction={<DocumentationHelpLink reference={TEMPLATES_DOCUMENTATION} />}
|
||||||
<DocumentationHelpLink reference={TEMPLATES_DOCUMENTATION} />
|
primaryActions={<>
|
||||||
<IconButton label="Discard and reload" icon={<RefreshCw size={17} />} disabled={loading || busy} disabledReason={loading ? TEMPLATES_I18N.loading : busy ? TEMPLATES_I18N.busy : undefined} onClick={() => requestDiscard(() => void reload(selectedId))} />
|
<Button helpContextId="templates.action.publish" helpModuleId="templates" disabled={!selected || !canPublish || dirty || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : !canPublish ? TEMPLATES_I18N.publishReason : dirty ? TEMPLATES_I18N.saveBeforeAction : undefined} onClick={() => setPublishOpen(true)}><FileCheck2 size={16} /> Publish</Button>
|
||||||
<Button variant="primary" disabled={!selected || readOnly || !dirty || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : !dirty ? TEMPLATES_I18N.noChanges : undefined} onClick={() => void save()}><Save size={16} /> Save revision</Button>
|
|
||||||
<Button disabled={!selected || !canPublish || dirty || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : !canPublish ? TEMPLATES_I18N.publishReason : dirty ? TEMPLATES_I18N.saveBeforeAction : undefined} onClick={() => setPublishOpen(true)}><FileCheck2 size={16} /> Publish</Button>
|
|
||||||
<IconButton label="Delete template" icon={<Trash2 size={17} />} variant="danger" disabled={!selected || readOnly} disabledReason={!selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : undefined} onClick={() => setDeleteOpen(true)} />
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={view}
|
value={view}
|
||||||
onChange={setView}
|
onChange={setView}
|
||||||
options={[{ id: "definition", label: "Definition" }, { id: "preview", label: "Preview" }]}
|
options={[{ id: "definition", label: "Definition" }, { id: "preview", label: "Preview" }]}
|
||||||
ariaLabel="Template workspace"
|
ariaLabel="Template workspace"
|
||||||
/>
|
/>
|
||||||
</div>
|
</>}
|
||||||
</header>
|
destructiveActions={<IconButton label="Delete template" helpContextId="templates.action.delete" helpModuleId="templates" icon={<Trash2 size={17} />} variant="danger" disabled={!selected || readOnly} disabledReason={!selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : undefined} onClick={() => setDeleteOpen(true)} />}
|
||||||
|
discardAction={{ label: "Discard and reload", onClick: () => requestDiscard(() => void reload(selectedId)), disabled: !selected }}
|
||||||
|
saveAction={{
|
||||||
|
label: <><Save size={16} /> Save revision</>,
|
||||||
|
disabled: !selected || readOnly || busy,
|
||||||
|
disabledReason: busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : undefined,
|
||||||
|
onClick: () => void save()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="templates-alerts">
|
<div className="templates-alerts">
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
@@ -362,7 +390,7 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
|||||||
|
|
||||||
<LoadingFrame loading={loading} label="Loading templates">
|
<LoadingFrame loading={loading} label="Loading templates">
|
||||||
<div className="templates-content">
|
<div className="templates-content">
|
||||||
{!selected ? <p className="templates-empty">Create or select a reusable template.</p> : view === "definition" ? <>
|
{!selected ? <StatePanel size="fill" title="Templates" description="Create or select a reusable template." /> : view === "definition" ? <>
|
||||||
<DefinitionEditor draft={draft} disabled={readOnly || busy} auth={auth} onChange={setDraft} />
|
<DefinitionEditor draft={draft} disabled={readOnly || busy} auth={auth} onChange={setDraft} />
|
||||||
<RevisionHistory revisions={revisions} currentRevisionId={selected.current_revision_id} publishedRevisionId={selected.published_revision_id ?? null} />
|
<RevisionHistory revisions={revisions} currentRevisionId={selected.current_revision_id} publishedRevisionId={selected.published_revision_id ?? null} />
|
||||||
</> : <>
|
</> : <>
|
||||||
@@ -387,25 +415,24 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
|||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</section>
|
</WorkspaceLayout>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={createOpen} title="Add template" onClose={closeCreate} closeDisabled={busy} footer={<><Button onClick={closeCreate} disabled={busy} disabledReason={busy ? TEMPLATES_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={!createName.trim() || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !createName.trim() ? TEMPLATES_I18N.incomplete : undefined} onClick={() => void create()}>Create</Button></>}>
|
<Dialog open={createOpen} title="Add template" onClose={closeCreate} closeDisabled={busy} footer={<><Button onClick={closeCreate} disabled={busy} disabledReason={busy ? TEMPLATES_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={!createName.trim() || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !createName.trim() ? TEMPLATES_I18N.incomplete : undefined} onClick={() => void create()}>Create</Button></>}>
|
||||||
<div className="templates-dialog-form">
|
<DialogSection className="templates-dialog-form">
|
||||||
<FormField label="Name" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
|
<FormField label="Name" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
|
||||||
<FormField label="Type" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><select value={createType} onChange={(event) => setCreateType(event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
|
<FormField label="Type" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><select value={createType} onChange={(event) => setCreateType(event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
|
||||||
</div>
|
</DialogSection>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<ConfirmDialog open={publishOpen} title="i18n:govoplan-templates.publish_title" message="i18n:govoplan-templates.publish_message" confirmLabel="Publish" busy={busy} onCancel={() => setPublishOpen(false)} onConfirm={() => void publish()} />
|
<ConfirmDialog open={publishOpen} title="i18n:govoplan-templates.publish_title" message="i18n:govoplan-templates.publish_message" confirmLabel="Publish" busy={busy} onCancel={() => setPublishOpen(false)} onConfirm={() => void publish()} />
|
||||||
<ConfirmDialog open={finalRenderOpen} title="i18n:govoplan-templates.render_title" message="i18n:govoplan-templates.render_message" confirmLabel="Render final output" busy={busy} onCancel={() => setFinalRenderOpen(false)} onConfirm={() => void runRender(true)} />
|
<ConfirmDialog open={finalRenderOpen} title="i18n:govoplan-templates.render_title" message="i18n:govoplan-templates.render_message" confirmLabel="Render final output" busy={busy} onCancel={() => setFinalRenderOpen(false)} onConfirm={() => void runRender(true)} />
|
||||||
<ConfirmDialog open={deleteOpen} title="Delete template?" message="Existing render evidence remains until module retention removes it. Consumers can no longer select this template." confirmLabel="Delete" tone="danger" busy={busy} onCancel={() => setDeleteOpen(false)} onConfirm={() => void remove()} />
|
<ConfirmDialog open={deleteOpen} title="Delete template?" message="Existing render evidence remains until module retention removes it. Consumers can no longer select this template." confirmLabel="Delete" tone="danger" busy={busy} onCancel={() => setDeleteOpen(false)} onConfirm={() => void remove()} />
|
||||||
</main>
|
</WorkspaceFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) {
|
function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) {
|
||||||
return <section className="templates-section templates-history">
|
return <ContentSection className="templates-history">
|
||||||
<div className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></div>
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></ActionToolbar>
|
||||||
<div className="templates-history-list">
|
<div className="templates-history-list">
|
||||||
{revisions.map((revision) => <div key={revision.id}>
|
{revisions.map((revision) => <div key={revision.id}>
|
||||||
<span><strong>Revision {revision.revision}</strong><small>{formatDateTime(revision.created_at)} · {shortHash(revision.definition_hash)}</small></span>
|
<span><strong>Revision {revision.revision}</strong><small>{formatDateTime(revision.created_at)} · {shortHash(revision.definition_hash)}</small></span>
|
||||||
@@ -414,22 +441,22 @@ function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }:
|
|||||||
{revision.id === publishedRevisionId && <StatusBadge status="active" label="Published" />}
|
{revision.id === publishedRevisionId && <StatusBadge status="active" label="Published" />}
|
||||||
</span>
|
</span>
|
||||||
</div>)}
|
</div>)}
|
||||||
{!revisions.length && <p className="templates-inline-empty">No revision evidence is available.</p>}
|
{!revisions.length && <StatePanel size="inline" description="No revision evidence is available." />}
|
||||||
</div>
|
</div>
|
||||||
</section>;
|
</ContentSection>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) {
|
function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) {
|
||||||
return <section className="templates-section templates-history">
|
return <ContentSection className="templates-history">
|
||||||
<div className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></div>
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></ActionToolbar>
|
||||||
<div className="templates-history-list">
|
<div className="templates-history-list">
|
||||||
{renders.map((item) => <div key={item.render_id}>
|
{renders.map((item) => <div key={item.render_id}>
|
||||||
<span><strong>{item.filename}</strong><small>{item.generated_at ? formatDateTime(item.generated_at) : "Generated"} · {item.item_count} item(s) · {shortHash(item.output_sha256)}</small></span>
|
<span><strong>{item.filename}</strong><small>{item.generated_at ? formatDateTime(item.generated_at) : "Generated"} · {item.item_count} item(s) · {shortHash(item.output_sha256)}</small></span>
|
||||||
<Button disabled={!item.artifact?.download_path} onClick={() => void downloadTemplateRender(settings, item).catch((caught) => onError(errorMessage(caught)))}><Download size={15} /> Download</Button>
|
<Button disabled={!item.artifact?.download_path} onClick={() => void downloadTemplateRender(settings, item).catch((caught) => onError(errorMessage(caught)))}><Download size={15} /> Download</Button>
|
||||||
</div>)}
|
</div>)}
|
||||||
{!renders.length && <p className="templates-inline-empty">No output has been rendered for this template.</p>}
|
{!renders.length && <StatePanel size="inline" description="No output has been rendered for this template." />}
|
||||||
</div>
|
</div>
|
||||||
</section>;
|
</ContentSection>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: TemplatePayload; disabled: boolean; auth: AuthInfo; onChange: (draft: TemplatePayload) => void }) {
|
function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: TemplatePayload; disabled: boolean; auth: AuthInfo; onChange: (draft: TemplatePayload) => void }) {
|
||||||
@@ -452,8 +479,8 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
|
|||||||
<FormField label="Description"><input disabled={disabled} value={draft.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></FormField>
|
<FormField label="Description"><input disabled={disabled} value={draft.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="templates-section">
|
<ContentSection>
|
||||||
<div className="templates-section-heading"><strong>Required data contract</strong><Button disabled={disabled} onClick={() => update("required_fields", [...draft.required_fields, emptyField()])}><Plus size={15} /> Add field</Button></div>
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Required data contract</strong><Button disabled={disabled} onClick={() => update("required_fields", [...draft.required_fields, emptyField()])}><Plus size={15} /> Add field</Button></ActionToolbar>
|
||||||
<div className="templates-fields-table">
|
<div className="templates-fields-table">
|
||||||
{draft.required_fields.map((field, index) => (
|
{draft.required_fields.map((field, index) => (
|
||||||
<div className="templates-field-row" key={`${index}:${field.path}`}>
|
<div className="templates-field-row" key={`${index}:${field.path}`}>
|
||||||
@@ -464,12 +491,12 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
|
|||||||
<IconButton label="Remove field" icon={<X size={16} />} variant="ghost" disabled={disabled} onClick={() => update("required_fields", draft.required_fields.filter((_, fieldIndex) => fieldIndex !== index))} />
|
<IconButton label="Remove field" icon={<X size={16} />} variant="ghost" disabled={disabled} onClick={() => update("required_fields", draft.required_fields.filter((_, fieldIndex) => fieldIndex !== index))} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!draft.required_fields.length && <p className="templates-inline-empty">No required fields. Tokens still resolve from supplied parameters and items.</p>}
|
{!draft.required_fields.length && <StatePanel size="inline" description="No required fields. Tokens still resolve from supplied parameters and items." />}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</ContentSection>
|
||||||
|
|
||||||
<section className="templates-section">
|
<ContentSection>
|
||||||
<div className="templates-section-heading"><strong>Page and media</strong></div>
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Page and media</strong></ActionToolbar>
|
||||||
<div className="templates-layout-fields">
|
<div className="templates-layout-fields">
|
||||||
<FormField label="Page size"><select disabled={disabled} value={String(layout.page_size ?? pageSizeForType(draft.template_type))} onChange={(event) => update("layout", { ...layout, page_size: event.target.value })}>{["A3", "A4", "A5", "Letter", "Legal", "DL"].map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
|
<FormField label="Page size"><select disabled={disabled} value={String(layout.page_size ?? pageSizeForType(draft.template_type))} onChange={(event) => update("layout", { ...layout, page_size: event.target.value })}>{["A3", "A4", "A5", "Letter", "Legal", "DL"].map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
|
||||||
<FormField label="Margin (mm)"><input type="number" min="0" max="60" disabled={disabled} value={Number(layout.margin_mm ?? 15)} onChange={(event) => update("layout", { ...layout, margin_mm: Number(event.target.value) })} /></FormField>
|
<FormField label="Margin (mm)"><input type="number" min="0" max="60" disabled={disabled} value={Number(layout.margin_mm ?? 15)} onChange={(event) => update("layout", { ...layout, margin_mm: Number(event.target.value) })} /></FormField>
|
||||||
@@ -479,12 +506,12 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
|
|||||||
<FormField label="Gap (mm)"><input type="number" min="0" max="20" disabled={disabled} value={Number(layout.gap_mm ?? 2)} onChange={(event) => update("layout", { ...layout, gap_mm: Number(event.target.value) })} /></FormField>
|
<FormField label="Gap (mm)"><input type="number" min="0" max="20" disabled={disabled} value={Number(layout.gap_mm ?? 2)} onChange={(event) => update("layout", { ...layout, gap_mm: Number(event.target.value) })} /></FormField>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</ContentSection>
|
||||||
|
|
||||||
<section className="templates-section templates-body-section">
|
<ContentSection className="templates-body-section">
|
||||||
<div className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></div>
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></ActionToolbar>
|
||||||
<WysiwygEditor disabled={disabled} value={draft.content_html ?? ""} onChange={(value) => update("content_html", value || null)} minHeight={300} />
|
<WysiwygEditor disabled={disabled} value={draft.content_html ?? ""} onChange={(value) => update("content_html", value || null)} minHeight={300} />
|
||||||
</section>
|
</ContentSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -508,8 +535,8 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="templates-preview">
|
<div className="templates-preview">
|
||||||
<section className="templates-section">
|
<ContentSection>
|
||||||
<div className="templates-section-heading"><strong>Validated sample input</strong><small>Preview and final output use the same pinned revision and canonical input.</small></div>
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Validated sample input</strong><small>Preview and final output use the same pinned revision and canonical input.</small></ActionToolbar>
|
||||||
<div className="templates-preview-controls">
|
<div className="templates-preview-controls">
|
||||||
<FormField label="Usage" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><select value={usage} onChange={(event) => onUsage(event.target.value)}>{item.revision.usages.map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
|
<FormField label="Usage" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><select value={usage} onChange={(event) => onUsage(event.target.value)}>{item.revision.usages.map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
|
||||||
<FormField label="Output" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><SegmentedControl value={outputFormat} onChange={onOutputFormat} options={[{ id: "html", label: "Printable HTML" }, { id: "text", label: "Plain text" }]} ariaLabel="Output format" /></FormField>
|
<FormField label="Output" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><SegmentedControl value={outputFormat} onChange={onOutputFormat} options={[{ id: "html", label: "Printable HTML" }, { id: "text", label: "Plain text" }]} ariaLabel="Output format" /></FormField>
|
||||||
@@ -520,7 +547,7 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
|
|||||||
<Button disabled={disabled} disabledReason={disabled ? disabledReason : undefined} onClick={() => onRender(false)}><Eye size={16} /> Validate and preview</Button>
|
<Button disabled={disabled} disabledReason={disabled ? disabledReason : undefined} onClick={() => onRender(false)}><Eye size={16} /> Validate and preview</Button>
|
||||||
<Button variant="primary" disabled={disabled || !item.revision.published_at} disabledReason={disabled ? disabledReason : !item.revision.published_at ? "Publish this revision before producing final output." : undefined} onClick={() => onRender(true)}><Send size={16} /> Render final output</Button>
|
<Button variant="primary" disabled={disabled || !item.revision.published_at} disabledReason={disabled ? disabledReason : !item.revision.published_at ? "Publish this revision before producing final output." : undefined} onClick={() => onRender(true)}><Send size={16} /> Render final output</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</ContentSection>
|
||||||
|
|
||||||
{compatibility && <DismissibleAlert tone={compatibility.compatible ? "success" : "danger"}>
|
{compatibility && <DismissibleAlert tone={compatibility.compatible ? "success" : "danger"}>
|
||||||
{compatibility.compatible
|
{compatibility.compatible
|
||||||
@@ -528,8 +555,8 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
|
|||||||
: compatibility.diagnostics.map((item) => String(item.message ?? item.code ?? "Incompatible input")).join(" ")}
|
: compatibility.diagnostics.map((item) => String(item.message ?? item.code ?? "Incompatible input")).join(" ")}
|
||||||
</DismissibleAlert>}
|
</DismissibleAlert>}
|
||||||
|
|
||||||
{render && <section className="templates-section templates-render-result">
|
{render && <ContentSection className="templates-render-result">
|
||||||
<div className="templates-section-heading"><strong>Render evidence</strong><Button disabled={!render.artifact?.download_path} onClick={onDownload}><Download size={16} /> Download</Button></div>
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Render evidence</strong><Button disabled={!render.artifact?.download_path} onClick={onDownload}><Download size={16} /> Download</Button></ActionToolbar>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>Revision</dt><dd>{render.revision} · {shortHash(render.template_hash)}</dd></div>
|
<div><dt>Revision</dt><dd>{render.revision} · {shortHash(render.template_hash)}</dd></div>
|
||||||
<div><dt>Input</dt><dd>{shortHash(render.input_hash)}</dd></div>
|
<div><dt>Input</dt><dd>{shortHash(render.input_hash)}</dd></div>
|
||||||
@@ -539,7 +566,7 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
|
|||||||
<div><dt>Generated</dt><dd>{render.generated_at ? formatDateTime(render.generated_at) : "Now"}</dd></div>
|
<div><dt>Generated</dt><dd>{render.generated_at ? formatDateTime(render.generated_at) : "Now"}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<p>{render.artifact?.kind === "managed_file" ? "Managed by Files" : "Bounded Templates download"} · {render.output_size_bytes.toLocaleString()} bytes</p>
|
<p>{render.artifact?.kind === "managed_file" ? "Managed by Files" : "Bounded Templates download"} · {render.output_size_bytes.toLocaleString()} bytes</p>
|
||||||
</section>}
|
</ContentSection>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -551,7 +578,11 @@ function emptyPayload(templateType: TemplateType = "form_letter"): TemplatePaylo
|
|||||||
scope_type: "tenant",
|
scope_type: "tenant",
|
||||||
scope_id: null,
|
scope_id: null,
|
||||||
template_type: templateType,
|
template_type: templateType,
|
||||||
usages: [templateType === "email" ? "campaign.email" : "campaign.postal"],
|
usages: templateType === "email"
|
||||||
|
? ["campaign.email", "campaign.content"]
|
||||||
|
: templateType === "content_fragment"
|
||||||
|
? ["campaign.content"]
|
||||||
|
: ["campaign.postal"],
|
||||||
locale: "en",
|
locale: "en",
|
||||||
required_fields: [],
|
required_fields: [],
|
||||||
output_profiles: [],
|
output_profiles: [],
|
||||||
|
|||||||
@@ -1,71 +1,19 @@
|
|||||||
.templates-page {
|
.templates-workspace-toolbar { min-width: 0; }
|
||||||
height: calc(100vh - 115px);
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.templates-page *, .templates-page *::before, .templates-page *::after { box-sizing: border-box; }
|
|
||||||
|
|
||||||
.templates-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border: var(--border-line);
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.templates-sidebar, .templates-workspace { min-width: 0; min-height: 0; }
|
|
||||||
.templates-sidebar { display: flex; flex-direction: column; overflow: hidden; border-right: var(--border-line); background: var(--panel-soft); }
|
|
||||||
.templates-workspace { display: flex; flex-direction: column; overflow: hidden; background: var(--bg); }
|
|
||||||
|
|
||||||
.templates-sidebar-toolbar, .templates-workspace-toolbar, .templates-section-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
background: var(--panel-header);
|
|
||||||
}
|
|
||||||
|
|
||||||
.templates-sidebar-toolbar { min-height: 52px; padding: 8px 10px 8px 14px; }
|
|
||||||
.templates-workspace-toolbar { min-height: 58px; padding: 8px 10px 8px 14px; }
|
|
||||||
.templates-toolbar-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
|
.templates-toolbar-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
|
||||||
.templates-toolbar-actions .btn { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
.templates-toolbar-actions .btn { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||||
.templates-current-title { min-width: 0; flex: 1 1 auto; }
|
.templates-current-title { min-width: 0; flex: 1 1 auto; }
|
||||||
.templates-current-title strong, .templates-current-title small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.templates-current-title strong, .templates-current-title small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.templates-current-title small { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
.templates-current-title small { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||||
|
|
||||||
.templates-search { padding: 9px; border-bottom: var(--border-line); background: var(--panel); }
|
|
||||||
.templates-search input { width: 100%; min-height: 34px; padding: 7px 9px; }
|
|
||||||
.templates-list { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 6px; }
|
|
||||||
.templates-list > button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 56px; padding: 8px 9px; border: 0; border-radius: var(--radius-sm); color: var(--text); background: transparent; cursor: pointer; text-align: left; }
|
|
||||||
.templates-list > button:hover, .templates-list > button:focus-visible { background: var(--primary-soft); outline: 0; }
|
|
||||||
.templates-list > button.is-selected { background: var(--primary-soft-strong); box-shadow: inset 3px 0 0 var(--accent); }
|
|
||||||
.templates-list strong, .templates-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.templates-list small { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
|
||||||
|
|
||||||
.templates-alerts { flex: 0 0 auto; padding: 0 12px; }
|
.templates-alerts { flex: 0 0 auto; padding: 0 12px; }
|
||||||
.templates-alerts:empty { display: none; }
|
.templates-alerts:empty { display: none; }
|
||||||
.templates-alerts .alert { margin: 10px 0 0; }
|
.templates-alerts .alert { margin: 10px 0 0; }
|
||||||
.templates-workspace > .loading-frame { flex: 1 1 auto; min-height: 0; }
|
.templates-workspace > .loading-frame { flex: 1 1 auto; min-height: 0; }
|
||||||
.templates-content { height: 100%; min-width: 0; min-height: 0; overflow: auto; padding: 14px; }
|
.templates-content { height: 100%; min-width: 0; min-height: 0; overflow: auto; padding: 14px; }
|
||||||
.templates-empty, .templates-inline-empty { display: grid; place-items: center; min-height: 90px; padding: 16px; color: var(--muted); font-size: 13px; text-align: center; }
|
|
||||||
|
|
||||||
.templates-definition-fields { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(130px, .7fr)); gap: 12px; margin-bottom: 14px; }
|
.templates-definition-fields { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(130px, .7fr)); gap: 12px; margin-bottom: 14px; }
|
||||||
.templates-definition-fields .form-field:nth-child(5) { grid-column: span 2; }
|
.templates-definition-fields .form-field:nth-child(5) { grid-column: span 2; }
|
||||||
.templates-definition-fields input, .templates-definition-fields select, .templates-dialog-form input, .templates-dialog-form select, .templates-layout-fields input, .templates-layout-fields select, .templates-preview-controls select { width: 100%; }
|
.templates-definition-fields input, .templates-definition-fields select, .templates-dialog-form input, .templates-dialog-form select, .templates-layout-fields input, .templates-layout-fields select, .templates-preview-controls select { width: 100%; }
|
||||||
|
|
||||||
.templates-section { min-width: 0; margin-bottom: 14px; border: var(--border-line); background: var(--panel); }
|
|
||||||
.templates-section-heading { min-height: 44px; padding: 7px 10px; }
|
|
||||||
.templates-section-heading small { color: var(--muted); font-weight: 400; }
|
.templates-section-heading small { color: var(--muted); font-weight: 400; }
|
||||||
.templates-section-heading .btn { display: inline-flex; align-items: center; gap: 6px; }
|
.templates-section-heading .btn { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
.templates-fields-table { overflow: auto; padding: 8px; }
|
.templates-fields-table { overflow: auto; padding: 8px; }
|
||||||
@@ -93,8 +41,7 @@
|
|||||||
.templates-history-badges { display: flex; align-items: center; gap: 6px; }
|
.templates-history-badges { display: flex; align-items: center; gap: 6px; }
|
||||||
.templates-dialog-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(180px, .7fr); gap: 12px; min-width: min(560px, 80vw); }
|
.templates-dialog-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(180px, .7fr); gap: 12px; min-width: min(560px, 80vw); }
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 1100px) {
|
||||||
.templates-shell { grid-template-columns: minmax(210px, 250px) minmax(0, 1fr); }
|
|
||||||
.templates-definition-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.templates-definition-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.templates-definition-fields .form-field:nth-child(5) { grid-column: auto; }
|
.templates-definition-fields .form-field:nth-child(5) { grid-column: auto; }
|
||||||
.templates-layout-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.templates-layout-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
@@ -102,11 +49,8 @@
|
|||||||
.templates-render-result dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.templates-render-result dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 760px) {
|
||||||
.templates-page { height: auto; min-height: calc(100vh - 100px); overflow: visible; }
|
.templates-page { height: auto; min-height: calc(100vh - 100px); overflow: visible; }
|
||||||
.templates-shell { display: flex; flex-direction: column; height: auto; overflow: visible; }
|
|
||||||
.templates-sidebar { max-height: 280px; border-right: 0; border-bottom: var(--border-line); }
|
|
||||||
.templates-workspace { overflow: visible; }
|
|
||||||
.templates-workspace-toolbar { align-items: flex-start; flex-wrap: wrap; }
|
.templates-workspace-toolbar { align-items: flex-start; flex-wrap: wrap; }
|
||||||
.templates-toolbar-actions { flex-wrap: wrap; }
|
.templates-toolbar-actions { flex-wrap: wrap; }
|
||||||
.templates-content { height: auto; overflow: visible; }
|
.templates-content { height: auto; overflow: visible; }
|
||||||
|
|||||||
Reference in New Issue
Block a user