feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""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")
|
||||
async def get_links(request: Request):
|
||||
user = _auth_user(request)
|
||||
return {"links": cal.list_links(user["id"])}
|
||||
|
||||
|
||||
@router.delete("/api/v2/calendar-links/{link_id}")
|
||||
async 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")
|
||||
async 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
|
||||
Reference in New Issue
Block a user