Files
bruno 0218d8f5e5
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Failing after 24m19s
FlowDeck CI / docker (push) Skipped
fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
Corrige deux régressions de /local-workspace :

- le chemin du header restait bloqué sur « Home / <workspace> » quel que
  soit le dossier affiché : la route rend désormais breadcrumb_items
  (Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
  collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
  le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
  fois : Alpine.data('wsInitData') retournait le même objet singleton, le
  2e montage (navigation partielle) levait « Cannot redefine property:
  \ » et initTree abandonnait, laissant tout le contenu au state
  brut. La factory retourne désormais une enveloppe fraîche par montage
  qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
  exécution (le 2e montage gardait le folder_id du 1er chargement).

Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
2026-10-09 16:58:04 -04:00

594 lines
25 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 json
import secrets
from datetime import UTC, date, datetime, timedelta
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
# ── upcoming (sidebar "Meeting" tab) ────────────────────────────────────────
@router.get("/api/v2/meetings/upcoming")
def upcoming_meetings(request: Request, days: int = 30):
"""Upcoming calendar events for the sidebar Meeting tab.
Reads every calendar collection linked to the current user, extracts the
date property of each linked row and returns the ones falling in the next
``days`` days (today included), sorted chronologically. Rows imported from
Google/CalDAV carry an ``external_event_id`` and are flagged ``synced``.
"""
user = _auth_user(request)
horizon = max(1, min(days, 365))
today = datetime.now(UTC).date()
end = today + timedelta(days=horizon)
events: list[dict] = []
with get_conn() as conn:
links = conn.execute(
"SELECT id, collection_id, date_property, calendar_id, provider "
"FROM calendar_links WHERE user_id=? ORDER BY id",
(user["id"],),
).fetchall()
for link in links:
collection_id = link["collection_id"]
if not collection_id:
continue
date_prop = cal.date_prop_id(conn, collection_id,
link["date_property"] or "")
if not date_prop:
continue
prop_id, prop_name = date_prop
rows = conn.execute(
"SELECT id, title, property_values_json, external_event_id "
"FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchall()
for row in rows:
try:
values = json.loads(row["property_values_json"] or "{}")
except (TypeError, ValueError):
continue
raw = cal.row_date(values, prop_id, prop_name)[:10]
if len(raw) != 10:
continue
try:
day = date.fromisoformat(raw)
except ValueError:
continue
if not (today <= day <= end):
continue
events.append({
"id": row["id"],
"title": row["title"] or "Untitled",
"date": raw,
"today": day == today,
"collection_id": collection_id,
"calendar_id": link["calendar_id"] or "primary",
"provider": link["provider"],
"synced": bool(row["external_event_id"]),
"url": f"/db/{collection_id}",
})
events.sort(key=lambda e: (e["date"], e["title"].lower()))
return {"today": today.isoformat(), "days": horizon,
"events": events[:200], "count": len(events)}
# ── meetings ───────────────────────────────────────────────────────────────
@router.post("/api/v2/meetings/transcripts")
def create_transcript(request: Request, body: dict = Body(default={})):
"""Crée une note de réunion vide (état idle) pour une page — §6.5.
Idempotent par occurrence calendrier : renvoie la note existante.
"""
user = _auth_user(request, require_write=True)
try:
page_id = int(body.get("page_id", 0))
except (TypeError, ValueError):
raise HTTPException(400, "page_id required") from None
occ = (body.get("event_occurrence_id") or "").strip()[:200]
with get_conn() as conn:
if occ:
row = conn.execute(
"SELECT * FROM meeting_transcripts WHERE event_occurrence_id=?",
(occ,)).fetchone()
if row:
return {**row_to_dict(row), "already_existed": True}
try:
tid = meet.save_transcript(
page_id, "", (body.get("language") or "fr")[:10],
mode=(body.get("mode") or "mic_only")
if (body.get("mode") or "mic_only") in meet.CAPTURE_MODES else "mic_only",
instruction_id=(body.get("instruction_id") or "auto")[:40],
event_occurrence_id=occ)
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
audit_log(user, "meeting.create", "page", page_id, f"transcript={tid}", request)
return JSONResponse(status_code=201,
content={**row_to_dict(row), "already_existed": False})
@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,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = await meet.summarize_transcript(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
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
# ── Bloc Notion v7.1.1 : machine à états, consentement, segments, partage ───
@router.get("/api/v2/meetings/instructions")
def meeting_instructions(request: Request):
_auth_user(request)
return {"instructions": [
{"id": k, "label": v["label"]} for k, v in meet.INSTRUCTIONS.items()
]}
# Une page est une « note de réunion » dès qu'elle contient un bloc
# AI Meeting Notes. Le contenu est du JSON (``"type":"meeting"`` côté
# navigateur, ``"type": "meeting"`` côté Python) → comparaison sans espaces.
MEETING_BLOCK_PREDICATE = (
"REPLACE(COALESCE({col},''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
)
@router.get("/api/v2/meetings/notes")
def meeting_notes(request: Request, limit: int = 50):
"""Notes de réunion : pages contenant un bloc AI Meeting Notes.
Alimente la section Meetings de la sidebar (ces pages sont exclues de
l'arbre Workspace, voir ``_load_workspace_pages``). Inclut les notes
jamais enregistrées (aucune ligne ``meeting_transcripts``) avec
``status = idle``.
"""
user = _auth_user(request)
uid = user.get("id")
lim = max(1, min(int(limit or 50), 200))
pred = MEETING_BLOCK_PREDICATE.format(col="p.content")
with get_conn() as conn:
rows = conn.execute(
f"""SELECT p.id, p.title, p.updated_at,
(SELECT mt.status FROM meeting_transcripts mt
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS status,
(SELECT mt.summary FROM meeting_transcripts mt
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS summary
FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL
AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND {pred}
ORDER BY p.updated_at DESC LIMIT ?""",
(uid, lim),
).fetchall()
notes = []
for r in rows:
d = row_to_dict(r)
notes.append({
"id": d["id"],
"title": d.get("title") or "Untitled",
"updated_at": d.get("updated_at") or "",
"status": d.get("status") or "idle",
"has_summary": bool((d.get("summary") or "").strip()),
})
return {"notes": notes, "count": len(notes)}
@router.get("/api/v2/meetings/history")
def meetings_history(request: Request, query: str = "", limit: int = 50):
"""Historique des notes de réunion (passé + recherche plein texte)."""
user = _auth_user(request)
_ = user
like = f"%{query.strip()}%" if query.strip() else "%"
lim = max(1, min(int(limit or 50), 200))
with get_conn() as conn:
rows = conn.execute(
"""SELECT mt.*, p.title AS page_title FROM meeting_transcripts mt
JOIN pages p ON p.id = mt.page_id
WHERE (p.title LIKE ? OR mt.transcript LIKE ?
OR mt.summary LIKE ? OR mt.generated_title LIKE ?)
ORDER BY mt.id DESC LIMIT ?""",
(like, like, like, like, lim),
).fetchall()
out = []
for r in rows:
d = row_to_dict(r)
d["title"] = d.get("generated_title") or d.get("page_title") or "Meeting"
out.append(d)
return {"meetings": out, "count": len(out)}
@router.get("/api/v2/meetings/transcripts/{transcript_id}")
def get_transcript_row(transcript_id: int, request: Request):
_auth_user(request)
try:
return meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
@router.post("/api/v2/meetings/transcripts/{transcript_id}/consents")
def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
entry = meet.record_consent(
transcript_id, method=(body.get("method") or "start_attestation"),
message_ref=(body.get("message_ref") or "")[:200],
attested_by=user.get("id"),
participants=body.get("participants") or [])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.consent", "transcript", transcript_id,
entry["method"], request)
return entry
@router.post("/api/v2/meetings/transcripts/{transcript_id}/sessions")
def start_session(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.start_session(
transcript_id, mode=(body.get("mode") or "mic_only"),
channels=body.get("channels"), language=(body.get("language") or "fr"),
instruction_id=(body.get("instruction_id") or "auto"),
user_id=user.get("id"))
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.session.start", "transcript", transcript_id,
out.get("mode", ""), request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/pause")
def pause_session(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.pause_session(transcript_id,
paused_ms_delta=int(body.get("paused_ms") or 0))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.pause", "transcript", transcript_id, "", request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/resume")
def resume_session(transcript_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
out = meet.resume_session(transcript_id)
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.resume", "transcript", transcript_id, "", request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/stop")
async def stop_session(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
stopped = meet.stop_session(transcript_id,
duration_ms=int((body or {}).get("duration_ms") or 0))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.stop", "transcript", transcript_id, "", request)
# Le traitement démarre aussitôt (étapes Thinking persistées) ; en cas
# d'échec LLM le transcript reste consultable (état failed, retry).
# allow_empty : arrêt sans rien de capté → diagnostic honnête (§12.8),
# jamais une ligne coincée en « processing ».
try:
out = await meet.run_processing(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
return {"session": {"id": stopped["id"], "status": "done"}, **out}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/segments")
def append_segments(transcript_id: int, request: Request,
body: dict = Body(default={})):
_auth_user(request, require_write=True)
try:
return meet.append_segments(transcript_id, body.get("segments") or [])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
@router.get("/api/v2/meetings/transcripts/{transcript_id}/transcript")
def grouped_transcript(transcript_id: int, request: Request):
_auth_user(request)
try:
return meet.grouped_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/segments/{seq}/speaker")
def patch_speaker(transcript_id: int, seq: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
return meet.set_segment_speaker(transcript_id, seq,
body.get("speaker") or "",
user.get("id"))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
@router.get("/api/v2/meetings/transcripts/{transcript_id}/processing-steps")
def processing_steps(transcript_id: int, request: Request):
_auth_user(request)
try:
tr = meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
import json as _json
try:
steps = _json.loads(tr.get("processing_steps_json") or "[]")
except (TypeError, ValueError):
steps = []
return {"transcript_id": transcript_id, "status": tr.get("status") or "idle",
"steps": steps}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/processing:retry")
async def retry_processing(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
tr = meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
if tr.get("status") not in ("failed", "processing", "done"):
raise HTTPException(400, "nothing to retry — no failed processing")
try:
return await meet.run_processing(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/generated-title")
def patch_title(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
title = (body.get("title") or "").strip()[:200]
if not title:
raise HTTPException(400, "title 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 generated_title=?,"
" title_source='user' WHERE id=?", (title, transcript_id))
conn.commit()
audit_log(user, "meeting.retitle", "transcript", transcript_id, title, request)
return {"transcript_id": transcript_id, "generated_title": title}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/distributions")
def post_distribution(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.record_distribution(
transcript_id, channel=(body.get("channel") or "copy_link"),
actor_id=user.get("id"), recipients=(body.get("recipients") or "")[:500],
status=body.get("status") or "created")
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.share", "transcript", transcript_id,
out["channel"], request)
return out
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/notes")
def patch_notes(transcript_id: int, request: Request, body: dict = Body(default={})):
_auth_user(request, require_write=True)
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 notes_text=? WHERE id=?",
((body.get("notes") or "")[:50000], transcript_id))
conn.commit()
return {"transcript_id": transcript_id, "saved": True}