Files
flowdeck/app/routers/api_v2/workspaces.py
T
bruno 6a5fe0524a
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
refactor: A28 lot 1 — api_v2.py (2 110 L) → package 14 fichiers (v7.29.0)
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
2026-10-01 23:26:45 -04:00

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