"""Template import: YAML → validated config → templates table. Every row in `templates` is the customer's own, whichever way it got there — pasted YAML, a fork, or added from the shipped catalog (`template_catalog.py`). None of them is read-only. """ import logging import yaml from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.authoring.schema import AuthoringTemplate from app.models import Template logger = logging.getLogger("pablan.templates") class TemplateImportError(Exception): """Sanitized import failure — safe to surface to an admin.""" def parse_template(source: str) -> AuthoringTemplate: try: raw = yaml.safe_load(source) except yaml.YAMLError as exc: raise TemplateImportError(f"Invalid YAML: {type(exc).__name__}") from None if not isinstance(raw, dict): raise TemplateImportError("Template must be a YAML mapping.") try: return AuthoringTemplate.model_validate(raw) except ValueError as exc: first_error = str(exc).splitlines()[1] if "\n" in str(exc) else str(exc) raise TemplateImportError( f"Template failed validation: {first_error.strip()}" ) from None async def upsert_template( db: AsyncSession, template: AuthoringTemplate ) -> tuple[Template, bool]: """Insert or update by the template's config id. Returns (row, created).""" existing = ( await db.execute( select(Template).where(Template.config["id"].astext == template.id) ) ).scalar_one_or_none() config = template.model_dump() if existing is not None: existing.name = template.name existing.version = template.version existing.config = config return existing, False row = Template( name=template.name, version=template.version, config=config, ) db.add(row) await db.flush() return row, True