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
177 lines
8.2 KiB
Python
177 lines
8.2 KiB
Python
"""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 ""}))
|
|
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("<h2>Page not found</h2>", status_code=404)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row:
|
|
return HTMLResponse("<h2>Page not found</h2>", 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)
|
|
ctx = {**sidebar, "page": page, "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"})
|