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
165 lines
8.0 KiB
Python
165 lines
8.0 KiB
Python
"""FlowDeck — Public API v2 : views.
|
|
|
|
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}/views")
|
|
def create_view_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 "New View").strip()
|
|
vtype = body.get("view_type") or body.get("type") or "table"
|
|
config = body.get("config") or body.get("config_json") or {}
|
|
with get_conn() as conn:
|
|
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
|
raise HTTPException(404, "Collection not found")
|
|
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
|
cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, name, vtype, json.dumps(config), max_pos, user["id"]))
|
|
vid = cur.lastrowid
|
|
conn.commit()
|
|
audit_log(user, "view.create", "view", vid, name, request)
|
|
try:
|
|
run_event_sync(_fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype}))
|
|
except Exception:
|
|
logger.exception("create_view_v2")
|
|
return {"id": vid, "name": name, "view_type": vtype, "status": "created"}
|
|
|
|
|
|
@router.patch("/views/{view_id}")
|
|
def patch_view_v2(view_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 collection_views WHERE id=?", (view_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "View not found")
|
|
cfg = json.loads(row["config_json"] or "{}")
|
|
if "config" in body:
|
|
cfg.update(body["config"])
|
|
elif "config_json" in body:
|
|
try:
|
|
cfg.update(json.loads(body["config_json"]) if isinstance(body["config_json"], str) else body["config_json"])
|
|
except Exception:
|
|
logger.exception("patch_view_v2")
|
|
# also flat keys
|
|
for k in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "date_property"):
|
|
if k in body:
|
|
cfg[k] = body[k]
|
|
name = body.get("name", row["name"])
|
|
vtype = body.get("view_type") or body.get("type") or row["view_type"]
|
|
conn.execute("UPDATE collection_views SET name=?, view_type=?, config_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, vtype, json.dumps(cfg), view_id))
|
|
conn.commit()
|
|
audit_log(user, "view.update", "view", view_id, "", request)
|
|
return {"id": view_id, "status": "updated"}
|
|
|
|
|
|
@router.delete("/views/{view_id}")
|
|
def delete_view_v2(view_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:
|
|
if not conn.execute("SELECT id FROM collection_views WHERE id=?", (view_id,)).fetchone():
|
|
raise HTTPException(404, "View not found")
|
|
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
|
|
conn.commit()
|
|
audit_log(user, "view.delete", "view", view_id, "", request)
|
|
return {"id": view_id, "status": "deleted"}
|
|
|
|
|
|
@router.post("/views/{view_id}/save-as")
|
|
def save_as_view_v2(view_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 "").strip() or "Copy"
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "View not found")
|
|
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (row["collection_id"],)).fetchone()[0]
|
|
cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (row["collection_id"], name, row["view_type"], row["config_json"], max_pos, user["id"]))
|
|
nid = cur.lastrowid
|
|
conn.commit()
|
|
return {"id": nid, "name": name, "status": "created"}
|
|
|
|
|
|
@router.get("/collections/{collection_id}/dashboards")
|
|
def list_dashboards_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 collection_dashboards WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall()
|
|
return {"dashboards": [row_to_dict(r) for r in rows]}
|
|
|
|
|
|
@router.post("/collections/{collection_id}/dashboards")
|
|
def create_dashboard_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 "Dashboard").strip()
|
|
layout = body.get("layout") or body.get("layout_json") or {"columns": 1, "widgets": []}
|
|
with get_conn() as conn:
|
|
cur = conn.execute("INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?, ?, ?)", (collection_id, name, json.dumps(layout)))
|
|
did = cur.lastrowid
|
|
conn.commit()
|
|
return {"id": did, "name": name, "status": "created"}
|
|
|
|
|
|
@router.patch("/dashboards/{dashboard_id}")
|
|
def patch_dashboard_v2(dashboard_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 collection_dashboards WHERE id=?", (dashboard_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Dashboard not found")
|
|
name = body.get("name", row["name"])
|
|
layout = body.get("layout") or body.get("layout_json")
|
|
if layout is not None:
|
|
layout_json = json.dumps(layout)
|
|
else:
|
|
layout_json = row["layout_json"]
|
|
conn.execute("UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, layout_json, dashboard_id))
|
|
conn.commit()
|
|
return {"id": dashboard_id, "status": "updated"}
|
|
|
|
|
|
@router.delete("/dashboards/{dashboard_id}")
|
|
def delete_dashboard_v2(dashboard_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 collection_dashboards WHERE id=?", (dashboard_id,))
|
|
conn.commit()
|
|
return {"id": dashboard_id, "status": "deleted"}
|