Files
flowdeck/app/routers/api_v2/templates_io.py
T
bruno fc8548194a
FlowDeck CI / lint (push) Failing after 1m32s
FlowDeck CI / test (push) Failing after 27m50s
FlowDeck CI / docker (push) Skipped
feat(templates): refonte complète des templates façon Notion + vues/agents-skills
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/.
2026-10-10 18:52:19 -04:00

347 lines
17 KiB
Python

"""FlowDeck — Public API v2 : templates_io.
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, Header, HTTPException, Request
from fastapi.responses import Response
from app.db import get_conn
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
validate_scopes_input,
)
from ._common import _v2_rate_check
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api-v2"])
@router.post("/collections/{collection_id}/templates")
def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
name = (body.get("name") or "Template").strip()
pv = json.dumps(body.get("property_values") or body.get("property_values_json") or {})
cj = json.dumps(body.get("content") or body.get("content_json") or [])
with get_conn() as conn:
cur = conn.execute("INSERT INTO page_templates (collection_id, name, property_values_json, content_json) VALUES (?, ?, ?, ?)", (collection_id, name, pv, cj))
tid = cur.lastrowid
conn.commit()
return {"id": tid, "name": name, "status": "created"}
@router.patch("/templates/{template_id}")
def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
row = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
if not row:
raise HTTPException(404, "Template not found")
name = body.get("name", row["name"])
pv = json.dumps(body.get("property_values", json.loads(row["property_values_json"] or "{}"))) if "property_values" in body else row["property_values_json"]
cj = json.dumps(body.get("content", json.loads(row["content_json"] or "[]"))) if "content" in body else row["content_json"]
conn.execute("UPDATE page_templates SET name=?, property_values_json=?, content_json=? WHERE id=?", (name, pv, cj, template_id))
conn.commit()
return {"id": template_id, "status": "updated"}
@router.delete("/templates/{template_id}")
def delete_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM page_templates WHERE id=?", (template_id,))
conn.commit()
return {"id": template_id, "status": "deleted"}
@router.post("/templates/{template_id}/apply")
def apply_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
tpl = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
if not tpl:
raise HTTPException(404, "Template not found")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (tpl["collection_id"],)).fetchone()[0]
pv = tpl["property_values_json"] or "{}"
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (tpl["collection_id"], tpl["name"], max_pos, pv))
pid = cur.lastrowid
conn.commit()
return {"template_id": template_id, "page_id": pid, "status": "applied"}
@router.get("/templates/database")
def list_db_templates_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
return {"templates": [row_to_dict(r) for r in rows]}
@router.post("/templates/database/{template_id}/apply")
def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
tpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (template_id,)).fetchone()
if not tpl:
raise HTTPException(404, "Template not found")
name = (body.get("name") or tpl["name"]).strip()
schema = json.loads(tpl["schema_json"] or "[]")
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, tpl["description"], tpl["icon"] if "icon" in tpl.keys() else "📋", json.dumps(schema), body.get("workspace_id"), user["id"]))
cid = cur.lastrowid
# A25 : pas de try — un échec de matérialisation doit interrompre la
# transaction plutôt que de commiter une collection sans schéma.
from app.services.db_templates import materialize_properties
materialize_properties(conn, cid, schema)
conn.commit()
return {"collection_id": cid, "name": name, "status": "created"}
@router.get("/pages/{page_id}/export")
def export_page_v2(page_id: int, request: Request, format: str = "markdown", authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
fmt = (format or request.query_params.get("format") or "markdown").lower()
if fmt not in ("markdown", "html", "pdf"):
raise HTTPException(400, "format must be markdown, html or pdf")
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
# try collection_pages
row2 = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row2:
raise HTTPException(404, "Page not found")
# collection pages: return JSON
return {"page": row_to_dict(row2), "format": fmt}
# block pages: delegate to export service
from app.services.export import export_page as _export
try:
data, mime, fname = _export(row, fmt) # type: ignore
return Response(content=data, media_type=mime, headers={"Content-Disposition": f'attachment; filename="{fname}"'})
except Exception as e:
raise HTTPException(500, f"Export failed: {e}") from None
@router.get("/collections/{collection_id}/export/csv")
def export_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
import csv
import io
with get_conn() as conn:
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
raise HTTPException(404, "Collection not found")
props = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()]
rows = conn.execute("SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
out = io.StringIO()
writer = csv.writer(out)
header = ["Title"] + [p["name"] for p in props]
writer.writerow(header)
for r in rows:
try:
pv = json.loads(r["property_values_json"] or "{}")
except Exception:
pv = {}
vals = [r["title"]]
for p in props:
vals.append(str(pv.get(str(p["id"])) or pv.get(p["name"]) or ""))
writer.writerow(vals)
return Response(content=out.getvalue().encode("utf-8"), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="collection-{collection_id}.csv"'})
@router.post("/collections/{collection_id}/import/csv")
async def import_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
form = await request.form()
file = form.get("file")
data = await file.read() if file else b""
text = data.decode("utf-8", errors="ignore")
except Exception as err:
raise HTTPException(400, "file required (multipart)") from err
import csv
import io
reader = csv.DictReader(io.StringIO(text))
created = 0
with get_conn() as conn:
for row in reader:
title = row.get("Title") or row.get("title") or "Untitled"
# map remaining columns to property names
pv = {}
# resolve prop name -> id
props = {p["name"]: p["id"] for p in conn.execute("SELECT id, name FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()}
for k, v in row.items():
if k in ("Title", "title"):
continue
pid = props.get(k)
if pid:
pv[str(pid)] = v
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (collection_id, title, max_pos, json.dumps(pv)))
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"}