- Section « Design System – Canevas » : liste des canevas (intégrés +
personnels, GET /board/api/page-templates) puis page de détail avec
« Créer une page à partir du canevas », « Insérer dans le document ouvert »
(désactivé sans document ouvert) et « Enregistrer le document ouvert comme
canevas ».
- GET /board/api/page-templates/{id}/blocks (nouvelle route) : blocs d'un
canevas — intégré via template_id=0&key=, sinon id ; 404 clé/id inconnu.
Insertion côté front via ensureBlockIds() global + concat dans E.blocks
+ autoSave/render.
- Canevas « Design System » ajouté aux builtins (callout tokens, TOC, toggles
composants, grille, checklist revue UI, citation).
- Fix : le front du panneau appelait /api/page-templates (404 silencieux) —
le router est sous prefix=/board → /board/api/page-templates.
- Piège : pour /use la clé builtin se passe DANS LE BODY (body.key), pas en
query string — test garde-fou ajouté.
- Tests : tests/test_v753_canvases.py (5) + test_v751 adapté. Suite complète
1285 verts (-n auto), ruff 0, eslint 0 problème. OpenAPI 516 chemins.
264 lines
11 KiB
Python
264 lines
11 KiB
Python
"""FlowDeck — Board : page_api.
|
|
|
|
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Request
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
from app.services.automations import fire_event, run_event_sync
|
|
|
|
from ._common import _ensure_block_ids
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["board"], prefix="/board")
|
|
|
|
|
|
|
|
|
|
@router.post("/api/pages/{page_id}/lock")
|
|
def set_page_lock(request: Request, page_id: int, body: dict = Body(default={})):
|
|
"""v5.12.0: lock/unlock a page (read-only for everyone except the locker,
|
|
admins and the page creator)."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user or not user.get("id"):
|
|
raise HTTPException(401, "Authentication required")
|
|
locked = bool(body.get("locked"))
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
|
|
(page_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
is_admin = 1 if user.get("is_admin") else 0
|
|
if row["is_locked"] and not is_admin and row["locked_by"] != user["id"]:
|
|
raise HTTPException(403, "Only the person who locked this page (or an admin) can unlock it")
|
|
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
|
|
(1 if locked else 0, user["id"] if locked else None, page_id))
|
|
conn.commit()
|
|
run_event_sync(fire_event("page.locked" if locked else "page.unlocked",
|
|
{"page_id": page_id, "by": user["id"]}))
|
|
return {"status": "ok", "is_locked": int(locked)}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/pages/{page_id}/options")
|
|
def set_page_options(request: Request, page_id: int, body: dict = Body(default={})):
|
|
"""v5.12.0: page layout options — full-width and compact typography."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user or not user.get("id"):
|
|
raise HTTPException(401, "Authentication required")
|
|
updates = {}
|
|
for key in ("full_width", "font_small"):
|
|
if key in body:
|
|
updates[key] = 1 if body[key] else 0
|
|
if not updates:
|
|
raise HTTPException(400, "nothing to update")
|
|
sets = ", ".join(f"{k}=?" for k in updates)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
conn.execute(f"UPDATE pages SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(*updates.values(), page_id))
|
|
conn.commit()
|
|
return {"status": "ok", **{k: bool(v) for k, v in updates.items()}}
|
|
|
|
|
|
|
|
|
|
@router.get("/api/page-templates")
|
|
def list_page_templates_api(request: Request):
|
|
"""v5.12.0: built-in + user global page templates for the picker."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = (user or {}).get("id")
|
|
from app.services.block_templates import template_list
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"""SELECT id, name, icon, description, created_by
|
|
FROM page_global_templates
|
|
WHERE created_by IS NULL OR created_by=?
|
|
ORDER BY created_at""",
|
|
(uid,),
|
|
).fetchall()
|
|
mine = [dict(r) for r in rows]
|
|
for t in mine:
|
|
t["builtin"] = False
|
|
return {"templates": template_list() + mine}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/page-templates")
|
|
def create_page_template(request: Request, body: dict = Body(default={})):
|
|
"""v5.12.0: save the current page (or a raw block list) as a personal
|
|
global template: {name, icon?, description?, page_id? | blocks?}."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user or not user.get("id"):
|
|
raise HTTPException(401, "Authentication required")
|
|
name = (body.get("name") or "").strip()
|
|
if not name:
|
|
raise HTTPException(400, "name is required")
|
|
blocks = body.get("blocks")
|
|
if body.get("page_id"):
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?",
|
|
(int(body["page_id"]),)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
if row["content_format"] == "blocks" and row["content"]:
|
|
try:
|
|
blocks = json.loads(row["content"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
raise HTTPException(400, "Page content is not block JSON") from None
|
|
if not isinstance(blocks, list) or not blocks:
|
|
raise HTTPException(400, "blocks (or page_id) required")
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"""INSERT INTO page_global_templates (name, icon, description, blocks_json, created_by)
|
|
VALUES (?, ?, ?, ?, ?)""",
|
|
(name, body.get("icon") or "📄", body.get("description") or "",
|
|
json.dumps(blocks), user["id"]),
|
|
)
|
|
conn.commit()
|
|
tid = cur.lastrowid
|
|
return {"status": "ok", "id": tid}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/page-templates/{template_id}/use")
|
|
def use_page_template(request: Request, template_id: int, body: dict = Body(default={})):
|
|
"""v5.12.0: instantiate a page from a template (built-in or user).
|
|
|
|
Body: {key?} for built-ins OR uses the row id for user templates.
|
|
Creates 'blocks'-format page in the caller's workspace and returns its id.
|
|
"""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
title = (body.get("title") or "").strip()
|
|
blocks_json = None
|
|
if template_id == 0:
|
|
from app.services.block_templates import blocks_json_for
|
|
key = body.get("key") or "empty"
|
|
blocks_json = blocks_json_for(key)
|
|
name = key
|
|
if blocks_json is None:
|
|
raise HTTPException(404, "Unknown built-in template")
|
|
else:
|
|
with get_conn() as conn:
|
|
uid = (user or {}).get("id")
|
|
row = conn.execute(
|
|
"SELECT * FROM page_global_templates WHERE id=? AND (created_by IS NULL OR created_by=?)",
|
|
(template_id, uid),
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Template not found")
|
|
blocks_json = row["blocks_json"]
|
|
name = row["name"]
|
|
title = title or row["name"]
|
|
# Resolve the target workspace so the new page actually shows up in the
|
|
# active local workspace (bugfix: template pages previously got
|
|
# workspace_id = NULL and never appeared in the sidebar/tree).
|
|
uid = (user or {}).get("id")
|
|
ws_id_raw = body.get("workspace_id")
|
|
ws_id = None
|
|
if ws_id_raw is not None:
|
|
try:
|
|
ws_id = int(ws_id_raw)
|
|
except (TypeError, ValueError):
|
|
ws_id = None
|
|
ws_key = user.get("login", "Bruno") if user else "Bruno"
|
|
if ws_id is not None:
|
|
with get_conn() as conn:
|
|
ws_row = conn.execute(
|
|
"SELECT id, name, owner_id FROM workspaces WHERE id=?", (ws_id,)
|
|
).fetchone()
|
|
if ws_row and (uid is None or ws_row["owner_id"] == uid):
|
|
ws_key = ws_row["name"] or ws_key
|
|
else:
|
|
ws_id = None
|
|
else:
|
|
body_ws = (body.get("workspace") or "").strip()
|
|
if body_ws:
|
|
ws_key = body_ws
|
|
# Optional target folder: instantiate the template as a child of it.
|
|
parent_id = body.get("parent_id")
|
|
try:
|
|
parent_id = int(parent_id) if parent_id not in (None, "", 0, "0") else None
|
|
except (TypeError, ValueError):
|
|
parent_id = None
|
|
try:
|
|
parsed_blocks = json.loads(blocks_json)
|
|
except (json.JSONDecodeError, TypeError):
|
|
raise HTTPException(500, "Template content corrupted") from None
|
|
_ensure_block_ids(parsed_blocks)
|
|
blocks_json = json.dumps(parsed_blocks)
|
|
with get_conn() as conn:
|
|
if parent_id is not None:
|
|
next_order = conn.execute(
|
|
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE parent_id=?",
|
|
(parent_id,),
|
|
).fetchone()[0]
|
|
elif ws_id is not None:
|
|
next_order = conn.execute(
|
|
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace_id=? AND parent_id IS NULL",
|
|
(ws_id,),
|
|
).fetchone()[0]
|
|
else:
|
|
next_order = conn.execute(
|
|
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
|
(ws_key,),
|
|
).fetchone()[0]
|
|
cur = conn.execute(
|
|
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
|
|
VALUES (?,?,?,?,?, 'Private', ?, ?)""",
|
|
(ws_key, ws_id, title or name, blocks_json, "blocks", parent_id, next_order),
|
|
)
|
|
conn.commit()
|
|
page_id = cur.lastrowid
|
|
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": title or name,
|
|
"workspace": ws_key, "from_template": name}))
|
|
return {"status": "ok", "id": page_id, "title": title or name}
|
|
|
|
|
|
@router.get("/api/page-templates/{template_id}/blocks")
|
|
def page_template_blocks(request: Request, template_id: int, key: str = ""):
|
|
"""v7.53.0 : blocs d'un canevas, pour l'insérer dans le document ouvert.
|
|
|
|
Builtin : ``template_id=0`` + ``?key=`` (même convention que ``/use``).
|
|
Canevas personnel : son ``id``. Les blocs builtin sont sans ``id`` — le
|
|
front appelle ``ensureBlockIds()`` (déjà global côté éditeur).
|
|
"""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if template_id == 0:
|
|
from app.services.block_templates import blocks_json_for
|
|
raw = blocks_json_for(key or "empty")
|
|
if raw is None:
|
|
raise HTTPException(404, "Unknown built-in template")
|
|
else:
|
|
uid = (user or {}).get("id")
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT blocks_json FROM page_global_templates "
|
|
"WHERE id=? AND (created_by IS NULL OR created_by=?)",
|
|
(template_id, uid),
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Template not found")
|
|
raw = row["blocks_json"]
|
|
try:
|
|
blocks = json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError) as exc:
|
|
raise HTTPException(500, "Template content corrupted") from exc
|
|
if not isinstance(blocks, list):
|
|
raise HTTPException(500, "Template content corrupted")
|
|
return {"blocks": blocks}
|
|
|
|
|
|
# ── Core helpers ──
|