"""FlowDeck — Templates unifiés : gestionnaire SSR + API interne (cookie). Cahier des charges : ``docs/architecture-templates-notion-flowdeck.md`` §10.1. Toutes les surfaces (UI, API v2, agent, scheduler) passent par le même ``TemplateService`` — jamais d'accès direct aux silos legacy (P4, brief §0). """ from __future__ import annotations import json import logging from fastapi import APIRouter, Body, Query, Request from fastapi.exceptions import HTTPException from fastapi.responses import HTMLResponse from app.auth.session import SessionManager from app.db import get_conn from app.services.fd_templates import TemplateService, ensure_registry logger = logging.getLogger(__name__) router = APIRouter(tags=["fd-templates"]) def _actor(request: Request) -> dict | None: try: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) except Exception: user = None return user def _require_actor(request: Request) -> dict: user = _actor(request) if not user or not user.get("id"): raise HTTPException(401, "Authentication required") return user def _as_http(exc: Exception) -> HTTPException: if isinstance(exc, LookupError): return HTTPException(404, str(exc) or "Template introuvable") if isinstance(exc, PermissionError): return HTTPException(403, str(exc) or "Accès refusé") if isinstance(exc, ValueError): return HTTPException(400, str(exc) or "Requête invalide") logger.exception("templates API") return HTTPException(500, "Erreur interne") # ═══════════ Page SSR — gestionnaire /templates (§7.2) ═══════════ @router.get("/templates", response_class=HTMLResponse) def templates_manager_page(request: Request): """Gestionnaire de templates : liste filtrable + side peek d'aperçu.""" from app.routers.board import _sidebar_data as board_sidebar from app.templating import ENV sidebar = board_sidebar(request, "", "") try: with get_conn() as conn: ensure_registry(conn) except Exception: logger.exception("ensure_registry /templates") template = ENV.get_template("fd_templates_manager.html") return template.render(**sidebar) # ═══════════ API interne — lecture ═══════════ @router.get("/api/templates") def list_templates( request: Request, kind: str | None = Query(default=None), scope: str | None = Query(default=None), target_collection_id: int | None = Query(default=None), q: str | None = Query(default=None), include_archived: bool = Query(default=False), ): actor = _actor(request) with get_conn() as conn: try: items = TemplateService.list_templates( conn, actor, kind=kind, scope=scope, target_collection_id=target_collection_id, query=q, include_archived=include_archived, ) except Exception as exc: raise _as_http(exc) from None return {"templates": items} @router.get("/api/templates/{template_id}") def get_template(request: Request, template_id: str): actor = _actor(request) with get_conn() as conn: try: row = TemplateService.get_template(conn, actor, template_id) from app.services.fd_templates import _row_to_dto dto = _row_to_dto(conn, row) preview = TemplateService.preview(conn, actor, template_id) dto["blocks_preview"] = preview.get("blocks_preview", []) except Exception as exc: raise _as_http(exc) from None return dto @router.get("/api/collections/{collection_id}/templates") def list_collection_templates(request: Request, collection_id: int): """Menu *New ▾* d'une base : templates de lignes + défaut (§7.3).""" actor = _actor(request) with get_conn() as conn: try: items = TemplateService.list_templates( conn, actor, kind="row", target_collection_id=collection_id ) default_id = next( (t["id"] for t in items if t.get("is_default")), None ) except Exception as exc: raise _as_http(exc) from None return {"templates": items, "default_id": default_id} @router.get("/api/templates/{template_id}/runs") def list_template_runs(request: Request, template_id: str, limit: int = Query(default=20)): actor = _require_actor(request) with get_conn() as conn: try: TemplateService.get_template(conn, actor, template_id) rows = conn.execute( "SELECT * FROM template_runs WHERE template_id=? ORDER BY created_at DESC LIMIT ?", (template_id, max(1, min(int(limit), 100))), ).fetchall() except Exception as exc: raise _as_http(exc) from None return {"runs": [dict(r) for r in rows]} # ═══════════ API interne — écriture ═══════════ @router.post("/api/templates") def create_template(request: Request, body: dict = Body(default={})): actor = _require_actor(request) with get_conn() as conn: try: # Création depuis une source existante (create_from_*, §2.1). if body.get("from_page_id"): dto = _create_from_page(conn, actor, int(body["from_page_id"]), body) elif body.get("from_collection_id"): dto = _create_from_collection( conn, actor, int(body["from_collection_id"]), body ) elif body.get("from_row"): dto = _create_from_row(conn, actor, body["from_row"], body) else: dto = TemplateService.create_template(conn, actor, body) conn.commit() except Exception as exc: raise _as_http(exc) from None return dto @router.patch("/api/templates/{template_id}") def update_template(request: Request, template_id: str, body: dict = Body(default={})): actor = _require_actor(request) with get_conn() as conn: try: dto = TemplateService.update_template(conn, actor, template_id, body) conn.commit() except Exception as exc: raise _as_http(exc) from None return dto @router.post("/api/templates/{template_id}/duplicate") def duplicate_template(request: Request, template_id: str, body: dict = Body(default={})): actor = _require_actor(request) with get_conn() as conn: try: dto = TemplateService.duplicate_template(conn, actor, template_id, body) conn.commit() except Exception as exc: raise _as_http(exc) from None return dto @router.delete("/api/templates/{template_id}") def delete_template(request: Request, template_id: str): actor = _require_actor(request) with get_conn() as conn: try: result = TemplateService.delete_template(conn, actor, template_id) conn.commit() except Exception as exc: raise _as_http(exc) from None return result @router.post("/api/templates/{template_id}/archive") def archive_template(request: Request, template_id: str): actor = _require_actor(request) with get_conn() as conn: try: dto = TemplateService.archive_template(conn, actor, template_id, archived=True) conn.commit() except Exception as exc: raise _as_http(exc) from None return dto @router.post("/api/templates/{template_id}/restore") def restore_template(request: Request, template_id: str): actor = _require_actor(request) with get_conn() as conn: try: dto = TemplateService.archive_template(conn, actor, template_id, archived=False) conn.commit() except Exception as exc: raise _as_http(exc) from None return dto @router.put("/api/templates/{template_id}/property-defaults") def set_property_defaults(request: Request, template_id: str, body: dict = Body(default={})): actor = _require_actor(request) with get_conn() as conn: try: dto = TemplateService.update_template( conn, actor, template_id, {"property_defaults": body.get("property_defaults") or body}, ) conn.commit() except Exception as exc: raise _as_http(exc) from None return dto @router.put("/api/templates/{template_id}/variables") def set_variables(request: Request, template_id: str, body: dict = Body(default={})): actor = _require_actor(request) variables = body.get("variables") if isinstance(body.get("variables"), list) else body.get("variables_json") if variables is None and isinstance(body, list): variables = body with get_conn() as conn: try: dto = TemplateService.update_template( conn, actor, template_id, {"variables": variables or []} ) conn.commit() except Exception as exc: raise _as_http(exc) from None return dto @router.post("/api/templates/{template_id}/set-default") def set_default(request: Request, template_id: str): actor = _require_actor(request) with get_conn() as conn: try: dto = TemplateService.set_default(conn, actor, template_id) conn.commit() except Exception as exc: raise _as_http(exc) from None return dto @router.post("/api/templates/{template_id}/unset-default") def unset_default(request: Request, template_id: str): actor = _require_actor(request) with get_conn() as conn: try: dto = TemplateService.unset_default(conn, actor, template_id) conn.commit() except Exception as exc: raise _as_http(exc) from None return dto @router.put("/api/templates/{template_id}/recurrence") def set_recurrence(request: Request, template_id: str, body: dict = Body(default={})): actor = _require_actor(request) spec = None if body and body.get("rrule"): spec = { "rrule": body.get("rrule"), "timezone": body.get("timezone") or "", "enabled": body.get("enabled", True), } with get_conn() as conn: try: recurrence = TemplateService.set_recurrence(conn, actor, template_id, spec) conn.commit() except Exception as exc: raise _as_http(exc) from None return {"template_id": template_id, "recurrence": recurrence} @router.post("/api/templates/{template_id}/preview") def preview_template(request: Request, template_id: str, body: dict = Body(default={})): actor = _actor(request) with get_conn() as conn: try: result = TemplateService.preview( conn, actor, template_id, body.get("variables") or {}, title=(body.get("title") or ""), ) except Exception as exc: raise _as_http(exc) from None return result @router.post("/api/templates/{template_id}/instantiate") def instantiate_template(request: Request, template_id: str, body: dict = Body(default={})): actor = _actor(request) idempotency_key = request.headers.get("Idempotency-Key") or body.get("idempotency_key") context = body.get("context") or {} # La cible « page courante » porte l'id de page pour ajout/remplacement. if body.get("page_id") and "target" not in context: context["target"] = {"type": "current_page", "page_id": body.get("page_id"), "mode": body.get("mode") or "fill"} if body.get("collection_id") and "collection_id" not in context: context["collection_id"] = body.get("collection_id") if body.get("parent_page_id") and "parent_page_id" not in context: context["parent_page_id"] = body.get("parent_page_id") with get_conn() as conn: try: result = TemplateService.instantiate( conn, actor, template_id, body.get("variables") or {}, context, idempotency_key=idempotency_key, run_source="api" if request.headers.get("authorization") else "ui", ) except Exception as exc: raise _as_http(exc) from None return result @router.post("/api/templates/runs/{run_id}/undo") def undo_run(request: Request, run_id: str): actor = _require_actor(request) with get_conn() as conn: try: result = TemplateService.mark_undone(conn, actor, run_id) except Exception as exc: raise _as_http(exc) from None return result # ═══════════ Création depuis l'existant (create_from_*) ═══════════ def _create_from_page(conn, actor, page_id: int, spec: dict) -> dict: row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() if not row: raise LookupError("Page source introuvable") blocks: list = [] try: if (row["content_format"] or "") == "blocks" and row["content"]: blocks = json.loads(row["content"]) or [] except (json.JSONDecodeError, TypeError): blocks = [] return TemplateService.create_template( conn, actor, { "kind": spec.get("kind") or "page", "name": spec.get("name") or f"{row['title'] or 'Sans titre'} (template)", "description": spec.get("description") or "", "icon": spec.get("icon") or (row["page_icon"] if "page_icon" in row.keys() else "📄") or "📄", "category": spec.get("category") or "", "scope": spec.get("scope") or "personal", "workspace_id": spec.get("workspace_id"), "target_collection_id": spec.get("target_collection_id"), "blocks": blocks if isinstance(blocks, list) else [], "variables": spec.get("variables") or [], "title_template": spec.get("title_template") or f"{row['title'] or ''}", }, ) def _create_from_collection(conn, actor, collection_id: int, spec: dict) -> dict: collection = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not collection: raise LookupError("Base source introuvable") props = [ dict(p) for p in conn.execute( "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() ] schema = [ { "name": p["name"], "type": p["prop_type"] if "prop_type" in p else p.get("type", "text"), "options": json.loads(p.get("options_json") or "[]"), } for p in props ] manifest = { "format": "flowdeck-template", "version": 1, "collection": { "name": collection["name"], "icon": collection["icon"] if "icon" in collection.keys() else "📋", "schema": schema, }, } if spec.get("with_seed_rows"): manifest["collection"]["seed_rows"] = [] return TemplateService.create_template( conn, actor, { "kind": "database", "name": spec.get("name") or f"{collection['name']} (template)", "description": spec.get("description") or (collection["description"] or ""), "icon": (collection["icon"] if "icon" in collection.keys() else "") or "🗂", "category": spec.get("category") or "Base", "scope": spec.get("scope") or "personal", "workspace_id": spec.get("workspace_id"), "manifest": manifest, "variables": spec.get("variables") or [], }, ) def _create_from_row(conn, actor, from_row: dict | int, spec: dict) -> dict: if isinstance(from_row, dict): collection_id = from_row.get("collection_id") row_id = from_row.get("row_id") else: collection_id, row_id = spec.get("target_collection_id"), from_row if not collection_id or not row_id: raise ValueError("from_row exige {collection_id, row_id}") source = conn.execute( "SELECT * FROM collection_pages WHERE id=? AND collection_id=?", (row_id, collection_id), ).fetchone() if not source: raise LookupError("Ligne source introuvable") try: pv = json.loads(source["property_values_json"] or "{}") except (json.JSONDecodeError, TypeError): pv = {} # Les valeurs sont re-cléées par nom de propriété pour survivre à l'export. props = { str(p["id"]): p["name"] for p in conn.execute( "SELECT id, name FROM collection_properties WHERE collection_id=?", (collection_id,), ).fetchall() } defaults = {props.get(k, k): v for k, v in (pv if isinstance(pv, dict) else {}).items()} shadow_blocks: list = [] try: shadow = conn.execute( "SELECT content, content_format FROM pages WHERE collection_row_id=?", (row_id,), ).fetchone() if shadow and (shadow["content_format"] or "") == "blocks" and shadow["content"]: shadow_blocks = json.loads(shadow["content"]) or [] except Exception: shadow_blocks = [] return TemplateService.create_template( conn, actor, { "kind": "row", "name": spec.get("name") or f"{source['title'] or 'Sans titre'} (template)", "description": spec.get("description") or "", "icon": spec.get("icon") or "📄", "category": spec.get("category") or "", "scope": spec.get("scope") or "personal", "workspace_id": spec.get("workspace_id"), "target_collection_id": collection_id, "blocks": shadow_blocks if isinstance(shadow_blocks, list) else [], "manifest": {"property_defaults": defaults}, "title_template": spec.get("title_template") or (source["title"] or ""), "variables": spec.get("variables") or [], }, )