from __future__ import annotations from collections.abc import Mapping, Sequence from sqlalchemy.orm import Session from govoplan_core.audit.logging import audit_from_principal from govoplan_core.auth import ApiPrincipal from govoplan_core.core.events import ( EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event, ) from govoplan_core.core.modules import ModuleContext from govoplan_core.core.templates import ( TemplateCatalogProvider, TemplateCompatibility, TemplateContentDraftRequest, TemplateContentLibraryProvider, TemplateRef, ) from govoplan_templates.backend.rendering import SqlTemplateRenderer from govoplan_templates.backend.schemas import TemplateCreateRequest from govoplan_templates.backend.service import ( READ_SCOPE, WRITE_SCOPE, compatibility, create_template, get_template, get_template_revision, list_templates, template_ref, ) class SqlTemplateCatalog(TemplateCatalogProvider): def list_templates( self, session: object, principal: object, *, query: str = "", usage: str | None = None, template_type: str | None = None, locale: str | None = None, limit: int = 100, ) -> Sequence[TemplateRef]: sql_session, api_principal = _context(session, principal) _require_read(api_principal) rows = list_templates( sql_session, api_principal, query=query, usage=usage, template_type=template_type, locale=locale, limit=limit, ) return tuple( template_ref( row, get_template_revision(sql_session, row, published_preferred=True), read_only=_read_only(api_principal, row.scope_type, row.scope_id), ) for row in rows ) def get_template( self, session: object, principal: object, *, template_id: str, revision: int | None = None, ) -> TemplateRef | None: sql_session, api_principal = _context(session, principal) _require_read(api_principal) try: row = get_template(sql_session, api_principal, template_id) item_revision = get_template_revision( sql_session, row, revision=revision, published_preferred=revision is None, ) except ValueError: return None return template_ref( row, item_revision, read_only=_read_only(api_principal, row.scope_type, row.scope_id), ) def check_compatibility( self, session: object, principal: object, *, template_id: str, revision: int | None = None, usage: str | None = None, output_format: str | None = None, available_fields: Mapping[str, str] | Sequence[str] = (), ) -> TemplateCompatibility: sql_session, api_principal = _context(session, principal) _require_read(api_principal) row = get_template(sql_session, api_principal, template_id) item_revision = get_template_revision( sql_session, row, revision=revision, published_preferred=revision is None, ) return compatibility( item_revision, usage=usage, output_format=output_format, available_fields=available_fields, ) class SqlTemplateContentLibrary(TemplateContentLibraryProvider): def create_content_draft( self, session: object, principal: object, *, request: TemplateContentDraftRequest, ) -> TemplateRef: sql_session, api_principal = _context(session, principal) _require_write(api_principal) if request.template_type not in {"content_fragment", "email", "generic"}: raise ValueError( "Reusable content drafts must be a content fragment, email, or generic template." ) item, revision = create_template( sql_session, api_principal, TemplateCreateRequest( name=request.name, description=request.description, scope_type=request.scope_type, scope_id=request.scope_id, template_type=request.template_type, usages=list(request.usages), locale=request.locale, required_fields=[ { "path": field.path, "value_type": field.value_type, "label": field.label, "required": field.required, "description": field.description, } for field in request.required_fields ], output_profiles=[], content_text=request.content_text, content_html=request.content_html, layout={}, metadata={ **dict(request.metadata), "created_through": "templates.content_library", }, ), ) audit_from_principal( sql_session, api_principal, action="templates.template.created", object_type="template", object_id=item.id, details={ "revision": revision.revision, "definition_hash": revision.definition_hash, "template_type": revision.template_type, "usages": list(revision.usages or []), "required_fields": [ str(field.get("path") or "") for field in revision.required_fields or [] if field.get("path") ], "source": "content_library_capability", }, commit=False, ) emit_platform_event( sql_session, PlatformEvent( type="templates.template.created.v1", module_id="templates", actor=EventActorRef(type="account", id=api_principal.account_id), tenant=EventTenantRef(id=api_principal.tenant_id), resource=EventObjectRef(type="template", id=item.id), classification="internal", ), ) return template_ref(item, revision, read_only=False) def catalog_capability(_context: ModuleContext) -> SqlTemplateCatalog: return SqlTemplateCatalog() def renderer_capability(context: ModuleContext) -> SqlTemplateRenderer: return SqlTemplateRenderer(context.registry) def content_library_capability(_context: ModuleContext) -> SqlTemplateContentLibrary: return SqlTemplateContentLibrary() def _context(session: object, principal: object) -> tuple[Session, ApiPrincipal]: if not isinstance(session, Session): raise TypeError("Template catalogue access requires a SQLAlchemy session.") if not isinstance(principal, ApiPrincipal): raise TypeError("Template catalogue access requires an API principal.") return session, principal def _require_read(principal: ApiPrincipal) -> None: if not any( principal.has(scope) for scope in ( READ_SCOPE, "templates:template:write", "templates:template:publish", "templates:template:admin", ) ): raise PermissionError(f"Template catalogue access requires {READ_SCOPE}.") def _require_write(principal: ApiPrincipal) -> None: if not any( principal.has(scope) for scope in (WRITE_SCOPE, "templates:template:admin") ): raise PermissionError(f"Template draft creation requires {WRITE_SCOPE}.") def _read_only(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -> bool: if principal.has("templates:template:admin") or scope_type == "tenant": return False if scope_type == "user": return scope_id != principal.account_id return scope_id not in principal.group_ids __all__ = [ "SqlTemplateCatalog", "SqlTemplateContentLibrary", "catalog_capability", "content_library_capability", "renderer_capability", ]