Découpe par concern de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) en package `app/routers/api_v2/` : - 12 modules de routes : collections 566 L (23 r.), engagement 338 (21), workspaces 230 (9), templates_io 205 (9), webhooks 195 (8), identity 195 (7), views 164 (8), sharing 160 (8), properties 151 (7), planning 148 (7), projects 93 (4), admin 91 (4) - `_common.py` : helpers partagés (_hash, _v2_rate_check) - `__init__.py` : router = APIRouter(prefix="/api/v2") + include_router sur les routers de sections (sans prefix, tags « api-v2 ») Preuve contractuelle : `docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte (0 changement de chemin/tag/operation_id). Seul importateur (app/main.py : from app.routers.api_v2 import router) fonctionne via le package. En-tête d'imports copié par module puis émondé par ruff --fix (143 imports morts), I001 réordonnés. Reste A28 : dashboard.py 2 735 L, collections.py 2 622 L, board.py 2 101 L (même recette, lots suivants). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
339 lines
15 KiB
Python
339 lines
15 KiB
Python
"""FlowDeck — Public API v2 : engagement.
|
|
|
|
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
|
|
|
from app.db import get_conn
|
|
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
|
audit_log,
|
|
check_idempotency,
|
|
check_v2_rate_limit,
|
|
get_bearer_user,
|
|
has_scope,
|
|
paginate_headers,
|
|
parse_pagination,
|
|
require_scope,
|
|
row_to_dict,
|
|
store_idempotency,
|
|
to_iso8601,
|
|
validate_scopes_input,
|
|
)
|
|
from app.services.automations import fire_event as _fire_event
|
|
from app.services.automations import run_event_sync
|
|
|
|
from ._common import _v2_rate_check
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["api-v2"])
|
|
|
|
|
|
|
|
@router.get("/pages/{page_id}/comments")
|
|
def list_comments_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
limit, offset = parse_pagination(request)
|
|
with get_conn() as conn:
|
|
total = conn.execute("SELECT COUNT(*) FROM comments WHERE target_id=? OR page_id=?", (page_id, page_id)).fetchone()[0]
|
|
rows = conn.execute("SELECT c.*, u.login, u.full_name FROM comments c LEFT JOIN users u ON u.id=c.user_id WHERE c.target_id=? OR c.page_id=? ORDER BY c.created_at LIMIT ? OFFSET ?", (page_id, page_id, limit, offset)).fetchall()
|
|
return {"comments": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
|
|
|
|
|
@router.post("/pages/{page_id}/comments")
|
|
def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
text = (body.get("body") or body.get("content") or "").strip()
|
|
if not text:
|
|
raise HTTPException(400, "body is required")
|
|
with get_conn() as conn:
|
|
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, target_type, target_id, anchor_block_id, anchor_start, anchor_end) VALUES (?, ?, ?, 'page', ?, ?, ?, ?)", (page_id, user["id"], text, page_id, body.get("anchor_block_id"), body.get("anchor_start"), body.get("anchor_end")))
|
|
nid = cur.lastrowid
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM comments WHERE id=?", (nid,)).fetchone()
|
|
audit_log(user, "comment.create", "comment", nid, text[:80], request)
|
|
try:
|
|
run_event_sync(_fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]}))
|
|
except Exception:
|
|
logger.exception("create_comment_v2")
|
|
return {"id": nid, "status": "created", "comment": row_to_dict(row)}
|
|
|
|
|
|
@router.patch("/comments/{comment_id}")
|
|
def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
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"] and not user.get("is_admin"):
|
|
raise HTTPException(403, "Not your comment")
|
|
body_text = body.get("body", row["body"])
|
|
resolved = body.get("resolved", row["resolved"])
|
|
was_resolved = int(row["resolved"] or 0)
|
|
conn.execute("UPDATE comments SET body=?, resolved=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (body_text, int(bool(resolved)), comment_id))
|
|
conn.commit()
|
|
if int(bool(resolved)) and not was_resolved:
|
|
try:
|
|
run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
|
|
except Exception:
|
|
logger.exception("patch_comment_v2")
|
|
return {"id": comment_id, "status": "updated"}
|
|
|
|
|
|
@router.delete("/comments/{comment_id}")
|
|
def delete_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
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"] and not user.get("is_admin"):
|
|
raise HTTPException(403, "Not your comment")
|
|
conn.execute("DELETE FROM comments WHERE id=?", (comment_id,))
|
|
conn.commit()
|
|
return {"id": comment_id, "status": "deleted"}
|
|
|
|
|
|
@router.post("/pages/{page_id}/mentions")
|
|
def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
targets = body.get("user_ids") or body.get("mentions") or []
|
|
if isinstance(targets, int):
|
|
targets = [targets]
|
|
if not targets:
|
|
raise HTTPException(400, "user_ids required")
|
|
created = 0
|
|
with get_conn() as conn:
|
|
for uid in targets:
|
|
try:
|
|
conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}"))
|
|
created += 1
|
|
except Exception:
|
|
logger.exception("create_mention_v2")
|
|
conn.commit()
|
|
if created:
|
|
try:
|
|
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created}))
|
|
except Exception:
|
|
logger.exception("create_mention_v2")
|
|
return {"mentions": created, "status": "created"}
|
|
|
|
|
|
@router.get("/notifications")
|
|
def list_notifications_v2(request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
limit, offset = parse_pagination(request)
|
|
unread = request.query_params.get("unread")
|
|
with get_conn() as conn:
|
|
where = "user_id=?"
|
|
params: list = [user["id"]]
|
|
if unread == "1":
|
|
where += " AND is_read=0"
|
|
total = conn.execute(f"SELECT COUNT(*) FROM notifications WHERE {where}", params).fetchone()[0]
|
|
rows = conn.execute(f"SELECT * FROM notifications WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
|
|
return {"notifications": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
|
|
|
|
|
@router.get("/notifications/unread-count")
|
|
def unread_count(request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
cnt = conn.execute("SELECT COUNT(*) FROM notifications WHERE user_id=? AND is_read=0", (user["id"],)).fetchone()[0]
|
|
return {"unread": cnt}
|
|
|
|
|
|
@router.post("/notifications/{notif_id}/read")
|
|
def mark_read(notif_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?", (notif_id, user["id"]))
|
|
conn.commit()
|
|
return {"id": notif_id, "status": "read"}
|
|
|
|
|
|
@router.post("/notifications/read-all")
|
|
def mark_all_read(request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE notifications SET is_read=1 WHERE user_id=?", (user["id"],))
|
|
conn.commit()
|
|
return {"status": "all read"}
|
|
|
|
|
|
@router.patch("/users/me/preferences")
|
|
def patch_prefs(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone()
|
|
try:
|
|
cur = json.loads(row["notification_prefs"] or "{}")
|
|
except Exception:
|
|
cur = {}
|
|
cur.update(body)
|
|
conn.execute("UPDATE users SET notification_prefs=? WHERE id=?", (json.dumps(cur), user["id"]))
|
|
conn.commit()
|
|
return {"preferences": cur}
|
|
|
|
|
|
@router.get("/favorites")
|
|
def list_favorites_v2(request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
rows = conn.execute("SELECT f.*, p.title, p.workspace_id FROM favorites f JOIN pages p ON p.id=f.page_id WHERE f.user_id=? ORDER BY f.position", (user["id"],)).fetchall()
|
|
return {"favorites": [row_to_dict(r) for r in rows]}
|
|
|
|
|
|
@router.post("/favorites")
|
|
def add_favorite_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
pid = body.get("page_id")
|
|
if not pid:
|
|
raise HTTPException(400, "page_id required")
|
|
with get_conn() as conn:
|
|
try:
|
|
conn.execute("INSERT INTO favorites (user_id, page_id) VALUES (?, ?)", (user["id"], pid))
|
|
conn.commit()
|
|
except Exception as e:
|
|
raise HTTPException(409, str(e)) from None
|
|
try:
|
|
run_event_sync(_fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]}))
|
|
except Exception:
|
|
logger.exception("add_favorite_v2")
|
|
return {"page_id": pid, "status": "added"}
|
|
|
|
|
|
@router.delete("/favorites/{page_id}")
|
|
def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id))
|
|
conn.commit()
|
|
try:
|
|
run_event_sync(_fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]}))
|
|
except Exception:
|
|
logger.exception("remove_favorite_v2")
|
|
return {"page_id": page_id, "status": "removed"}
|
|
|
|
|
|
@router.get("/tags")
|
|
def list_tags_v2(request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
q = (request.query_params.get("q") or "").strip()
|
|
with get_conn() as conn:
|
|
if q:
|
|
rows = conn.execute("SELECT * FROM tags WHERE user_id=? AND name LIKE ? ORDER BY name", (user["id"], f"%{q}%")).fetchall()
|
|
else:
|
|
rows = conn.execute("SELECT * FROM tags WHERE user_id=? ORDER BY name", (user["id"],)).fetchall()
|
|
return {"tags": [dict(r) for r in rows]}
|
|
|
|
|
|
@router.post("/tags")
|
|
def create_tag_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
name = (body.get("name") or "").strip()
|
|
if not name:
|
|
raise HTTPException(400, "name required")
|
|
color = body.get("color", "#787774")
|
|
with get_conn() as conn:
|
|
try:
|
|
cur = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (name, color, user["id"]))
|
|
tid = cur.lastrowid
|
|
conn.commit()
|
|
except Exception as e:
|
|
raise HTTPException(409, str(e)) from None
|
|
return {"id": tid, "name": name, "color": color, "status": "created"}
|
|
|
|
|
|
@router.patch("/tags/{tag_id}")
|
|
def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Tag not found")
|
|
name = body.get("name", row["name"])
|
|
color = body.get("color", row["color"])
|
|
conn.execute("UPDATE tags SET name=?, color=? WHERE id=?", (name, color, tag_id))
|
|
conn.commit()
|
|
return {"id": tag_id, "status": "updated"}
|
|
|
|
|
|
@router.delete("/tags/{tag_id}")
|
|
def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
conn.execute("DELETE FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"]))
|
|
conn.commit()
|
|
return {"id": tag_id, "status": "deleted"}
|
|
|
|
|
|
@router.post("/pages/{page_id}/tags")
|
|
def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
tag_id = body.get("tag_id")
|
|
if not tag_id:
|
|
raise HTTPException(400, "tag_id required")
|
|
with get_conn() as conn:
|
|
try:
|
|
conn.execute("INSERT INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id))
|
|
conn.commit()
|
|
except Exception as e:
|
|
raise HTTPException(409, str(e)) from None
|
|
return {"page_id": page_id, "tag_id": tag_id, "status": "attached"}
|
|
|
|
|
|
@router.delete("/pages/{page_id}/tags/{tag_id}")
|
|
def detach_tag_v2(page_id: int, tag_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
conn.execute("DELETE FROM page_tags WHERE page_id=? AND tag_id=?", (page_id, tag_id))
|
|
conn.commit()
|
|
return {"status": "detached"}
|
|
|
|
|
|
@router.get("/recents")
|
|
def list_recents_v2(request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
limit = int(request.query_params.get("limit", "20"))
|
|
st = request.query_params.get("source_type")
|
|
with get_conn() as conn:
|
|
if st:
|
|
rows = conn.execute("SELECT * FROM recents WHERE user_id=? AND source_type=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], st, limit)).fetchall()
|
|
else:
|
|
rows = conn.execute("SELECT * FROM recents WHERE user_id=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], limit)).fetchall()
|
|
return {"recents": [row_to_dict(r) for r in rows]}
|
|
|
|
|
|
@router.get("/pages/{page_id}/shares")
|
|
def list_shares_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
rows = conn.execute("SELECT * FROM page_shares WHERE page_id=?", (page_id,)).fetchall()
|
|
return {"shares": [dict(r) for r in rows]}
|