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.
594 lines
25 KiB
Python
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}
|