Files
flowdeck/app/routers/dashboard/account_settings.py
T
bruno c0925e511b
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 9m39s
refactor: A28 lot 2 — dashboard.py (2 735 L) → package 10 fichiers (v7.30.0)
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
2026-10-02 07:47:41 -04:00

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 ═══════════