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