530 lines
18 KiB
Python
530 lines
18 KiB
Python
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"]
|