- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient loovverture et le redimensionnement inoperants -> cblage direct sur le document. - Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. - database-table-container margin:0 (tableau colle a gauche, marge Library). - .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. - ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
168 lines
5.9 KiB
Python
168 lines
5.9 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,
|
|
_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 ═══════════
|