"""FlowDeck — Dashboard : workspaces. Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. """ from __future__ import annotations import logging from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from app.auth.session import SessionManager from app.db import get_conn from app.services.collection_lifecycle import ( delete_collections, workspace_collection_ids, ) from ._common import ( WORKSPACE_COOKIE, _get_active_workspace, _get_user_id, _require_user_id, _sidebar_data, ) logger = logging.getLogger(__name__) router = APIRouter(tags=["dashboard"]) def _require_ws_owner(request: Request, ws_id: int) -> int: """A3/A4 — session obligatoire + l'appelant doit posseder l'espace (ou être admin global). Sans ce garde, un POST/DELETE anonyme créait ou supprimait des espaces au nom de l'utilisateur 1.""" uid = _require_user_id(request) with get_conn() as conn: row = conn.execute( "SELECT owner_id FROM workspaces WHERE id=?", (ws_id,) ).fetchone() if not row: raise HTTPException(404, "Workspace not found") is_admin = conn.execute( "SELECT is_admin FROM users WHERE id=?", (uid,) ).fetchone() if row["owner_id"] != uid and not (is_admin and is_admin["is_admin"]): raise HTTPException(403, "Not your workspace") return uid @router.get("/workspaces", response_class=HTMLResponse) def workspaces_page(request: Request): """Workspaces list page.""" from app.templating import ENV env = ENV sidebar = _sidebar_data(request, [], include_workspace=False) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302) ctx = {**sidebar, "user": user} # Pass active workspace for breadcrumb nav menu (null on workspaces home) ws = _get_active_workspace(request, user_id=_get_user_id(request)) ctx["nav_workspace_id"] = ws["id"] if ws else 0 template = env.get_template("workspaces.html") return template.render(**ctx) @router.get("/api/workspaces") def list_workspaces(request: Request): """List all workspaces for the current user.""" uid = _require_user_id(request) with get_conn() as conn: rows = conn.execute( "SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count " "FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC", (uid,), ).fetchall() workspaces = [] for r in rows: d = dict(r) workspaces.append(d) active = _get_active_workspace(request, user_id=_get_user_id(request)) return {"workspaces": workspaces, "active_id": active["id"] if active else None} @router.post("/api/workspaces") def create_workspace(request: Request, body: dict = Body(default={})): """Create a new workspace.""" name = body.get("name", "New Workspace").strip() if not name: return {"error": "Name required"} user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) uid = _require_user_id(request) with get_conn() as conn: # Ensure user exists (FK constraint) uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone() if not uid_ok: conn.execute( "INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?, ?, ?, 1)", (uid, user.get("login", "admin") if user else "admin", user.get("full_name", "Admin") if user else "Admin"), ) cursor = conn.execute( "INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", (name, uid), ) ws_id = cursor.lastrowid # Add owner as member conn.execute( "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (ws_id, uid), ) conn.commit() return {"id": ws_id, "name": name} @router.put("/api/workspaces/{ws_id:int}") def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})): """Rename a workspace.""" name = body.get("name", "").strip() if not name: return {"error": "Name required"} _require_ws_owner(request, ws_id) with get_conn() as conn: conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id)) conn.commit() return {"status": "ok"} @router.delete("/api/workspaces/{ws_id:int}") def delete_workspace(request: Request, ws_id: int): """Delete a workspace, its pages and its databases.""" _require_ws_owner(request, ws_id) with get_conn() as conn: # Les bases du workspace partaient avec ses pages : les laisser créait # des collections orphelines (workspace_id pointant sur rien) que # /db et My Tasks continuaient de lister. collections = workspace_collection_ids(conn, ws_id) delete_collections(conn, collections) conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,)) conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,)) conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,)) conn.commit() return {"status": "ok", "collections": len(collections)} @router.post("/api/workspaces/{ws_id:int}/select") def select_workspace(request: Request, ws_id: int): """Set the active workspace via cookie.""" _require_ws_owner(request, ws_id) response = JSONResponse({"status": "ok", "workspace_id": ws_id}) response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/") return response # ═══════════ Settings Page ═══════════