"""FlowDeck — Public API v2 : sharing. 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 app.services.publish import fire_published, fire_unpublished, publish, unpublish from ._common import _v2_rate_check logger = logging.getLogger(__name__) router = APIRouter(tags=["api-v2"]) @router.post("/pages/{page_id}/shares") 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) 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") email = (body.get("email") or "").strip() uid = body.get("user_id") if not email and not uid: raise HTTPException(400, "email or user_id required") with get_conn() as conn: cur = conn.execute("INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by) VALUES (?, ?, ?, ?, ?)", (page_id, uid, email, perm, user["id"])) conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,)) conn.commit() nid = cur.lastrowid audit_log(user, "share.create", "share", nid, f"page={page_id}", request) try: 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"} @router.patch("/shares/{share_id}") def patch_share_v2(share_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) perm = (body.get("permission") or "").strip().lower() if perm not in ("view", "comment", "edit"): raise HTTPException(400, "Invalid permission") with get_conn() as conn: if not conn.execute("SELECT id FROM page_shares WHERE id=?", (share_id,)).fetchone(): raise HTTPException(404, "Share not found") conn.execute("UPDATE page_shares SET permission=? WHERE id=?", (perm, share_id)) conn.commit() return {"id": share_id, "status": "updated"} @router.delete("/shares/{share_id}") def delete_share_v2(share_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 page_id FROM page_shares WHERE id=?", (share_id,)).fetchone() if not row: raise HTTPException(404, "Share not found") conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,)) # unset is_shared if no shares left cnt = conn.execute("SELECT COUNT(*) FROM page_shares WHERE page_id=?", (row["page_id"],)).fetchone()[0] if cnt == 0: conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (row["page_id"],)) conn.commit() return {"id": share_id, "status": "revoked"} @router.post("/pages/{page_id}/publish") 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) 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) 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") 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) run_event_sync(fire_unpublished(page_id)) return {"page_id": page_id, "status": "unpublished"} @router.get("/pages/{page_id}/history") def list_history_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 h.*, u.login FROM page_history h LEFT JOIN users u ON u.id=h.user_id WHERE h.page_id=? ORDER BY h.created_at DESC", (page_id,)).fetchall() # also page_versions for block pages vrows = conn.execute("SELECT * FROM page_versions WHERE page_id=? ORDER BY created_at DESC", (page_id,)).fetchall() return {"history": [row_to_dict(r) for r in rows], "versions": [row_to_dict(r) for r in vrows]} @router.post("/pages/{page_id}/history/restore") def restore_history_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) hid = body.get("history_id") or body.get("id") or body.get("version_id") if not hid: raise HTTPException(400, "history_id required") with get_conn() as conn: h = conn.execute("SELECT * FROM page_versions WHERE id=? AND page_id=?", (hid, page_id)).fetchone() if h: conn.execute("UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (h["blocks_json"], h["title"], page_id)) conn.commit() return {"page_id": page_id, "restored_version": hid, "status": "restored"} h2 = conn.execute("SELECT * FROM page_history WHERE id=? AND page_id=?", (hid, page_id)).fetchone() if h2: try: snap = json.loads(h2["snapshot_json"] or "{}") except Exception: snap = {} # best-effort restore content if snap.get("content"): conn.execute("UPDATE pages SET content=? WHERE id=?", (snap["content"], page_id)) conn.commit() return {"page_id": page_id, "restored_version": hid, "status": "restored"} raise HTTPException(404, "History not found") @router.get("/collections/{collection_id}/sprints") def list_sprints_v2(collection_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 sprints WHERE collection_id=?", (collection_id,)).fetchone()[0] rows = conn.execute("SELECT * FROM sprints WHERE collection_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall() return {"sprints": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}