Files
flowdeck/app/routers/admin.py
T
bruno 224bda74d5
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
fix: A21 phase 1 — 352 routes async sans await → threadpool (v7.8.0)
- 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
2026-10-01 10:53:26 -04:00

175 lines
7.5 KiB
Python

"""FlowDeck — Admin API: users, roles, stats, audit."""
from fastapi import APIRouter, 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 = await 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")
async def create_user(request: Request, _admin=Depends(admin_required)):
"""Create a new user (admin only)."""
from app.db import get_conn
from app.password_utils import hash_password
try:
body = await request.json()
except Exception:
body = {}
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}")
async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)):
"""Update a user: name, email, password, admin status, active status."""
from app.db import get_conn
from app.password_utils import hash_password
try:
body = await request.json()
except Exception:
body = {}
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]}