Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f61464fd6 | ||
|
|
19cbdd303b | ||
|
|
69416faca9 | ||
|
|
ee6fbc784f | ||
|
|
dff2508698 | ||
|
|
80001bbe98 | ||
|
|
5de54ccd2e | ||
|
|
1c12359750 | ||
|
|
58be2482ec | ||
|
|
3856765520 | ||
|
|
cf4205f780 | ||
|
|
21ed6270a3 |
@@ -36,3 +36,21 @@ decision.
|
|||||||
|
|
||||||
User and administrator procedures are in [docs/USER_GUIDE.md](docs/USER_GUIDE.md)
|
User and administrator procedures are in [docs/USER_GUIDE.md](docs/USER_GUIDE.md)
|
||||||
and [docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md).
|
and [docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md).
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/templates-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/templates-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/templates-webui",
|
||||||
|
"version": "0.1.22",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/templates.css": "./webui/src/styles/templates.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-templates"
|
name = "govoplan-templates"
|
||||||
version = "0.1.18"
|
version = "0.1.22"
|
||||||
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"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.18",
|
"govoplan-core>=0.1.45",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN Templates module."""
|
"""GovOPlaN Templates module."""
|
||||||
|
|
||||||
__version__ = "0.1.18"
|
__version__ = "0.1.22"
|
||||||
|
|||||||
@@ -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.22"
|
||||||
|
|
||||||
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 = (
|
||||||
@@ -73,6 +102,21 @@ ROLE_TEMPLATES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
DOCUMENTATION = (
|
DOCUMENTATION = (
|
||||||
|
DocumentationTopic(
|
||||||
|
id="templates.workspace-layout",
|
||||||
|
title="Templates workspace actions",
|
||||||
|
summary="Find collection-wide commands in their consistent workspace position.",
|
||||||
|
body="Reload and Add template use the persistent full-width workspace header at the upper right; Reload sits immediately before creation. Selecting a record, changing filters, or opening an editor does not move these collection-wide commands into the left pane. Template editing, saving, publishing, and rendering remain scoped to the selected template. Existing permissions, disabled-state rules, and unsaved-change guards still apply. Administrators configure authority through the existing permission system; no new permission or automatic operation is introduced.",
|
||||||
|
layer="static",
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("user", "module_admin", "operator"),
|
||||||
|
order=5,
|
||||||
|
translations={"de": {
|
||||||
|
"title": "Vorlagen: Aktionen im Arbeitsbereich",
|
||||||
|
"summary": "Sammlungsweite Aktionen an ihrer einheitlichen Position im Arbeitsbereich finden.",
|
||||||
|
"body": "Neu laden und Vorlage hinzufügen stehen oben rechts in der dauerhaft sichtbaren, arbeitsbereichsweiten Leiste; Neu laden steht unmittelbar vor dem Anlegen. Auswahl, Filterwechsel und Bearbeitung verschieben diese sammlungsweiten Aktionen nicht in den linken Bereich. Bearbeiten, Speichern, Veröffentlichen und Rendern bleiben der ausgewählten Vorlage zugeordnet. Bestehende Berechtigungen, Deaktivierungsregeln und der Schutz ungespeicherter Änderungen gelten weiterhin. Administratoren konfigurieren Rechte im bestehenden Berechtigungssystem; es entstehen weder neue Rechte noch automatische Vorgänge.",
|
||||||
|
}},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="templates.library",
|
id="templates.library",
|
||||||
title="Template library",
|
title="Template library",
|
||||||
@@ -80,12 +124,32 @@ DOCUMENTATION = (
|
|||||||
body=(
|
body=(
|
||||||
"Templates are reusable, scoped definitions. Every edit creates an immutable revision. "
|
"Templates are reusable, scoped definitions. Every edit creates an immutable revision. "
|
||||||
"Publish the revision that consumers may use for final output. A compatibility check explains "
|
"Publish the revision that consumers may use for final output. A compatibility check explains "
|
||||||
"missing fields, unsupported usages, and unavailable output formats before rendering."
|
"missing fields, unsupported usages, and unavailable output formats before rendering. "
|
||||||
|
"The Add template dialog uses the shared responsive form layout: name, type, and footer actions remain inside the dialog on narrow screens without horizontal form scrolling."
|
||||||
),
|
),
|
||||||
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. "
|
||||||
|
"Der Dialog zum Hinzufügen einer Vorlage verwendet das gemeinsame responsive Formularlayout: Name, Typ und Fußzeilenaktionen bleiben auch auf schmalen Bildschirmen ohne horizontales Formularscrollen im Dialog."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"templates.page",
|
"templates.page",
|
||||||
@@ -95,6 +159,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",
|
||||||
@@ -103,12 +204,29 @@ DOCUMENTATION = (
|
|||||||
"Preview output may use a draft revision. Final output requires a published revision and an "
|
"Preview output may use a draft revision. Final output requires a published revision and an "
|
||||||
"idempotency key. Results pin the template hash, input hash, renderer version, item/page counts, "
|
"idempotency key. Results pin the template hash, input hash, renderer version, item/page counts, "
|
||||||
"diagnostics, and output digest. Files stores artifacts when available and authorized; otherwise "
|
"diagnostics, and output digest. Files stores artifacts when available and authorized; otherwise "
|
||||||
"Templates provides a bounded download. Browser printing is the supported baseline output path."
|
"Templates provides a bounded download. The existing 5 MiB output ceiling is enforced while substituting tokens and composing items, "
|
||||||
|
"including UTF-8 bytes, escaping, separators, and the final HTML wrapper. Oversized expansion stops before the whole bundle is allocated "
|
||||||
|
"or any output is persisted; reduce the selected items or template content and retry. Browser printing is the supported baseline output path."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
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. Die bestehende Ausgabegrenze von 5 MiB wird bereits beim Ersetzen von Platzhaltern und Zusammenstellen "
|
||||||
|
"der Elemente durchgesetzt, einschließlich UTF-8-Bytes, Maskierung, Trennzeichen und abschließender HTML-Hülle. Übermäßige Erweiterung "
|
||||||
|
"stoppt vor dem vollständigen Speicheraufbau oder Speichern einer Ausgabe; reduzieren Sie die ausgewählten Elemente oder den Vorlageninhalt "
|
||||||
|
"und versuchen Sie es erneut. Drucken im Browser ist der unterstützte grundlegende Ausgabepfad."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
@@ -136,7 +254,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 +313,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 +347,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 +374,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 +391,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 +406,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 +500,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 +557,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 +570,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
|
||||||
|
|||||||
@@ -362,22 +362,49 @@ def _render_payload(
|
|||||||
) -> tuple[bytes, str, int]:
|
) -> tuple[bytes, str, int]:
|
||||||
if request.output_format == "text":
|
if request.output_format == "text":
|
||||||
body = revision.content_text or _html_to_text(revision.content_html or "")
|
body = revision.content_text or _html_to_text(revision.content_html or "")
|
||||||
rendered = [
|
|
||||||
_substitute(body, _render_context(request.parameters, item, index), html=False)
|
|
||||||
for index, item in enumerate(items)
|
|
||||||
]
|
|
||||||
separator = "\n\n---\n\n" if revision.template_type != "list_layout" else "\n"
|
separator = "\n\n---\n\n" if revision.template_type != "list_layout" else "\n"
|
||||||
|
rendered = _render_items(body, request.parameters, items, html=False, separator=separator)
|
||||||
payload = separator.join(rendered).encode("utf-8")
|
payload = separator.join(rendered).encode("utf-8")
|
||||||
return payload, "text/plain; charset=utf-8", _page_count(revision, len(items))
|
return payload, "text/plain; charset=utf-8", _page_count(revision, len(items))
|
||||||
|
|
||||||
body = revision.content_html or f"<pre>{escape(revision.content_text or '')}</pre>"
|
body = revision.content_html or f"<pre>{escape(revision.content_text or '')}</pre>"
|
||||||
rendered = [
|
rendered = _render_items(body, request.parameters, items, html=True)
|
||||||
_substitute(body, _render_context(request.parameters, item, index), html=True)
|
|
||||||
for index, item in enumerate(items)
|
|
||||||
]
|
|
||||||
page_count = _page_count(revision, len(items))
|
page_count = _page_count(revision, len(items))
|
||||||
document = _html_document(definition, revision, rendered)
|
document = _html_document(definition, revision, rendered)
|
||||||
return document.encode("utf-8"), "text/html; charset=utf-8", page_count
|
payload = document.encode("utf-8")
|
||||||
|
if len(payload) > MAX_OUTPUT_BYTES:
|
||||||
|
_output_limit_exceeded()
|
||||||
|
return payload, "text/html; charset=utf-8", page_count
|
||||||
|
|
||||||
|
|
||||||
|
def _output_limit_exceeded() -> None:
|
||||||
|
raise TemplateRenderError(
|
||||||
|
f"Rendered output exceeds the {MAX_OUTPUT_BYTES} byte bounded-download limit."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_items(
|
||||||
|
body: str,
|
||||||
|
parameters: Mapping[str, object],
|
||||||
|
items: Sequence[Mapping[str, object]],
|
||||||
|
*,
|
||||||
|
html: bool,
|
||||||
|
separator: str = "",
|
||||||
|
) -> list[str]:
|
||||||
|
# Reject as soon as the same existing output budget is exhausted; never
|
||||||
|
# build thousands of oversized documents and only then measure the join.
|
||||||
|
remaining = MAX_OUTPUT_BYTES
|
||||||
|
separator_bytes = len(separator.encode("utf-8"))
|
||||||
|
rendered: list[str] = []
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
if index:
|
||||||
|
remaining -= separator_bytes
|
||||||
|
if remaining < 0:
|
||||||
|
_output_limit_exceeded()
|
||||||
|
value = _substitute(body, _render_context(parameters, item, index), html=html, max_bytes=remaining)
|
||||||
|
remaining -= len(value.encode("utf-8"))
|
||||||
|
rendered.append(value)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
def _html_document(
|
def _html_document(
|
||||||
@@ -552,15 +579,32 @@ def _render_context(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _substitute(template: str, context: Mapping[str, object], *, html: bool) -> str:
|
def _substitute(template: str, context: Mapping[str, object], *, html: bool, max_bytes: int | None = None) -> str:
|
||||||
def replacement(match: re.Match[str]) -> str:
|
remaining = MAX_OUTPUT_BYTES if max_bytes is None else max_bytes
|
||||||
value, present = _resolve_path(context, match.group(1))
|
pieces: list[str] = []
|
||||||
if not present or value is None:
|
|
||||||
return ""
|
|
||||||
rendered = _display_value(value)
|
|
||||||
return escape(rendered, quote=True) if html else rendered
|
|
||||||
|
|
||||||
return _TOKEN_PATTERN.sub(replacement, template)
|
def append(value: str) -> None:
|
||||||
|
nonlocal remaining
|
||||||
|
# Character count is a cheap lower bound before allocating UTF-8 bytes.
|
||||||
|
if len(value) > remaining:
|
||||||
|
_output_limit_exceeded()
|
||||||
|
remaining -= len(value.encode("utf-8"))
|
||||||
|
if remaining < 0:
|
||||||
|
_output_limit_exceeded()
|
||||||
|
pieces.append(value)
|
||||||
|
|
||||||
|
previous = 0
|
||||||
|
for match in _TOKEN_PATTERN.finditer(template):
|
||||||
|
append(template[previous:match.start()])
|
||||||
|
value, present = _resolve_path(context, match.group(1))
|
||||||
|
if present and value is not None:
|
||||||
|
rendered = _display_value(value)
|
||||||
|
if len(rendered) > remaining:
|
||||||
|
_output_limit_exceeded()
|
||||||
|
append(escape(rendered, quote=True) if html else rendered)
|
||||||
|
previous = match.end()
|
||||||
|
append(template[previous:])
|
||||||
|
return "".join(pieces)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_path(context: Mapping[str, object], path: str) -> tuple[object | None, bool]:
|
def _resolve_path(context: Mapping[str, object], path: str) -> tuple[object | None, bool]:
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.core.templates import TemplateRenderError
|
||||||
|
from govoplan_templates.backend import rendering
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateRenderLimitTests(unittest.TestCase):
|
||||||
|
def test_many_items_stop_before_rendering_the_entire_oversized_bundle(self):
|
||||||
|
with patch.object(rendering, "MAX_OUTPUT_BYTES", 128), patch.object(rendering, "_substitute", wraps=rendering._substitute) as substitute:
|
||||||
|
with self.assertRaisesRegex(TemplateRenderError, "bounded-download limit"):
|
||||||
|
rendering._render_items("x" * 64, {}, [{}] * 5000, html=False)
|
||||||
|
self.assertEqual(3, substitute.call_count)
|
||||||
|
|
||||||
|
def test_repeated_token_expansion_stops_before_allocating_the_whole_row(self):
|
||||||
|
with patch.object(rendering, "MAX_OUTPUT_BYTES", 128), patch.object(rendering, "_display_value", wraps=rendering._display_value) as display:
|
||||||
|
with self.assertRaisesRegex(TemplateRenderError, "bounded-download limit"):
|
||||||
|
rendering._substitute("{{value}}" * 5000, {"value": "x" * 64}, html=False)
|
||||||
|
self.assertEqual(3, display.call_count)
|
||||||
|
|
||||||
|
def test_utf8_byte_limit_is_not_a_character_limit(self):
|
||||||
|
with patch.object(rendering, "MAX_OUTPUT_BYTES", 4):
|
||||||
|
self.assertEqual("üü", rendering._substitute("{{value}}", {"value": "üü"}, html=False))
|
||||||
|
with self.assertRaises(TemplateRenderError):
|
||||||
|
rendering._substitute("{{value}}", {"value": "üüü"}, html=False)
|
||||||
|
|
||||||
|
def test_html_escaping_counts_expanded_bytes_and_preserves_valid_output(self):
|
||||||
|
with patch.object(rendering, "MAX_OUTPUT_BYTES", 9):
|
||||||
|
self.assertEqual("<&", rendering._substitute("{{value}}", {"value": "<&"}, html=True))
|
||||||
|
with self.assertRaises(TemplateRenderError):
|
||||||
|
rendering._substitute("{{value}}!", {"value": "<&"}, html=True)
|
||||||
|
|
||||||
|
def test_text_separator_is_part_of_the_same_budget(self):
|
||||||
|
with patch.object(rendering, "MAX_OUTPUT_BYTES", 5):
|
||||||
|
self.assertEqual(["ab", "cd"], rendering._render_items("{{value}}", {}, [{"value": "ab"}, {"value": "cd"}], html=False, separator="\n"))
|
||||||
|
with self.assertRaises(TemplateRenderError):
|
||||||
|
rendering._render_items("{{value}}", {}, [{"value": "ab"}, {"value": "cd"}], html=False, separator="\n\n")
|
||||||
|
|
||||||
|
def test_html_wrapper_is_also_subject_to_output_limit(self):
|
||||||
|
definition = SimpleNamespace(name="Example")
|
||||||
|
revision = SimpleNamespace(content_html="<p>Ada</p>", content_text=None, template_type="list_layout", layout={}, output_profiles=[])
|
||||||
|
request = SimpleNamespace(output_format="html", parameters={})
|
||||||
|
with patch.object(rendering, "MAX_OUTPUT_BYTES", 32):
|
||||||
|
with self.assertRaises(TemplateRenderError):
|
||||||
|
rendering._render_payload(definition, revision, request=request, items=[{}])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.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)
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/templates-webui",
|
"name": "@govoplan/templates-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,10 +14,11 @@
|
|||||||
"./styles/templates.css": "./src/styles/templates.css"
|
"./styles/templates.css": "./src/styles/templates.css"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:dialog-layout": "node scripts/test-dialog-layout.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const page = readFileSync(new URL("../src/features/templates/TemplatesPage.tsx", import.meta.url), "utf8");
|
||||||
|
const styles = readFileSync(new URL("../src/styles/templates.css", import.meta.url), "utf8");
|
||||||
|
assert.match(page, /<Dialog open=\{createOpen\}[^]*?<FormGrid columns=\{2\} collapseAt="standard">/);
|
||||||
|
assert.doesNotMatch(page, /templates-dialog-form/);
|
||||||
|
assert.doesNotMatch(styles, /templates-dialog-form/);
|
||||||
|
console.log("Templates dialog uses the shared shrinking form layout.");
|
||||||
@@ -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,36 @@ 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 { FormGrid, ActionToolbar,
|
||||||
ApiError,
|
ApiError,
|
||||||
ActionBlockerHint,
|
ActionBlockerHint,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
ContentSection,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogSection,
|
||||||
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 +79,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 +302,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">
|
<WorkspaceActionBar
|
||||||
<aside className="templates-sidebar">
|
scope="workspace"
|
||||||
<div className="templates-sidebar-toolbar">
|
variant="collection"
|
||||||
<strong>Template library</strong>
|
refreshable
|
||||||
<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />
|
reloadAction={{ onReload: () => void reload(selectedId), loading: loading || busy }}
|
||||||
</div>
|
contextActions={<strong>Template library</strong>}
|
||||||
<div className="templates-search"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></div>
|
createAction={<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />}
|
||||||
<div className="templates-list">
|
/>
|
||||||
|
<WorkspaceLayout
|
||||||
|
variant="split"
|
||||||
|
primarySize="compact"
|
||||||
|
surface="contained"
|
||||||
|
primaryScrollable={false}
|
||||||
|
contentScrollable={false}
|
||||||
|
primaryLabel="Template library"
|
||||||
|
contentLabel="Template workspace"
|
||||||
|
contentClassName="templates-workspace"
|
||||||
|
primary={<>
|
||||||
|
<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 +335,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 +391,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 +416,26 @@ 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>
|
||||||
<FormField label="Name" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
|
<FormGrid columns={2} collapseAt="standard">
|
||||||
<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="Name" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
|
||||||
</div>
|
<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>
|
||||||
|
</FormGrid>
|
||||||
|
</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 +444,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 +482,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 +494,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 +509,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 +538,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 +550,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 +558,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 +569,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 +581,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-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; }
|
||||||
@@ -91,10 +39,8 @@
|
|||||||
.templates-history-list small { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
.templates-history-list small { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||||
.templates-history-list .btn { display: inline-flex; align-items: center; gap: 6px; }
|
.templates-history-list .btn { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
.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); }
|
|
||||||
|
|
||||||
@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,13 +48,10 @@
|
|||||||
.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; }
|
||||||
.templates-definition-fields, .templates-dialog-form, .templates-render-result dl { grid-template-columns: 1fr; }
|
.templates-definition-fields, .templates-render-result dl { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user