"""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"}