refactor: A28 TERMINÉ — board.py (2 101 L) → package 14 fichiers (v7.32.0)
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
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
"""FlowDeck — Board : pages.
|
||||
|
||||
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, Query, Request
|
||||
|
||||
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 _block_texts, _ensure_page_editable, _record_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
|
||||
@router.post("/api/pages")
|
||||
def create_page(request: Request, title: str = Query(default=""),
|
||||
section: str = Query(default="Private"),
|
||||
project: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
"""Create a new Markdown page, optionally as a sub-page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
# A7 : la création de page exige une session (route sortue de la liste CSRF).
|
||||
raise HTTPException(401, "Authentication required")
|
||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||
page_title = title.strip() if title else ""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
# Compute next sort_order for this parent
|
||||
next_order = 0
|
||||
parent_val = parent_id if parent_id > 0 else None
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
|
||||
(ws_key, parent_val),
|
||||
).fetchone()
|
||||
if row:
|
||||
next_order = row[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
|
||||
(ws_key, page_title, section, parent_val, next_order),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id}))
|
||||
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
||||
except Exception as e:
|
||||
logger.error("create_page failed: %s", e)
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Failed to create page", "detail": str(e)}, status_code=500)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}")
|
||||
def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}")
|
||||
def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
content: str = Query(default=""),
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible
|
||||
# page the caller cannot edit yields 403.
|
||||
pm = PermissionManager(user["id"], bool(user.get("is_admin")))
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not pm.can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
if content:
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
|
||||
if content_format:
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown",
|
||||
"actor_id": user.get("id")}))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/blocks")
|
||||
def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Save blocks JSON content (Notion-style block editor).
|
||||
|
||||
v5.4.0: a version snapshot is recorded (if the block content actually
|
||||
changed) so the UI can browse the version history and restore any of them.
|
||||
v5.14.0: synced block references are tracked in page_synced_blocks.
|
||||
"""
|
||||
blocks = body.get("blocks", [])
|
||||
blocks_json = json.dumps(blocks)
|
||||
title = body.get("title", "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
# No session → legacy single-user behaviour; otherwise enforce edit rights.
|
||||
if uid and not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
|
||||
# Extract synced block ids from the blocks
|
||||
def _extract_synced(blocks: list[dict]) -> set[int]:
|
||||
ids: set[int] = set()
|
||||
for b in blocks:
|
||||
if b.get("type") == "synced" and b.get("synced_id"):
|
||||
ids.add(b["synced_id"])
|
||||
if isinstance(b.get("children"), list):
|
||||
ids |= _extract_synced(b["children"])
|
||||
return ids
|
||||
|
||||
synced_ids = _extract_synced(blocks)
|
||||
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(blocks_json, page_id),
|
||||
)
|
||||
_record_version(conn, page_id, uid, title or "", blocks_json)
|
||||
# Update synced block references
|
||||
existing = {r["synced_block_id"] for r in conn.execute(
|
||||
"SELECT synced_block_id FROM page_synced_blocks WHERE page_id=?", (page_id,)
|
||||
).fetchall()}
|
||||
for sid in synced_ids:
|
||||
if sid not in existing:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO page_synced_blocks (page_id, synced_block_id, block_index) VALUES (?, ?, 0)",
|
||||
(page_id, sid),
|
||||
)
|
||||
for sid in existing - synced_ids:
|
||||
conn.execute(
|
||||
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
|
||||
(page_id, sid),
|
||||
)
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks",
|
||||
"actor_id": uid}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/backlinks")
|
||||
def page_backlinks(request: Request, page_id: int):
|
||||
"""v5.4.0: pages that link to this one ("Lié depuis…").
|
||||
|
||||
Scans every non-deleted page's blocks (and raw markdown) for an internal
|
||||
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
|
||||
"""
|
||||
target = f"/pages/{page_id}" if page_id else None
|
||||
wiki_target = f"[[fdpage:{page_id}]]" if page_id else None
|
||||
backlinks = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, content, content_format, updated_at "
|
||||
"FROM pages WHERE deleted_at IS NULL AND id != ?",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
fmt = r["content_format"]
|
||||
hits = False
|
||||
if fmt == "blocks" and r["content"]:
|
||||
try:
|
||||
blocks = json.loads(r["content"])
|
||||
for b in blocks if isinstance(blocks, list) else []:
|
||||
for text in _block_texts(b):
|
||||
if target and (target in text or (wiki_target and wiki_target in text)):
|
||||
hits = True
|
||||
break
|
||||
if hits:
|
||||
break
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
||||
elif fmt == "markdown":
|
||||
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
||||
elif r["content"]:
|
||||
hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"])))
|
||||
if not hits and target:
|
||||
hits = f"/pages/{page_id}" in (r["content"] or "")
|
||||
if hits:
|
||||
backlinks.append({
|
||||
"id": r["id"],
|
||||
"title": r["title"] or "Untitled",
|
||||
"workspace": r["workspace"] or "",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
})
|
||||
backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
|
||||
return {"backlinks": backlinks}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/versions")
|
||||
def page_versions(request: Request, page_id: int):
|
||||
"""v5.4.0: version history for a block-editor page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT pv.id, pv.title, pv.note, pv.created_at, "
|
||||
"COALESCE(u.login, '') AS author "
|
||||
"FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id "
|
||||
"WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"versions": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
|
||||
def restore_version(request: Request, page_id: int, version_id: int):
|
||||
"""v5.4.0: restore a page from a version snapshot."""
|
||||
with get_conn() as conn:
|
||||
ver = conn.execute(
|
||||
"SELECT * FROM page_versions WHERE id=? AND page_id=?",
|
||||
(version_id, page_id),
|
||||
).fetchone()
|
||||
if not ver:
|
||||
raise HTTPException(404, "Version not found")
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(ver["blocks_json"], ver["title"] or "", page_id),
|
||||
)
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"}))
|
||||
return {"status": "ok", "restored": version_id}
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Page & collection duplication ═══════════
|
||||
Reference in New Issue
Block a user