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