Templates (v7.71.x) : - registre unifié \ emplates\ (migrations 48-49) + TemplateService.instantiate unique (UI, API v2, agent, scheduler) - sélecteur (pilule page vide, menu •••, commande /template), gestionnaire /templates, menu New ▾, From template, base inline dans un document - 141 presets système (59 pages, 42 bases, 15 blocs, 25 lignes), titre auto depuis le template, variables title réservée - récurrences RRULE + scheduler dédupliqué, agent apply_template/list_templates, API /api/templates + /api/v2/fd-templates - correctifs : bouton Templates, centrage fenêtre, filtres CSP, flux de création, variable title - tests : tests/test_fd_templates.py (19) et e2e/templates_picker.spec.js (8) Inclut le travail déjà présent dans le working tree (vues Notion : view_query/view_aggregate/form_projection/geocoding, property_types, database_table, docs agents-skills) et ignore .playwright-mcp/.
481 lines
18 KiB
Python
481 lines
18 KiB
Python
"""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 [],
|
|
},
|
|
)
|