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
231 lines
12 KiB
Python
231 lines
12 KiB
Python
"""FlowDeck — Public API v2 : workspaces.
|
|
|
|
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 fastapi.responses import JSONResponse
|
|
|
|
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 ._common import _v2_rate_check
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["api-v2"])
|
|
|
|
|
|
|
|
@router.get("/workspaces")
|
|
def list_workspaces(request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
limit, offset = parse_pagination(request)
|
|
with get_conn() as conn:
|
|
total = conn.execute("SELECT COUNT(*) FROM workspaces WHERE owner_id=? OR id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?)", (user["id"], user["id"])).fetchone()[0]
|
|
rows = conn.execute("SELECT w.*, wm.role FROM workspaces w LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=? WHERE w.owner_id=? OR w.id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?) ORDER BY w.created_at DESC LIMIT ? OFFSET ?", (user["id"], user["id"], user["id"], limit, offset)).fetchall()
|
|
out = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
d["created_at"] = to_iso8601(d.get("created_at"))
|
|
try:
|
|
d["settings"] = json.loads(d.get("settings_json") or "{}")
|
|
except Exception:
|
|
d["settings"] = {}
|
|
out.append(d)
|
|
return {"workspaces": out, "total": total, "limit": limit, "offset": offset}
|
|
|
|
|
|
@router.post("/workspaces")
|
|
def create_workspace(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
idem = check_idempotency(request, user["id"])
|
|
if idem:
|
|
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
|
name = (body.get("name") or "").strip()
|
|
if not name:
|
|
raise HTTPException(400, "name is required")
|
|
settings_json = json.dumps(body.get("settings") or body.get("settings_json") or {})
|
|
with get_conn() as conn:
|
|
cur = conn.execute("INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)", (name, user["id"], settings_json))
|
|
wid = cur.lastrowid
|
|
# owner is implicitly admin member
|
|
try:
|
|
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (wid, user["id"]))
|
|
except Exception:
|
|
logger.exception("create_workspace")
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (wid,)).fetchone()
|
|
audit_log(user, "workspace.create", "workspace", wid, name, request)
|
|
data = {"id": wid, "name": name, "owner_id": user["id"], "status": "created", "workspace": row_to_dict(row)}
|
|
key = (request.headers.get("Idempotency-Key") or "").strip()
|
|
if key:
|
|
store_idempotency(key, user["id"], data, 200)
|
|
return JSONResponse(content=data, status_code=201)
|
|
|
|
|
|
@router.get("/workspaces/{workspace_id}")
|
|
def get_workspace(workspace_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:
|
|
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Workspace not found")
|
|
# ACL: must be member or owner
|
|
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
|
is_owner = row["owner_id"] == user["id"]
|
|
if not is_owner and not member and not user.get("is_admin"):
|
|
raise HTTPException(404, "Workspace not found")
|
|
members = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall()
|
|
d = row_to_dict(row)
|
|
d["members"] = [dict(m) for m in members]
|
|
return d
|
|
|
|
|
|
@router.patch("/workspaces/{workspace_id}")
|
|
def patch_workspace(workspace_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 workspaces WHERE id=?", (workspace_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Workspace not found")
|
|
if row["owner_id"] != user["id"] and not user.get("is_admin"):
|
|
# check admin member
|
|
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
|
if not mem or mem["role"] not in ("owner", "admin"):
|
|
raise HTTPException(403, "Only owner/admin can edit workspace")
|
|
name = body.get("name", row["name"])
|
|
sj = body.get("settings_json") or body.get("settings")
|
|
if sj is not None:
|
|
sj = json.dumps(sj) if isinstance(sj, (dict, list)) else str(sj)
|
|
else:
|
|
sj = row["settings_json"]
|
|
conn.execute("UPDATE workspaces SET name=?, settings_json=? WHERE id=?", (name, sj, workspace_id))
|
|
conn.commit()
|
|
audit_log(user, "workspace.update", "workspace", workspace_id, "", request)
|
|
return {"id": workspace_id, "status": "updated"}
|
|
|
|
|
|
@router.delete("/workspaces/{workspace_id}")
|
|
def delete_workspace(workspace_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:
|
|
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Workspace not found")
|
|
if row["owner_id"] != user["id"] and not user.get("is_admin"):
|
|
raise HTTPException(403, "Only owner can delete workspace")
|
|
conn.execute("DELETE FROM workspaces WHERE id=?", (workspace_id,))
|
|
conn.commit()
|
|
audit_log(user, "workspace.delete", "workspace", workspace_id, "", request)
|
|
return {"id": workspace_id, "status": "deleted"}
|
|
|
|
|
|
@router.get("/workspaces/{workspace_id}/members")
|
|
def list_workspace_members(workspace_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:
|
|
ws = conn.execute("SELECT id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
|
if not ws:
|
|
raise HTTPException(404, "Workspace not found")
|
|
rows = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall()
|
|
return {"members": [row_to_dict(r) for r in rows]}
|
|
|
|
|
|
@router.post("/workspaces/{workspace_id}/members")
|
|
def invite_member(workspace_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)
|
|
target_id = body.get("user_id") or body.get("uid")
|
|
email = (body.get("email") or "").strip()
|
|
role = (body.get("role") or "editor").strip().lower()
|
|
if role not in ("owner", "admin", "editor", "viewer", "commenter"):
|
|
raise HTTPException(400, "Invalid role")
|
|
with get_conn() as conn:
|
|
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
|
if not ws:
|
|
raise HTTPException(404, "Workspace not found")
|
|
# only owner/admin can invite
|
|
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
|
|
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
|
if not mem or mem["role"] not in ("owner", "admin"):
|
|
raise HTTPException(403, "Only owner/admin can invite")
|
|
uid = target_id
|
|
if not uid and email:
|
|
u = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()
|
|
if not u:
|
|
raise HTTPException(404, f"User with email {email} not found")
|
|
uid = u["id"]
|
|
if not uid:
|
|
raise HTTPException(400, "user_id or email required")
|
|
try:
|
|
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", (workspace_id, uid, role))
|
|
except Exception:
|
|
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid))
|
|
conn.commit()
|
|
audit_log(user, "workspace.invite", "workspace", workspace_id, f"uid={uid} role={role}", request)
|
|
return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "added"}
|
|
|
|
|
|
@router.patch("/workspaces/{workspace_id}/members/{uid}")
|
|
def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
role = (body.get("role") or "").strip().lower()
|
|
if role not in ("owner", "admin", "editor", "viewer", "commenter"):
|
|
raise HTTPException(400, "Invalid role")
|
|
with get_conn() as conn:
|
|
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
|
if not ws:
|
|
raise HTTPException(404, "Workspace not found")
|
|
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
|
|
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
|
if not mem or mem["role"] not in ("owner", "admin"):
|
|
raise HTTPException(403, "Only owner/admin can change roles")
|
|
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid))
|
|
if conn.total_changes == 0:
|
|
raise HTTPException(404, "Member not found")
|
|
conn.commit()
|
|
audit_log(user, "workspace.role_change", "workspace", workspace_id, f"uid={uid} role={role}", request)
|
|
return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "updated"}
|
|
|
|
|
|
@router.delete("/workspaces/{workspace_id}/members/{uid}")
|
|
def remove_member(workspace_id: int, uid: 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:
|
|
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
|
if not ws:
|
|
raise HTTPException(404, "Workspace not found")
|
|
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
|
|
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
|
if not mem or mem["role"] not in ("owner", "admin"):
|
|
raise HTTPException(403, "Only owner/admin can remove members")
|
|
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, uid))
|
|
conn.commit()
|
|
audit_log(user, "workspace.remove_member", "workspace", workspace_id, f"uid={uid}", request)
|
|
return {"workspace_id": workspace_id, "user_id": uid, "status": "removed"}
|