354 lines
13 KiB
Python
354 lines
13 KiB
Python
"""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 (
|
|
_cleanup_empty_mirrors,
|
|
_ensure_forge_mirror,
|
|
_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 (same layout as /local-workspace)."""
|
|
from app.templating import ENV
|
|
env = ENV
|
|
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"
|
|
# Ensure the local workspace mirror of the same name BEFORE building the
|
|
# sidebar, then drop other EMPTY mirrors so switching repositories never
|
|
# accumulates empty workspace-locals.
|
|
local_ws_id = _ensure_forge_mirror(user["id"], owner, repo, forge="gitea") if ws_key else None
|
|
if ws_key:
|
|
_cleanup_empty_mirrors(user["id"], keep_name=ws_key, keep_id=local_ws_id or 0)
|
|
# Build the sidebar with the explicit project context so the local mirror
|
|
# (''Workspace'' section) is resolved even when the flowdeck_workspace
|
|
# cookie is absent (direct navigation / bookmark).
|
|
sidebar = _sidebar_data(request, [], gitea_owner=owner, gitea_repo=repo)
|
|
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,
|
|
"forge": "gitea",
|
|
"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("/github-workspace", response_class=HTMLResponse)
|
|
def github_workspace_page(request: Request):
|
|
"""GitHub workspace — same layout as /local-workspace and /gitea-workspace."""
|
|
from app.templating import ENV
|
|
env = ENV
|
|
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 "GitHub Workspace"
|
|
local_ws_id = _ensure_forge_mirror(user["id"], owner, repo, forge="github") if ws_key else None
|
|
if ws_key:
|
|
_cleanup_empty_mirrors(user["id"], keep_name=ws_key, keep_id=local_ws_id or 0)
|
|
# Reuse the same sidebar mechanism (Repository section + parallel local
|
|
# mirror). The cookie prefix carries the forge so refresh APIs resolve it.
|
|
sidebar = _sidebar_data(request, [], gitea_owner=owner, gitea_repo=repo)
|
|
ctx = {
|
|
**sidebar,
|
|
"user": user,
|
|
"active_ws_name": ws_name,
|
|
"workspace_key": ws_key,
|
|
"gitea_workspace": True, # sidebar Repository section visible
|
|
"gitea_owner": owner,
|
|
"gitea_repo": repo,
|
|
"forge": "github",
|
|
"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,
|
|
}
|
|
template = env.get_template("github_workspace.html")
|
|
resp = HTMLResponse(content=template.render(**ctx))
|
|
resp.set_cookie("flowdeck_workspace", f"github:{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) ═══════════
|