"""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]}