fix: A21 phase 1 — 352 routes async sans await → threadpool (v7.8.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s

- Conversion `async def` → `def` de TOUTES les routes dont le corps ne contient
  ni `await`, ni `async with`, ni `async for`, ni `asyncio` (scan automatique
  corps par corps sur app/ : 352 converties, 0 dangereuses, vérifié
  `asyncio`/`run_coroutine`/`.result()` absents). FastAPI exécute ces handlers
  dans son threadpool → tout leur SQLite (`get_conn()` + `conn.execute`) quitte
  l'event loop, sans changer une ligne de logique.
- Répartition : api_v2 60, dashboard 40, collections 25, board 23,
  workspace 19, wiki 17, permissions 14, api 14, main.py 6, + 35 fichiers.
- Les 4 routers prioritaires de l'audit sont couverts par ce lot :
  api_v2 60 + dashboard 40 + collections 25 + board 23 = 148 conversions
  (le reste de leurs routes attend la phase 2 : elles ont de vrais `await`).
- Reste (phase 2) : les 311 routes avec de vrais `await` → enrouler les blocs
  DB dans `await anyio.to_thread.run_sync(...)` ; pas de wrapper partagé livré
  (rien ne l'appellerait — YAGNI jusqu'au premier usage).

suite **1037/1037** (233 s) · `ruff check app tests` OK · docs à jour
This commit is contained in:
2026-10-01 10:53:26 -04:00
parent c718fe06de
commit 224bda74d5
48 changed files with 379 additions and 358 deletions
+40 -40
View File
@@ -252,7 +252,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
@router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Trash page — scoped to workspace if owner/repo provided."""
from app.routers.board import _sidebar_data as board_sidebar
from app.templating import ENV
@@ -279,7 +279,7 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str
@router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace).
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
@@ -308,7 +308,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page_root(request: Request, page_id: int):
def view_page_root(request: Request, page_id: int):
"""Render a Markdown page at root level with workspace context — or file viewer.
?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1"
@@ -434,7 +434,7 @@ async def view_page_root(request: Request, page_id: int):
@router.get("/accounts", response_class=HTMLResponse)
async def accounts_page(request: Request):
def accounts_page(request: Request):
"""Account management panel."""
from app.templating import ENV
env = ENV
@@ -451,7 +451,7 @@ async def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request):
def help_page(request: Request):
"""Comprehensive help & documentation page."""
from app.templating import ENV
env = ENV
@@ -656,7 +656,7 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&amp;o
@router.get("/accounts/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
def settings_page(request: Request):
"""User settings page — profile, forges, tokens."""
from app.templating import ENV
env = ENV
@@ -731,7 +731,7 @@ async def update_password(request: Request):
@router.post("/api/user/token")
async def generate_token(request: Request):
def generate_token(request: Request):
import secrets
uid = _require_user_id(request)
token = secrets.token_hex(32)
@@ -745,7 +745,7 @@ async def generate_token(request: Request):
@router.delete("/api/user/forge/{provider}")
async def disconnect_forge(request: Request, provider: str):
def disconnect_forge(request: Request, provider: str):
uid = _require_user_id(request)
with get_conn() as conn:
conn.execute(
@@ -834,7 +834,7 @@ async def dashboard(
# ═══════════ Workspace ═══════════
@router.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request):
def workspace_page(request: Request):
"""Unified workspace showing all projects."""
from app.templating import ENV
env = ENV
@@ -851,7 +851,7 @@ async def workspace_page(request: Request):
@router.get("/gitea-workspace", response_class=HTMLResponse)
async def gitea_workspace_page(request: Request):
def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files."""
import json
@@ -971,7 +971,7 @@ async def create_workspace_project(request: Request):
# ═══════════ Workspace Members API ═══════════
@router.get("/api/workspace/{ws_id:int}/members")
async def list_members(request: Request, ws_id: int):
def list_members(request: Request, ws_id: int):
"""List all members of a workspace."""
with get_conn() as conn:
rows = conn.execute(
@@ -1022,7 +1022,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def remove_member(request: Request, ws_id: int, user_id: int):
def remove_member(request: Request, ws_id: int, user_id: int):
"""Remove a member from a workspace."""
with get_conn() as conn:
conn.execute(
@@ -1036,7 +1036,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
@router.get("/local-workspace", response_class=HTMLResponse)
async def local_workspace_page(request: Request, folder: int = None):
def local_workspace_page(request: Request, folder: int = None):
"""Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
@@ -1084,7 +1084,7 @@ async def local_workspace_page(request: Request, folder: int = None):
@router.get("/api/local-workspace/tree")
async def local_workspace_tree(request: Request, folder: int = None):
def local_workspace_tree(request: Request, folder: int = None):
"""Return the file/folder tree filtered by active workspace.
If ?folder=ID is provided, returns only that folder's children.
@@ -1109,7 +1109,7 @@ async def local_workspace_tree(request: Request, folder: int = None):
@router.get("/api/local-workspace/page-content/{page_id:int}")
async def get_page_content(page_id: int):
def get_page_content(page_id: int):
"""Return the raw content of a page (for preview)."""
with get_conn() as conn:
row = conn.execute(
@@ -1174,7 +1174,7 @@ def _require_page_view(request: Request, page_id: int) -> None:
@router.get("/api/pages/{page_id}/download")
async def download_page_file(request: Request, page_id: int):
def download_page_file(request: Request, page_id: int):
"""Download the original uploaded file of a ``file`` page (attachment)."""
_require_page_view(request, page_id)
with get_conn() as conn:
@@ -1197,7 +1197,7 @@ async def download_page_file(request: Request, page_id: int):
@router.get("/api/pages/{page_id}/file-content")
async def page_file_content(request: Request, page_id: int):
def page_file_content(request: Request, page_id: int):
"""Return the textual content of a ``file`` page (for copy to clipboard).
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
@@ -1225,7 +1225,7 @@ async def page_file_content(request: Request, page_id: int):
@router.get("/api/local-workspace/breadcrumb")
async def local_workspace_breadcrumb(request: Request, folder: int):
def local_workspace_breadcrumb(request: Request, folder: int):
"""Return breadcrumb trail for a folder."""
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
@@ -1393,7 +1393,7 @@ def _nav_breadcrumb(conn, page_id: int) -> list:
@router.get("/api/nav/menu")
async def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
"""Return the pages at one level for the header breadcrumb navigation menu.
If ``parent_id`` is given, returns that page's children; otherwise the
@@ -1483,7 +1483,7 @@ async def rename_local_workspace_item(request: Request, item_id: int):
@router.delete("/api/local-workspace/items/{item_id:int}")
async def delete_local_workspace_item(request: Request, item_id: int):
def delete_local_workspace_item(request: Request, item_id: int):
"""Soft-delete a file/folder (sets deleted_at)."""
from datetime import datetime
with get_conn() as conn:
@@ -1496,7 +1496,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
@router.post("/api/local-workspace/items/{item_id:int}/restore")
async def restore_local_workspace_item(request: Request, item_id: int):
def restore_local_workspace_item(request: Request, item_id: int):
"""Restore a soft-deleted file/folder."""
with get_conn() as conn:
conn.execute(
@@ -1508,7 +1508,7 @@ async def restore_local_workspace_item(request: Request, item_id: int):
@router.get("/api/files/{ws_id:int}/{filename:path}")
async def serve_uploaded_file(ws_id: int, filename: str):
def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
import mimetypes
from pathlib import Path
@@ -1774,7 +1774,7 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
@router.get("/workspaces", response_class=HTMLResponse)
async def workspaces_page(request: Request):
def workspaces_page(request: Request):
"""Workspaces list page."""
from app.templating import ENV
env = ENV
@@ -1791,7 +1791,7 @@ async def workspaces_page(request: Request):
@router.get("/api/workspaces")
async def list_workspaces(request: Request):
def list_workspaces(request: Request):
"""List all workspaces for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
@@ -1855,7 +1855,7 @@ async def rename_workspace(request: Request, ws_id: int):
@router.delete("/api/workspaces/{ws_id:int}")
async def delete_workspace(request: Request, ws_id: int):
def delete_workspace(request: Request, ws_id: int):
"""Delete a workspace and all its pages."""
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
@@ -1866,7 +1866,7 @@ async def delete_workspace(request: Request, ws_id: int):
@router.post("/api/workspaces/{ws_id:int}/select")
async def select_workspace(request: Request, ws_id: int):
def select_workspace(request: Request, ws_id: int):
"""Set the active workspace via cookie."""
from fastapi.responses import JSONResponse
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
@@ -1877,7 +1877,7 @@ async def select_workspace(request: Request, ws_id: int):
# ═══════════ Settings Page ═══════════
@router.get("/settings", response_class=HTMLResponse)
async def app_settings_page(request: Request):
def app_settings_page(request: Request):
"""Settings & configuration page."""
from app.templating import ENV
env = ENV
@@ -1923,7 +1923,7 @@ async def upload_avatar(request: Request):
@router.get("/api/settings/avatar/{filename:path}")
async def serve_avatar_file(filename: str):
def serve_avatar_file(filename: str):
"""Serve an uploaded avatar image file."""
from pathlib import Path
@@ -1942,7 +1942,7 @@ async def serve_avatar_file(filename: str):
@router.get("/api/avatar/{user_id:int}")
async def get_avatar(user_id: int):
def get_avatar(user_id: int):
"""Redirect to the user's avatar."""
with get_conn() as conn:
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
@@ -2002,7 +2002,7 @@ async def update_tag_global(tag_id: int, request: Request):
@router.delete("/api/settings/tags/{tag_id:int}")
async def delete_tag_global(tag_id: int, request: Request):
def delete_tag_global(tag_id: int, request: Request):
"""Delete a tag — only if owned by user."""
uid = _get_user_id(request)
with get_conn() as conn:
@@ -2013,7 +2013,7 @@ async def delete_tag_global(tag_id: int, request: Request):
@router.get("/api/settings/tags/all")
async def list_all_tags_global(request: Request):
def list_all_tags_global(request: Request):
"""List current user's tags with counts."""
uid = _get_user_id(request)
with get_conn() as conn:
@@ -2032,7 +2032,7 @@ async def list_all_tags_global(request: Request):
# ═══════════ Workspace Tags API ═══════════
@router.get("/api/local-workspace/tags")
async def list_tags(request: Request):
def list_tags(request: Request):
"""List ALL user tags with counts scoped to the active workspace."""
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
@@ -2055,7 +2055,7 @@ async def list_tags(request: Request):
@router.get("/api/local-workspace/items/{item_id:int}/tags")
async def get_item_tags(item_id: int):
def get_item_tags(item_id: int):
"""Get tags for a specific item."""
with get_conn() as conn:
rows = conn.execute(
@@ -2104,7 +2104,7 @@ async def add_item_tag(request: Request, item_id: int):
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
async def remove_item_tag(item_id: int, tag_id: int):
def remove_item_tag(item_id: int, tag_id: int):
"""Remove a tag from an item."""
with get_conn() as conn:
conn.execute(
@@ -2116,7 +2116,7 @@ async def remove_item_tag(item_id: int, tag_id: int):
@router.get("/api/local-workspace/tags/search")
async def search_by_tags(request: Request, tags: str = ""):
def search_by_tags(request: Request, tags: str = ""):
"""Search items by tags (comma-separated)."""
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
@@ -2198,7 +2198,7 @@ async def update_account(request: Request):
# ═══════════ Sidebar Refresh API ═══════════
@router.get("/api/sidebar/workspace-tree")
async def sidebar_workspace_tree(request: Request):
def sidebar_workspace_tree(request: Request):
"""Return the sidebar workspace tree as HTML fragment.
Called by appState().refreshSidebarTree() after CRUD operations
@@ -2260,7 +2260,7 @@ async def sidebar_workspace_tree(request: Request):
@router.get("/p/{slug}", response_class=HTMLResponse)
async def public_published_page(request: Request, slug: str):
def public_published_page(request: Request, slug: str):
"""Serve a published page at /p/<slug> — no auth required."""
from app.templating import ENV
@@ -2525,7 +2525,7 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
# ═══════════ Library page actions API ═══════════
@router.get("/api/pages/{page_id:int}/content")
async def api_page_content(page_id: int):
def api_page_content(page_id: int):
"""Get page content for side peek preview."""
with get_conn() as conn:
row = conn.execute(
@@ -2563,7 +2563,7 @@ async def api_rename_page(page_id: int, request: Request):
@router.post("/api/pages/{page_id:int}/trash")
async def api_trash_page(page_id: int):
def api_trash_page(page_id: int):
"""Soft-delete a page (move to trash)."""
with get_conn() as conn:
conn.execute(
@@ -2641,7 +2641,7 @@ async def api_convert_to_database(page_id: int, request: Request):
@router.get("/api/collections/{collection_id:int}/table-data")
async def api_collection_table_data(collection_id: int):
def api_collection_table_data(collection_id: int):
"""Get collection properties + pages for rendering the table view."""
with get_conn() as conn:
coll = conn.execute(