Découpe par concern de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) en package `app/routers/dashboard/` : - 8 modules de routes : local_workspace 559 L (15 r.), pages_html 485 (6), account_settings 439 (16), workspace 321 (9), pages_api 240 (6), workspaces 131 (6), public 78 (1), account_api 77 (4) - _common.py (774 L) : les 15 helpers top-level INTERCALÉS dans l'ancien fichier + état (logger, _VERSION, WORKSPACE_COOKIE) - __init__.py : ordre d'enregistrement identique à l'origine, re-export complet (7 importateurs inchangés : main, board ×3, my_tasks, web_clipper, wiki, sites `_dash._render_blocks_public`, tests) + __all__ Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE byte-à-byte (509 chemins, ordre préservé). Pièges rattrapés : - segment décorateur sans sa fonction → assert `def in seg` + récupération git (corps perdus en silence à la 1ʳᵉ exécution) - collision `settings` (section vs from app.config import settings → hasattr du fromlist) → renommée account_settings - WORKSPACE_COOKIE utilisé sans import dans workspaces.py (F821) - script __all__ mangeant la fin du fichier → __init__ réécrit Reste A28 : collections.py 2 622 L, board.py 2 101 L (lot 3). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
440 lines
16 KiB
Python
440 lines
16 KiB
Python
"""FlowDeck — Dashboard : settings.
|
|
|
|
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, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
|
|
from ._common import _format_size, _get_active_workspace, _get_user_id, _sidebar_data
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["dashboard"])
|
|
|
|
|
|
|
|
|
|
# ═══════════ Settings Page ═══════════
|
|
|
|
@router.get("/settings", response_class=HTMLResponse)
|
|
def app_settings_page(request: Request):
|
|
"""Settings & configuration page."""
|
|
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("settings.html")
|
|
return template.render(**ctx)
|
|
|
|
|
|
|
|
|
|
@router.post("/api/settings/avatar")
|
|
async def upload_avatar(request: Request):
|
|
"""Upload a user avatar image."""
|
|
import os
|
|
import uuid
|
|
from pathlib import Path
|
|
form = await request.form()
|
|
file = form.get("file")
|
|
if not file:
|
|
return {"error": "No file"}, 400
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return {"error": "Not authenticated"}, 401
|
|
# Save to data/avatars
|
|
avatars_dir = Path("/data/avatars")
|
|
avatars_dir.mkdir(parents=True, exist_ok=True)
|
|
ext = os.path.splitext(file.filename)[1] or ".png"
|
|
filename = f"{user['id']}_{uuid.uuid4().hex[:8]}{ext}"
|
|
filepath = avatars_dir / filename
|
|
content = await file.read()
|
|
filepath.write_bytes(content)
|
|
# Update user avatar_url
|
|
avatar_url = f"/api/settings/avatar/{filename}"
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user["id"]))
|
|
conn.commit()
|
|
return {"avatar_url": avatar_url}
|
|
|
|
|
|
|
|
|
|
@router.get("/api/settings/avatar/{filename:path}")
|
|
def serve_avatar_file(filename: str):
|
|
"""Serve an uploaded avatar image file."""
|
|
from pathlib import Path
|
|
|
|
from fastapi.responses import FileResponse
|
|
# A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette
|
|
# accepte les `/`, donc `..%2f` ressortirait du dossier avatars.
|
|
base_dir = Path("/data/avatars").resolve()
|
|
filepath = (base_dir / filename).resolve()
|
|
try:
|
|
filepath.relative_to(base_dir)
|
|
except ValueError:
|
|
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
|
if not filepath.is_file():
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
return FileResponse(filepath)
|
|
|
|
|
|
|
|
|
|
@router.get("/api/avatar/{user_id:int}")
|
|
def get_avatar(user_id: int):
|
|
"""Redirect to the user's avatar."""
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
if row and row["avatar_url"]:
|
|
return RedirectResponse(row["avatar_url"], status_code=302)
|
|
return JSONResponse({"error": "No avatar"}, status_code=404)
|
|
|
|
|
|
|
|
|
|
@router.post("/api/settings/avatar-color")
|
|
def set_avatar_color(request: Request, body: dict = Body(default={})):
|
|
"""Set the user's avatar background color."""
|
|
color = body.get("color", "#3A3A3A")
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return {"error": "Not authenticated"}, 401
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE users SET avatar_url = '', avatar_color = ? WHERE id = ?", (color, user["id"]))
|
|
conn.commit()
|
|
return {"status": "ok", "color": color}
|
|
|
|
|
|
# ═══════════ Tag Management API (per-user) ═══════════
|
|
|
|
|
|
|
|
|
|
# ═══════════ Tag Management API (per-user) ═══════════
|
|
|
|
@router.post("/api/settings/tags")
|
|
def create_tag_global(request: Request, body: dict = Body(default={})):
|
|
"""Create a tag for the current user."""
|
|
tag_name = body.get("name", "").strip().lower()
|
|
color = body.get("color", "#787774")
|
|
uid = _get_user_id(request)
|
|
if not tag_name or not uid:
|
|
return {"error": "Tag name required"}, 400
|
|
with get_conn() as conn:
|
|
tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
|
if tag:
|
|
conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"]))
|
|
conn.commit()
|
|
return {"tag": {"id": tag["id"], "name": tag_name, "color": color}}
|
|
cursor = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, color, uid))
|
|
conn.commit()
|
|
return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}}
|
|
|
|
|
|
|
|
|
|
@router.put("/api/settings/tags/{tag_id:int}")
|
|
def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})):
|
|
"""Update a tag (name or color) — only if owned by user."""
|
|
uid = _get_user_id(request)
|
|
with get_conn() as conn:
|
|
if "name" in body:
|
|
conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid))
|
|
if "color" in body:
|
|
conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
@router.delete("/api/settings/tags/{tag_id:int}")
|
|
def delete_tag_global(tag_id: int, request: Request):
|
|
"""Delete a tag — only if owned by user."""
|
|
uid = _get_user_id(request)
|
|
with get_conn() as conn:
|
|
conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,))
|
|
conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
@router.get("/api/settings/tags/all")
|
|
def list_all_tags_global(request: Request):
|
|
"""List current user's tags with counts."""
|
|
uid = _get_user_id(request)
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count "
|
|
"FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id "
|
|
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
|
(uid,),
|
|
).fetchall()
|
|
return JSONResponse(
|
|
{"tags": [dict(r) for r in rows]},
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
|
|
# ═══════════ Workspace Tags API ═══════════
|
|
|
|
|
|
|
|
|
|
# ═══════════ Workspace Tags API ═══════════
|
|
|
|
@router.get("/api/local-workspace/tags")
|
|
def list_tags(request: Request):
|
|
"""List ALL user tags with counts scoped to the active workspace."""
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = ws["id"] if ws else None
|
|
uid = _get_user_id(request)
|
|
if not ws_id:
|
|
return {"tags": []}
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT t.id, t.name, t.color, "
|
|
"(SELECT COUNT(*) FROM page_tags pt "
|
|
" JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
|
|
" WHERE pt.tag_id = t.id) as count "
|
|
"FROM tags t WHERE t.user_id = ? ORDER BY t.name",
|
|
(ws_id, uid),
|
|
).fetchall()
|
|
return JSONResponse(
|
|
{"tags": [dict(r) for r in rows]},
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
|
|
|
|
|
|
@router.get("/api/local-workspace/items/{item_id:int}/tags")
|
|
def get_item_tags(item_id: int):
|
|
"""Get tags for a specific item."""
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT t.id, t.name, t.color FROM tags t "
|
|
"JOIN page_tags pt ON pt.tag_id = t.id "
|
|
"WHERE pt.page_id = ? ORDER BY t.name",
|
|
(item_id,),
|
|
).fetchall()
|
|
return {"tags": [dict(r) for r in rows]}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/local-workspace/items/{item_id:int}/tags")
|
|
def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})):
|
|
"""Add a tag to an item (creates tag if new, scoped to user)."""
|
|
tag_name = body.get("name", "").strip().lower()
|
|
tag_color = body.get("color", "#787774")
|
|
uid = _get_user_id(request)
|
|
if not tag_name:
|
|
return {"error": "Tag name required"}, 400
|
|
|
|
with get_conn() as conn:
|
|
# Get or create tag (per user)
|
|
tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
|
if not tag:
|
|
cursor = conn.execute(
|
|
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid)
|
|
)
|
|
conn.commit()
|
|
tag_id = cursor.lastrowid
|
|
tag = {"id": tag_id, "name": tag_name, "color": tag_color}
|
|
else:
|
|
tag_id = tag["id"]
|
|
|
|
# Link tag to page (ignore duplicate)
|
|
try:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)",
|
|
(item_id, tag_id),
|
|
)
|
|
conn.commit()
|
|
except Exception:
|
|
logger.exception("add_item_tag")
|
|
|
|
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
|
|
|
|
|
|
|
|
|
|
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
|
|
def remove_item_tag(item_id: int, tag_id: int):
|
|
"""Remove a tag from an item."""
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?",
|
|
(item_id, tag_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
@router.get("/api/local-workspace/tags/search")
|
|
def search_by_tags(request: Request, tags: str = ""):
|
|
"""Search items by tags (comma-separated)."""
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = ws["id"] if ws else None
|
|
if not ws_id:
|
|
return {"items": []}
|
|
tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()]
|
|
if not tag_names:
|
|
return {"items": []}
|
|
with get_conn() as conn:
|
|
placeholders = ",".join("?" for _ in tag_names)
|
|
rows = conn.execute(
|
|
f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, "
|
|
f"p.content, p.created_at, p.updated_at "
|
|
f"FROM pages p "
|
|
f"JOIN page_tags pt ON pt.page_id = p.id "
|
|
f"JOIN tags t ON t.id = pt.tag_id "
|
|
f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL "
|
|
f"ORDER BY p.updated_at DESC",
|
|
tag_names + [ws_id],
|
|
).fetchall()
|
|
items = []
|
|
for r in rows:
|
|
is_folder = r["parent_section"] == "Workspace"
|
|
size = len(r["content"] or "")
|
|
items.append({
|
|
"id": r["id"],
|
|
"name": r["title"] or "Untitled",
|
|
"is_folder": is_folder,
|
|
"content_format": r["content_format"],
|
|
"created_at": r["created_at"],
|
|
"updated_at": r["updated_at"],
|
|
"size": size,
|
|
"size_display": _format_size(size),
|
|
})
|
|
return {"items": items}
|
|
|
|
|
|
# ── Account update ──
|
|
|
|
|
|
|
|
|
|
# ── Account update ──
|
|
@router.put("/api/settings/account")
|
|
def update_account(request: Request, body: dict = Body(default={})):
|
|
"""Update current user's profile: full_name, login, email, password."""
|
|
from app.password_utils import hash_password
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
|
with get_conn() as conn:
|
|
uid = user["id"]
|
|
if "full_name" in body:
|
|
conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid))
|
|
if "login" in body:
|
|
new_login = body["login"].strip()
|
|
if new_login and new_login != user.get("login"):
|
|
existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone()
|
|
if existing:
|
|
return JSONResponse({"error": "Username already taken"}, status_code=409)
|
|
conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid))
|
|
if "email" in body:
|
|
conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid))
|
|
if "password" in body and body["password"].strip():
|
|
pw = body["password"].strip()
|
|
if len(pw) < 6:
|
|
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
|
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid))
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
|
user_data = dict(row)
|
|
# Refresh session cookie with updated data (keeps the same session id)
|
|
cookie = request.cookies.get("flowdeck_session", "")
|
|
new_session = SessionManager.refresh_session(cookie, user_data, request)
|
|
response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}})
|
|
response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
|
return response
|
|
|
|
|
|
# ═══════════ Sidebar Refresh API ═══════════
|
|
|
|
|
|
|
|
|
|
# ═══════════ Sidebar Refresh API ═══════════
|
|
|
|
@router.get("/api/sidebar/workspace-tree")
|
|
def sidebar_workspace_tree(request: Request):
|
|
"""Return the sidebar workspace tree as HTML fragment.
|
|
|
|
Called by appState().refreshSidebarTree() after CRUD operations
|
|
in the main content area to keep the sidebar in sync.
|
|
"""
|
|
from app.routers.board import _load_workspace_pages
|
|
from app.templating import ENV
|
|
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return HTMLResponse("")
|
|
|
|
ws_cookie = request.cookies.get("flowdeck_workspace", "")
|
|
if not ws_cookie:
|
|
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
|
|
|
|
# Gitea workspace — no server-side tree, loaded client-side
|
|
if ws_cookie.startswith("gitea:"):
|
|
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
|
|
|
|
try:
|
|
ws_id = int(ws_cookie)
|
|
with get_conn() as conn:
|
|
# Verify workspace belongs to user
|
|
row = conn.execute(
|
|
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
|
(ws_id, user["id"])
|
|
).fetchone()
|
|
if not row:
|
|
return HTMLResponse(
|
|
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
|
'<span class="page-name text-dim">No pages yet</span></li>'
|
|
)
|
|
|
|
pages = _load_workspace_pages(ws_cookie)
|
|
if not pages:
|
|
return HTMLResponse(
|
|
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
|
'<span class="page-name text-dim">No pages yet</span></li>'
|
|
)
|
|
|
|
# Render the tree using the extracted macro
|
|
env = ENV
|
|
template = env.from_string(
|
|
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
|
"{{ render_workspace_tree(pages) }}"
|
|
)
|
|
html = template.render(pages=pages)
|
|
return HTMLResponse(html)
|
|
except (ValueError, Exception) as e:
|
|
logger.error(f"sidebar_workspace_tree failed: {e}", exc_info=True)
|
|
return HTMLResponse(
|
|
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
|
'<span class="page-name text-dim">No pages yet</span></li>'
|
|
)
|
|
|
|
|
|
# ═══════════ Public Published Page ═══════════
|