- Conversion `async def` → `def` de TOUTES les routes dont le corps ne contient ni `await`, ni `async with`, ni `async for`, ni `asyncio` (scan automatique corps par corps sur app/ : 352 converties, 0 dangereuses, vérifié `asyncio`/`run_coroutine`/`.result()` absents). FastAPI exécute ces handlers dans son threadpool → tout leur SQLite (`get_conn()` + `conn.execute`) quitte l'event loop, sans changer une ligne de logique. - Répartition : api_v2 60, dashboard 40, collections 25, board 23, workspace 19, wiki 17, permissions 14, api 14, main.py 6, + 35 fichiers. - Les 4 routers prioritaires de l'audit sont couverts par ce lot : api_v2 60 + dashboard 40 + collections 25 + board 23 = 148 conversions (le reste de leurs routes attend la phase 2 : elles ont de vrais `await`). - Reste (phase 2) : les 311 routes avec de vrais `await` → enrouler les blocs DB dans `await anyio.to_thread.run_sync(...)` ; pas de wrapper partagé livré (rien ne l'appellerait — YAGNI jusqu'au premier usage). suite **1037/1037** (233 s) · `ruff check app tests` OK · docs à jour
211 lines
8.6 KiB
Python
211 lines
8.6 KiB
Python
"""FlowDeck — Collaboration API (v4.9.0): inline comments on pages + mentions.
|
|
|
|
Comments live in the existing `comments` table, extended with a target_type /
|
|
target_id pair and inline anchors (anchor_block_id + text offsets). Mentions
|
|
written in a comment body automatically notify the mentioned users.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
from app.services import notifications as notif
|
|
from app.services.automations import fire_event as _fire_event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["collaboration"], prefix="/api")
|
|
|
|
|
|
def _current_user(request: Request) -> dict:
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user or not user.get("id"):
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
return user
|
|
|
|
|
|
def _page_url(page_id: int) -> str:
|
|
from app.config import settings
|
|
return f"{settings.app_base_url}/pages/{page_id}"
|
|
|
|
|
|
def _serialize(rows):
|
|
out = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
d["author"] = {
|
|
"id": r["author_id"],
|
|
"login": r["author_login"],
|
|
"full_name": r["author_name"],
|
|
"avatar_url": r["author_avatar"],
|
|
"avatar_color": r["author_color"],
|
|
}
|
|
for k in ("author_id", "author_login", "author_name", "author_avatar", "author_color"):
|
|
d.pop(k, None)
|
|
out.append(d)
|
|
return out
|
|
|
|
|
|
@router.get("/pages/{page_id}/comments")
|
|
def list_comments(request: Request, page_id: int):
|
|
"""List page-level and inline comments for a FlowDeck page."""
|
|
_current_user(request)
|
|
with get_conn() as conn:
|
|
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not page:
|
|
raise HTTPException(404, "Page not found")
|
|
rows = conn.execute(
|
|
"""SELECT c.*, c.user_id AS author_id, u.login AS author_login,
|
|
u.full_name AS author_name, u.avatar_url AS author_avatar,
|
|
u.avatar_color AS author_color
|
|
FROM comments c
|
|
JOIN users u ON c.user_id = u.id
|
|
WHERE c.target_type='page' AND c.target_id=?
|
|
ORDER BY c.created_at ASC, c.id ASC""",
|
|
(page_id,),
|
|
).fetchall()
|
|
return {"page_id": page_id, "comments": _serialize(rows)}
|
|
|
|
|
|
@router.post("/pages/{page_id}/comments")
|
|
async def add_comment(request: Request, page_id: int):
|
|
"""Create a page or inline comment. Mentions (@login) notify users."""
|
|
user = _current_user(request)
|
|
body = await request.json() if request.headers.get("content-type") else {}
|
|
text = (body.get("body") or "").strip()
|
|
if not text:
|
|
raise HTTPException(400, "body required")
|
|
|
|
anchor_block = body.get("anchor_block_id")
|
|
anchor_start = body.get("anchor_start")
|
|
anchor_end = body.get("anchor_end")
|
|
# normalize empty anchor → page-level comment
|
|
if not anchor_block or anchor_start is None or anchor_end is None:
|
|
anchor_block, anchor_start, anchor_end = None, None, None
|
|
elif int(anchor_start) == int(anchor_end):
|
|
anchor_block, anchor_start, anchor_end = None, None, None
|
|
|
|
parent_id = body.get("parent_id")
|
|
uid = user["id"]
|
|
with get_conn() as conn:
|
|
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not page:
|
|
raise HTTPException(404, "Page not found")
|
|
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, target_type, target_id,
|
|
anchor_block_id, anchor_start, anchor_end)
|
|
VALUES (?,?,?,?, 'page', ?, ?, ?, ?)""",
|
|
(page_id, uid, text, parent_id, page_id, anchor_block, anchor_start, anchor_end),
|
|
)
|
|
comment_id = cur.lastrowid
|
|
conn.commit()
|
|
|
|
# v7.3.0: commenting implies following — the author gets the
|
|
# (throttled) page.updated notifications like any other follower.
|
|
from app.services import wiki as wiki_svc
|
|
wiki_svc.ensure_follow(page_id, uid, conn=conn)
|
|
conn.commit()
|
|
|
|
# Notify users @-mentioned in the comment (skip the author).
|
|
url = _page_url(page_id)
|
|
title = f"New comment on “{page['title']}”"
|
|
message = f"{user.get('full_name') or user.get('login')} commented: {text[:300]}"
|
|
notif.process_mentions(
|
|
text, uid, "mention", title, message,
|
|
"page", page_id, url, conn=conn,
|
|
)
|
|
conn.commit()
|
|
|
|
try:
|
|
await _fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid})
|
|
mentioned_ids = notif.extract_mentions(text)
|
|
if mentioned_ids:
|
|
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
|
|
except Exception:
|
|
logger.exception("add_comment")
|
|
|
|
return {"id": comment_id, "status": "created"}
|
|
|
|
|
|
@router.post("/pages/{page_id}/mentions")
|
|
async def notify_page_mentions(request: Request, page_id: int):
|
|
"""Notify users @-mentioned in a page's content (called on save).
|
|
|
|
Accepts {"text": "..."} containing @login handles. Deduplicated server-side
|
|
against a per-page cache so repeated auto-saves don't spam notifications.
|
|
"""
|
|
user = _current_user(request)
|
|
body = await request.json() if request.headers.get("content-type") else {}
|
|
text = body.get("text") or ""
|
|
with get_conn() as conn:
|
|
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not page:
|
|
raise HTTPException(404, "Page not found")
|
|
url = _page_url(page_id)
|
|
mentioned = notif.process_mentions(
|
|
text, user["id"], "mention", f"You were mentioned in “{page['title']}”",
|
|
f"{user.get('full_name') or user.get('login')} mentioned you on a page.",
|
|
"page", page_id, url, conn=conn,
|
|
)
|
|
conn.commit()
|
|
if mentioned:
|
|
try:
|
|
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
|
except Exception:
|
|
logger.exception("notify_page_mentions")
|
|
return {"mentioned": mentioned}
|
|
|
|
|
|
@router.put("/comments/{comment_id}")
|
|
async def update_comment(request: Request, comment_id: int):
|
|
"""Update a comment body or resolve/unresolve it."""
|
|
user = _current_user(request)
|
|
body = await request.json() if request.headers.get("content-type") else {}
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM comments WHERE id=?", (comment_id,)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Comment not found")
|
|
if row["user_id"] != user["id"]:
|
|
raise HTTPException(403, "Not allowed to edit this comment")
|
|
if "body" in body and body.get("body") is not None:
|
|
conn.execute(
|
|
"UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(body["body"].strip(), comment_id),
|
|
)
|
|
was_resolved = int(row["resolved"] or 0)
|
|
if "resolved" in body and body.get("resolved") is not None:
|
|
conn.execute("UPDATE comments SET resolved=? WHERE id=?",
|
|
(1 if body["resolved"] else 0, comment_id))
|
|
conn.commit()
|
|
if body.get("resolved") and not was_resolved:
|
|
try:
|
|
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
|
except Exception:
|
|
logger.exception("update_comment")
|
|
return {"id": comment_id, "status": "updated"}
|
|
|
|
|
|
@router.delete("/comments/{comment_id}")
|
|
def delete_comment(request: Request, comment_id: int):
|
|
"""Delete a comment and its replies."""
|
|
user = _current_user(request)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Comment not found")
|
|
if row["user_id"] != user["id"]:
|
|
# allow page "owners" — fall back to a simple ownership rule for now
|
|
raise HTTPException(403, "Not allowed to delete this comment")
|
|
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
|
|
conn.commit()
|
|
return {"id": comment_id, "status": "deleted"}
|