Implement typed template library and rendering

This commit is contained in:
2026-08-02 12:38:38 +02:00
parent 142c3a26f1
commit b65b905b6e
28 changed files with 4385 additions and 104 deletions
+529
View File
@@ -0,0 +1,529 @@
from __future__ import annotations
from dataclasses import asdict
from urllib.parse import quote
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.concurrency import (
ConcurrencyError,
MissingPreconditionError,
RevisionConflictError,
assert_revision_precondition,
)
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.core.runtime import get_registry
from govoplan_core.core.templates import (
TemplateCompatibilityError,
TemplateNotFoundError,
TemplateRenderError,
TemplateRenderRequest,
)
from govoplan_core.db.session import get_session
from govoplan_templates.backend.db.models import TemplateDefinition, TemplateRevision
from govoplan_templates.backend.rendering import (
get_render_for_principal,
list_renders,
render_result,
render_template,
)
from govoplan_templates.backend.schemas import (
TemplateCompatibilityRequest,
TemplateCompatibilityResponse,
TemplateCreateRequest,
TemplateDeleteRequest,
TemplateListResponse,
TemplatePublishRequest,
TemplateRenderListResponse,
TemplateRenderRequestModel,
TemplateRenderResponse,
TemplateResponse,
TemplateRevisionResponse,
TemplateUpdateRequest,
)
from govoplan_templates.backend.service import (
ADMIN_SCOPE,
PUBLISH_SCOPE,
READ_SCOPE,
RENDER_SCOPE,
WRITE_SCOPE,
compatibility,
create_template,
delete_template,
get_template,
get_template_revision,
list_template_revisions,
list_templates,
publish_template,
update_template,
)
router = APIRouter(prefix="/templates", tags=["templates"])
@router.get("", response_model=TemplateListResponse)
def api_list_templates(
query: str = Query(default="", max_length=200),
usage: str | None = Query(default=None, max_length=80),
template_type: str | None = Query(default=None, max_length=40),
locale: str | None = Query(default=None, max_length=35),
limit: int = Query(default=200, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateListResponse:
_require(principal, READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE)
rows = list_templates(
session,
principal,
query=query,
usage=usage,
template_type=template_type,
locale=locale,
limit=limit,
)
return TemplateListResponse(
items=[_template_response(session, principal, item) for item in rows],
total=len(rows),
)
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
def api_create_template(
payload: TemplateCreateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateResponse:
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
item, revision = create_template(session, principal, payload)
_record_change(
session,
principal,
item,
revision,
action="templates.template.created",
event_type="templates.template.created.v1",
)
session.commit()
except (TemplateCompatibilityError, IntegrityError) as exc:
session.rollback()
raise _error(exc) from exc
session.refresh(item)
response.headers["ETag"] = item.strong_etag
return _template_response(session, principal, item, revision)
@router.get("/{template_id}", response_model=TemplateResponse)
def api_get_template(
template_id: str,
response: Response,
revision: int | None = Query(default=None, ge=1),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateResponse:
_require(principal, READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE)
try:
item = get_template(session, principal, template_id)
item_revision = get_template_revision(session, item, revision=revision)
except TemplateNotFoundError as exc:
raise _error(exc) from exc
response.headers["ETag"] = item.strong_etag
return _template_response(session, principal, item, item_revision)
@router.put("/{template_id}", response_model=TemplateResponse)
def api_update_template(
template_id: str,
payload: TemplateUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateResponse:
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
item = get_template(session, principal, template_id)
assert_revision_precondition(
if_match,
resource_type="template_definition",
resource_id=item.id,
submitted_base_revision=payload.base_revision,
)
item, revision = update_template(session, principal, item, payload)
_record_change(
session,
principal,
item,
revision,
action="templates.template.revised",
event_type="templates.template.revised.v1",
)
session.commit()
except (ConcurrencyError, TemplateCompatibilityError, TemplateNotFoundError, IntegrityError) as exc:
session.rollback()
raise _error(exc) from exc
session.refresh(item)
response.headers["ETag"] = item.strong_etag
return _template_response(session, principal, item, revision)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_template(
template_id: str,
payload: TemplateDeleteRequest,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> Response:
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
item = get_template(session, principal, template_id)
assert_revision_precondition(
if_match,
resource_type="template_definition",
resource_id=item.id,
submitted_base_revision=payload.base_revision,
)
delete_template(session, principal, item, base_revision=payload.base_revision)
_audit(session, principal, action="templates.template.deleted", item=item)
_event(session, principal, item.id, "templates.template.deleted.v1")
session.commit()
except (ConcurrencyError, TemplateCompatibilityError, TemplateNotFoundError) as exc:
session.rollback()
raise _error(exc) from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{template_id}/revisions", response_model=list[TemplateRevisionResponse])
def api_list_revisions(
template_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> list[TemplateRevisionResponse]:
_require(principal, READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE)
try:
item = get_template(session, principal, template_id)
except TemplateNotFoundError as exc:
raise _error(exc) from exc
return [_revision_response(row) for row in list_template_revisions(session, item)]
@router.post("/{template_id}/publish", response_model=TemplateResponse)
def api_publish_template(
template_id: str,
payload: TemplatePublishRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateResponse:
_require(principal, PUBLISH_SCOPE, ADMIN_SCOPE)
try:
item = get_template(session, principal, template_id)
assert_revision_precondition(
if_match,
resource_type="template_definition",
resource_id=item.id,
submitted_base_revision=payload.base_revision,
)
item, revision = publish_template(
session,
principal,
item,
revision=payload.revision,
base_revision=payload.base_revision,
)
_record_change(
session,
principal,
item,
revision,
action="templates.template.published",
event_type="templates.template.published.v1",
)
session.commit()
except (ConcurrencyError, TemplateCompatibilityError, TemplateNotFoundError) as exc:
session.rollback()
raise _error(exc) from exc
session.refresh(item)
response.headers["ETag"] = item.strong_etag
return _template_response(session, principal, item, revision)
@router.post("/{template_id}/compatibility", response_model=TemplateCompatibilityResponse)
def api_check_compatibility(
template_id: str,
payload: TemplateCompatibilityRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateCompatibilityResponse:
_require(principal, READ_SCOPE, WRITE_SCOPE, PUBLISH_SCOPE, RENDER_SCOPE, ADMIN_SCOPE)
try:
item = get_template(session, principal, template_id)
revision = get_template_revision(
session,
item,
revision=payload.revision,
published_preferred=payload.revision is None,
)
except TemplateNotFoundError as exc:
raise _error(exc) from exc
return TemplateCompatibilityResponse.model_validate(
asdict(
compatibility(
revision,
usage=payload.usage,
output_format=payload.output_format,
available_fields=payload.available_fields,
)
)
)
@router.post("/{template_id}/render", response_model=TemplateRenderResponse)
def api_render_template(
template_id: str,
payload: TemplateRenderRequestModel,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateRenderResponse:
_require(principal, RENDER_SCOPE, ADMIN_SCOPE)
try:
result = render_template(
session,
principal,
registry=get_registry(),
request=TemplateRenderRequest(template_id=template_id, **payload.model_dump()),
)
_audit(
session,
principal,
action=f"templates.render.{payload.mode}",
item_id=result.render_id,
details={
"template_id": result.template_id,
"revision_id": result.revision_id,
"template_hash": result.template_hash,
"input_hash": result.input_hash,
"output_sha256": result.output_sha256,
"item_count": result.item_count,
"page_count": result.page_count,
},
)
_event(
session,
principal,
result.render_id,
f"templates.render.{payload.mode}.v1",
resource_type="template_render",
)
session.commit()
except (TemplateCompatibilityError, TemplateNotFoundError, TemplateRenderError) as exc:
session.rollback()
raise _error(exc) from exc
return _render_response(result)
@router.get("/renders/history", response_model=TemplateRenderListResponse)
def api_list_renders(
template_id: str | None = Query(default=None, max_length=36),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TemplateRenderListResponse:
_require(principal, READ_SCOPE, RENDER_SCOPE, ADMIN_SCOPE)
rows = list_renders(session, principal, template_id=template_id, limit=limit)
return TemplateRenderListResponse(
items=[_render_response(render_result(row)) for row in rows],
total=len(rows),
)
@router.get("/renders/{render_id}/download")
def api_download_render(
render_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> Response:
_require(principal, READ_SCOPE, RENDER_SCOPE, ADMIN_SCOPE)
try:
row = get_render_for_principal(session, principal, render_id)
except (TemplateNotFoundError, TemplateRenderError) as exc:
raise _error(exc) from exc
if row.payload is None:
raise HTTPException(
status_code=409,
detail="This output is managed by Files and is not retained as a Templates download.",
)
filename = quote(row.filename, safe="._-")
return Response(
content=row.payload,
media_type=row.content_type,
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}",
"X-Content-SHA256": row.output_sha256,
"X-Content-Type-Options": "nosniff",
},
)
def _require(principal: ApiPrincipal, *scopes: str) -> None:
if not any(principal.has(scope) for scope in scopes):
raise HTTPException(status_code=403, detail=f"Requires one of: {', '.join(scopes)}")
def _template_response(
session: Session,
principal: ApiPrincipal,
item: TemplateDefinition,
revision: TemplateRevision | None = None,
) -> TemplateResponse:
revision = revision or get_template_revision(session, item)
read_only = not (
principal.has(ADMIN_SCOPE)
or item.scope_type == "tenant"
or (item.scope_type == "user" and item.scope_id == principal.account_id)
or (item.scope_type == "group" and item.scope_id in principal.group_ids)
)
return TemplateResponse(
id=item.id,
tenant_id=item.tenant_id,
scope_type=item.scope_type,
scope_id=item.scope_id,
name=item.name,
slug=item.slug,
description=item.description,
template_type=item.template_type,
status=item.status,
current_revision=item.current_revision,
resource_revision=item.resource_revision,
strong_etag=item.strong_etag,
current_revision_id=item.current_revision_id,
published_revision_id=item.published_revision_id,
read_only=read_only,
metadata=dict(item.metadata_ or {}),
created_at=item.created_at,
updated_at=item.updated_at,
revision=_revision_response(revision),
)
def _revision_response(revision: TemplateRevision) -> TemplateRevisionResponse:
return TemplateRevisionResponse(
id=revision.id,
revision=revision.revision,
definition_hash=revision.definition_hash,
template_type=revision.template_type,
usages=list(revision.usages or []),
locale=revision.locale,
required_fields=list(revision.required_fields or []),
output_profiles=list(revision.output_profiles or []),
content_text=revision.content_text,
content_html=revision.content_html,
layout=dict(revision.layout or {}),
metadata=dict(revision.metadata_ or {}),
created_by_account_id=revision.created_by_account_id,
published_at=revision.published_at,
published_by_account_id=revision.published_by_account_id,
created_at=revision.created_at,
)
def _render_response(result) -> TemplateRenderResponse:
payload = asdict(result)
payload.pop("payload", None)
return TemplateRenderResponse.model_validate(payload)
def _record_change(
session: Session,
principal: ApiPrincipal,
item: TemplateDefinition,
revision: TemplateRevision,
*,
action: str,
event_type: str,
) -> None:
_audit(
session,
principal,
action=action,
item=item,
details={
"revision": revision.revision,
"definition_hash": revision.definition_hash,
"template_type": revision.template_type,
"usages": list(revision.usages or []),
},
)
_event(session, principal, item.id, event_type)
def _audit(
session: Session,
principal: ApiPrincipal,
*,
action: str,
item: TemplateDefinition | None = None,
item_id: str | None = None,
details: dict[str, object] | None = None,
) -> None:
audit_from_principal(
session,
principal,
action=action,
object_type="template" if item is not None else "template_render",
object_id=item.id if item is not None else str(item_id or ""),
details=details or {},
commit=False,
)
def _event(
session: Session,
principal: ApiPrincipal,
resource_id: str,
event_type: str,
*,
resource_type: str = "template",
) -> None:
emit_platform_event(
session,
PlatformEvent(
type=event_type,
module_id="templates",
actor=EventActorRef(type="account", id=principal.account_id),
tenant=EventTenantRef(id=principal.tenant_id),
resource=EventObjectRef(type=resource_type, id=resource_id),
classification="internal",
),
)
def _error(exc: Exception) -> HTTPException:
if isinstance(exc, MissingPreconditionError):
return HTTPException(status_code=428, detail=exc.as_dict())
if isinstance(exc, RevisionConflictError):
return HTTPException(status_code=412, detail=exc.as_dict())
if isinstance(exc, ConcurrencyError):
return HTTPException(status_code=409, detail=str(exc))
if isinstance(exc, TemplateNotFoundError):
return HTTPException(status_code=404, detail=str(exc))
if isinstance(exc, IntegrityError):
return HTTPException(status_code=409, detail="Template data conflicts with an existing record.")
return HTTPException(status_code=422, detail=str(exc))
__all__ = ["router"]