fix: A21 phase 2b — run_event_sync + 15 routes api_v2 en def (v7.10.0)
- `run_event_sync(coro)` (app/services/automations.py) : exécute une coroutine
d'événement depuis un handler synchrone — `asyncio.run` sur une boucle
dédiée dans le worker threadpool : le worker est bloqué, JAMAIS la boucle
d'event, et la réponse n'est produite qu'une fois l'événement terminé
(déterministe, équivalent sémantique de l'await). ponytail: les clients
httpx sont créés à chaque appel partout → aucun lien de boucle ; sinon
run_coroutine_threadsafe + boucle du lifespan.
- 15 routes api_v2 dont les SEULS awaits étaient `request.json`,
`_fire_event`, `fire_published`, `fire_unpublished` →
`Body(default={})` + `run_event_sync(...)` + conversion en `def` (script
: wrapping par appariement de parenthèses chaîne-aware, assert de flip
« plus aucun await »).
- api_v2 : **111/115 routes hors loop**. Les 4 restantes ont de vrais awaits
réseau et restent async volontairement : import_csv_v2 (multipart),
project_tree_v2 (gitea), test_webhook_v2 (delivery), retry_webhook_deliveries.
- Repo-wide : 403 routes sync (hors loop) / 260 async (phase 2c).
tests : ciblé public_api_v2 + v65 + webhooks_v2 + audit = 90/90 (les webhooks
prouvent la détermination de run_event_sync) ; suite complète **1037/1037**
(228 s) · `ruff check app tests` OK · docs à jour
This commit is contained in:
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.9.0",
|
||||
version="7.10.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
+31
-74
@@ -31,6 +31,7 @@ from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est ut
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -661,16 +662,12 @@ def list_collection_pages_v2(collection_id: int, request: Request, authorization
|
||||
return JSONResponse(content={"pages": out, "total": total, "limit": limit, "offset": offset}, headers=paginate_headers(total))
|
||||
|
||||
@router.post("/collections/{collection_id}/pages")
|
||||
async def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def create_collection_page_v2(collection_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)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = (body.get("title") or body.get("name") or "Untitled").strip() or "Untitled"
|
||||
icon = body.get("icon", "file")
|
||||
parent_id = body.get("parent_id")
|
||||
@@ -703,7 +700,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
|
||||
audit_log(user, "page.create", "collection_page", pid, title, request)
|
||||
try:
|
||||
await _fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})
|
||||
run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title}))
|
||||
except Exception:
|
||||
logger.exception("create_collection_page_v2")
|
||||
data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)}
|
||||
@@ -741,13 +738,9 @@ def get_page_v2(page_id: int, request: Request, authorization: str | None = Head
|
||||
return d
|
||||
|
||||
@router.patch("/pages/{page_id}")
|
||||
async def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def patch_page_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)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -782,13 +775,13 @@ async def patch_page_v2(page_id: int, request: Request, authorization: str | Non
|
||||
conn.commit()
|
||||
audit_log(user, "page.update", "collection_page", page_id, "", request)
|
||||
try:
|
||||
await _fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})
|
||||
run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title}))
|
||||
except Exception:
|
||||
logger.exception("patch_page_v2")
|
||||
return {"id": page_id, "status": "updated"}
|
||||
|
||||
@router.delete("/pages/{page_id}")
|
||||
async def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def delete_page_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:
|
||||
@@ -799,13 +792,13 @@ async def delete_page_v2(page_id: int, request: Request, authorization: str | No
|
||||
conn.commit()
|
||||
audit_log(user, "page.delete", "collection_page", page_id, "", request)
|
||||
try:
|
||||
await _fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})
|
||||
run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]}))
|
||||
except Exception:
|
||||
logger.exception("delete_page_v2")
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
@router.post("/pages/{page_id}/restore")
|
||||
async def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
# For soft-deleted pages (deleted_at) - but collection_pages has no deleted_at; handle pages table
|
||||
@@ -815,7 +808,7 @@ async def restore_page_v2(page_id: int, request: Request, authorization: str | N
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
await _fire_event("page.restored", {"page_id": page_id})
|
||||
run_event_sync(_fire_event("page.restored", {"page_id": page_id}))
|
||||
except Exception:
|
||||
logger.exception("restore_page_v2")
|
||||
return {"id": page_id, "status": "restored"}
|
||||
@@ -1029,13 +1022,9 @@ def list_views_v2(collection_id: int, request: Request, authorization: str | Non
|
||||
return {"views": [row_to_dict(r) for r in rows]}
|
||||
|
||||
@router.post("/collections/{collection_id}/views")
|
||||
async def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def create_view_v2(collection_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)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "New View").strip()
|
||||
vtype = body.get("view_type") or body.get("type") or "table"
|
||||
config = body.get("config") or body.get("config_json") or {}
|
||||
@@ -1048,7 +1037,7 @@ async def create_view_v2(collection_id: int, request: Request, authorization: st
|
||||
conn.commit()
|
||||
audit_log(user, "view.create", "view", vid, name, request)
|
||||
try:
|
||||
await _fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype})
|
||||
run_event_sync(_fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype}))
|
||||
except Exception:
|
||||
logger.exception("create_view_v2")
|
||||
return {"id": vid, "name": name, "view_type": vtype, "status": "created"}
|
||||
@@ -1167,13 +1156,9 @@ def list_comments_v2(page_id: int, request: Request, authorization: str | None =
|
||||
return {"comments": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
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)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
text = (body.get("body") or body.get("content") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "body is required")
|
||||
@@ -1184,19 +1169,15 @@ async def create_comment_v2(page_id: int, request: Request, authorization: str |
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (nid,)).fetchone()
|
||||
audit_log(user, "comment.create", "comment", nid, text[:80], request)
|
||||
try:
|
||||
await _fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]})
|
||||
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}")
|
||||
async def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
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)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1210,7 +1191,7 @@ async def patch_comment_v2(comment_id: int, request: Request, authorization: str
|
||||
conn.commit()
|
||||
if int(bool(resolved)) and not was_resolved:
|
||||
try:
|
||||
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
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"}
|
||||
@@ -1230,13 +1211,9 @@ def delete_comment_v2(comment_id: int, request: Request, authorization: str | No
|
||||
return {"id": comment_id, "status": "deleted"}
|
||||
|
||||
@router.post("/pages/{page_id}/mentions")
|
||||
async def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
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)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
targets = body.get("user_ids") or body.get("mentions") or []
|
||||
if isinstance(targets, int):
|
||||
targets = [targets]
|
||||
@@ -1253,7 +1230,7 @@ async def create_mention_v2(page_id: int, request: Request, authorization: str |
|
||||
conn.commit()
|
||||
if created:
|
||||
try:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created})
|
||||
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"}
|
||||
@@ -1327,13 +1304,9 @@ def list_favorites_v2(request: Request, authorization: str | None = Header(defau
|
||||
return {"favorites": [row_to_dict(r) for r in rows]}
|
||||
|
||||
@router.post("/favorites")
|
||||
async def add_favorite_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
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)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
pid = body.get("page_id")
|
||||
if not pid:
|
||||
raise HTTPException(400, "page_id required")
|
||||
@@ -1344,20 +1317,20 @@ async def add_favorite_v2(request: Request, authorization: str | None = Header(d
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
try:
|
||||
await _fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]})
|
||||
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}")
|
||||
async def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
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:
|
||||
await _fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]})
|
||||
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"}
|
||||
@@ -1462,13 +1435,9 @@ def list_shares_v2(page_id: int, request: Request, authorization: str | None = H
|
||||
return {"shares": [dict(r) for r in rows]}
|
||||
|
||||
@router.post("/pages/{page_id}/shares")
|
||||
async def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def create_share_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)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
perm = (body.get("permission") or "view").strip().lower()
|
||||
if perm not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit")
|
||||
@@ -1483,7 +1452,7 @@ async def create_share_v2(page_id: int, request: Request, authorization: str | N
|
||||
nid = cur.lastrowid
|
||||
audit_log(user, "share.create", "share", nid, f"page={page_id}", request)
|
||||
try:
|
||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm})
|
||||
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm}))
|
||||
except Exception:
|
||||
logger.exception("create_share_v2")
|
||||
return {"id": nid, "page_id": page_id, "status": "shared"}
|
||||
@@ -1519,25 +1488,21 @@ def delete_share_v2(share_id: int, request: Request, authorization: str | None =
|
||||
return {"id": share_id, "status": "revoked"}
|
||||
|
||||
@router.post("/pages/{page_id}/publish")
|
||||
async def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def publish_page_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)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
slug_in = (body.get("slug") or body.get("publish_slug") or "").strip() or None
|
||||
slug, _title = publish(page_id, explicit_slug=slug_in)
|
||||
audit_log(user, "page.publish", "page", page_id, slug, request)
|
||||
await fire_published(page_id, slug)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
|
||||
|
||||
@router.delete("/pages/{page_id}/publish")
|
||||
async def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
unpublish(page_id)
|
||||
await fire_unpublished(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {"page_id": page_id, "status": "unpublished"}
|
||||
|
||||
# ── History ───────────────────────────────────────────────────────────────
|
||||
@@ -1591,13 +1556,9 @@ def list_sprints_v2(collection_id: int, request: Request, authorization: str | N
|
||||
return {"sprints": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints")
|
||||
async def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def create_sprint_v2(collection_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)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "Sprint").strip()
|
||||
start = body.get("start_date") or body.get("start") or ""
|
||||
end = body.get("end_date") or body.get("end") or ""
|
||||
@@ -1608,19 +1569,15 @@ async def create_sprint_v2(collection_id: int, request: Request, authorization:
|
||||
sid = cur.lastrowid
|
||||
conn.commit()
|
||||
try:
|
||||
await _fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name})
|
||||
run_event_sync(_fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name}))
|
||||
except Exception:
|
||||
logger.exception("create_sprint_v2")
|
||||
return {"id": sid, "name": name, "status": "created"}
|
||||
|
||||
@router.patch("/sprints/{sprint_id}")
|
||||
async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def patch_sprint_v2(sprint_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)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1633,7 +1590,7 @@ async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str |
|
||||
conn.execute("UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=? WHERE id=?", (name, start, end, goal, status, sprint_id))
|
||||
conn.commit()
|
||||
try:
|
||||
await _fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status})
|
||||
run_event_sync(_fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status}))
|
||||
except Exception:
|
||||
logger.exception("patch_sprint_v2")
|
||||
return {"id": sprint_id, "status": "updated"}
|
||||
|
||||
@@ -355,6 +355,19 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
|
||||
return {"status": "error", "detail": str(exc)}
|
||||
|
||||
|
||||
def run_event_sync(coro, timeout: float = 60.0):
|
||||
"""A21 phase 2b : exécute une coroutine d'événement depuis un handler synchrone.
|
||||
|
||||
Bloque le worker threadpool (jamais la boucle d'event) et attend la fin —
|
||||
déterministe, exactement ce que faisait l'await avant la conversion des
|
||||
routes en `def`.
|
||||
ponytail: les clients httpx des services sont créés à chaque appel (aucun
|
||||
lien de boucle) ; si un jour un client/queue est lié à la boucle de l'app,
|
||||
passer à `asyncio.run_coroutine_threadsafe` + boucle capturée au lifespan.
|
||||
"""
|
||||
return asyncio.run(asyncio.wait_for(coro, timeout))
|
||||
|
||||
|
||||
async def fire_event(event: str, payload: dict):
|
||||
"""Dispatch an event to outbound webhooks and matching automations."""
|
||||
# v7.3.0: page.updated → in-app notification to followers (throttled).
|
||||
|
||||
Reference in New Issue
Block a user