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/.
This commit is contained in:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user