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/.
347 lines
17 KiB
Python
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"}
|