Files
flowdeck/app/routers/dashboard/workspaces.py
T
bruno d95178156f
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Successful in 25m11s
FlowDeck CI / docker (push) Successful in 1m32s
feat: sidebar Repository identique au Workspace-local, pages forge unifiees, GC miroirs vides
2026-10-09 22:33:34 -04:00

174 lines
6.1 KiB
Python

"""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,
_cleanup_empty_mirrors,
_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."""
uid = _require_ws_owner(request, ws_id)
# Switching workspace: prune other empty forge mirrors.
try:
_cleanup_empty_mirrors(uid, keep_id=ws_id)
except Exception:
pass
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 ═══════════