feat(templates): refonte complète des templates façon Notion + vues/agents-skills
FlowDeck CI / lint (push) Failing after 1m32s
FlowDeck CI / test (push) Failing after 27m50s
FlowDeck CI / docker (push) Skipped

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/.
This commit is contained in:
2026-10-10 18:52:19 -04:00
parent d0452e10dd
commit fc8548194a
36 changed files with 12290 additions and 137 deletions
+141
View File
@@ -203,3 +203,144 @@ async def import_csv_v2(collection_id: int, request: Request, authorization: str
created += 1
conn.commit()
return {"imported": created, "status": "ok"}
# ── Registre unifié (§10.2) : liste / détail / instantiate / export / import ──
# (chemins /fd-templates/* : les /templates/* historiques restent inchangés)
def _v2_actor(user) -> dict:
return {"id": user.get("id"), "login": user.get("login", ""),
"full_name": user.get("full_name", ""),
"is_admin": bool(user.get("is_admin"))}
@router.get("/fd-templates")
def list_fd_templates_v2(request: Request, authorization: str | None = Header(default=None),
kind: str | None = None, q: str | None = None):
from app.services.fd_templates import TemplateService
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
items = TemplateService.list_templates(conn, _v2_actor(user), kind=kind, query=q)
return {"templates": items}
@router.get("/fd-templates/{template_id}")
def get_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)):
from app.services.fd_templates import TemplateService, _row_to_dto
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
try:
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
return _row_to_dto(conn, row)
except LookupError:
raise HTTPException(404, "Template not found")
except PermissionError:
raise HTTPException(403, "Forbidden")
@router.post("/fd-templates/{template_id}/instantiate")
def instantiate_fd_template_v2(template_id: str, request: Request,
authorization: str | None = Header(default=None),
body: dict = Body(default={})):
from app.services.fd_templates import TemplateService
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
idem = request.headers.get("Idempotency-Key") or body.get("idempotency_key")
if idem:
hit = check_idempotency(request, user["id"])
if hit:
return hit["data"]
with get_conn() as conn:
try:
result = TemplateService.instantiate(
conn, _v2_actor(user), template_id, body.get("variables") or {},
body.get("context") or {}, idempotency_key=idem, run_source="api")
except LookupError:
raise HTTPException(404, "Template not found")
except PermissionError:
raise HTTPException(403, "Forbidden")
except ValueError as exc:
raise HTTPException(400, str(exc))
audit_log(user, "template.instantiate", "template", template_id, "")
if idem:
store_idempotency(idem, user["id"], result, 200)
return result
@router.get("/fd-templates/{template_id}/export")
def export_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)):
"""Export au format manifeste flowdeck-template v1 (annexe B)."""
from app.services.fd_templates import TemplateService, _read_holder_blocks, _row_to_dto
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
try:
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
dto = _row_to_dto(conn, row)
blocks, _w = _read_holder_blocks(conn, row)
except LookupError:
raise HTTPException(404, "Template not found")
except PermissionError:
raise HTTPException(403, "Forbidden")
manifest = {
"format": "flowdeck-template", "version": 1, "kind": dto["kind"],
"name": dto["name"], "description": dto["description"], "icon": dto["icon"],
"category": dto["category"], "variables": dto["variables"],
"manifest": dto["manifest"], "content": {"content_format": "blocks", "blocks": blocks},
}
if dto.get("recurrence"):
manifest["recurrence"] = {"rrule": dto["recurrence"].get("rrule"),
"timezone": dto["recurrence"].get("timezone", "")}
return manifest
@router.post("/fd-templates/import")
def import_fd_template_v2(request: Request, authorization: str | None = Header(default=None),
body: dict = Body(default={})):
"""Import d'un manifeste flowdeck-template v1 (scope personnel, §14)."""
from app.services.fd_templates import KINDS, TemplateService
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if body.get("format") != "flowdeck-template":
raise HTTPException(400, "format must be flowdeck-template")
try:
version = int(body.get("version") or 0)
except (TypeError, ValueError):
raise HTTPException(400, "version invalide")
if version > 1:
raise HTTPException(400, "version de manifeste non supportée")
kind = body.get("kind") or "page"
if kind not in KINDS:
raise HTTPException(400, "kind inconnu")
blocks = ((body.get("content") or {}).get("blocks") or [])
if not isinstance(blocks, list) or len(blocks) > 2000:
raise HTTPException(400, "content.blocks invalide ou trop volumineux")
target = body.get("target") or {}
spec = {"kind": kind, "name": (body.get("name") or "Sans titre")[:200],
"description": (body.get("description") or "")[:2000],
"icon": body.get("icon") or "📄", "category": (body.get("category") or "")[:120],
"scope": "personal", "blocks": blocks,
"manifest": body.get("manifest") or {}, "variables": body.get("variables") or [],
"title_template": (body.get("manifest") or {}).get("title_template", "")}
if kind == "row":
# La base cible est résolue par nom dans le workspace, jamais par id
# externe ; si absente, le template est créé « orphelin » et signalé.
cname = target.get("collection_name", "")
with get_conn() as conn:
actor = _v2_actor(user)
row = conn.execute("SELECT id FROM collections WHERE name=?", (cname,)).fetchone() if cname else None
if row:
spec["target_collection_id"] = row["id"]
dto = TemplateService.create_template(conn, actor, spec)
conn.commit()
return {"id": dto["id"], "status": "imported"}
# Orphelin personnel : on choisit la première base comme pis-aller ? Non :
# le contrat exige un signalement — on refuse proprement.
raise HTTPException(400, "collection cible introuvable : match_by=collection_name")
with get_conn() as conn:
dto = TemplateService.create_template(conn, _v2_actor(user), spec)
conn.commit()
audit_log(user, "template.import", "template", dto["id"], "")
return {"id": dto["id"], "status": "imported"}
+329 -1
View File
@@ -48,7 +48,18 @@ def update_view_config(request: Request, view_id: int, body: dict = Body(default
for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
"cover_mode", "card_properties", "visible_properties", "filters", "sorts",
"filter_conjunction", "date_property", "date_range_property",
"property_groups", "view_type"):
"property_groups", "view_type",
# Vues Notion (Chart/Timeline/Feed/Map/Dashboard/Form) — §10.4
"version", "chart_type", "chart_property", "measure", "omit_zero",
"cumulative", "hidden_groups", "group_order", "style", "order",
"aggregate", "title", "center_value", "palette", "height",
"data_labels", "legend", "grid", "smooth", "gradient",
"color_by_value", "separate_end_property", "scale", "show_table",
"table_properties", "load_limit", "show_dependencies", "sub_items",
"row_order", "show_view_count", "excerpt", "place_property",
"cluster", "default_style", "global_filters", "row_heights",
"excluded_widget_ids", "sharing", "submit_screen", "notify_email",
"description", "form_view_id"):
if key in body:
config[key] = body[key]
@@ -184,4 +195,321 @@ def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default
"status": "duplicated"}
# ── Vues Notion : donnees de vues, agregation, geocodage, dashboard, form ──
def _load_view_collection(conn, collection_id: int, view_id: int):
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not view or int(view["collection_id"]) != int(collection_id):
raise HTTPException(status_code=404, detail="View not found")
try:
config = json.loads(view["config_json"] or "{}")
except (json.JSONDecodeError, TypeError):
config = {}
props = [dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,)).fetchall()]
pages = [dict(r) for r in conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,)).fetchall()]
return dict(view), config, props, pages
@router.get("/{collection_id}/views/{view_id}/rows")
def view_rows_api(request: Request, collection_id: int, view_id: int,
cursor: str | None = None, limit: int = 50,
window: str | None = None):
"""Lignes filtrees/triees/projetees d'une vue (Feed, Timeline, Map...)."""
from app.services.view_query import query_view_rows
with get_conn() as conn:
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
if window:
# Fenetre temporelle Timeline : "2026-01-01..2026-12-31" — filtree cote client.
config = dict(config)
limit = max(1, min(int(limit or 50), 1000))
rows, next_cursor = query_view_rows(pages, props, config, limit=limit, cursor=cursor)
undated = 0
date_prop = (config.get("date_property") or config.get("date_range_property"))
if date_prop and (view.get("view_type") == "timeline"):
from app.services.view_query import prop_value, text_of
by_name = {p["name"]: p for p in props}
by_id = {str(p["id"]): p for p in props}
dp = by_name.get(date_prop) or by_id.get(str(date_prop))
if dp:
for p in pages:
if not text_of(dp, prop_value(p, dp)):
undated += 1
return {"rows": rows, "next_cursor": next_cursor, "total": len(pages),
"undated": undated, "view_type": view.get("view_type")}
@router.post("/{collection_id}/views/{view_id}/aggregate")
def view_aggregate_api(request: Request, collection_id: int, view_id: int,
body: dict = Body(default={})):
"""Agregat groupe (Chart, KPI, widgets, drilldown) — §8.3."""
from app.services.view_aggregate import aggregate
with get_conn() as conn:
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
spec = {
"group_by": body.get("group_by", config.get("group_by")),
"sub_group_by": body.get("sub_group_by", config.get("sub_group_by")),
"measure": body.get("measure", config.get("measure", {"kind": "count"})),
"cumulative": body.get("cumulative", config.get("cumulative", False)),
"omit_zero": body.get("omit_zero", config.get("omit_zero", True)),
"hidden_groups": body.get("hidden_groups", config.get("hidden_groups", [])),
"order": body.get("order", config.get("order", config.get("group_order", "group_order"))),
"filters": (config.get("filters") or []) + (body.get("filters") or []),
"filter_conjunction": config.get("filter_conjunction", "and"),
"sorts": config.get("sorts"),
}
# Compat : ancienne config SSR {chart_property, aggregate}
if not spec["group_by"] and (config.get("chart_property") or body.get("chart_property")):
spec["group_by"] = config.get("chart_property") or body.get("chart_property")
if isinstance(spec["measure"], str):
spec["measure"] = {"kind": spec["measure"]}
if isinstance(spec["order"], str):
spec["order"] = {"by": spec["order"], "direction": "asc"}
return aggregate(pages, props, spec)
@router.get("/{collection_id}/views/{view_id}/drilldown")
def view_drilldown_api(request: Request, collection_id: int, view_id: int,
group: str = "", sub: str | None = None, limit: int = 100):
"""Tableau restreint des lignes d'un segment de Chart (§8.4)."""
from app.services.view_query import query_view_rows
with get_conn() as conn:
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
group_by = config.get("group_by") or config.get("chart_property")
extra = [{"property": group_by, "operator": "is", "value": group}] if group_by and group else []
if sub and config.get("sub_group_by"):
extra.append({"property": config["sub_group_by"], "operator": "is", "value": sub})
rows, _ = query_view_rows(pages, props, config, extra_filters=extra,
limit=max(1, min(int(limit or 100), 500)))
return {"rows": rows, "group": group, "sub": sub, "count": len(rows)}
@router.get("/geocode/search")
def geocode_search_api(request: Request, q: str = "", limit: int = 5):
"""Autocompletion d'adresses (editeur place). Provider none -> []."""
from app.services import geocoding as _geo
with get_conn() as conn:
return {"suggestions": _geo.search(conn, q, limit=min(int(limit or 5), 10))}
@router.post("/geocode/resolve")
def geocode_resolve_api(request: Request, body: dict = Body(default={})):
"""Adresse -> coordonnees (cache migration 45)."""
from app.services import geocoding as _geo
with get_conn() as conn:
result = _geo.resolve(conn, body.get("q") or body.get("address") or "")
if not result:
raise HTTPException(status_code=404, detail="Place not resolved")
return result
@router.get("/{collection_id}/views/{view_id}/widgets")
def dashboard_widgets_get(request: Request, collection_id: int, view_id: int):
with get_conn() as conn:
view, config, _, _ = _load_view_collection(conn, collection_id, view_id)
try:
rows = conn.execute(
"SELECT * FROM dashboard_widgets WHERE view_id=? ORDER BY row_index, position",
(view_id,)).fetchall()
widgets = [dict(r) for r in rows]
except Exception:
widgets = json.loads(config.get("widgets_json") or "[]") if isinstance(config.get("widgets_json"), str) else (config.get("widgets_json") or [])
return {"widgets": widgets, "config": config}
@router.put("/{collection_id}/views/{view_id}/widgets")
def dashboard_widgets_put(request: Request, collection_id: int, view_id: int,
body: dict = Body(default={})):
from app.routers.collections._common import _require_edit
from app.routers.collections._common import _session_user as _su
_require_edit(collection_id, _su(request))
widgets = body.get("widgets") or []
if len(widgets) > 12:
raise HTTPException(status_code=400, detail="12 widgets maximum")
with get_conn() as conn:
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not view or int(view["collection_id"]) != int(collection_id):
raise HTTPException(status_code=404, detail="View not found")
try:
conn.execute("DELETE FROM dashboard_widgets WHERE view_id=?", (view_id,))
for i, w in enumerate(widgets):
conn.execute(
"""INSERT INTO dashboard_widgets
(view_id, position, row_index, col_span, height_units,
source_collection_id, source_view_id, inline_config_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(view_id, i, int(w.get("row_index", i // 4)), int(w.get("col_span", 1) or 1),
int(w.get("height_units", 2) or 2),
w.get("source_collection_id"), w.get("source_view_id"),
json.dumps(w.get("inline_config") or w.get("inline_config_json") or {})),
)
conn.commit()
except HTTPException:
raise
except Exception:
# Repli : stockage JSON dans le config (pre-migration 46).
config = json.loads(view["config_json"] or "{}")
config["widgets_json"] = widgets
config["global_filters"] = body.get("global_filters", config.get("global_filters", []))
if body.get("global_filters") is not None:
config["global_filters"] = body["global_filters"]
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
(json.dumps(config), view_id))
conn.commit()
return {"widgets": widgets, "fallback": "config_json"}
if body.get("global_filters") is not None:
config = json.loads(view["config_json"] or "{}")
config["global_filters"] = body["global_filters"]
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
(json.dumps(config), view_id))
conn.commit()
try:
run_event_sync(fire_event("dashboard.layout_updated", {"view_id": view_id}))
except Exception:
pass
return {"widgets": widgets, "status": "saved"}
@router.get("/{collection_id}/views/{view_id}/widgets/{widget_id}/data")
def dashboard_widget_data(request: Request, collection_id: int, view_id: int, widget_id: int):
from app.services.view_aggregate import aggregate
from app.services.view_query import query_view_rows
with get_conn() as conn:
try:
w = conn.execute("SELECT * FROM dashboard_widgets WHERE id=? AND view_id=?",
(widget_id, view_id)).fetchone()
widget = dict(w) if w else None
except Exception:
widget = None
if widget is None:
raise HTTPException(status_code=404, detail="Widget not found")
src_cid = widget.get("source_collection_id") or collection_id
props = [dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(src_cid,)).fetchall()]
pages = [dict(r) for r in conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(src_cid,)).fetchall()]
src_view = None
if widget.get("source_view_id"):
src_view = conn.execute("SELECT * FROM collection_views WHERE id=?",
(widget["source_view_id"],)).fetchone()
try:
inline = json.loads(widget.get("inline_config_json") or "{}")
except (json.JSONDecodeError, TypeError):
inline = {}
cfg = dict(inline)
if src_view:
try:
base = json.loads(src_view["config_json"] or "{}")
except (json.JSONDecodeError, TypeError):
base = {}
cfg = {**base, **inline}
vtype = (cfg.get("view_type") or src_view["view_type"] if src_view else cfg.get("view_type", "table"))
if (cfg.get("group_by") or cfg.get("chart_type")) and vtype in ("chart", "dashboard", None):
return aggregate(pages, props, {
"group_by": cfg.get("group_by"), "sub_group_by": cfg.get("sub_group_by"),
"measure": cfg.get("measure", {"kind": "count"}),
"omit_zero": cfg.get("omit_zero", True), "hidden_groups": cfg.get("hidden_groups", []),
"order": cfg.get("order", "group_order"), "filters": cfg.get("filters") or [],
})
rows, _ = query_view_rows(pages, props, cfg, limit=50)
return {"rows": rows, "view_type": vtype or "table"}
@router.get("/{collection_id}/views/{view_id}/form")
def form_builder_get(request: Request, collection_id: int, view_id: int):
from app.services.form_projection import default_questions_for
with get_conn() as conn:
view, config, props, _ = _load_view_collection(conn, collection_id, view_id)
try:
qs = [dict(r) for r in conn.execute(
"SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()]
except Exception:
qs = []
if not qs:
qs = default_questions_for([{"name": p["name"], "prop_type": p["prop_type"]} for p in props])
return {"config": config, "questions": qs}
@router.put("/{collection_id}/views/{view_id}/form")
def form_builder_put(request: Request, collection_id: int, view_id: int,
body: dict = Body(default={})):
from app.routers.collections._common import _require_edit
from app.routers.collections._common import _session_user as _su
from app.services.form_projection import project_form_config
_require_edit(collection_id, _su(request))
questions = body.get("questions") or []
form_cfg = body.get("config") or {}
with get_conn() as conn:
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not view or int(view["collection_id"]) != int(collection_id):
raise HTTPException(status_code=404, detail="View not found")
try:
base = json.loads(view["config_json"] or "{}")
except (json.JSONDecodeError, TypeError):
base = {}
base.update({k: v for k, v in form_cfg.items()
if k in ("title", "description", "sharing", "submit_screen", "notify_email")})
base["version"] = 2
try:
conn.execute("DELETE FROM form_questions WHERE view_id=?", (view_id,))
for i, q in enumerate(questions):
conn.execute(
"""INSERT INTO form_questions
(view_id, property_name, position, label_override, sync_label,
help_text, required, widget, max_selections, conditional_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(view_id, q.get("property_name"), i,
q.get("label_override"), 1 if q.get("sync_label", True) else 0,
q.get("help_text", ""), 1 if q.get("required") else 0,
q.get("widget", "auto"), q.get("max_selections"),
json.dumps(q.get("conditional")) if q.get("conditional") else q.get("conditional_json")),
)
stored_qs = [dict(r) for r in conn.execute(
"SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()]
except Exception:
base["form_questions_json"] = questions
stored_qs = questions
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
(json.dumps(base), view_id))
conn.commit()
return {"questions": stored_qs, "status": "saved", "fallback": "config_json"}
projected = project_form_config(base, [{
"property_name": r["property_name"], "position": r["position"],
"label_override": r["label_override"], "sync_label": r["sync_label"],
"help_text": r["help_text"], "required": r["required"],
"widget": r["widget"], "max_selections": r["max_selections"],
"conditional_json": r["conditional_json"],
} for r in stored_qs])
base["projected_form_config"] = projected
# Synchro libelle -> nom de propriete (defaut Notion).
for r in stored_qs:
if r["sync_label"] and r["label_override"]:
conn.execute(
"UPDATE collection_properties SET name=? WHERE collection_id=? AND name=?",
(r["label_override"], collection_id, r["property_name"]))
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
(json.dumps(base), view_id))
# La projection alimente aussi le pipeline public /f/ existant (meme collection).
try:
conn.execute("UPDATE collections SET form_config_json=? WHERE id=?",
(json.dumps(projected), collection_id))
except Exception:
pass
conn.commit()
return {"questions": stored_qs, "status": "saved",
"projected_fields": len(projected.get("fields", []))}
@router.post("/{collection_id}/views/{view_id}/form/preview-token")
def form_preview_token(request: Request, collection_id: int, view_id: int):
"""Jeton d'apercu non-soumissible (soumissions refusees cote serveur)."""
import secrets
return {"preview_token": "preview_" + secrets.token_hex(8), "submittable": False}
# ── v1.8.0 Sub-items & Dependencies ──
+480
View File
@@ -0,0 +1,480 @@
"""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 [],
},
)