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
167 lines
7.4 KiB
Python
167 lines
7.4 KiB
Python
"""FlowDeck — Admin API: users, roles, stats, audit."""
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
router = APIRouter(tags=["admin"], prefix="/api/admin")
|
|
|
|
|
|
# ── Dependency ──
|
|
async def admin_required(request: Request):
|
|
from app.auth.session import get_current_user
|
|
user = get_current_user(request)
|
|
if not user:
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
# Also check DB directly (session cookie may be stale)
|
|
if not user.get("is_admin"):
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
|
if not row or not row["is_admin"]:
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
return user
|
|
|
|
|
|
# ── Users ──
|
|
@router.get("/users")
|
|
def list_users(_admin=Depends(admin_required)):
|
|
"""List all users with workspace/file/folder counts and storage usage."""
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
rows = conn.execute("""
|
|
SELECT u.id, u.login, u.full_name, u.email, u.is_admin, u.is_active,
|
|
u.last_login, u.created_at,
|
|
(SELECT COUNT(*) FROM workspaces WHERE owner_id=u.id) AS ws_count,
|
|
(SELECT COUNT(*) FROM pages WHERE workspace_id IN (SELECT id FROM workspaces WHERE owner_id=u.id)) AS page_count
|
|
FROM users u
|
|
ORDER BY u.id
|
|
""").fetchall()
|
|
users = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
d["file_count"] = d["page_count"]
|
|
d["folder_count"] = 0
|
|
d["total_mb"] = 0
|
|
users.append(d)
|
|
return {"users": users}
|
|
|
|
|
|
@router.post("/users")
|
|
def create_user(request: Request, _admin=Depends(admin_required), body: dict = Body(default={})):
|
|
"""Create a new user (admin only)."""
|
|
|
|
from app.db import get_conn
|
|
from app.password_utils import hash_password
|
|
login = body.get("login", "").strip()
|
|
name = body.get("name", login)
|
|
email = body.get("email", login)
|
|
password = body.get("password", "").strip()
|
|
is_admin = int(body.get("is_admin", 0))
|
|
if not login or not password:
|
|
return JSONResponse({"error": "Login and password required"}, status_code=400)
|
|
if len(password) < 6:
|
|
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
|
with get_conn() as conn:
|
|
existing = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()
|
|
if existing:
|
|
return JSONResponse({"error": "User already exists"}, status_code=409)
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
|
|
(login, name, email, hash_password(password), is_admin),
|
|
)
|
|
conn.commit()
|
|
uid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
return {"status": "ok", "user": {"id": uid, "login": login}}
|
|
|
|
|
|
@router.put("/users/{user_id:int}")
|
|
def update_user(user_id: int, request: Request, _admin=Depends(admin_required), body: dict = Body(default={})):
|
|
"""Update a user: name, email, password, admin status, active status."""
|
|
|
|
from app.db import get_conn
|
|
from app.password_utils import hash_password
|
|
with get_conn() as conn:
|
|
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
|
if not user:
|
|
return JSONResponse({"error": "User not found"}, status_code=404)
|
|
if "name" in body:
|
|
conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["name"], user_id))
|
|
if "email" in body:
|
|
conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"], user_id))
|
|
if "password" in body and body["password"].strip():
|
|
pw = body["password"].strip()
|
|
if len(pw) < 6:
|
|
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
|
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), user_id))
|
|
if "is_admin" in body:
|
|
conn.execute("UPDATE users SET is_admin=? WHERE id=?", (int(body["is_admin"]), user_id))
|
|
if "is_active" in body:
|
|
conn.execute("UPDATE users SET is_active=? WHERE id=?", (int(body["is_active"]), user_id))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/users/{user_id:int}")
|
|
def delete_user(user_id: int, _admin=Depends(admin_required)):
|
|
"""Delete a user and cascade their data."""
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
|
if not user:
|
|
return JSONResponse({"error": "User not found"}, status_code=404)
|
|
# Cascade delete: first delete child records
|
|
conn.execute("DELETE FROM login_history WHERE user_id=?", (user_id,))
|
|
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (user_id,))
|
|
conn.execute("DELETE FROM user_tokens WHERE gitea_user_id=?", (user_id,))
|
|
conn.execute("DELETE FROM workspace_members WHERE user_id=?", (user_id,))
|
|
conn.execute("DELETE FROM comments WHERE user_id=?", (user_id,))
|
|
conn.execute("DELETE FROM page_history WHERE user_id=?", (user_id,))
|
|
conn.execute("DELETE FROM favorites WHERE user_id=?", (user_id,))
|
|
conn.execute("DELETE FROM gitea_private_pages WHERE user_id=?", (user_id,))
|
|
conn.execute("DELETE FROM tags WHERE user_id=?", (user_id,))
|
|
# Delete workspaces owned by this user
|
|
ws_rows = conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (user_id,)).fetchall()
|
|
for ws in ws_rows:
|
|
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws["id"],))
|
|
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws["id"],))
|
|
conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,))
|
|
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ── Stats ──
|
|
@router.get("/stats")
|
|
def user_stats(_admin=Depends(admin_required)):
|
|
"""Aggregate stats: total users, workspaces, files, storage."""
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
total_ws = conn.execute("SELECT COUNT(*) FROM workspaces").fetchone()[0]
|
|
total_files = conn.execute("SELECT COUNT(*) FROM pages").fetchone()[0]
|
|
total_folders = 0
|
|
total_bytes = 0
|
|
return {
|
|
"total_users": total_users,
|
|
"total_workspaces": total_ws,
|
|
"total_files": total_files,
|
|
"total_folders": total_folders,
|
|
"total_mb": round(total_bytes / (1024 * 1024), 2),
|
|
}
|
|
|
|
|
|
# ── Audit ──
|
|
@router.get("/audit")
|
|
def audit_log(limit: int = 100, _admin=Depends(admin_required)):
|
|
"""Recent login history."""
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
rows = conn.execute("""
|
|
SELECT lh.id, lh.user_id, u.login, u.full_name,
|
|
lh.ip_address, lh.user_agent, lh.logged_at
|
|
FROM login_history lh
|
|
JOIN users u ON u.id = lh.user_id
|
|
ORDER BY lh.logged_at DESC
|
|
LIMIT ?
|
|
""", (min(limit, 500),)).fetchall()
|
|
return {"entries": [dict(r) for r in rows]}
|