Files
flowdeck/app/routers/api.py
T
bruno 336c7d8dfe feat: FlowDeck v0.2.0 — Kanban Gitea intégré
- Dashboard projets Gitea (user + orgs)
- Board Kanban 5 colonnes avec drag & drop (SortableJS)
- Sync bidirectionnelle colonnes ↔ labels Gitea
- Filtres milestone/label/assignee
- Notes Markdown par projet
- API REST (/api/health, /api/move, /api/col-mapping…)
- Colonnes custom, WIP limits
- Thème clair/sombre
- Cache Gitea TTL
- Docker + docker-compose
- Tests pytest
- Docs: README, ARCHITECTURE, CHANGELOG, ROADMAP
2026-07-08 08:41:31 -04:00

233 lines
7.6 KiB
Python

"""FlowDeck — API REST v1."""
from __future__ import annotations
import json
import logging
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query
from app.config import settings
from app.db import get_conn
from app.services.gitea_client import gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api"], prefix="/api")
@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.2.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"]
return {
"boards": board_count,
"cards": card_count,
"notes": note_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)
orgs = await gitea.get_user_orgs()
for org in orgs:
try:
org_repos = await gitea.get_org_repos(org["username"])
for r in org_repos:
r["_org"] = org["username"]
repos.extend(org_repos)
except Exception:
pass
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(
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."""
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"]
# Upsert card
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),
)
# Check label mapping for sync to Gitea
mapping = conn.execute(
"SELECT gitea_label, close_issue FROM col_mapping WHERE board_id=? AND column_name=?",
(board_id, column),
).fetchone()
conn.commit()
# Sync to Gitea
if mapping:
try:
if mapping["close_issue"]:
await gitea.close_issue(owner, repo, issue_id)
else:
# Get current labels, add mapped label
issue = await gitea.get_issue(owner, repo, issue_id)
current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
# Remove other status labels, add new one
status_labels = await _get_status_labels(owner, repo, board_id)
filtered = [l for l in current_labels if l not in status_labels]
filtered.append(mapping["gitea_label"])
await gitea.update_issue_labels(owner, repo, issue_id, filtered)
except Exception as e:
logger.warning("Gitea sync failed: %s", e)
return {"status": "ok", "issue_id": issue_id, "column": column}
async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
"""Get all status labels configured for this board."""
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.get("/board-config/{owner}/{repo}")
async def get_board_config(owner: str, repo: str):
"""Get board columns and WIP limits."""
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=""),
):
"""Update board columns and WIP limits."""
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"}