"""Changing what this instance offers. Admin only, by the router it hangs on. Two ways in, one guarantee: the form builder sends a structured config and the YAML editor sends text, but both end as the same validated blueprint, so neither path can save something the other would reject. """ import uuid from typing import Annotated from fastapi import Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.templates.blueprints import ( ensure_config_id_free, parse_or_422, taken_config_ids, unique_config_id, ) from app.api.templates.routing import editor_router from app.api.templates.schemas import ( TemplateBuildRequest, TemplateDetail, TemplateImportRequest, ) from app.api.templates.view import detail from app.db import get_db from app.errors import ApiError from app.models import Template router = editor_router() # What a template's config id falls back to when the form has nothing to slug. FALLBACK_ID = "vorlage" async def _row_or_404(db: AsyncSession, template_id: uuid.UUID) -> Template: row = await db.get(Template, template_id) if row is None: raise ApiError(404, "Template not found.", "not_found") return row @router.post("/build") async def build_template( body: TemplateBuildRequest, db: Annotated[AsyncSession, Depends(get_db)], ) -> TemplateDetail: """Save a template from the structured form builder. Creates a new row (template_id null) or updates one in place.""" template = body.config config = template.model_dump(mode="json") if body.template_id is None: # The config id is an internal slug the form derives from the name; # make it unique so a second "Onboarding" never overwrites the first. config["id"] = unique_config_id( template.id or FALLBACK_ID, await taken_config_ids(db) ) row = Template(name=template.name, version=template.version, config=config) db.add(row) else: await ensure_config_id_free(db, template.id, except_row=body.template_id) row = await _row_or_404(db, body.template_id) row.name = template.name row.version = template.version row.config = config await db.commit() return detail(row) @router.put("/{template_id}") async def update_template( template_id: uuid.UUID, body: TemplateImportRequest, db: Annotated[AsyncSession, Depends(get_db)], ) -> TemplateDetail: """Replace a template's YAML. Validated against the schema on save.""" row = await _row_or_404(db, template_id) template = parse_or_422(body.yaml) await ensure_config_id_free(db, template.id, except_row=row.id) row.name = template.name row.version = template.version row.config = template.model_dump(mode="json") await db.commit() return detail(row) async def _copy_name(db: AsyncSession, name: str) -> str: """The next free " (2)". A number rather than a word, because this name is shown in the interface and the backend never renders UI-language strings (CLAUDE.md) — a German "(Kopie)" would sit untranslated in an English admin panel. It is also what file managers do, so it needs no explaining. """ taken = set((await db.execute(select(Template.name))).scalars().all()) counter = 2 while f"{name} ({counter})" in taken: counter += 1 return f"{name} ({counter})" @router.post("/{template_id}/duplicate") async def duplicate_template( template_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db)], ) -> TemplateDetail: """Fork a template — for trying a variant without losing the original. The copy gets a fresh config id so the two never collide.""" row = await _row_or_404(db, template_id) config_id = unique_config_id( row.config.get("id", "template"), await taken_config_ids(db), suffix="-copy" ) config = {**row.config, "id": config_id, "name": await _copy_name(db, row.name)} copy = Template(name=config["name"], version=row.version, config=config) db.add(copy) await db.commit() return detail(copy) @router.delete("/{template_id}", status_code=204) async def delete_template( template_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db)], ) -> None: """Remove a template. Documents created from it are independent and survive (a template is only a starting point). If it came from the catalog it can always be added back.""" row = await _row_or_404(db, template_id) await db.delete(row) await db.commit()