Files
flowdeck/app/routers/dashboard.py
T
bruno d3ace7f3ab
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped
feat: settings page route + user API endpoints + sidebar link
- GET /accounts/settings — settings page with profile, forges, tokens, sessions
- PUT /api/user/profile — update display name
- PUT /api/user/password — change password
- POST /api/user/token — generate API token
- DELETE /api/user/forge/{provider} — disconnect forge
- Redirects to /auth/login?provider=local if not logged in
2026-07-10 15:37:49 -04:00

306 lines
12 KiB
Python

"""FlowDeck — Dashboard: liste des projets Gitea + sidebar data."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request, Query
from fastapi.responses import HTMLResponse
from app.services.gitea_client import gitea
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
def _sidebar_data(request: Request, repos: list[dict]) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
initial = ws[0].upper() if ws else "B"
recent_pages = []
for repo in repos[:10]:
full_name = repo.get("full_name", "")
recent_pages.append({
"id": full_name,
"name": repo.get("name", full_name),
"icon": "📁",
"url": f"/board/{full_name}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
})
return {
"workspace_name": ws, "workspace_initial": initial,
"current_page": "Dashboard", "last_edited": "now",
"recent_pages": recent_pages,
"private_pages": [],
"favorite_pages": [],
"user": user,
"workspace_key": "",
}
@router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Trash page — scoped to workspace if owner/repo provided."""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = board_sidebar(request, owner, repo)
with get_conn() as conn:
ws_key = f"{owner}/{repo}" if owner and repo else ""
# Pages are soft-deleted via parent_section='Trash'
if ws_key:
rows = conn.execute(
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC",
(ws_key,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC",
).fetchall()
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
template = env.get_template("trash.html")
return template.render(**sidebar)
@router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace.
La sidebar garde exactement la même arborescence que dans le workspace.
Seul le contenu principal change pour afficher les pages triées par catégorie.
"""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = board_sidebar(request, owner, repo) # Sidebar intacte, inchangée
ws_key = f"{owner}/{repo}" if owner and repo else ""
with get_conn() as conn:
base_query = (
"SELECT id, title, workspace, parent_section, updated_at FROM pages "
"WHERE parent_section != 'Trash'"
)
params = []
if ws_key:
base_query += " AND workspace=?"
params.append(ws_key)
base_query += " ORDER BY updated_at DESC"
rows = conn.execute(base_query, params).fetchall()
def to_page(r):
r = dict(r)
return {
"id": f"page/{r['id']}",
"name": r["title"] or "Untitled",
"icon": "📄",
"url": f"/pages/{r['id']}",
"created_by": "You",
"source": r.get("workspace") or "🔒 Private",
"last_edited": r.get("updated_at", "now"),
"last_visited": r.get("updated_at", "now"),
"section": r.get("parent_section", "Private"),
}
all_pages = [to_page(r) for r in rows]
# Load favorites from DB for Library
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
fav_rows = conn.execute(
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
"JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id=? ORDER BY f.position", (uid,)
).fetchall()
lib_favs = []
for r in fav_rows:
r = dict(r)
lib_favs.append({
"id": f"page/{r['id']}",
"name": r["title"] or "Untitled",
"icon": "📄",
"url": f"/pages/{r['id']}",
"created_by": "You",
"source": r.get("workspace") or "🔒 Private",
"last_edited": r.get("updated_at", "now"),
"last_visited": r.get("updated_at", "now"),
})
# Catégoriser par parent_section — sidebar data intacte
sidebar["lib_recent"] = all_pages
sidebar["lib_favorites"] = lib_favs
sidebar["lib_shared"] = []
sidebar["lib_private"] = [p for p in all_pages if p.get("section") == "Private"]
sidebar["lib_has_favorites"] = len(lib_favs) > 0
sidebar["lib_has_shared"] = False
template = env.get_template("library.html")
return template.render(**sidebar)
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page_root(request: Request, page_id: int):
"""Render a Markdown page at root level with workspace context."""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
page = dict(row)
ws = page.get("workspace", "")
parts = ws.split("/") if "/" in ws else ["", ""]
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
sidebar = board_sidebar(request, owner, repo)
# Load sub-pages
with get_conn() as conn:
subs = conn.execute(
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC",
(page_id,),
).fetchall()
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
"page_favorited": False,
"page_share_mode": page.get("share_mode", "private"),
"page_published": bool(page.get("published", 0)),
"page_data": {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": False, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}}
template = env.get_template("page_editor.html")
response = template.render(**ctx)
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
@router.get("/accounts", response_class=HTMLResponse)
async def accounts_page(request: Request):
"""Account management panel."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn:
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
template = env.get_template("accounts.html")
return template.render(**ctx)
@router.get("/accounts/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
"""User settings page — profile, forges, tokens."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
from fastapi.responses import RedirectResponse
return RedirectResponse("/auth/login?provider=local", status_code=302)
# Check forge connections
gitea_connected = False
github_connected = False
if user.get("id"):
with get_conn() as conn:
tokens = conn.execute(
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
).fetchall()
for t in tokens:
if t["provider"] == "gitea":
gitea_connected = True
elif t["provider"] == "github":
github_connected = True
ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected}
template = env.get_template("settings.html")
response = template.render(**ctx)
return HTMLResponse(content=response)
# ═══════════ User API endpoints ═══════════
def _get_user_id(request: Request) -> int:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
return user["id"] if user and user.get("id") else 1
@router.put("/api/user/profile")
async def update_profile(request: Request):
import json
body = await request.json()
full_name = body.get("full_name", "").strip()
uid = _get_user_id(request)
with get_conn() as conn:
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
conn.commit()
return {"status": "ok"}
@router.put("/api/user/password")
async def update_password(request: Request):
import json
from app.password_utils import hash_password
body = await request.json()
password = body.get("password", "").strip()
if len(password) < 6:
return {"error": "Password must be at least 6 characters"}
uid = _get_user_id(request)
with get_conn() as conn:
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
conn.commit()
return {"status": "ok"}
@router.post("/api/user/token")
async def generate_token(request: Request):
import secrets
uid = _get_user_id(request)
token = secrets.token_hex(32)
with get_conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(uid, token),
)
conn.commit()
return {"token": f"fd_{token}"}
@router.delete("/api/user/forge/{provider}")
async def disconnect_forge(request: Request, provider: str):
uid = _get_user_id(request)
with get_conn() as conn:
conn.execute(
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
)
conn.commit()
return {"status": "ok"}
@router.get("/", response_class=HTMLResponse)
async def dashboard(
request: Request,
search: str = Query(default=""),
show_archived: bool = Query(default=False),
):
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 jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
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)