Implement typed template library and rendering
This commit is contained in:
@@ -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"]
|
||||
Reference in New Issue
Block a user