feat(share,permissions): partage de page par groupes (page_shares)
FlowDeck CI / lint (push) Failing after 1m10s
FlowDeck CI / test (push) Successful in 9m55s
FlowDeck CI / docker (push) Successful in 1m11s

- app/db.py: ajout colonne shared_with_group_id (FK user_groups, migration
  backfill v5.x) dans page_shares
- app/routers/sharing.py: POST /api/pages/{id}/share accepte group_id
  (upsert, verif FK groupe), GET /shares expose kind/group_name, synchro
  bidirectionnelle avec page_permissions (mirror grant/revoke) pour que
  PermissionManager donne un acces effectif (view/comment/edit) aux membres
  du groupe; PUT/DELETE gardent le miroir a jour
- app/routers/board.py, library.py: received/made incluent les partages via
  groupes (JOIN group_members)
- app/templates/_page_editor_content.html, _page_editor_scripts.html:
  dialogue Share — invite groups (fetch /api/v2/groups, filtre deja partages),
  pickInviteGroup, shareInvite(group_id), rendu accessList avec avatar groupe
- tests/test_share_groups.py: 10 tests (CRUD groupe, kind, miroir ACL)

Chore: inclut evolutions v6.4.0 deja en working copy (webhooks prod,
migrations, sync, config/main) pour garder l'arbre coherent.
This commit is contained in:
2026-09-21 06:38:02 -04:00
parent 95bc861cdb
commit ce0d561ade
13 changed files with 774 additions and 85 deletions
+60 -20
View File
@@ -2184,15 +2184,32 @@ async def admin_audit_logs_v2(request: Request, limit: int = 50, authorization:
rows = conn.execute("SELECT * FROM api_audit_log ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
return {"logs": [row_to_dict(r) for r in rows]}
# ── Webhooks (CRUD simple) ────────────────────────────────────────────────
# ── Webhooks (v6.4.0 prod) ────────────────────────────────────────────────
@router.get("/webhooks/events")
async def list_webhook_events_v2(request: Request, authorization: str | None = Header(default=None)):
"""Catalogue of deliverable events (+ wildcard syntax)."""
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
from app.services.webhook_outbound import EVENTS
return {"events": EVENTS, "wildcards": ["*", "page.*", "collection.*"]}
@router.get("/webhooks")
async def list_webhooks_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)
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]}
total = conn.execute("SELECT COUNT(*) AS n FROM webhook_subscriptions").fetchone()["n"]
rows = conn.execute(
"SELECT * FROM webhook_subscriptions ORDER BY created_at DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return JSONResponse(
content={"webhooks": [dict(r) for r in rows]},
headers=paginate_headers(total),
)
@router.post("/webhooks")
async def create_webhook_v2(request: Request, authorization: str | None = Header(default=None)):
@@ -2204,18 +2221,25 @@ async def create_webhook_v2(request: Request, authorization: str | None = Header
body = await request.json()
except Exception:
body = {}
from app.services.webhook_outbound import EVENTS, _event_matches
url = (body.get("url") or "").strip()
event = (body.get("event") or "page.created").strip()
secret = (body.get("secret") or "").strip()
if not url or not url.startswith("http"):
raise HTTPException(400, "url must start with http")
if not event or (event not in EVENTS and not (event.endswith(".*") or event in ("*", "all"))):
raise HTTPException(400, f"Unknown event '{event}'. See GET /api/v2/webhooks/events")
# make sure the pattern matches at least one known event
if not any(_event_matches(event, e) for e in EVENTS):
raise HTTPException(400, f"Event pattern '{event}' matches no known event")
with get_conn() as conn:
cur = conn.execute("INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?, ?, ?)", (url, event, secret))
wid = cur.lastrowid
conn.commit()
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (wid,)).fetchone()
audit_log(user, "webhook.create", "webhook", wid, url, request)
return {"id": wid, "status": "created", "webhook": dict(row) if row else {}}
return {"id": wid, "status": "created", "webhook": dict(row) if row else {},
"signature_header": "X-FlowDeck-Signature (HMAC-SHA256, sha256=<hex>)" if secret else None}
@router.patch("/webhooks/{webhook_id}")
async def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
@@ -2262,24 +2286,40 @@ async def test_webhook_v2(webhook_id: int, request: Request, authorization: str
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
if not row:
raise HTTPException(404, "Webhook not found")
# fire test ping (best-effort, record delivery)
try:
from app.services.webhook_outbound import fire_event
await fire_event("ping", {"webhook_id": webhook_id, "test": True})
conn.execute("INSERT INTO webhook_deliveries (webhook_id, status, http_code, payload) VALUES (?, 'delivered', 200, ?)", (webhook_id, json.dumps({"event": "ping"})))
conn.commit()
except Exception as e:
try:
conn.execute("INSERT INTO webhook_deliveries (webhook_id, status, http_code, error) VALUES (?, 'failed', 0, ?)", (webhook_id, str(e)[:1000]))
conn.commit()
except Exception:
pass
return {"webhook_id": webhook_id, "status": "tested"}
# live delivery via the prod dispatcher (HMAC + retry + journal),
# direct to this subscription only (no wildcard fan-out)
from app.services.webhook_outbound import deliver_to_sub
ok = await deliver_to_sub(webhook_id, row["url"], "ping",
{"webhook_id": webhook_id, "test": True},
row["secret"] or "")
audit_log(user, "webhook.test", "webhook", webhook_id, f"ok={ok}", request)
return {"webhook_id": webhook_id, "status": "tested", "delivered": ok}
@router.get("/webhooks/{webhook_id}/deliveries")
async def list_deliveries_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
async def list_deliveries_v2(webhook_id: int, request: Request,
status: str | None = None,
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:
rows = conn.execute("SELECT * FROM webhook_deliveries WHERE webhook_id=? ORDER BY created_at DESC LIMIT 50", (webhook_id,)).fetchall()
return {"deliveries": [row_to_dict(r) for r in rows]}
if status:
total = conn.execute(
"SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=? AND status=?",
(webhook_id, status)).fetchone()["n"]
rows = conn.execute(
"SELECT * FROM webhook_deliveries WHERE webhook_id=? AND status=? "
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
(webhook_id, status, limit, offset)).fetchall()
else:
total = conn.execute(
"SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=?",
(webhook_id,)).fetchone()["n"]
rows = conn.execute(
"SELECT * FROM webhook_deliveries WHERE webhook_id=? "
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
(webhook_id, limit, offset)).fetchall()
return JSONResponse(
content={"deliveries": [row_to_dict(r) for r in rows]},
headers=paginate_headers(total),
)