Files
flowdeck/app/routers/api.py
T
bruno cb47f5c7f4
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
fix: A26 + A33 + A34 + A35 + A36 + A43 — bande S/XS du P2 (v7.3.9)
- A26 — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…` ne
  produit plus de chemin UNC sous Windows ; `.env.example` ne promet plus
  PostgreSQL ; raise au boot si `APP_SECRET_KEY` vaut la valeur par défaut
- A33 — rate limit : préfixes manquants (`/scim/v2/`, `/workspace/`, `/db/` +
  non-GET sur `/s/` et `/f/`), limite lue dans `settings.rate_limit_requests`
  (60 annoncés / 100 codés en dur), clé `X-Forwarded-For` seulement derrière un
  proxy local (anti-spoof), `_store` épuré (mémoire bornée) + test dédié
- A34 — `_spawn()` : les 10 schedulers loggent leur exception et redémarrent
  après 10 s au lieu de mourir en silence ; 2 `logger.debug` de scheduler → warning
- A35 — OpenAPI régénéré 439 → 511 chemins (`info.version 7.3.9`), README
  (était v6.7.0), compteur API_GUIDE, titre dupliqué retiré du ROADMAP ;
  le drift Python 3.12/3.13 reste noté (rebuild d'image à valider)
- A36 — 4 dépendances mortes purgées de requirements.txt (aiosqlite, slowapi,
  loguru, packaging = 0 import) ; pyproject reste sans [project] : Docker et la
  CI installent requirements.txt, dupliquer les 22 deps créerait 2 sources
- A43 — 15 `datetime.utcnow()` → `now(UTC).replace(tzinfo=None)` (format ISO
  naïf identique, warnings de dépréciation divisés : 2374 → 926)

suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
2026-10-01 08:50:39 -04:00

681 lines
23 KiB
Python

"""FlowDeck — API REST v1."""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card
from app.services.gitea_client import gitea
logger = logging.getLogger(__name__)
# A17 : ce router legacy ne sert plus un anonymous. Deux exceptions assumées —
# le probe d'infra (Docker HEALTHCHECK / CI) et la remontée d'erreur client.
_API_PUBLIC_PATHS = {"/api/health", "/api/frontend-error"}
async def _require_session_or_bearer(request: Request) -> None:
"""Session de cookie **ou** Bearer d'API valide sur toutes les routes /api."""
if request.url.path in _API_PUBLIC_PATHS:
return
if SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
return
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
from app.routers.public_api import verify_token
verify_token(auth)
return
raise HTTPException(401, "Authentication required")
router = APIRouter(tags=["api"], prefix="/api", dependencies=[Depends(_require_session_or_bearer)])
# ── Simple rate limiter (in-memory, per-IP) ──
_rate_limit_store: dict[str, tuple[float, int]] = {}
def _check_rate_limit(request: Request) -> bool:
"""Simple sliding window rate limiter. Returns True if allowed."""
if not settings.rate_limit_enabled:
return True
ip = request.client.host if request.client else "unknown"
now = datetime.now(UTC).replace(tzinfo=None).timestamp()
window_start, count = _rate_limit_store.get(ip, (0, 0))
if now - window_start > 60:
_rate_limit_store[ip] = (now, 1)
return True
if count >= settings.rate_limit_requests:
return False
_rate_limit_store[ip] = (window_start, count + 1)
return True
@router.get("/health")
async def health(request: Request):
"""Health check: DB + Gitea connectivity."""
db_ok = False
gitea_ok = False
try:
with get_conn() as conn:
conn.execute("SELECT 1")
db_ok = True
except Exception:
logger.exception("health")
try:
await gitea.get_user_repos(page=1, limit=1)
gitea_ok = True
except Exception:
logger.exception("health")
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
"version": request.app.version,
"db": db_ok,
"gitea": gitea_ok,
}
@router.get("/stats")
async def stats():
"""Global stats for dashboard."""
with get_conn() as conn:
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
card_count = conn.execute("SELECT COUNT(*) as c FROM cards").fetchone()["c"]
note_count = conn.execute("SELECT COUNT(*) as c FROM notes").fetchone()["c"]
user_count = conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"]
return {
"boards": board_count,
"cards": card_count,
"notes": note_count,
"users": user_count,
}
@router.post("/move")
async def move_card(
request: Request,
owner: str = Query(...),
repo: str = Query(...),
issue_id: int = Query(...),
column: str = Query(...),
):
"""Move a card to a column. Updates DB position + Gitea labels if mapped."""
if not _check_rate_limit(request):
raise HTTPException(status_code=429, detail="Rate limit exceeded")
with get_conn() as conn:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
raise HTTPException(status_code=404, detail="Board not found")
board_id = board["id"]
existing = conn.execute(
"SELECT id FROM cards WHERE board_id=? AND gitea_issue_id=?",
(board_id, issue_id),
).fetchone()
if existing:
conn.execute(
"UPDATE cards SET column_name=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(column, existing["id"]),
)
else:
conn.execute(
"INSERT INTO cards (board_id, gitea_issue_id, column_name, position) VALUES (?, ?, ?, 0)",
(board_id, issue_id, column),
)
mapping = conn.execute(
"SELECT gitea_label, close_issue FROM col_mapping WHERE board_id=? AND column_name=?",
(board_id, column),
).fetchone()
conn.commit()
labels = []
if mapping:
try:
if mapping["close_issue"]:
await gitea.close_issue(owner, repo, issue_id)
else:
issue = await gitea.get_issue(owner, repo, issue_id)
current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
status_labels = await _get_status_labels(owner, repo, board_id)
filtered_names = [name for name in current_labels if name not in status_labels]
filtered_names.append(mapping["gitea_label"])
# Resolve label names to IDs
all_labels = await gitea.get_labels(owner, repo)
name_to_id = {lbl["name"]: lbl["id"] for lbl in all_labels}
label_ids = [name_to_id[n] for n in filtered_names if n in name_to_id]
result = await gitea.update_issue_labels(owner, repo, issue_id, label_ids)
labels = [{"name": lbl["name"], "color": lbl.get("color", "666")} for lbl in result]
except Exception as e:
logger.warning("Gitea sync failed: %s", e)
return {"status": "ok", "issue_id": issue_id, "column": column, "labels": labels}
async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
with get_conn() as conn:
rows = conn.execute(
"SELECT gitea_label FROM col_mapping WHERE board_id=?",
(board_id,),
).fetchall()
return [r["gitea_label"] for r in rows]
@router.post("/col-mapping")
async def set_col_mapping(
owner: str = Query(...),
repo: str = Query(...),
column: str = Query(...),
gitea_label: str = Query(...),
close_issue: bool = Query(default=False),
):
"""Set column-to-label mapping."""
with get_conn() as conn:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
raise HTTPException(status_code=404, detail="Board not found")
board_id = board["id"]
conn.execute(
"""INSERT INTO col_mapping (board_id, column_name, gitea_label, close_issue)
VALUES (?, ?, ?, ?)
ON CONFLICT(board_id, column_name)
DO UPDATE SET gitea_label=excluded.gitea_label, close_issue=excluded.close_issue""",
(board_id, column, gitea_label, int(close_issue)),
)
conn.commit()
return {"status": "ok", "column": column, "label": gitea_label}
@router.delete("/col-mapping")
async def delete_col_mapping(
owner: str = Query(...),
repo: str = Query(...),
column: str = Query(...),
):
"""Delete a column-to-label mapping."""
with get_conn() as conn:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
raise HTTPException(status_code=404, detail="Board not found")
board_id = board["id"]
conn.execute(
"DELETE FROM col_mapping WHERE board_id=? AND column_name=?",
(board_id, column),
)
conn.commit()
return {"status": "ok", "column": column}
@router.get("/board-config/{owner}/{repo}")
async def get_board_config(owner: str, repo: str):
with get_conn() as conn:
board = conn.execute(
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
return {"columns": ["Backlog", "À faire", "En cours", "Révision", "Terminé"], "wip_limits": {}}
mappings = conn.execute(
"SELECT * FROM col_mapping WHERE board_id=?", (board["id"],)
).fetchall()
return {
"columns": json.loads(board["columns_json"]),
"wip_limits": json.loads(board["wip_limits_json"] or "{}"),
"col_mappings": [dict(m) for m in mappings],
}
@router.post("/board-config/{owner}/{repo}")
async def update_board_config(
owner: str,
repo: str,
columns: str = Query(default=""),
wip_limits: str = Query(default=""),
):
with get_conn() as conn:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
conn.execute(
"INSERT INTO boards (project_owner, project_name, columns_json, wip_limits_json) VALUES (?, ?, ?, ?)",
(owner, repo, columns, wip_limits),
)
else:
conn.execute(
"UPDATE boards SET columns_json=?, wip_limits_json=? WHERE id=?",
(columns, wip_limits, board["id"]),
)
conn.commit()
return {"status": "ok"}
# ── v0.5.0: Issue CRUD ──
@router.post("/issues/{owner}/{repo}")
async def create_issue(
request: Request,
owner: str,
repo: str,
title: str = Query(...),
body: str = Query(default=""),
labels: str = Query(default=""),
milestone: str = Query(default=""),
assignee: str = Query(default=""),
):
"""Create a new issue in Gitea and add card to board."""
if not _check_rate_limit(request):
raise HTTPException(status_code=429, detail="Rate limit exceeded")
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()] if labels else None
milestone_id = int(milestone) if milestone.strip().isdigit() else None
issue = await gitea.create_issue(
owner, repo, title, body,
labels=label_ids, milestone=milestone_id, assignee=assignee,
)
# Add card to local board — determine column from label mapping
with get_conn() as conn:
board_row = conn.execute(
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if board_row:
columns = json.loads(board_row["columns_json"])
col = _issue_column(issue, columns, board_row["id"])
conn.execute(
"INSERT INTO cards (board_id, gitea_issue_id, column_name, position) VALUES (?, ?, ?, 0)",
(board_row["id"], issue["number"], col),
)
conn.commit()
return {"status": "ok", "issue": issue}
@router.patch("/issues/{owner}/{repo}/{issue_id}")
async def update_issue_api(
request: Request,
owner: str,
repo: str,
issue_id: int,
title: str = Query(default=""),
body: str = Query(default=""),
state: str = Query(default=""),
labels: str = Query(default=""),
milestone: str = Query(default=""),
assignee: str = Query(default=""),
):
"""Update an issue (partial update)."""
if not _check_rate_limit(request):
raise HTTPException(status_code=429, detail="Rate limit exceeded")
kwargs = {}
label_ids = None
if title:
kwargs["title"] = title
if body:
kwargs["body"] = body
if state:
kwargs["state"] = state
if labels:
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()]
if milestone and milestone.strip().isdigit():
kwargs["milestone"] = int(milestone)
if assignee:
kwargs["assignees"] = [assignee]
if kwargs:
await gitea.update_issue(owner, repo, issue_id, **kwargs)
if label_ids is not None:
await gitea.update_issue_labels(owner, repo, issue_id, label_ids)
# Re-fetch issue to get complete updated state for column recalculation
issue = await gitea.get_issue(owner, repo, issue_id)
# Recalculate card column based on updated labels
with get_conn() as conn:
board_row = conn.execute(
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if board_row:
columns = json.loads(board_row["columns_json"])
col = _issue_column(issue, columns, board_row["id"])
conn.execute(
"UPDATE cards SET column_name=?, updated_at=CURRENT_TIMESTAMP WHERE board_id=? AND gitea_issue_id=?",
(col, board_row["id"], issue_id),
)
conn.commit()
return {"status": "ok", "issue": issue}
# ── v0.5.0: Card detail ──
@router.get("/issues/{owner}/{repo}/{issue_id}")
@router.get("/issues/{owner}/{repo}/{issue_id}/html")
async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Query(default="json")):
"""Get full issue details + comments. Set format=html for HTML response."""
try:
issue = await gitea.get_issue(owner, repo, issue_id)
comments = await gitea.get_issue_comments(owner, repo, issue_id)
except Exception as e:
logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, e)
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found") from e
# Get checklists from local DB
with get_conn() as conn:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
board_id = board["id"] if board else None
checklists = []
if board_id:
rows = conn.execute(
"SELECT * FROM checklists WHERE board_id=? AND gitea_issue_id=? ORDER BY position",
(board_id, issue_id),
).fetchall()
for cl in rows:
items = conn.execute(
"SELECT * FROM checklist_items WHERE checklist_id=? ORDER BY position",
(cl["id"],),
).fetchall()
checklists.append({
**dict(cl),
"items": [dict(it) for it in items],
})
card = conn.execute(
"SELECT * FROM cards WHERE board_id=? AND gitea_issue_id=?",
(board_id, issue_id),
).fetchone() if board_id else None
# HTML format for modal
if format == "html":
card_data = _map_issue_to_card(issue, owner, repo)
ctx = {
"issue": {
"id": issue_id,
"title": issue.get("title", ""),
"body": issue.get("body", ""),
"icon": card_data.get("icon", "📄"),
"author": issue.get("user", {}).get("login", "unknown") if issue.get("user") else "unknown",
"created": issue.get("created_at", "")[:10] if issue.get("created_at") else "",
"assignee": (issue.get("assignee") or {}).get("login", ""),
"due_date": issue.get("due_date", "") or "",
"status": card_data.get("status", "todo"),
"status_color": STATUS_COLORS.get(card_data.get("status", ""), "var(--gray)"),
"status_label": STATUS_LABELS.get(card_data.get("status", ""), "To-do"),
"labels": issue.get("labels", []),
},
"comments": comments,
"checklists": checklists,
}
from app.templating import ENV
env = ENV
template = env.get_template("card_detail.html")
return HTMLResponse(template.render(**ctx))
return {
"issue": issue,
"comments": comments,
"checklists": checklists,
"card": dict(card) if card else None,
}
# ── v0.5.0: Checklists ──
@router.post("/checklists/{owner}/{repo}/{issue_id}")
async def create_checklist(
owner: str,
repo: str,
issue_id: int,
title: str = Query(default="Checklist"),
):
"""Create a new checklist for an issue."""
with get_conn() as conn:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
raise HTTPException(status_code=404, detail="Board not found")
cur = conn.execute(
"INSERT INTO checklists (board_id, gitea_issue_id, title) VALUES (?, ?, ?)",
(board["id"], issue_id, title),
)
conn.commit()
return {"status": "ok", "checklist_id": cur.lastrowid}
@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}")
async def add_checklist_item(
owner: str,
repo: str,
issue_id: int,
checklist_id: int,
content: str = Query(...),
):
"""Add an item to a checklist."""
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO checklist_items (checklist_id, content) VALUES (?, ?)",
(checklist_id, content),
)
conn.commit()
return {"status": "ok", "item_id": cur.lastrowid}
@router.patch("/checklist-items/{item_id}")
async def toggle_checklist_item(
item_id: int,
checked: bool = Query(default=False),
content: str = Query(default=""),
):
"""Toggle or update a checklist item."""
with get_conn() as conn:
if content:
conn.execute(
"UPDATE checklist_items SET checked=?, content=? WHERE id=?",
(int(checked), content, item_id),
)
else:
conn.execute(
"UPDATE checklist_items SET checked=? WHERE id=?",
(int(checked), item_id),
)
conn.commit()
return {"status": "ok"}
@router.delete("/checklist-items/{item_id}")
async def delete_checklist_item(item_id: int):
"""Delete a checklist item."""
with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,))
conn.commit()
return {"status": "ok"}
@router.delete("/checklists/{checklist_id}")
async def delete_checklist(checklist_id: int):
"""Delete a checklist and all its items."""
with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,))
conn.execute("DELETE FROM checklists WHERE id=?", (checklist_id,))
conn.commit()
return {"status": "ok"}
# ── v1.0.0: User management ──
@router.get("/users/me")
async def get_my_profile(request: Request):
"""Get current user profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return {"login": "guest", "full_name": "Guest", "email": ""}
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM users WHERE login=?", (user.get("login", ""),)
).fetchone()
if row:
return dict(row)
return {"login": user.get("login", ""), "full_name": "", "email": ""}
@router.put("/users/me")
async def update_my_profile(request: Request, full_name: str = Query(default=""),
email: str = Query(default="")):
"""Update current user's local profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(401, "Not authenticated")
login = user.get("login", "")
with get_conn() as conn:
conn.execute(
"UPDATE users SET full_name=?, email=? WHERE login=?",
(full_name, email, login),
)
conn.commit()
return {"status": "ok"}
# ── v0.5.0: Card priority & due date ──
@router.post("/card/{owner}/{repo}/{issue_id}")
async def update_card(
owner: str,
repo: str,
issue_id: int,
priority: str = Query(default=""),
due_date: str = Query(default=""),
):
"""Update card metadata (priority, due date)."""
with get_conn() as conn:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
raise HTTPException(status_code=404, detail="Board not found")
updates = []
params = []
if priority:
updates.append("priority=?")
params.append(priority)
if due_date:
updates.append("due_date=?")
params.append(due_date)
if updates:
updates.append("updated_at=CURRENT_TIMESTAMP")
params.extend([board["id"], issue_id])
conn.execute(
f"""UPDATE cards SET {', '.join(updates)} WHERE board_id=? AND gitea_issue_id=?""",
params,
)
conn.commit()
return {"status": "ok"}
# ── v0.5.0: Collaborators for assignee selector ──
@router.get("/collaborators/{owner}/{repo}")
async def get_collaborators(owner: str, repo: str):
"""Get repo collaborators (for assignee dropdown)."""
try:
collaborators = await gitea.get_collaborators(owner, repo)
return {"collaborators": collaborators}
except Exception as e:
return {"collaborators": [], "error": str(e)}
# ── Frontend Error Capture ───────────────────────────────────
# Hermes diagnostique le frontend en appelant GET /api/frontend-errors
_frontend_errors: list[dict] = []
_MAX_STORED_ERRORS = 100
@router.post("/frontend-error")
async def capture_frontend_error(request: Request):
"""Reçoit les erreurs JS du navigateur. Appelé automatiquement par app.js."""
try:
body = await request.json()
except Exception:
return {"status": "ignored", "reason": "invalid json"}
msg = body.get("message", "")
err_type = body.get("type", "error")
source = body.get("source", "")
line = body.get("line", 0)
# Dédupliquer les erreurs identiques consécutives
if _frontend_errors and _frontend_errors[-1].get("message") == msg:
_frontend_errors[-1]["count"] = _frontend_errors[-1].get("count", 1) + 1
_frontend_errors[-1]["time"] = body.get("time", "")
else:
body["count"] = 1
_frontend_errors.append(body)
while len(_frontend_errors) > _MAX_STORED_ERRORS:
_frontend_errors.pop(0)
if err_type == "error":
logger.warning("Frontend JS error: %s (%s:%s)", msg, source, line)
else:
logger.warning("Frontend unhandled rejection: %s", msg)
return {"status": "ok"}
@router.get("/frontend-errors")
async def get_frontend_errors(request: Request, clear: bool = True):
"""Retourne les erreurs frontend récentes. Hermes appelle cette route."""
errors = list(_frontend_errors)
if clear:
_frontend_errors.clear()
return {
"errors": errors,
"count": len(errors),
"cleared": clear,
}