Découpe par concern de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) en package `app/routers/api_v2/` : - 12 modules de routes : collections 566 L (23 r.), engagement 338 (21), workspaces 230 (9), templates_io 205 (9), webhooks 195 (8), identity 195 (7), views 164 (8), sharing 160 (8), properties 151 (7), planning 148 (7), projects 93 (4), admin 91 (4) - `_common.py` : helpers partagés (_hash, _v2_rate_check) - `__init__.py` : router = APIRouter(prefix="/api/v2") + include_router sur les routers de sections (sans prefix, tags « api-v2 ») Preuve contractuelle : `docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte (0 changement de chemin/tag/operation_id). Seul importateur (app/main.py : from app.routers.api_v2 import router) fonctionne via le package. En-tête d'imports copié par module puis émondé par ruff --fix (143 imports morts), I001 réordonnés. Reste A28 : dashboard.py 2 735 L, collections.py 2 622 L, board.py 2 101 L (même recette, lots suivants). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
206 lines
9.9 KiB
Python
206 lines
9.9 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"}
|