fix: A21 phase 2c — 190 routes hors loop, 86 % total (v7.26.0)
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 15m23s
FlowDeck CI / docker (push) Canceled after 0s

4 passes (283 → 93 routes async sur 667 = 86 % hors loop, avant 61 %) :

A. RACINE AUTH — `get_current_user` (auth/session.py) était `async def`
   SANS aucun await (cookie decode = synchrone) ; idem ses clones :
   `agent._current_user_id/_workspace_id/_current_admin` (34 sites) et
   `sso._require_admin` (corps 0 await, 6 sites) → `def` +
   47 `await` supprimés. Piège : 3 call sites passaient par l'alias `gcu`
   (grep littéral aveugle) — 8 tests en échec → corrigés.

B. Re-scan : 19 routes devenues SANS await → `def` (agent 8, sso 5,
   web_clipper 3, projects 2, auth 1…).

C/D. 155 routes dont les seuls awaits = `request.json()` / événements :
   - try/except `body = {}` → `Body(default={})` (même tolérance)
   - try/except `raise HTTPException(400)` → `Body(...)` REQUIS
     (422 FastAPI — aucun test ne couvrait le 400)
   - forme conditionnelle `request.json() if content-type else {}`
     (54 sites) → défaut `{}` (sans corps = `{}` dans les 2 cas)
   - `await fire_*` → `run_event_sync(...)` ; imports `Body` /
     `run_event_sync` ajoutés aux routers convertis

Reste async (93, justifié) : form/upload/file (22), réseau gitea/llm/oidc,
`_json_body` (9), 2 JSON inline en argument, 1 fallback logique
(capture_frontend_error), 1 lecture conditionnelle (web_clipper), mixtes.

suite **1089/1089** · ruff OK · docs à jour
This commit is contained in:
2026-10-01 22:17:48 -04:00
parent 8d0d69e7b8
commit 45e59009c3
34 changed files with 2479 additions and 680 deletions
+19 -46
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from datetime import UTC
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Body, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
@@ -720,8 +720,7 @@ def _require_user_id(request: Request) -> int:
@router.put("/api/user/profile")
async def update_profile(request: Request):
body = await request.json()
def update_profile(request: Request, body: dict = Body(default={})):
full_name = body.get("full_name", "").strip()
uid = _require_user_id(request)
with get_conn() as conn:
@@ -731,9 +730,8 @@ async def update_profile(request: Request):
@router.put("/api/user/password")
async def update_password(request: Request):
def update_password(request: Request, body: dict = Body(default={})):
from app.password_utils import hash_password, verify_password
body = await request.json()
password = body.get("password", "").strip()
if len(password) < 6:
return {"error": "Password must be at least 6 characters"}
@@ -972,8 +970,7 @@ async def list_workspace_projects(request: Request):
@router.post("/api/workspace/projects")
async def create_workspace_project(request: Request):
body = await request.json()
def create_workspace_project(request: Request, body: dict = Body(default={})):
name = body.get("name", "").strip()
if not name:
return {"error": "Name required"}
@@ -1002,9 +999,8 @@ def list_members(request: Request, ws_id: int):
@router.post("/api/workspace/{ws_id:int}/members")
async def invite_member(request: Request, ws_id: int):
def invite_member(request: Request, ws_id: int, body: dict = Body(default={})):
"""Invite a user to a workspace by email."""
body = await request.json()
email = body.get("email", "").strip()
role = body.get("role", "editor")
if role not in ("owner", "admin", "editor", "viewer"):
@@ -1025,9 +1021,8 @@ async def invite_member(request: Request, ws_id: int):
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def update_member_role(request: Request, ws_id: int, user_id: int):
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
"""Change a member's role."""
body = await request.json()
role = body.get("role", "editor")
if role not in ("owner", "admin", "editor", "viewer"):
return {"error": "Invalid role"}
@@ -1468,9 +1463,8 @@ def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
@router.post("/api/local-workspace/items")
async def create_local_workspace_item(request: Request):
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
"""Create a new file in the active workspace."""
body = await request.json()
name = (body.get("name") or "").strip() or "Untitled"
item_type = body.get("type", "page")
parent_id = body.get("parent_id")
@@ -1491,9 +1485,8 @@ async def create_local_workspace_item(request: Request):
@router.put("/api/local-workspace/items/{item_id:int}")
async def rename_local_workspace_item(request: Request, item_id: int):
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
"""Rename a file."""
body = await request.json()
name = body.get("name", "Untitled").strip()
with get_conn() as conn:
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
@@ -1547,9 +1540,8 @@ def serve_uploaded_file(ws_id: int, filename: str):
@router.put("/api/local-workspace/items/{item_id:int}/move")
async def move_local_workspace_item(request: Request, item_id: int):
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
"""Move an item to a new parent (drag & drop)."""
body = await request.json()
new_parent_id = body.get("parent_id") # None = move to root
with get_conn() as conn:
conn.execute(
@@ -1829,9 +1821,8 @@ def list_workspaces(request: Request):
@router.post("/api/workspaces")
async def create_workspace(request: Request):
def create_workspace(request: Request, body: dict = Body(default={})):
"""Create a new workspace."""
body = await request.json()
name = body.get("name", "New Workspace").strip()
if not name:
return {"error": "Name required"}
@@ -1861,9 +1852,8 @@ async def create_workspace(request: Request):
@router.put("/api/workspaces/{ws_id:int}")
async def rename_workspace(request: Request, ws_id: int):
def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})):
"""Rename a workspace."""
body = await request.json()
name = body.get("name", "").strip()
if not name:
return {"error": "Name required"}
@@ -1971,9 +1961,8 @@ def get_avatar(user_id: int):
@router.post("/api/settings/avatar-color")
async def set_avatar_color(request: Request):
def set_avatar_color(request: Request, body: dict = Body(default={})):
"""Set the user's avatar background color."""
body = await request.json()
color = body.get("color", "#3A3A3A")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -1987,9 +1976,8 @@ async def set_avatar_color(request: Request):
# ═══════════ Tag Management API (per-user) ═══════════
@router.post("/api/settings/tags")
async def create_tag_global(request: Request):
def create_tag_global(request: Request, body: dict = Body(default={})):
"""Create a tag for the current user."""
body = await request.json()
tag_name = body.get("name", "").strip().lower()
color = body.get("color", "#787774")
uid = _get_user_id(request)
@@ -2007,9 +1995,8 @@ async def create_tag_global(request: Request):
@router.put("/api/settings/tags/{tag_id:int}")
async def update_tag_global(tag_id: int, request: Request):
def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})):
"""Update a tag (name or color) — only if owned by user."""
body = await request.json()
uid = _get_user_id(request)
with get_conn() as conn:
if "name" in body:
@@ -2087,9 +2074,8 @@ def get_item_tags(item_id: int):
@router.post("/api/local-workspace/items/{item_id:int}/tags")
async def add_item_tag(request: Request, item_id: int):
def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})):
"""Add a tag to an item (creates tag if new, scoped to user)."""
body = await request.json()
tag_name = body.get("name", "").strip().lower()
tag_color = body.get("color", "#787774")
uid = _get_user_id(request)
@@ -2175,16 +2161,12 @@ def search_by_tags(request: Request, tags: str = ""):
# ── Account update ──
@router.put("/api/settings/account")
async def update_account(request: Request):
def update_account(request: Request, body: dict = Body(default={})):
"""Update current user's profile: full_name, login, email, password."""
from app.password_utils import hash_password
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
uid = user["id"]
if "full_name" in body:
@@ -2563,9 +2545,8 @@ def api_page_content(page_id: int):
@router.put("/api/pages/{page_id:int}/rename")
async def api_rename_page(page_id: int, request: Request):
def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})):
"""Inline rename a page title."""
body = await request.json()
title = (body.get("title") or "").strip()
if not title:
return JSONResponse({"error": "Title required"}, status_code=400)
@@ -2594,17 +2575,13 @@ def api_trash_page(page_id: int):
@router.post("/api/pages/{page_id:int}/convert-to-database")
async def api_convert_to_database(page_id: int, request: Request):
def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})):
"""Convert a page into a full-page database (Notion-style).
Creates a collection linked to this page, adds the default 'Name' property,
and sets the page's content_format to 'collection'.
"""
import json as _json
try:
body = await request.json()
except Exception:
body = {}
db_name = (body.get("name") or "").strip()
with get_conn() as conn:
@@ -2699,13 +2676,9 @@ def api_collection_table_data(collection_id: int):
@router.post("/api/collections/{collection_id:int}/pages")
async def api_create_collection_page(collection_id: int, request: Request):
def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})):
"""Create a new page (row) in a collection."""
import json as _json
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "New page").strip() or "New page"
icon = body.get("icon", "file")