Files
flowdeck/app/routers/meetings.py
T
bruno 45e59009c3
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 15m23s
FlowDeck CI / docker (push) Canceled after 0s
fix: A21 phase 2c — 190 routes hors loop, 86 % total (v7.26.0)
4 passes (283 → 93 routes async sur 667 = 86 % hors loop, avant 61 %) :

A. RACINE AUTH — `get_current_user` (auth/session.py) était `async def`
   SANS aucun await (cookie decode = synchrone) ; idem ses clones :
   `agent._current_user_id/_workspace_id/_current_admin` (34 sites) et
   `sso._require_admin` (corps 0 await, 6 sites) → `def` +
   47 `await` supprimés. Piège : 3 call sites passaient par l'alias `gcu`
   (grep littéral aveugle) — 8 tests en échec → corrigés.

B. Re-scan : 19 routes devenues SANS await → `def` (agent 8, sso 5,
   web_clipper 3, projects 2, auth 1…).

C/D. 155 routes dont les seuls awaits = `request.json()` / événements :
   - try/except `body = {}` → `Body(default={})` (même tolérance)
   - try/except `raise HTTPException(400)` → `Body(...)` REQUIS
     (422 FastAPI — aucun test ne couvrait le 400)
   - forme conditionnelle `request.json() if content-type else {}`
     (54 sites) → défaut `{}` (sans corps = `{}` dans les 2 cas)
   - `await fire_*` → `run_event_sync(...)` ; imports `Body` /
     `run_event_sync` ajoutés aux routers convertis

Reste async (93, justifié) : form/upload/file (22), réseau gitea/llm/oidc,
`_json_body` (9), 2 JSON inline en argument, 1 fallback logique
(capture_frontend_error), 1 lecture conditionnelle (web_clipper), mixtes.

suite **1089/1089** · ruff OK · docs à jour
2026-10-01 22:17:48 -04:00

199 lines
8.5 KiB
Python

"""FlowDeck — Calendar links + Meetings API (v7.1.0).
``/api/v2/calendar-links*`` — Google/CalDAV link CRUD (session or Bearer
``write``), manual sync trigger. ``GET /db/{id}/calendar/freebusy`` —
weekday availability. ``/api/v2/meetings/*`` — audio upload, manual
transcript, AI summary (fires ``meeting.summarized``).
See ``docs/V71_Calendar_Meetings.md``.
"""
from __future__ import annotations
import secrets
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import calendar_sync as cal
from app.services import meetings as meet
from app.services.api_v2_helpers import (
audit_log,
has_scope,
resolve_bearer_token,
row_to_dict,
)
router = APIRouter(tags=["calendar-meetings"])
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if sess:
return sess
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
user = resolve_bearer_token(auth[7:].strip())
if not user:
raise HTTPException(401, "Invalid or expired API token")
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
return user
raise HTTPException(401, "Authentication required")
# ── calendar links ─────────────────────────────────────────────────────────
@router.post("/api/v2/calendar-links")
def create_link(request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
provider = (body.get("provider") or "").lower()
if provider not in cal.PROVIDERS:
raise HTTPException(400, "provider must be google|caldav")
try:
collection_id = int(body.get("collection_id", 0))
except (TypeError, ValueError):
raise HTTPException(400, "collection_id required") from None
creds = body.get("credentials") or {}
if provider == "google" and not creds.get("access_token"):
raise HTTPException(400, "google needs credentials.access_token")
if provider == "caldav" and not creds.get("url"):
raise HTTPException(400, "caldav needs credentials.url")
try:
out = cal.save_link(user["id"], provider, collection_id, creds,
body.get("calendar_id") or "primary",
body.get("date_property") or "")
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "calendar.link", "collection", collection_id, provider, request)
return JSONResponse(status_code=201, content=out)
@router.get("/api/v2/calendar-links")
def get_links(request: Request):
user = _auth_user(request)
return {"links": cal.list_links(user["id"])}
@router.delete("/api/v2/calendar-links/{link_id}")
def remove_link(link_id: int, request: Request):
user = _auth_user(request, require_write=True)
if not cal.delete_link(user["id"], link_id):
raise HTTPException(404, "Link not found")
audit_log(user, "calendar.unlink", "calendar_link", link_id, "", request)
return {"status": "deleted", "id": link_id}
@router.post("/api/v2/calendar-links/{link_id}/sync")
async def sync_now(link_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone()
if not row or (row["user_id"] != user["id"] and not user.get("is_admin")):
raise HTTPException(404, "Link not found")
try:
stats = await cal.sync_link(link_id)
except (cal.SyncError, ValueError) as exc:
raise HTTPException(502 if isinstance(exc, cal.SyncError) else 400,
str(exc)) from None
audit_log(user, "calendar.sync", "calendar_link", link_id, str(stats), request)
return {"link_id": link_id, **stats}
# ── free/busy ──────────────────────────────────────────────────────────────
@router.get("/db/{collection_id}/calendar/freebusy")
def freebusy(collection_id: int, request: Request):
_auth_user(request)
qp = request.query_params
try:
out = cal.freebusy(collection_id, qp.get("from", ""), qp.get("to", ""),
qp.get("date_property", ""))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
return out
# ── meetings ───────────────────────────────────────────────────────────────
@router.post("/api/v2/meetings/transcribe")
async def upload_and_transcribe(request: Request):
user = _auth_user(request, require_write=True)
try:
form = await request.form()
except Exception:
raise HTTPException(400, "multipart upload required") from None
upload = form.get("audio")
try:
page_id = int(form.get("page_id", 0))
except (TypeError, ValueError):
raise HTTPException(400, "page_id required") from None
language = (form.get("language") or "fr")[:10]
manual = (form.get("transcript") or "").strip()
if upload is None and not manual:
raise HTTPException(400, "audio file or transcript required")
audio_path = ""
if upload is not None:
filename = (upload.filename or "").lower()
ext = filename.rsplit(".", 1)[-1] if "." in filename else ""
if ext not in meet.AUDIO_EXTENSIONS:
raise HTTPException(400, f"audio must be one of {sorted(meet.AUDIO_EXTENSIONS)}")
data = await upload.read()
if len(data) > meet.MAX_AUDIO_BYTES:
raise HTTPException(413, "audio exceeds 100 MB")
if not data:
raise HTTPException(400, "empty audio file")
audio_path = str(meet.meetings_dir()
/ f"{page_id}_{secrets.token_hex(8)}.{ext}")
with open(audio_path, "wb") as fh:
fh.write(data)
transcript = manual
if not transcript and audio_path:
try:
transcript = meet.transcribe_audio(audio_path, language)
except meet.TranscriptionUnavailable as exc:
transcript = "" # stored; client transcribes or posts manual text later
_ = exc
try:
tid = meet.save_transcript(page_id, transcript, language, audio_path)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
audit_log(user, "meeting.transcribe", "page", page_id, f"transcript={tid}", request)
return JSONResponse(status_code=201, content={
**row_to_dict(row), "transcribed": bool(transcript)})
@router.post("/api/v2/meetings/transcripts/{transcript_id}/text")
def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(default={})):
"""Store a client-side (manual) transcript on an existing row."""
_auth_user(request, require_write=True)
text = (body.get("transcript") or "").strip()
if not text:
raise HTTPException(400, "transcript required")
with get_conn() as conn:
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone():
raise HTTPException(404, "Transcript not found")
conn.execute("UPDATE meeting_transcripts SET transcript=? WHERE id=?",
(text, transcript_id))
conn.commit()
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone()
return row_to_dict(row)
@router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize")
async def summarize(transcript_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
out = await meet.summarize_transcript(transcript_id, user.get("id"))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
return out