Découpe par concern de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) en package `app/routers/api_v2/` : - 12 modules de routes : collections 566 L (23 r.), engagement 338 (21), workspaces 230 (9), templates_io 205 (9), webhooks 195 (8), identity 195 (7), views 164 (8), sharing 160 (8), properties 151 (7), planning 148 (7), projects 93 (4), admin 91 (4) - `_common.py` : helpers partagés (_hash, _v2_rate_check) - `__init__.py` : router = APIRouter(prefix="/api/v2") + include_router sur les routers de sections (sans prefix, tags « api-v2 ») Preuve contractuelle : `docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte (0 changement de chemin/tag/operation_id). Seul importateur (app/main.py : from app.routers.api_v2 import router) fonctionne via le package. En-tête d'imports copié par module puis émondé par ruff --fix (143 imports morts), I001 réordonnés. Reste A28 : dashboard.py 2 735 L, collections.py 2 622 L, board.py 2 101 L (même recette, lots suivants). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
149 lines
6.5 KiB
Python
149 lines
6.5 KiB
Python
"""FlowDeck — Public API v2 : planning.
|
|
|
|
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
|
|
|
from app.db import get_conn
|
|
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
|
audit_log,
|
|
check_idempotency,
|
|
check_v2_rate_limit,
|
|
get_bearer_user,
|
|
has_scope,
|
|
paginate_headers,
|
|
parse_pagination,
|
|
require_scope,
|
|
row_to_dict,
|
|
store_idempotency,
|
|
to_iso8601,
|
|
validate_scopes_input,
|
|
)
|
|
from app.services.automations import fire_event as _fire_event
|
|
from app.services.automations import run_event_sync
|
|
|
|
from ._common import _v2_rate_check
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["api-v2"])
|
|
|
|
|
|
|
|
@router.post("/collections/{collection_id}/sprints")
|
|
def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
name = (body.get("name") or "Sprint").strip()
|
|
start = body.get("start_date") or body.get("start") or ""
|
|
end = body.get("end_date") or body.get("end") or ""
|
|
if not start or not end:
|
|
raise HTTPException(400, "start_date and end_date required (YYYY-MM-DD)")
|
|
with get_conn() as conn:
|
|
cur = conn.execute("INSERT INTO sprints (collection_id, name, start_date, end_date, goal) VALUES (?, ?, ?, ?, ?)", (collection_id, name, start, end, body.get("goal") or ""))
|
|
sid = cur.lastrowid
|
|
conn.commit()
|
|
try:
|
|
run_event_sync(_fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name}))
|
|
except Exception:
|
|
logger.exception("create_sprint_v2")
|
|
return {"id": sid, "name": name, "status": "created"}
|
|
|
|
|
|
@router.patch("/sprints/{sprint_id}")
|
|
def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Sprint not found")
|
|
name = body.get("name", row["name"])
|
|
start = body.get("start_date", row["start_date"])
|
|
end = body.get("end_date", row["end_date"])
|
|
goal = body.get("goal", row["goal"])
|
|
status = body.get("status", row["status"])
|
|
conn.execute("UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=? WHERE id=?", (name, start, end, goal, status, sprint_id))
|
|
conn.commit()
|
|
try:
|
|
run_event_sync(_fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status}))
|
|
except Exception:
|
|
logger.exception("patch_sprint_v2")
|
|
return {"id": sprint_id, "status": "updated"}
|
|
|
|
|
|
@router.delete("/sprints/{sprint_id}")
|
|
def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
conn.execute("DELETE FROM sprints WHERE id=?", (sprint_id,))
|
|
conn.commit()
|
|
return {"id": sprint_id, "status": "deleted"}
|
|
|
|
|
|
@router.post("/sprints/{sprint_id}/assign")
|
|
def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
pid = body.get("page_id")
|
|
if not pid:
|
|
raise HTTPException(400, "page_id required")
|
|
with get_conn() as conn:
|
|
try:
|
|
conn.execute("INSERT INTO sprint_pages (sprint_id, page_id, velocity_points) VALUES (?, ?, ?)", (sprint_id, pid, body.get("velocity_points", 1)))
|
|
conn.commit()
|
|
except Exception as e:
|
|
raise HTTPException(409, str(e)) from None
|
|
return {"sprint_id": sprint_id, "page_id": pid, "status": "assigned"}
|
|
|
|
|
|
@router.delete("/sprints/{sprint_id}/assign/{page_id}")
|
|
def unassign_sprint_v2(sprint_id: int, page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sprint_id, page_id))
|
|
conn.commit()
|
|
return {"status": "removed"}
|
|
|
|
|
|
@router.get("/sprints/{sprint_id}/burndown")
|
|
def burndown_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
s = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
|
|
if not s:
|
|
raise HTTPException(404, "Sprint not found")
|
|
pages = conn.execute("SELECT sp.*, cp.property_values_json FROM sprint_pages sp JOIN collection_pages cp ON cp.id=sp.page_id WHERE sp.sprint_id=?", (sprint_id,)).fetchall()
|
|
total = len(pages)
|
|
# crude: completed where status property == Done (best-effort)
|
|
completed = 0
|
|
for p in pages:
|
|
try:
|
|
pv = json.loads(p["property_values_json"] or "{}")
|
|
for v in pv.values():
|
|
if str(v).lower() in ("done", "completed", "terminé"):
|
|
completed += 1
|
|
break
|
|
except Exception:
|
|
logger.exception("burndown_v2")
|
|
remaining = total - completed
|
|
# ideal linear
|
|
ideal = [round(total * (1 - i / 10)) for i in range(11)]
|
|
return {"sprint_id": sprint_id, "total": total, "completed": completed, "remaining": remaining, "ideal": ideal}
|
|
|
|
|
|
@router.get("/collections/{collection_id}/templates")
|
|
def list_templates_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
rows = conn.execute("SELECT * FROM page_templates WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall()
|
|
return {"templates": [row_to_dict(r) for r in rows]}
|