Files
flowdeck/app/routers/workspace.py
T
bruno 45e59009c3
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 15m23s
FlowDeck CI / docker (push) Canceled after 0s
fix: A21 phase 2c — 190 routes hors loop, 86 % total (v7.26.0)
4 passes (283 → 93 routes async sur 667 = 86 % hors loop, avant 61 %) :

A. RACINE AUTH — `get_current_user` (auth/session.py) était `async def`
   SANS aucun await (cookie decode = synchrone) ; idem ses clones :
   `agent._current_user_id/_workspace_id/_current_admin` (34 sites) et
   `sso._require_admin` (corps 0 await, 6 sites) → `def` +
   47 `await` supprimés. Piège : 3 call sites passaient par l'alias `gcu`
   (grep littéral aveugle) — 8 tests en échec → corrigés.

B. Re-scan : 19 routes devenues SANS await → `def` (agent 8, sso 5,
   web_clipper 3, projects 2, auth 1…).

C/D. 155 routes dont les seuls awaits = `request.json()` / événements :
   - try/except `body = {}` → `Body(default={})` (même tolérance)
   - try/except `raise HTTPException(400)` → `Body(...)` REQUIS
     (422 FastAPI — aucun test ne couvrait le 400)
   - forme conditionnelle `request.json() if content-type else {}`
     (54 sites) → défaut `{}` (sans corps = `{}` dans les 2 cas)
   - `await fire_*` → `run_event_sync(...)` ; imports `Body` /
     `run_event_sync` ajoutés aux routers convertis

Reste async (93, justifié) : form/upload/file (22), réseau gitea/llm/oidc,
`_json_body` (9), 2 JSON inline en argument, 1 fallback logique
(capture_frontend_error), 1 lecture conditionnelle (web_clipper), mixtes.

suite **1089/1089** · ruff OK · docs à jour
2026-10-01 22:17:48 -04:00

755 lines
30 KiB
Python

"""FlowDeck — Workspace, Comments, Favorites, History, Templates (v2.0.0)."""
from __future__ import annotations
import csv
import html
import io
import json
import logging
import sqlite3
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
logger = logging.getLogger(__name__)
router = APIRouter(tags=["workspace"], prefix="/workspace")
ROLES = ["admin", "editor", "commenter", "viewer"]
def _current_user(request: Request) -> dict:
s = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
def _require_admin(request: Request) -> dict:
"""A15 : webhooks sortants = admin — le serveur POSTe le contenu des pages."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
if not user.get("is_admin"):
raise HTTPException(403, "Admin only")
return user
def _require_ws_admin(request: Request, ws_id: int) -> None:
"""A5 — CRUD des membres : session obligatoire + rôle admin de l'espace
(ou admin global). Un anonymous ne peut plus s'ajouter lui-même ni se
promouvoir admin."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
if conn.execute("SELECT 1 FROM users WHERE id=? AND is_admin=1", (user["id"],)).fetchone():
return
row = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(ws_id, user["id"]),
).fetchone()
if not row or row["role"] != "admin":
raise HTTPException(403, "Workspace admin role required")
# ── Workspaces ──
@router.post("")
def create_workspace(request: Request, body: dict = Body(default={})):
name = body.get("name", "Default Workspace")
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
uid_ensured = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
if not uid_ensured:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(uid, user.get("login", "admin"), user.get("full_name", "Admin")))
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?,?)", (name, uid))
ws_id = cur.lastrowid
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
(ws_id, uid, "admin"))
conn.commit()
return {"id": ws_id, "name": name, "status": "created"}
# ── Members ──
@router.get("/{ws_id}/members")
def list_members(request: Request, ws_id: int):
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
rows = conn.execute(
"SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?",
(ws_id,),
).fetchall()
return {"members": [dict(r) for r in rows]}
@router.post("/{ws_id}/members")
def add_member(request: Request, ws_id: int, body: dict = Body(default={})):
_require_ws_admin(request, ws_id)
user_id = body.get("user_id")
role = body.get("role", "editor")
if role not in ROLES:
raise HTTPException(400, f"Invalid role: {role}")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,0)",
(user_id, f"user_{user_id}", f"User {user_id}"))
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
(ws_id, user_id, role))
conn.commit()
return {"status": "added"}
@router.put("/{ws_id}/members/{user_id}")
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
_require_ws_admin(request, ws_id)
role = body.get("role", "editor")
if role not in ROLES:
raise HTTPException(400, f"Invalid role: {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": "updated"}
@router.delete("/{ws_id}/members/{user_id}")
def remove_member(request: Request, ws_id: int, user_id: int):
_require_ws_admin(request, ws_id)
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": "removed"}
# ── Comments ──
@router.get("/pages/{page_id}/comments")
def list_comments(request: Request, page_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
(page_id,),
).fetchall()
return {"comments": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/comments")
def add_comment(request: Request, page_id: int, body: dict = Body(default={})):
b = body.get("body", "").strip()
if not b:
raise HTTPException(400, "body required")
user = _current_user(request)
uid = user.get("id", 1)
parent_id = body.get("parent_id")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(uid, user.get("login", "admin"), user.get("full_name", "Admin")))
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, parent_id) VALUES (?,?,?,?)",
(page_id, uid, b, parent_id))
conn.commit()
try:
run_event_sync(fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid}))
except Exception:
logger.exception("add_comment")
return {"id": cur.lastrowid, "status": "created"}
@router.put("/comments/{comment_id}")
def update_comment(request: Request, comment_id: int, body: dict = Body(default={})):
b = body.get("body")
resolved = body.get("resolved")
with get_conn() as conn:
row = conn.execute("SELECT page_id, resolved FROM comments WHERE id=?", (comment_id,)).fetchone()
if b is not None:
conn.execute("UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (b, comment_id))
if resolved is not None:
conn.execute("UPDATE comments SET resolved=? WHERE id=?", (int(resolved), comment_id))
conn.commit()
if resolved and row and not int(row["resolved"] or 0):
try:
run_event_sync(fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
except Exception:
logger.exception("update_comment")
return {"status": "updated"}
# ── Page History ──
@router.get("/pages/{page_id}/history")
def page_history(request: Request, page_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
(page_id,),
).fetchall()
return {"history": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/history")
def record_history(request: Request, page_id: int, body: dict = Body(default={})):
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
conn.execute(
"INSERT INTO page_history (page_id, user_id, change_type, snapshot_json) VALUES (?,?,?,?)",
(page_id, uid, body.get("change_type", "updated"), json.dumps(body.get("snapshot", {}))),
)
conn.commit()
return {"status": "recorded"}
# ── Favorites ──
@router.get("/favorites")
def list_favorites(request: Request):
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
rows = conn.execute(
"""SELECT f.*, cp.title as page_title, c.name as collection_name
FROM favorites f
LEFT JOIN collection_pages cp ON f.page_id=cp.id
LEFT JOIN collections c ON f.collection_id=c.id
WHERE f.user_id=? ORDER BY f.position""",
(uid,),
).fetchall()
return {"favorites": [dict(r) for r in rows]}
@router.post("/favorites")
def add_favorite(request: Request, body: dict = Body(default={})):
user = _current_user(request)
uid = user.get("id", 1)
page_id = body.get("page_id")
collection_id = body.get("collection_id")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
conn.execute(
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
(uid, page_id, collection_id),
)
conn.commit()
try:
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}))
except Exception:
logger.exception("add_favorite")
return {"status": "favorited"}
@router.delete("/favorites/{fav_id}")
def remove_favorite(request: Request, fav_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
conn.commit()
return {"status": "removed"}
# ── Templates ──
@router.get("/templates/database")
def list_db_templates(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
return {"templates": [dict(r) for r in rows]}
@router.post("/templates/database")
def create_db_template(request: Request, body: dict = Body(default={})):
cur = None
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO database_templates (name, description, icon, schema_json) VALUES (?,?,?,?)",
(body.get("name", "Template"), body.get("description", ""),
body.get("icon", "📋"), json.dumps(body.get("schema", []))),
)
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
@router.post("/templates/database/{tid}/apply")
def apply_db_template(request: Request, tid: int, body: dict = Body(default={})):
from app.services.db_templates import create_from_template
name = body.get("name", "New Database")
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
collection_id = create_from_template(conn, name, dict(tmpl))
conn.commit()
return {"collection_id": collection_id, "name": name, "status": "created"}
@router.get("/collections/{collection_id}/templates/page")
def list_page_templates(request: Request, collection_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
).fetchall()
return {"templates": [dict(r) for r in rows]}
@router.post("/collections/{collection_id}/templates/page")
def create_page_template(request: Request, collection_id: int, body: dict = Body(default={})):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)",
(collection_id, body.get("name", "Default"), json.dumps(body.get("properties", {}))),
)
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
@router.post("/collections/{collection_id}/templates/page/{tid}/apply")
def apply_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})):
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (collection_id,)
).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
)
conn.commit()
run_event_sync(fire_event("page.created", {
"page_id": cur.lastrowid,
"collection_id": collection_id,
"title": body.get("title", "New Page"),
"properties": json.loads(tmpl["property_values_json"]) if tmpl["property_values_json"] else {},
}))
return {"id": cur.lastrowid, "status": "created"}
@router.put("/collections/{collection_id}/templates/page/{tid}")
def update_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})):
"""Update a page template — name, properties, content, recurrence."""
with get_conn() as conn:
tmpl = conn.execute(
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
name = body.get("name", tmpl["name"])
tmpl_dict = dict(tmpl)
description = body.get("description", tmpl_dict.get("description", ""))
property_values_json = json.dumps(body.get("properties", json.loads(tmpl["property_values_json"])))
content_json = json.dumps(body.get("content", json.loads(tmpl_dict.get("content_json", "[]"))))
is_recurring = int(body.get("is_recurring", tmpl_dict.get("is_recurring", 0)))
recurrence_rule = body.get("recurrence_rule", tmpl_dict.get("recurrence_rule", ""))
conn.execute(
"""UPDATE page_templates SET name=?, description=?, property_values_json=?,
content_json=?, is_recurring=?, recurrence_rule=? WHERE id=?""",
(name, description, property_values_json, content_json, is_recurring, recurrence_rule, tid),
)
conn.commit()
return {"id": tid, "status": "updated"}
@router.delete("/collections/{collection_id}/templates/page/{tid}")
def delete_page_template(request: Request, collection_id: int, tid: int):
"""Delete a page template."""
with get_conn() as conn:
tmpl = conn.execute(
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
conn.execute("DELETE FROM page_templates WHERE id=?", (tid,))
conn.commit()
return {"id": tid, "status": "deleted"}
# ── v4.2.0: Dashboards ──
@router.get("/collections/{collection_id}/dashboards")
def list_dashboards(request: Request, collection_id: int):
"""List all dashboards for a collection."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY name", (collection_id,)
).fetchall()
return {"dashboards": [dict(r) for r in rows]}
@router.post("/collections/{collection_id}/dashboards")
def create_dashboard(request: Request, collection_id: int, body: dict = Body(default={})):
"""Create a new dashboard for a collection."""
name = body.get("name", "Dashboard").strip()
layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []}))
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Collection not found")
cur = conn.execute(
"INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?,?,?)",
(collection_id, name, layout),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.put("/collections/{collection_id}/dashboards/{did}")
def update_dashboard(request: Request, collection_id: int, did: int, body: dict = Body(default={})):
"""Update a dashboard — name or layout (widgets grid)."""
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
name = body.get("name", dash["name"])
layout = json.dumps(body.get("layout", json.loads(dash["layout_json"])))
conn.execute(
"UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(name, layout, did),
)
conn.commit()
return {"id": did, "status": "updated"}
@router.delete("/collections/{collection_id}/dashboards/{did}")
def delete_dashboard(request: Request, collection_id: int, did: int):
"""Delete a dashboard."""
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (did,))
conn.commit()
return {"id": did, "status": "deleted"}
# ── v4.5.0: Sprints ──
@router.get("/collections/{collection_id}/sprints")
def list_sprints(request: Request, collection_id: int):
"""List all sprints for a collection."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM sprints WHERE collection_id=? ORDER BY start_date DESC", (collection_id,)
).fetchall()
sprints = []
for r in rows:
s = dict(r)
# Count pages in sprint
count = conn.execute(
"SELECT COUNT(*) as cnt FROM sprint_pages WHERE sprint_id=?", (r["id"],)
).fetchone()["cnt"]
s["page_count"] = count
sprints.append(s)
return {"sprints": sprints}
@router.post("/collections/{collection_id}/sprints")
def create_sprint(request: Request, collection_id: int, body: dict = Body(default={})):
"""Create a new sprint."""
name = body.get("name", "").strip()
start_date = body.get("start_date", "")
end_date = body.get("end_date", "")
if not name or not start_date or not end_date:
raise HTTPException(400, "name, start_date, end_date are required")
goal = body.get("goal", "")
status = body.get("status", "planning")
auto_complete = int(body.get("auto_complete", 1))
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO sprints (collection_id, name, start_date, end_date, goal, status, auto_complete) VALUES (?,?,?,?,?,?,?)",
(collection_id, name, start_date, end_date, goal, status, auto_complete),
)
conn.commit()
try:
run_event_sync(fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name}))
except Exception:
logger.exception("create_sprint")
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.put("/collections/{collection_id}/sprints/{sid}")
def update_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})):
"""Update a sprint."""
with get_conn() as conn:
sprint = conn.execute(
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
name = body.get("name", sprint["name"])
start_date = body.get("start_date", sprint["start_date"])
end_date = body.get("end_date", sprint["end_date"])
goal = body.get("goal", sprint["goal"])
status = body.get("status", sprint["status"])
auto_complete = int(body.get("auto_complete", sprint["auto_complete"]))
conn.execute(
"UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=?, auto_complete=? WHERE id=?",
(name, start_date, end_date, goal, status, auto_complete, sid),
)
conn.commit()
try:
run_event_sync(fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status}))
except Exception:
logger.exception("update_sprint")
return {"id": sid, "status": "updated"}
@router.delete("/collections/{collection_id}/sprints/{sid}")
def delete_sprint(request: Request, collection_id: int, sid: int):
"""Delete a sprint."""
with get_conn() as conn:
sprint = conn.execute(
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
conn.execute("DELETE FROM sprints WHERE id=?", (sid,))
conn.commit()
return {"id": sid, "status": "deleted"}
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
def assign_page_to_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})):
"""Assign a page to a sprint."""
page_id = body.get("page_id")
if not page_id:
raise HTTPException(400, "page_id is required")
velocity_points = body.get("velocity_points", 1)
status_at_start = body.get("status_at_start", "")
with get_conn() as conn:
sprint = conn.execute("SELECT id FROM sprints WHERE id=?", (sid,)).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
page = conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
try:
conn.execute(
"INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)",
(sid, page_id, status_at_start, velocity_points),
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(409, "Page already assigned to this sprint") from None
return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
"""Remove a page from a sprint."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id)
).fetchone()
if not existing:
raise HTTPException(404, "Assignment not found")
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id))
conn.commit()
return {"sprint_id": sid, "page_id": page_id, "status": "removed"}
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
def sprint_burndown(request: Request, collection_id: int, sid: int):
"""Calculate burndown data for a sprint."""
with get_conn() as conn:
sprint = conn.execute(
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
pages = conn.execute(
"""SELECT sp.velocity_points, cp.property_values_json
FROM sprint_pages sp JOIN collection_pages cp ON sp.page_id=cp.id
WHERE sp.sprint_id=?""", (sid,)
).fetchall()
total_points = sum(p["velocity_points"] for p in pages)
completed = 0
for p in pages:
props = json.loads(p["property_values_json"])
for v in props.values():
if isinstance(v, str) and v.lower() in ("done", "complete", "completed", "terminé"):
completed += p["velocity_points"]
break
from datetime import date
today = date.today()
start = date.fromisoformat(sprint["start_date"]) if sprint["start_date"] else today
end = date.fromisoformat(sprint["end_date"]) if sprint["end_date"] else today
total_days = max((end - start).days, 1)
elapsed = max((today - start).days, 0)
ideal_burn = total_points - (total_points * elapsed / total_days)
return {
"sprint": sprint["name"],
"total_points": total_points,
"completed_points": completed,
"remaining_points": total_points - completed,
"ideal_remaining": round(ideal_burn, 1),
"start_date": sprint["start_date"],
"end_date": sprint["end_date"],
"days_elapsed": elapsed,
"days_total": total_days,
}
# ── CSV Import/Export ──
@router.post("/collections/{collection_id}/import/csv")
def import_csv(request: Request, collection_id: int, body: dict = Body(default={})):
csv_data = body.get("csv", "")
if not csv_data:
raise HTTPException(400, "csv field required")
reader = csv.DictReader(io.StringIO(csv_data))
rows_imported = 0
with get_conn() as conn:
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) FROM collection_pages WHERE collection_id=?", (collection_id,)
).fetchone()[0]
for row in reader:
title = row.get("title", row.get("Title", row.get("Name", "Imported")))
props = {k: v for k, v in row.items() if k.lower() != "title"}
max_pos += 1
conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(collection_id, title, max_pos, json.dumps(props)),
)
rows_imported += 1
conn.commit()
return {"imported": rows_imported}
@router.get("/collections/{collection_id}/export/csv")
def export_csv(request: Request, collection_id: int):
with get_conn() as conn:
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
).fetchall()
# Collect all property keys
all_keys = set()
rows = []
for p in pages:
props = json.loads(p["property_values_json"])
all_keys.update(props.keys())
rows.append({"title": p["title"], **props})
output = io.StringIO()
fieldnames = ["title"] + sorted(all_keys)
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=export.csv"},
)
# ── Webhooks Outbound Management ──
@router.get("/webhooks")
def list_webhooks(request: Request):
_require_admin(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
return {"webhooks": [dict(r) for r in rows]}
@router.post("/webhooks")
def create_webhook(request: Request, body: dict = Body(default={})):
_require_admin(request)
url = body.get("url", "").strip()
event = body.get("event", "page.created")
secret = body.get("secret", "")
if not url:
raise HTTPException(400, "url required")
# A15 : SSRF — le scheduler POSTe le contenu des pages vers cette URL.
from urllib.parse import urlparse
from app.services.importers.url_fetch import _is_public_host
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname or not _is_public_host(parsed.hostname):
raise HTTPException(400, f"url non autorisée: {parsed.hostname}")
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)",
(url, event, secret),
)
conn.commit()
return {"id": cur.lastrowid, "url": url, "event": event, "status": "registered"}
@router.delete("/webhooks/{wh_id}")
def delete_webhook(request: Request, wh_id: int):
_require_admin(request)
with get_conn() as conn:
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
conn.commit()
return {"status": "deleted"}
# ── Public Sharing ──
@router.get("/public/{collection_id}")
def public_view(request: Request, collection_id: int):
"""Simple public read-only view — no auth required.
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
masquées (404) et toute interpolation part dans ``html.escape`` (XSS stocké
sur le titre de la base ou d'une ligne).
"""
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Collection not found")
ptype = coll["permission_type"] if "permission_type" in coll.keys() else "inherit"
if ptype in ("restricted", "private"):
# 404 explicite : le handler global transformerait un HTTPException(404)
# en redirection 302 → login pour un chemin HTML.
return HTMLResponse(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>404</title></head>"
"<body><h1>404 — Not found</h1></body></html>",
status_code=404,
)
pages = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
esc = html.escape
name = esc(str(coll["name"] or ""))
icon = esc(str(coll["icon"] or ""))
items = "".join(
f"<li>{esc(str(p['icon'] or ''))} <b>{esc(str(p['title'] or ''))}</b></li>"
for p in pages
)
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>{name} — FlowDeck Public</title>
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
<body><h1>{icon} {name}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")