"""FlowDeck — Board : page_ops. 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 logging from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse from app.auth.session import SessionManager from app.db import get_conn from app.services.automations import fire_event, run_event_sync from app.services.permission_manager import PermissionManager from ._common import _sidebar_data logger = logging.getLogger(__name__) router = APIRouter(tags=["board"], prefix="/board") @router.put("/api/pages/{page_id}/move") def move_page(request: Request, page_id: int, body: dict = Body(default={})): """Move a page to another workspace or reorder within tree. Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int } - workspace_id: move page to a different workspace - parent_id: change parent (0 = root level) - new_order: position among siblings (0 = append) """ new_ws_id = body.get("workspace_id") new_parent_id = body.get("parent_id", 0) new_order = body.get("new_order", 0) with get_conn() as conn: row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone() if not row: raise HTTPException(404, "Page not found") if new_ws_id: # Move to a different workspace: get the workspace name ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone() if not ws_row: return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404) conn.execute( "UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_ws_id, ws_row["name"], page_id), ) else: # Reorder within same workspace conn.execute( "UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_parent_id if new_parent_id > 0 else None, page_id), ) conn.execute( "UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_order, page_id), ) conn.commit() run_event_sync(fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0, "parent_id": new_parent_id})) return {"status": "ok", "id": page_id} @router.delete("/api/pages/{page_id}") def delete_page(request: Request, page_id: int): """Move a page to trash (soft delete).""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) uid = (user or {}).get("id") if not uid: raise HTTPException(403, "Authentication required") # v6.0.0: granular page permissions — need at least edit access to trash. if not PermissionManager(uid).can_edit_page(page_id): raise HTTPException(403, "You don't have edit access to this page") with get_conn() as conn: row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone() if not row: raise HTTPException(404, "Page not found") import datetime conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,)) conn.commit() run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})) from app.services.agent_evenements import emettre, url_page emettre("flowdeck.page.deleted", f"page:{page_id}", row["title"] or "", url=url_page(page_id), actor="user") return {"status": "ok", "deleted": page_id, "title": row["title"]} @router.get("/pages/{page_id}", response_class=HTMLResponse) def view_page(request: Request, page_id: int): """Render a page as HTML, or a file viewer for uploaded files. ?embed=1 — minimal mode for side peek (editor only, no header).""" embed = request.query_params.get("embed") == "1" from app.templating import ENV env = ENV # v6.0.0: granular page permissions — hide restricted pages (404). user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id): return HTMLResponse("

Page not found

", status_code=404) with get_conn() as conn: row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() if not row: return HTMLResponse("

Page not found

", status_code=404) page = dict(row) # v6.5.0: synced blocks resolve server-side at read time (fresh content # even when the stored cache is stale). from app.services.synced_blocks import resolve_content_json page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format")) ws = page.get("workspace", "") parts = ws.split("/") if "/" in ws else ["", ""] owner, repo = parts[0], parts[1] if len(parts) > 1 else "" sidebar = _sidebar_data(request, owner, repo) # Check if page is favorited user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) uid = user["id"] if user and user.get("id") else 1 with get_conn() as conn: fav = conn.execute( "SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id) ).fetchone() # Build page_data, including file metadata for uploaded files _locked = bool(page.get("is_locked", 0)) _locked_by = page.get("locked_by") if "locked_by" in page else None _can_edit = (not _locked) or bool(user and user.get("is_admin")) or (uid and _locked_by == uid) page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or "", "is_locked": _locked, "locked_by": _locked_by, "can_edit": _can_edit, "full_width": bool(page.get("full_width", 0)) if "full_width" in page else False, "font_small": bool(page.get("font_small", 0)) if "font_small" in page else False} # For file pages, extract file metadata and add to page_data if page.get("content_format") == "file": import json as _json try: meta = _json.loads(page.get("content", "{}")) except _json.JSONDecodeError: meta = {} file_path = meta.get("file_path", "").replace("\\", "/") mime_type = meta.get("mime_type", "application/octet-stream") file_size = meta.get("size", 0) # Build workspace_id from file_path fp_parts = file_path.split("/") ws_id = "" for p in fp_parts: if p.startswith("workspace_"): ws_id = p.replace("workspace_", "") break filename = fp_parts[-1] if fp_parts else page.get("title", "File") file_url = f"/api/files/{ws_id}/{filename}" if ws_id else "" page_data["file_url"] = file_url page_data["file_mime"] = mime_type page_data["file_size"] = file_size page_data["file_name"] = filename from app.routers.dashboard import _nav_breadcrumb with get_conn() as conn: nav_crumbs = _nav_breadcrumb(conn, page_id) # Fusion ACC-1.3 — références croisées de la page (section « Liens »). from app.services import external_refs as _refs_svc page_refs = _refs_svc.list_refs("flowdeck.page", page_id) ctx = {**sidebar, "page": page, "page_refs": page_refs, "page_favorited": fav is not None, "page_share_mode": page.get("share_mode", "private"), "page_published": bool(page.get("published", 0)), "page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)), "page_data": page_data, "breadcrumb_items": nav_crumbs, "nav_workspace_id": page.get("workspace_id") or 0, "nav_page_id": page_id, "embed_mode": embed} template = env.get_template("page_editor_embed.html" if embed else "page_editor.html") response = template.render(**ctx) return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})