"""The shipped template catalog. `templates/` holds blueprints, not active templates. A blueprint is inert product content: it sits in the catalog until an admin adds it, and adding it produces an ordinary row in `templates` that is theirs, editable, renameable, deletable, and never overwritten by a later deploy. That is the difference to the built-in help documents (`help_import.py`), which ARE re-imported on every start and stay read-only: those describe how Pablan works, so the product owns them. A template describes how a company documents its own knowledge, so the company owns it. **The only automatic write to `templates` is `seed_starter_templates`, and it runs exclusively against an empty table.** Everything else goes through an explicit admin action in `api/templates.py`. This is load-bearing: a startup upsert from the catalog would silently discard an admin's edits the next time we improved a shipped blueprint. File naming: `..yaml` (`prozess.de.yaml`). A file without a locale suffix is treated as belonging to the default locale, so a customer can drop their own YAML into the directory without learning the convention. See docs/authoring-templates.md. """ import logging from dataclasses import dataclass from pathlib import Path from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.models import Template from app.template_import import TemplateImportError, parse_template, upsert_template logger = logging.getLogger("pablan.templates") SUPPORTED_LOCALES = ("de", "en") # What a brand-new instance starts with, in its default language: the four # occasions on which anyone actually writes something down. Write it down # (no structure at all — a blank page beats three generic headings), how we # do this, what broke, and who you are. Everything more specific — a # machine, a decision, a project review — is a deliberate add from the # catalog, because a picker of ten options is a picker nobody reads. STARTER_TEMPLATE_IDS = ( "notiz", "prozess", "stoerung", "person", ) @dataclass(frozen=True) class CatalogEntry: """A blueprint as it sits on disk, never a DB row.""" id: str locale: str name: str description: str version: str sections: int source: str def _split_stem(stem: str) -> tuple[str, str]: """`prozess.de` -> (`prozess`, `de`); a stem without a known locale suffix belongs to the default locale.""" base, _, suffix = stem.rpartition(".") if base and suffix in SUPPORTED_LOCALES: return base, suffix return stem, get_settings().default_locale def load_catalog() -> list[CatalogEntry]: """Parse every blueprint in the catalog directory. Read per call rather than cached: the directory is small, and a self-hosted admin who drops a YAML file in it should not have to restart the server to see it. """ directory = Path(get_settings().templates_dir) if not directory.is_dir(): logger.info( "no template catalog directory", extra={"event": "catalog_missing", "directory": str(directory)}, ) return [] entries: list[CatalogEntry] = [] for path in sorted(directory.glob("*.yaml")): source = path.read_text() try: template = parse_template(source) except TemplateImportError as exc: # A broken blueprint must not take the catalog down with it. logger.error( "catalog blueprint invalid", extra={ "event": "catalog_invalid", "file": path.name, "error": str(exc), }, ) continue _base, from_name = _split_stem(path.stem) entries.append( CatalogEntry( id=template.id, # The YAML says what language it is written in; the filename # suffix is the human-facing convention and the fallback. locale=template.locale or from_name, name=template.name, description=template.description or "", version=template.version, sections=len(template.sections), source=source, ) ) return entries def catalog_for_locale(locale: str | None = None) -> list[CatalogEntry]: """One entry per blueprint id, in `locale` where a variant exists. A blueprint with no variant in the requested language still shows up in whatever language it has — a missing translation must not hide a template from the admin who needs it. """ wanted = locale or get_settings().default_locale best: dict[str, CatalogEntry] = {} for entry in load_catalog(): current = best.get(entry.id) if current is None or (entry.locale == wanted and current.locale != wanted): best[entry.id] = entry return sorted(best.values(), key=lambda entry: entry.name) def get_catalog_entry( catalog_id: str, locale: str | None = None ) -> CatalogEntry | None: return next( (entry for entry in catalog_for_locale(locale) if entry.id == catalog_id), None, ) async def seed_starter_templates(db: AsyncSession) -> int: """Give a brand-new instance something to capture with. Only ever runs against an EMPTY templates table. Once an admin has curated the list, added blueprints, deleted a starter, renamed things, that curation is the truth and startup must not re-litigate it. This is the ONLY automatic write to the table. """ existing = (await db.execute(select(func.count(Template.id)))).scalar_one() if existing: return 0 locale = get_settings().default_locale by_id = {entry.id: entry for entry in catalog_for_locale(locale)} seeded = 0 for catalog_id in STARTER_TEMPLATE_IDS: entry = by_id.get(catalog_id) if entry is None: logger.error( "starter template missing from catalog", extra={"event": "catalog_starter_missing", "template": catalog_id}, ) continue await upsert_template(db, parse_template(entry.source)) seeded += 1 await db.commit() logger.info( "starter templates seeded", extra={"event": "templates_seeded", "count": seeded, "locale": locale}, ) return seeded