"""FlowDeck — Dashboard : workspace. 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, Query, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.auth.session import SessionManager from app.db import get_conn from app.services.gitea_client import get_user_gitea_client, gitea from ._common import _get_active_workspace, _get_user_id, _sidebar_data logger = logging.getLogger(__name__) router = APIRouter(tags=["dashboard"]) @router.get("/", response_class=HTMLResponse) async def dashboard( request: Request, search: str = Query(default=""), show_archived: bool = Query(default=False), ): """Smart root route: landing for visitors, local workspace for new users, dashboard for Gitea users.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) # ── Not authenticated → show landing page ── if not user: # Allow through if DB is empty (fresh install) try: with get_conn() as conn: count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] if count == 0: from app.templating import ENV env = ENV template = env.get_template("landing.html") return template.render() except Exception: logger.exception("dashboard") from app.templating import ENV env = ENV template = env.get_template("landing.html") return template.render() # ── Authenticated ── user_id = user.get("id", 1) has_gitea = False try: with get_conn() as conn: tok = conn.execute( "SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'", (user_id,), ).fetchone() has_gitea = bool(tok) except Exception: logger.exception("dashboard") if not has_gitea: # Check if user has any workspace try: with get_conn() as conn: ws_count = conn.execute( "SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,) ).fetchone()[0] if ws_count == 0: # v5.2.0: first-launch → onboarding wizard return RedirectResponse("/welcome", status_code=302) except Exception: logger.exception("dashboard") return RedirectResponse("/local-workspace", status_code=302) # ── Gitea user → full dashboard ── try: repos = await gitea.get_user_repos(page=1, limit=50) if search: q = search.lower() repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()] if not show_archived: repos = [r for r in repos if not r.get("archived", False)] repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True) except Exception as e: logger.error("Dashboard error: %s", e) repos = [] from app.templating import ENV env = ENV sidebar = _sidebar_data(request, repos) template = env.get_template("dashboard.html") return template.render(request=request, repos=repos, search=search, show_archived=show_archived, **sidebar) # ═══════════ Workspace ═══════════ # ═══════════ Workspace ═══════════ @router.get("/workspace", response_class=HTMLResponse) def workspace_page(request: Request): """Unified workspace showing all projects.""" from app.templating import ENV env = ENV sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return RedirectResponse("/auth/login?provider=local", status_code=302) ctx = {**sidebar, "user": user} # Pass active workspace for breadcrumb nav menu 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("workspace.html") return template.render(**ctx) @router.get("/gitea-workspace", response_class=HTMLResponse) def gitea_workspace_page(request: Request): """Gitea workspace — browse repo files.""" import json from app.templating import ENV env = ENV sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return RedirectResponse("/auth/login?provider=local", status_code=302) owner = request.query_params.get("owner", "") repo = request.query_params.get("repo", "") ws_key = f"{owner}/{repo}" if owner and repo else "" ws_name = ws_key or "Gitea Workspace" # Auto-create local workspace mirror for storing local files local_ws_id = None if ws_key: with get_conn() as conn: existing = conn.execute( "SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", (user["id"], ws_key, "%gitea_repo%") ).fetchone() if existing: local_ws_id = existing["id"] else: c = conn.execute( "INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)", (ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner})) ) local_ws_id = c.lastrowid conn.execute( "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", (local_ws_id, user["id"], "admin") ) conn.commit() ctx = { **sidebar, "user": user, "active_ws_name": ws_name, "workspace_key": ws_key, "gitea_workspace": True, # always true on this page "gitea_owner": owner, "gitea_repo": repo, "workspace_name": ws_name, "workspace_initial": repo[0].upper() if repo else "G", "owner": owner, "repo": repo, "nav_workspace_id": local_ws_id or 0, # for breadcrumb nav menu } template = env.get_template("gitea_workspace.html") resp = HTMLResponse(content=template.render(**ctx)) resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax") return resp @router.get("/api/workspace/projects") async def list_workspace_projects(request: Request): """List all projects: built-in + Gitea + GitHub. Uses the user's own Gitea token if connected, not the global admin token.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) builtin = [] with get_conn() as conn: rows = conn.execute( "SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20" ).fetchall() # A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne. counts = {} if rows: for c in conn.execute( "SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format( ",".join("?" * len(rows)) ), [r["id"] for r in rows], ).fetchall(): counts[c["parent_id"]] = c["c"] for r in rows: builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"}) gitea_repos = [] # Use per-user token if available, otherwise return empty user_gitea = get_user_gitea_client(request) if user else None if user_gitea: try: repos = await user_gitea.get_user_repos(page=1, limit=50) for repo in repos: gitea_repos.append({ "id": str(repo.get("id", "")), "name": repo.get("name", ""), "full_name": repo.get("full_name", ""), "description": repo.get("description", ""), "html_url": repo.get("html_url", ""), "language": repo.get("language", ""), "forge": "gitea", }) except Exception: logger.exception("list_workspace_projects") return {"builtin": builtin, "gitea": gitea_repos, "github": []} @router.post("/api/workspace/projects") def create_workspace_project(request: Request, body: dict = Body(default={})): name = body.get("name", "").strip() if not name: return {"error": "Name required"} with get_conn() as conn: cursor = conn.execute( "INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')", (name,), ) conn.commit() pid = cursor.lastrowid return {"id": pid, "name": name, "forge": "builtin"} # ═══════════ Workspace Members API ═══════════ # ═══════════ Workspace Members API ═══════════ @router.get("/api/workspace/{ws_id:int}/members") def list_members(request: Request, ws_id: int): """List all members of a workspace.""" with get_conn() as conn: 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""", (ws_id,) ).fetchall() return {"members": [dict(r) for r in rows]} @router.post("/api/workspace/{ws_id:int}/members") def invite_member(request: Request, ws_id: int, body: dict = Body(default={})): """Invite a user to a workspace by email.""" email = body.get("email", "").strip() role = body.get("role", "editor") if role not in ("owner", "admin", "editor", "viewer"): return {"error": "Invalid role"}, 400 with get_conn() as conn: user = conn.execute("SELECT id FROM users WHERE login=? OR email=?", (email, email)).fetchone() if not user: return {"error": "User not found"}, 404 try: conn.execute( "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", (ws_id, user["id"], role), ) conn.commit() except Exception: return {"error": "Already a member"}, 409 return {"status": "ok", "user_id": user["id"], "role": role} @router.put("/api/workspace/{ws_id:int}/members/{user_id:int}") def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})): """Change a member's role.""" role = body.get("role", "editor") if role not in ("owner", "admin", "editor", "viewer"): return {"error": "Invalid role"} with get_conn() as conn: conn.execute( "UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, ws_id, user_id), ) conn.commit() return {"status": "ok"} @router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}") def remove_member(request: Request, ws_id: int, user_id: int): """Remove a member from a workspace.""" with get_conn() as conn: conn.execute( "DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id), ) conn.commit() return {"status": "ok"} # ═══════════ Local Workspace (file/folder CRUD) ═══════════