Lot 4/4 de l'A28 (god files) : l'ancien app/routers/board.py (2 101 lignes, 53 routes) devient le package `app/routers/board/` : - 12 modules de routes : pages 271 L (7 r.), page_api 229 (5), board_views 223 (8), page_ops 176 (3), sharing 175 (10), synced 144 (8), page_media 122 (4), import_ 85 (2), wiki 76 (2), library 66 (1), embed 64 (2), sync 51 (1) - _common.py (878 L) : 23 helpers dont 4 async + les 4 constantes (STATUS_COLORS, STATUS_LABELS, AI_KEYWORD_COLORS, _REPO_REF_RE) - __init__.py : __all__ complet — importateurs inchangés (api.py ×4 top-level, webhooks top-level, dashboard ×5 lazy, tests ×4) Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE byte-à-byte (509 chemins, ordre préservé). Pièges rattrapés : - constantes d'état oubliées dans _common à la 1ʳᵉ passe (F821 + ImportError au chargement) → ré-insérées avec les valeurs exactes - docstring du header copié → F404 → slice [1:21] - helpers `async def` non détectés par `def ` seul A28 TERMINÉ en 4 lots : api_v2 (7.29.0), dashboard (7.30.0), collections (7.31.0), board (7.32.0) — 0 changement d'URL sur les 4. suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
230 lines
9.3 KiB
Python
230 lines
9.3 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}
|
|
|
|
|
|
# ── Core helpers ──
|