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
161 lines
7.6 KiB
Python
161 lines
7.6 KiB
Python
"""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}
|