- Conversion `async def` → `def` de TOUTES les routes dont le corps ne contient ni `await`, ni `async with`, ni `async for`, ni `asyncio` (scan automatique corps par corps sur app/ : 352 converties, 0 dangereuses, vérifié `asyncio`/`run_coroutine`/`.result()` absents). FastAPI exécute ces handlers dans son threadpool → tout leur SQLite (`get_conn()` + `conn.execute`) quitte l'event loop, sans changer une ligne de logique. - Répartition : api_v2 60, dashboard 40, collections 25, board 23, workspace 19, wiki 17, permissions 14, api 14, main.py 6, + 35 fichiers. - Les 4 routers prioritaires de l'audit sont couverts par ce lot : api_v2 60 + dashboard 40 + collections 25 + board 23 = 148 conversions (le reste de leurs routes attend la phase 2 : elles ont de vrais `await`). - Reste (phase 2) : les 311 routes avec de vrais `await` → enrouler les blocs DB dans `await anyio.to_thread.run_sync(...)` ; pas de wrapper partagé livré (rien ne l'appellerait — YAGNI jusqu'au premier usage). suite **1037/1037** (233 s) · `ruff check app tests` OK · docs à jour
207 lines
8.7 KiB
Python
207 lines
8.7 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, 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")
|
|
async def create_link(request: Request):
|
|
user = _auth_user(request, require_write=True)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
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")
|
|
async def set_transcript_text(transcript_id: int, request: Request):
|
|
"""Store a client-side (manual) transcript on an existing row."""
|
|
_auth_user(request, require_write=True)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
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
|