Files
flowdeck/app/routers/api.py
T
bruno 74fe8aed64
CI / test (push) Failing after 8s
CI / lint (push) Failing after 4s
feat: feedback visuel immédiat après drag-and-drop + correction résolution IDs labels
- /api/move retourne maintenant les labels mis à jour
- Le frontend met à jour les labels de la carte sans recharger la page
- Flash vert sur la carte pour confirmer le succès du déplacement
- Correction: résolution noms→IDs des labels via l'API Gitea (update_issue_labels attend des ints)
- Mise à jour de data-column sur la carte déplacée
2026-07-08 11:42:05 -04:00

563 lines
18 KiB
Python

"""FlowDeck — API REST v1."""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from app.config import settings
from app.db import get_conn
from app.routers.board import _issue_column
from app.services.gitea_client import gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api"], prefix="/api")
# ── 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.utcnow().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():
"""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:
pass
try:
await gitea.get_user_repos(page=1, limit=1)
gitea_ok = True
except Exception:
pass
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
"version": "0.3.0",
"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.get("/projects")
async def projects(search: str = Query(default=""), show_archived: bool = Query(default=False)):
"""List Gitea projects (JSON)."""
try:
repos = await gitea.get_user_repos(page=1, limit=50)
if search:
q = search.lower()
repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()]
if not show_archived:
repos = [r for r in repos if not r.get("archived", False)]
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
except Exception:
repos = []
return {"projects": repos}
@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 = [l for l in current_labels if l 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(l) for l in labels.split(",") if l.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(l) for l in labels.split(",") if l.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}")
async def get_issue_detail(owner: str, repo: str, issue_id: int):
"""Get full issue details + comments."""
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")
# 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
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"}
# ── 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)}