Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dff2508698 | ||
|
|
80001bbe98 | ||
|
|
5de54ccd2e | ||
|
|
1c12359750 | ||
|
|
58be2482ec | ||
|
|
3856765520 | ||
|
|
cf4205f780 | ||
|
|
21ed6270a3 |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-templates"
|
||||
version = "0.1.18"
|
||||
version = "0.1.19"
|
||||
description = "GovOPlaN typed template library and deterministic printable rendering."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN Templates module."""
|
||||
|
||||
__version__ = "0.1.18"
|
||||
__version__ = "0.1.19"
|
||||
|
||||
@@ -4,17 +4,30 @@ from collections.abc import Mapping, Sequence
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
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.templates import (
|
||||
TemplateCatalogProvider,
|
||||
TemplateCompatibility,
|
||||
TemplateContentDraftRequest,
|
||||
TemplateContentLibraryProvider,
|
||||
TemplateRef,
|
||||
)
|
||||
from govoplan_templates.backend.rendering import SqlTemplateRenderer
|
||||
from govoplan_templates.backend.schemas import TemplateCreateRequest
|
||||
from govoplan_templates.backend.service import (
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
compatibility,
|
||||
create_template,
|
||||
get_template,
|
||||
get_template_revision,
|
||||
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:
|
||||
return SqlTemplateCatalog()
|
||||
|
||||
@@ -116,6 +208,10 @@ def renderer_capability(context: ModuleContext) -> SqlTemplateRenderer:
|
||||
return SqlTemplateRenderer(context.registry)
|
||||
|
||||
|
||||
def content_library_capability(_context: ModuleContext) -> SqlTemplateContentLibrary:
|
||||
return SqlTemplateContentLibrary()
|
||||
|
||||
|
||||
def _context(session: object, principal: object) -> tuple[Session, ApiPrincipal]:
|
||||
if not isinstance(session, 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}.")
|
||||
|
||||
|
||||
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:
|
||||
if principal.has("templates:template:admin") or scope_type == "tenant":
|
||||
return False
|
||||
@@ -147,6 +251,8 @@ def _read_only(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -
|
||||
|
||||
__all__ = [
|
||||
"SqlTemplateCatalog",
|
||||
"SqlTemplateContentLibrary",
|
||||
"catalog_capability",
|
||||
"content_library_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"]
|
||||
@@ -8,6 +8,8 @@ from govoplan_core.core.module_guards import (
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
@@ -18,21 +20,27 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.core.templates import (
|
||||
CAPABILITY_TEMPLATE_CATALOG,
|
||||
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||
CAPABILITY_TEMPLATE_RENDERER,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
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_NAME = "Templates"
|
||||
MODULE_VERSION = "0.1.18"
|
||||
MODULE_VERSION = "0.1.19"
|
||||
|
||||
READ_SCOPE = "templates:template:read"
|
||||
WRITE_SCOPE = "templates:template:write"
|
||||
@@ -56,11 +64,29 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(READ_SCOPE, "View templates", "Read template definitions, revisions, and render evidence."),
|
||||
_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."),
|
||||
_permission(
|
||||
READ_SCOPE,
|
||||
"View templates",
|
||||
"Read template definitions, revisions, and render evidence.",
|
||||
),
|
||||
_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 = (
|
||||
@@ -85,7 +111,25 @@ DOCUMENTATION = (
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
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={
|
||||
"kind": "workflow",
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"templates.page",
|
||||
@@ -95,6 +139,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(
|
||||
id="templates.printable-output",
|
||||
title="Printable template output",
|
||||
@@ -109,6 +190,18 @@ DOCUMENTATION = (
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
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={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
@@ -136,7 +229,26 @@ DOCUMENTATION = (
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
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={
|
||||
"kind": "reference",
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"templates.field.type",
|
||||
@@ -176,15 +288,29 @@ def _renderer(context: ModuleContext):
|
||||
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]:
|
||||
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.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,
|
||||
).count(),
|
||||
)
|
||||
.count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -196,8 +322,17 @@ manifest = ModuleManifest(
|
||||
optional_dependencies=("files", "dist_lists", "campaigns", "audit"),
|
||||
optional_capabilities=(CAPABILITY_FILES_ARTIFACT_STORE,),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name=CAPABILITY_TEMPLATE_CATALOG, version=MODULE_VERSION),
|
||||
ModuleInterfaceProvider(name=CAPABILITY_TEMPLATE_RENDERER, version=MODULE_VERSION),
|
||||
ModuleInterfaceProvider(
|
||||
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=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -214,7 +349,13 @@ manifest = ModuleManifest(
|
||||
path="/templates",
|
||||
label=MODULE_NAME,
|
||||
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,
|
||||
),
|
||||
),
|
||||
@@ -225,7 +366,13 @@ manifest = ModuleManifest(
|
||||
FrontendRoute(
|
||||
path="/templates",
|
||||
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,
|
||||
),
|
||||
),
|
||||
@@ -234,21 +381,74 @@ manifest = ModuleManifest(
|
||||
path="/templates",
|
||||
label=MODULE_NAME,
|
||||
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,
|
||||
),
|
||||
),
|
||||
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=(
|
||||
ViewSurface(id="templates.page", module_id=MODULE_ID, 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),
|
||||
ViewSurface(
|
||||
id="templates.page",
|
||||
module_id=MODULE_ID,
|
||||
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,
|
||||
capability_factories={
|
||||
CAPABILITY_TEMPLATE_CATALOG: _catalog,
|
||||
CAPABILITY_TEMPLATE_CONTENT_LIBRARY: _content_library,
|
||||
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,),
|
||||
migration_spec=MigrationSpec(
|
||||
@@ -275,7 +475,53 @@ manifest = ModuleManifest(
|
||||
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(
|
||||
layer="content_records_evidence",
|
||||
kind="domain",
|
||||
@@ -286,7 +532,11 @@ manifest = ModuleManifest(
|
||||
"The baseline emits safe deterministic HTML/text for browser or OS printing; PDF and printer delivery remain connector concerns.",
|
||||
),
|
||||
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"),
|
||||
recovery_docs=("docs/TEMPLATE_BOUNDARY.md",),
|
||||
security_docs=("docs/TEMPLATE_BOUNDARY.md",),
|
||||
|
||||
@@ -14,6 +14,7 @@ TemplateType = Literal[
|
||||
"form_letter",
|
||||
"list_layout",
|
||||
"email",
|
||||
"content_fragment",
|
||||
"generic",
|
||||
]
|
||||
OutputFormat = Literal["html", "text"]
|
||||
|
||||
@@ -444,6 +444,10 @@ def revision_ref(revision: TemplateRevision) -> TemplateRevisionRef:
|
||||
output_profiles=tuple(
|
||||
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,
|
||||
provenance={
|
||||
"module": "templates",
|
||||
@@ -502,6 +506,8 @@ def _create_revision(
|
||||
|
||||
|
||||
def _default_output_profiles(template_type: str) -> list[dict[str, object]]:
|
||||
if template_type == "content_fragment":
|
||||
return []
|
||||
media = "A4"
|
||||
if template_type == "envelope":
|
||||
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):
|
||||
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:
|
||||
frontend = manifest.frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
@@ -27,10 +35,12 @@ class TemplatesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
reference = topics["templates.reference.fields-and-consequences"]
|
||||
|
||||
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.field.usages", reference.metadata["help_contexts"])
|
||||
self.assertIn("publish_revision", reference.metadata["consequence_classes"])
|
||||
self.assertIn("delete_template", reference.metadata["consequence_classes"])
|
||||
self.assertEqual("reference", reference.metadata["kind"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+50
-1
@@ -2,20 +2,27 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.files import ManagedArtifactRef
|
||||
from govoplan_core.core.templates import (
|
||||
CAPABILITY_TEMPLATE_CATALOG,
|
||||
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||
CAPABILITY_TEMPLATE_RENDERER,
|
||||
TemplateCompatibilityError,
|
||||
TemplateContentDraftRequest,
|
||||
TemplateFieldRequirement,
|
||||
TemplateRenderError,
|
||||
TemplateRenderRequest,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
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 (
|
||||
TemplateDefinition,
|
||||
TemplateRender,
|
||||
@@ -158,10 +165,48 @@ class TemplateServiceTests(unittest.TestCase):
|
||||
self.assertEqual(1, len(refs))
|
||||
self.assertEqual("serial_letter", refs[0].template_type)
|
||||
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.assertNotEqual(first.definition_hash, second.definition_hash)
|
||||
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:
|
||||
frozen = (
|
||||
{"name": "Ada", "postal": {"address": "Street 1"}},
|
||||
@@ -322,6 +367,10 @@ class TemplateManifestTests(unittest.TestCase):
|
||||
self.assertFalse(manifest.dependencies)
|
||||
self.assertIn("files", manifest.optional_dependencies)
|
||||
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.assertEqual("@govoplan/templates-webui", manifest.frontend.package_name)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/templates-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -13,6 +13,7 @@ export type TemplateType =
|
||||
| "form_letter"
|
||||
| "list_layout"
|
||||
| "email"
|
||||
| "content_fragment"
|
||||
| "generic";
|
||||
|
||||
export type TemplateFieldType =
|
||||
|
||||
@@ -3,27 +3,35 @@ import {
|
||||
Eye,
|
||||
FileCheck2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Send,
|
||||
Trash2,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
import { DialogSection, ActionToolbar,
|
||||
ApiError,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
ContentSection,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FilterBar,
|
||||
FormField,
|
||||
IconButton,
|
||||
LoadingFrame,
|
||||
SegmentedControl,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
WorkspaceActionBar,
|
||||
WorkspaceFrame,
|
||||
WorkspaceLayout,
|
||||
formatDateTime,
|
||||
hasScope,
|
||||
useUnsavedChanges,
|
||||
@@ -70,6 +78,7 @@ const TEMPLATE_TYPES: Array<{ value: TemplateType; label: string }> = [
|
||||
{ value: "form_letter", label: "Form letter" },
|
||||
{ value: "list_layout", label: "List layout" },
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "content_fragment", label: "Content fragment" },
|
||||
{ value: "generic", label: "Generic" }
|
||||
];
|
||||
|
||||
@@ -292,20 +301,31 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="templates-page">
|
||||
<div className="templates-shell">
|
||||
<aside className="templates-sidebar">
|
||||
<div className="templates-sidebar-toolbar">
|
||||
<strong>Template library</strong>
|
||||
<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />
|
||||
</div>
|
||||
<div className="templates-search"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></div>
|
||||
<div className="templates-list">
|
||||
<WorkspaceFrame as="main" height="viewport" surface="plain" className="templates-page" label="Template workspace">
|
||||
<WorkspaceLayout
|
||||
variant="split"
|
||||
primarySize="compact"
|
||||
surface="contained"
|
||||
primaryScrollable={false}
|
||||
contentScrollable={false}
|
||||
primaryLabel="Template library"
|
||||
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) => (
|
||||
<button
|
||||
<SelectionListItem
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={item.id === selectedId ? "is-selected" : ""}
|
||||
selected={item.id === selectedId}
|
||||
onClick={() => {
|
||||
if (item.id === selectedId) return;
|
||||
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} />
|
||||
</button>
|
||||
</SelectionListItem>
|
||||
))}
|
||||
{!visibleItems.length && <p className="templates-empty">No matching templates.</p>}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="templates-workspace">
|
||||
<header className="templates-workspace-toolbar">
|
||||
<span className="templates-current-title">
|
||||
{!visibleItems.length && <StatePanel size="compact" description="No matching templates." />}
|
||||
</SelectionList>
|
||||
</>}
|
||||
>
|
||||
<WorkspaceActionBar
|
||||
scope="editor-pane"
|
||||
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>
|
||||
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
|
||||
</span>
|
||||
<div className="templates-toolbar-actions">
|
||||
<DocumentationHelpLink reference={TEMPLATES_DOCUMENTATION} />
|
||||
<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 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>
|
||||
</span>}
|
||||
helpAction={<DocumentationHelpLink reference={TEMPLATES_DOCUMENTATION} />}
|
||||
primaryActions={<>
|
||||
<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
|
||||
value={view}
|
||||
onChange={setView}
|
||||
options={[{ id: "definition", label: "Definition" }, { id: "preview", label: "Preview" }]}
|
||||
ariaLabel="Template workspace"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
</>}
|
||||
destructiveActions={<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)} />}
|
||||
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">
|
||||
{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">
|
||||
<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} />
|
||||
<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>
|
||||
</LoadingFrame>
|
||||
</section>
|
||||
</div>
|
||||
</WorkspaceLayout>
|
||||
|
||||
<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="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>
|
||||
<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={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 }) {
|
||||
return <section className="templates-section templates-history">
|
||||
<div className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></div>
|
||||
return <ContentSection className="templates-history">
|
||||
<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">
|
||||
{revisions.map((revision) => <div key={revision.id}>
|
||||
<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" />}
|
||||
</span>
|
||||
</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>
|
||||
</section>;
|
||||
</ContentSection>;
|
||||
}
|
||||
|
||||
function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) {
|
||||
return <section className="templates-section templates-history">
|
||||
<div className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></div>
|
||||
return <ContentSection className="templates-history">
|
||||
<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">
|
||||
{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>
|
||||
<Button disabled={!item.artifact?.download_path} onClick={() => void downloadTemplateRender(settings, item).catch((caught) => onError(errorMessage(caught)))}><Download size={15} /> Download</Button>
|
||||
</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>
|
||||
</section>;
|
||||
</ContentSection>;
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
<section className="templates-section">
|
||||
<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>
|
||||
<ContentSection>
|
||||
<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">
|
||||
{draft.required_fields.map((field, index) => (
|
||||
<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))} />
|
||||
</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>
|
||||
</section>
|
||||
</ContentSection>
|
||||
|
||||
<section className="templates-section">
|
||||
<div className="templates-section-heading"><strong>Page and media</strong></div>
|
||||
<ContentSection>
|
||||
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Page and media</strong></ActionToolbar>
|
||||
<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="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>
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
</ContentSection>
|
||||
|
||||
<section className="templates-section templates-body-section">
|
||||
<div className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></div>
|
||||
<ContentSection className="templates-body-section">
|
||||
<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} />
|
||||
</section>
|
||||
</ContentSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -508,8 +535,8 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
|
||||
}) {
|
||||
return (
|
||||
<div className="templates-preview">
|
||||
<section className="templates-section">
|
||||
<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>
|
||||
<ContentSection>
|
||||
<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">
|
||||
<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>
|
||||
@@ -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 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>
|
||||
</section>
|
||||
</ContentSection>
|
||||
|
||||
{compatibility && <DismissibleAlert tone={compatibility.compatible ? "success" : "danger"}>
|
||||
{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(" ")}
|
||||
</DismissibleAlert>}
|
||||
|
||||
{render && <section className="templates-section 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>
|
||||
{render && <ContentSection className="templates-render-result">
|
||||
<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>
|
||||
<div><dt>Revision</dt><dd>{render.revision} · {shortHash(render.template_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>
|
||||
</dl>
|
||||
<p>{render.artifact?.kind === "managed_file" ? "Managed by Files" : "Bounded Templates download"} · {render.output_size_bytes.toLocaleString()} bytes</p>
|
||||
</section>}
|
||||
</ContentSection>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -551,7 +578,11 @@ function emptyPayload(templateType: TemplateType = "form_letter"): TemplatePaylo
|
||||
scope_type: "tenant",
|
||||
scope_id: null,
|
||||
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",
|
||||
required_fields: [],
|
||||
output_profiles: [],
|
||||
|
||||
@@ -1,71 +1,19 @@
|
||||
.templates-page {
|
||||
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-workspace-toolbar { min-width: 0; }
|
||||
.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-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 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:empty { display: none; }
|
||||
.templates-alerts .alert { margin: 10px 0 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-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 .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-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 .btn { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.templates-fields-table { overflow: auto; padding: 8px; }
|
||||
@@ -93,8 +41,7 @@
|
||||
.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); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.templates-shell { grid-template-columns: minmax(210px, 250px) minmax(0, 1fr); }
|
||||
@media (max-width: 1100px) {
|
||||
.templates-definition-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.templates-definition-fields .form-field:nth-child(5) { grid-column: auto; }
|
||||
.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)); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@media (max-width: 760px) {
|
||||
.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-toolbar-actions { flex-wrap: wrap; }
|
||||
.templates-content { height: auto; overflow: visible; }
|
||||
|
||||
Reference in New Issue
Block a user