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