feat(review,teams): review foundation — segments, side-by-side editor, rebuild, XLIFF, team workspaces
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 2m14s

Foundations:
- TranslationSegment model + migration f7e8d9c0b1a2 (segments, workspaces,
  workspace_members, glossaries.workspace_id)
- SegmentRecorder injected into all 4 translators: unique (source,
  translation) pairs captured per job and persisted (best-effort)
- set_segment_overrides: human-reviewed translations applied verbatim on
  rebuild — top priority over TM and provider, zero API calls

Review API (routes/review_routes.py):
- GET /translations/{id}/segments (owner or job token)
- PATCH /segments/{id} edit/approve — feeds the per-user TM so approved
  translations are reused in later jobs
- POST /translations/{id}/rebuild — rebuild document with reviewed text
- GET/POST /translations/{id}/xliff — XLIFF 1.2 export/import (edited
  segments export their reviewed text)

Review editor (frontend /dashboard/reviews/[jobId]):
- side-by-side source/translation table, inline edit, approve (single or
  all), rebuild & download (auth blob), XLIFF export/import, 13 locales
- 'Relire et corriger' link on the translation-complete screen

Team workspaces (routes/workspace_routes.py + /dashboard/teams):
- Workspace/WorkspaceMember models, roles owner/admin/member
- create (Business plan), list with seat usage, invite by email with
  seat-limit enforcement (Business=5, Enterprise unlimited), removal
- shared glossaries: workspace members can use a glossary shared to their
  workspace (access check extended)

Tests: 1184 passed / 0 failed (11 new: recorder, overrides, docx
capture->rebuild e2e, XLIFF structure/escaping, seats, workspace CRUD,
shared glossary access)
This commit is contained in:
2026-08-29 19:04:32 +02:00
parent 526c87348f
commit b4e873ad2c
31 changed files with 2399 additions and 54 deletions

View File

@@ -16,6 +16,8 @@ from routes.legacy_routes import router as legacy_router
from routes.glossary_routes import router as glossary_router
from routes.prompt_routes import router as prompt_router
from routes.waitlist_routes import router as waitlist_router
from routes.review_routes import router as review_router
from routes.workspace_routes import router as workspace_router
router.include_router(translate_router, tags=["Translation"])
router.include_router(auth_router, tags=["Authentication"])
@@ -25,3 +27,5 @@ router.include_router(legacy_router, tags=["Legacy"])
router.include_router(glossary_router, tags=["Glossaries"])
router.include_router(prompt_router, tags=["Prompts"])
router.include_router(waitlist_router, tags=["Waitlist"])
router.include_router(review_router, tags=["Review"])
router.include_router(workspace_router, tags=["Workspaces"])

447
routes/review_routes.py Normal file
View File

@@ -0,0 +1,447 @@
"""
Review API — the side-by-side review foundation.
Endpoints (per translation job):
GET /api/v1/translations/{job_id}/segments list segments for review
PATCH /api/v1/segments/{segment_id} edit / approve / reject
POST /api/v1/translations/{job_id}/rebuild rebuild the document with
approved/edited segments
GET /api/v1/translations/{job_id}/xliff export XLIFF 1.2
POST /api/v1/translations/{job_id}/xliff import XLIFF back
Access mirrors the job endpoints: owner (JWT/API key) or the job's secret
token. Approvals also feed the per-user translation memory so future jobs
reuse human-reviewed translations.
"""
import asyncio
from pathlib import Path
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse, PlainTextResponse
from pydantic import BaseModel, Field
from core.logging import get_logger
from database.connection import get_sync_session
from database.models import TranslationSegment
from middleware.api_key_auth import get_authenticated_user
from routes.translate_routes import (
_check_job_access,
_translation_jobs,
)
logger = get_logger(__name__)
router = APIRouter(prefix="/api/v1", tags=["Review"])
XLIFF_NS = "urn:oasis:names:tc:xliff:document:1.2"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _load_job(job_id: str) -> Optional[dict]:
job = _translation_jobs.get(job_id)
if job:
return job
from core.redis import get_job_status_async
return await get_job_status_async(job_id)
async def _job_or_403(job_id: str, current_user, token: Optional[str]) -> dict:
job = await _load_job(job_id)
if not job:
raise HTTPException(status_code=404, detail={"error": "JOB_NOT_FOUND"})
denied = _check_job_access(job, current_user, token)
if denied:
# Convert JSONResponse to HTTPException semantics via raise
raise HTTPException(
status_code=denied.status_code, detail=denied.body
)
return job
def _fetch_segments(job_id: str) -> List[TranslationSegment]:
with get_sync_session() as session:
rows = (
session.query(TranslationSegment)
.filter(TranslationSegment.job_id == job_id)
.order_by(TranslationSegment.segment_index.asc())
.all()
)
# Detach into plain dicts before the session closes
return [r.to_dict() for r in rows]
# ---------------------------------------------------------------------------
# Segment list
# ---------------------------------------------------------------------------
@router.get("/translations/{job_id}/segments")
async def list_segments(
job_id: str,
token: Optional[str] = Query(None),
current_user=Depends(get_authenticated_user),
):
job = await _job_or_403(job_id, current_user, token)
segments = await asyncio.to_thread(_fetch_segments, job_id)
return JSONResponse(
status_code=200,
content={
"data": {
"job_id": job_id,
"file_name": job.get("file_name"),
"status": job.get("status"),
"segments": segments,
"counts": {
"total": len(segments),
"pending": sum(1 for s in segments if s["status"] == "pending"),
"approved": sum(1 for s in segments if s["status"] == "approved"),
"edited": sum(1 for s in segments if s["status"] == "edited"),
},
},
"meta": {},
},
)
# ---------------------------------------------------------------------------
# Segment edit / approve
# ---------------------------------------------------------------------------
class SegmentUpdate(BaseModel):
reviewed_text: Optional[str] = None
status: Optional[str] = Field(None, description="pending | approved | edited")
VALID_STATUSES = {"pending", "approved", "edited"}
@router.patch("/segments/{segment_id}")
async def update_segment(
segment_id: str,
update: SegmentUpdate,
token: Optional[str] = Query(None),
current_user=Depends(get_authenticated_user),
):
def _apply():
with get_sync_session() as session:
seg = (
session.query(TranslationSegment)
.filter(TranslationSegment.id == segment_id)
.first()
)
if not seg:
return None
# Segments are only persisted for authenticated users —
# ownership is checked directly on the row.
if not current_user or str(seg.user_id) != str(current_user.id):
raise PermissionError("access denied")
if update.reviewed_text is not None:
seg.reviewed_text = update.reviewed_text
if update.status is not None:
if update.status not in VALID_STATUSES:
raise ValueError("invalid status")
seg.status = update.status
# Editing implies the reviewed text is the new translation
if update.status == "edited" and seg.reviewed_text is None:
raise ValueError("reviewed_text required for status=edited")
if update.status == "pending":
seg.reviewed_text = None
session.commit()
refreshed = (
session.query(TranslationSegment)
.filter(TranslationSegment.id == segment_id)
.first()
)
return refreshed.to_dict()
try:
result = await asyncio.to_thread(_apply)
except PermissionError:
raise HTTPException(status_code=403, detail={"error": "ACCESS_DENIED"})
except FileNotFoundError:
raise HTTPException(status_code=404, detail={"error": "SEGMENT_NOT_FOUND"})
except ValueError as e:
raise HTTPException(status_code=422, detail={"error": str(e)})
if result is None:
raise HTTPException(status_code=404, detail={"error": "SEGMENT_NOT_FOUND"})
# Approved/edited segments feed the per-user translation memory so the
# next jobs reuse human-reviewed translations (broad context: these are
# explicit human approvals).
if result["status"] in ("approved", "edited"):
try:
from services.translation_tm import store_tm, TMScope
job = _translation_jobs.get(result["job_id"]) or {}
provider_name = job.get("provider") or "google"
final_text = result["reviewed_text"] or result["translated_text"]
store_tm(
[result["source_text"]],
[final_text],
target_language=(job or {}).get("target_lang", "en"),
source_language=(job or {}).get("source_lang", "auto"),
provider_name=provider_name,
scope=TMScope.from_prompt(result["user_id"], None),
)
except Exception as tm_err:
logger.warning("review_tm_sync_failed", error=str(tm_err))
return JSONResponse(status_code=200, content={"data": result, "meta": {}})
# ---------------------------------------------------------------------------
# Rebuild
# ---------------------------------------------------------------------------
def _build_overrides(job_id: str) -> dict:
with get_sync_session() as session:
rows = (
session.query(TranslationSegment)
.filter(
TranslationSegment.job_id == job_id,
TranslationSegment.status.in_(["approved", "edited"]),
)
.all()
)
return {
r.source_text: (r.reviewed_text or r.translated_text)
for r in rows
if (r.reviewed_text or r.translated_text)
}
@router.post("/translations/{job_id}/rebuild")
async def rebuild_translation(
job_id: str,
token: Optional[str] = Query(None),
current_user=Depends(get_authenticated_user),
):
job = await _job_or_403(job_id, current_user, token)
if job.get("status") != "completed":
raise HTTPException(
status_code=409,
detail={
"error": "JOB_NOT_COMPLETED",
"message": "Rebuild is only available for completed jobs.",
},
)
overrides = await asyncio.to_thread(_build_overrides, job_id)
if not overrides:
raise HTTPException(
status_code=422,
detail={
"error": "NO_REVIEWED_SEGMENTS",
"message": "Approve or edit at least one segment before rebuilding.",
},
)
input_path = Path(job.get("input_path", ""))
if not input_path.exists():
raise HTTPException(
status_code=410,
detail={
"error": "SOURCE_FILE_EXPIRED",
"message": (
"Le fichier source a été supprimé (rétention). "
"Re-téléversez-le : les segments approuvés sont dans la "
"mémoire de traduction et seront réutilisés automatiquement."
),
},
)
file_extension = job.get("file_extension") or input_path.suffix.lower()
target_lang = job.get("target_lang", "en")
source_lang = job.get("source_lang", "auto")
output_filename = (
f"rebuilt_{job_id[3:]}_{Path(job.get('file_name') or input_path.name).name}"
)
from utils.file_handler import FileHandler
output_path = Path(
FileHandler().generate_unique_filename(output_filename, "translated")
)
output_path = Path(job.get("input_path", "")).parent.parent / "outputs" / output_path.name
output_path.parent.mkdir(parents=True, exist_ok=True)
def _rebuild():
from translators import ExcelTranslator, WordTranslator, PowerPointTranslator
ext = file_extension
if ext == ".docx":
translator = WordTranslator(provider=None)
elif ext == ".xlsx":
translator = ExcelTranslator(provider=None)
elif ext == ".pptx":
translator = PowerPointTranslator(provider=None)
elif ext == ".pdf":
from translators.pdf_translator import PDFTranslator
translator = PDFTranslator(provider=None)
else:
raise ValueError(f"unsupported extension {ext}")
translator.set_segment_overrides(overrides)
kwargs = {}
if ext == ".pdf":
kwargs["pdf_mode"] = job.get("pdf_mode") or "layout"
result = translator.translate_file(
input_path, output_path, target_lang, source_lang, **kwargs
)
return Path(result), translator.get_translation_stats()
try:
result_path, stats = await asyncio.to_thread(_rebuild)
except Exception as e:
logger.error("rebuild_failed", job_id=job_id, error=str(e))
raise HTTPException(
status_code=500,
detail={"error": "REBUILD_FAILED", "message": str(e)[:300]},
)
if not result_path.exists() or result_path.stat().st_size == 0:
raise HTTPException(
status_code=500, detail={"error": "REBUILD_EMPTY_OUTPUT"}
)
# Point the job's download at the rebuilt file
job["output_path"] = str(result_path)
job["rebuilt_at"] = asyncio.get_event_loop().time()
_translation_jobs[job_id] = job
return JSONResponse(
status_code=200,
content={
"data": {
"job_id": job_id,
"rebuilt": True,
"segments_applied": len(overrides),
"stats": stats,
"download_url": f"/api/v1/download/{job_id}",
},
"meta": {},
},
)
# ---------------------------------------------------------------------------
# XLIFF 1.2 export / import
# ---------------------------------------------------------------------------
def _xliff_export(job: dict, segments: List[dict]) -> str:
from xml.sax.saxutils import escape, quoteattr
src_lang = job.get("source_lang") or "auto"
tgt_lang = job.get("target_lang") or "en"
file_name = job.get("file_name") or "document"
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
f'<xliff xmlns="{XLIFF_NS}" version="1.2">',
f' <file original={quoteattr(file_name)} source-language={quoteattr(src_lang)}'
f' target-language={quoteattr(tgt_lang)} datatype="plaintext">',
" <body>",
]
for seg in segments:
target = (
seg["reviewed_text"] if seg["status"] == "edited" and seg["reviewed_text"]
else seg["translated_text"]
)
lines.append(f' <trans-unit id={quoteattr(seg["id"])} resname="seg-{seg["segment_index"]}">')
lines.append(f" <source>{escape(seg['source_text'])}</source>")
lines.append(f" <target>{escape(target)}</target>")
if seg["status"] != "pending":
lines.append(f' <note>status: {seg["status"]}</note>')
lines.append(" </trans-unit>")
lines.extend([" </body>", " </file>", "</xliff>"])
return "\n".join(lines)
@router.get("/translations/{job_id}/xliff")
async def export_xliff(
job_id: str,
token: Optional[str] = Query(None),
current_user=Depends(get_authenticated_user),
):
job = await _job_or_403(job_id, current_user, token)
segments = await asyncio.to_thread(_fetch_segments, job_id)
if not segments:
raise HTTPException(
status_code=404,
detail={"error": "NO_SEGMENTS", "message": "Aucun segment pour ce job."},
)
xml = _xliff_export(job, segments)
return PlainTextResponse(
content=xml,
media_type="application/xliff+xml",
headers={
"Content-Disposition": f'attachment; filename="{job_id}.xliff"'
},
)
@router.post("/translations/{job_id}/xliff")
async def import_xliff(
job_id: str,
request: Request,
token: Optional[str] = Query(None),
current_user=Depends(get_authenticated_user),
):
job = await _job_or_403(job_id, current_user, token)
body = await request.body()
from lxml import etree
try:
root = etree.fromstring(body)
except Exception as e:
raise HTTPException(
status_code=422,
detail={"error": "INVALID_XLIFF", "message": str(e)[:200]},
)
updates = {}
for unit in root.iter(f"{{{XLIFF_NS}}}trans-unit"):
unit_id = unit.get("id")
target_el = unit.find(f"{{{XLIFF_NS}}}target")
if not unit_id or target_el is None or target_el.text is None:
continue
updates[unit_id] = target_el.text
if not updates:
raise HTTPException(
status_code=422,
detail={"error": "NO_TRANS_UNITS", "message": "Aucun trans-unit avec cible trouvé."},
)
def _apply():
applied = 0
with get_sync_session() as session:
segs = (
session.query(TranslationSegment)
.filter(TranslationSegment.job_id == job_id)
.all()
)
by_id = {s.id: s for s in segs}
for seg_id, new_target in updates.items():
seg = by_id.get(seg_id)
if not seg:
continue
seg.reviewed_text = new_target
seg.status = "edited" if new_target.strip() != seg.translated_text.strip() else "approved"
applied += 1
session.commit()
return applied
applied = await asyncio.to_thread(_apply)
return JSONResponse(
status_code=200,
content={
"data": {"job_id": job_id, "segments_updated": applied},
"meta": {},
},
)

View File

@@ -1487,6 +1487,10 @@ async def _run_translation_job(
job_translator.set_custom_prompt(full_prompt)
if hasattr(job_translator, "set_tm_scope"):
job_translator.set_tm_scope(user_id, full_prompt)
if hasattr(job_translator, "set_segment_recorder"):
from translators.segments import SegmentRecorder
job_translator.set_segment_recorder(SegmentRecorder())
await asyncio.to_thread(
job_translator.translate_file,
input_path,
@@ -1502,6 +1506,10 @@ async def _run_translation_job(
job_translator.set_custom_prompt(full_prompt)
if hasattr(job_translator, "set_tm_scope"):
job_translator.set_tm_scope(user_id, full_prompt)
if hasattr(job_translator, "set_segment_recorder"):
from translators.segments import SegmentRecorder
job_translator.set_segment_recorder(SegmentRecorder())
await asyncio.to_thread(
job_translator.translate_file,
input_path,
@@ -1517,6 +1525,10 @@ async def _run_translation_job(
job_translator.set_custom_prompt(full_prompt)
if hasattr(job_translator, "set_tm_scope"):
job_translator.set_tm_scope(user_id, full_prompt)
if hasattr(job_translator, "set_segment_recorder"):
from translators.segments import SegmentRecorder
job_translator.set_segment_recorder(SegmentRecorder())
await asyncio.to_thread(
job_translator.translate_file,
input_path,
@@ -1533,6 +1545,10 @@ async def _run_translation_job(
job_translator.set_custom_prompt(full_prompt)
if hasattr(job_translator, "set_tm_scope"):
job_translator.set_tm_scope(user_id, full_prompt)
if hasattr(job_translator, "set_segment_recorder"):
from translators.segments import SegmentRecorder
job_translator.set_segment_recorder(SegmentRecorder())
# OCR (PDF scannés) : réglages admin > variables d'env.
mistral_cfg = getattr(_admin_cfg, "mistral", None)
mistral_key = _cfg(
@@ -1761,6 +1777,48 @@ async def _run_translation_job(
f"Job {job_id}: quality L2 layer failed: {l2_err}"
)
# ------------------------------------------------------------------
# Persist segments (review foundation): (source, translation) pairs
# stored per job so reviewers can edit/approve, rebuild and export
# XLIFF. Best-effort — a persistence failure never breaks the job.
# ------------------------------------------------------------------
if user_id and hasattr(job_translator, "get_recorded_segments"):
try:
recorded = job_translator.get_recorded_segments()
if recorded:
from database.connection import get_sync_session
from database.models import TranslationSegment
from translators.segments import SegmentRecorder # noqa: F401
def _persist_segments():
from database.models import TranslationSegment as Seg
rows = [
Seg(
job_id=job_id,
user_id=str(user_id),
segment_index=i,
source_text=src_text,
translated_text=tr_text,
status="pending",
)
for i, (src_text, tr_text) in enumerate(recorded)
if tr_text # identities are not reviewable segments
]
if not rows:
return 0
with get_sync_session() as session:
session.add_all(rows)
session.commit()
return len(rows)
stored = await asyncio.to_thread(_persist_segments)
logger.info(
f"Job {job_id}: persisted {stored} review segments"
)
except Exception as seg_err:
logger.warning(f"Job {job_id}: segment persistence failed: {seg_err}")
# ------------------------------------------------------------------
# QA report (pure heuristics — numbers fidelity, untranslated
# ratio, 0-100 score). Never blocks the job; surfaced in the job

300
routes/workspace_routes.py Normal file
View File

@@ -0,0 +1,300 @@
"""
Workspace API — team workspaces with seat-based membership.
Business plan includes 5 seats (models.subscription.PLANS); the seat limit
is enforced here. Owners/admins manage members; glossaries can be shared
with a workspace (glossaries.workspace_id).
Endpoints:
POST /api/v1/workspaces create (owner membership)
GET /api/v1/workspaces list mine (with members)
POST /api/v1/workspaces/{id}/members add member by email
DELETE /api/v1/workspaces/{id}/members/{user_id} remove member
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from core.logging import get_logger
from database.connection import get_sync_session
from database.models import User, Workspace, WorkspaceMember
from middleware.api_key_auth import get_authenticated_user
from models.subscription import PlanType, PLANS
logger = get_logger(__name__)
router = APIRouter(prefix="/api/v1/workspaces", tags=["Workspaces"])
VALID_ROLES = {"admin", "member"}
def _seat_limit_for_owner(plan) -> int:
"""Seats allowed for the workspace owner's plan (Business/Enterprise)."""
try:
plan_cfg = PLANS.get(PlanType(plan))
except (ValueError, TypeError):
plan_cfg = None
seats = (plan_cfg or {}).get("team_seats", 0) or 0
return seats if seats > 0 else 0 # -1 (enterprise) treated as unlimited
def _seats_unlimited(plan) -> bool:
try:
return PlanType(plan) == PlanType.ENTERPRISE
except (ValueError, TypeError):
return False
class WorkspaceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
class MemberAdd(BaseModel):
email: str = Field(..., min_length=3)
role: str = Field("member", description="admin | member")
@router.post("", status_code=201)
async def create_workspace(
payload: WorkspaceCreate,
current_user=Depends(get_authenticated_user),
):
if not current_user:
raise HTTPException(status_code=401, detail={"error": "AUTH_REQUIRED"})
if not _seats_unlimited(current_user.plan) and _seat_limit_for_owner(current_user.plan) == 0:
raise HTTPException(
status_code=403,
detail={
"error": "PLAN_REQUIRED",
"message": "Les espaces de travail nécessitent le plan Business.",
},
)
def _create():
with get_sync_session() as session:
ws = Workspace(name=payload.name.strip(), owner_id=str(current_user.id))
session.add(ws)
session.flush()
session.add(
WorkspaceMember(
workspace_id=ws.id,
user_id=str(current_user.id),
role="owner",
)
)
session.commit()
session.refresh(ws)
return ws.to_dict()
from asyncio import to_thread
data = await to_thread(_create)
return JSONResponse(status_code=201, content={"data": data, "meta": {}})
@router.get("")
async def list_workspaces(current_user=Depends(get_authenticated_user)):
if not current_user:
raise HTTPException(status_code=401, detail={"error": "AUTH_REQUIRED"})
def _list():
with get_sync_session() as session:
memberships = (
session.query(WorkspaceMember)
.filter(WorkspaceMember.user_id == str(current_user.id))
.all()
)
result = []
for m in memberships:
ws = (
session.query(Workspace)
.filter(Workspace.id == m.workspace_id)
.first()
)
if not ws:
continue
members = (
session.query(WorkspaceMember)
.filter(WorkspaceMember.workspace_id == ws.id)
.all()
)
enriched = {
**ws.to_dict(),
"my_role": m.role,
"member_count": len(members),
"seat_limit": (
-1
if _seats_unlimited(current_user.plan)
else _seat_limit_for_owner(current_user.plan)
),
}
result.append(enriched)
return result
from asyncio import to_thread
data = await to_thread(_list)
return JSONResponse(status_code=200, content={"data": data, "meta": {}})
@router.post("/{workspace_id}/members", status_code=201)
async def add_member(
workspace_id: str,
payload: MemberAdd,
current_user=Depends(get_authenticated_user),
):
if not current_user:
raise HTTPException(status_code=401, detail={"error": "AUTH_REQUIRED"})
if payload.role not in VALID_ROLES:
raise HTTPException(
status_code=422, detail={"error": "INVALID_ROLE"}
)
def _add():
with get_sync_session() as session:
ws = (
session.query(Workspace)
.filter(Workspace.id == workspace_id)
.first()
)
if not ws:
return ("not_found", None)
me = (
session.query(WorkspaceMember)
.filter(
WorkspaceMember.workspace_id == workspace_id,
WorkspaceMember.user_id == str(current_user.id),
)
.first()
)
if not me or me.role not in ("owner", "admin"):
return ("forbidden", None)
# Seat enforcement (owner's plan)
if ws.owner_id == str(current_user.id):
owner_plan = current_user.plan
else:
owner = session.query(User).filter(User.id == ws.owner_id).first()
owner_plan = getattr(owner, "plan", None)
count = (
session.query(WorkspaceMember)
.filter(WorkspaceMember.workspace_id == workspace_id)
.count()
)
if not _seats_unlimited(owner_plan) and count >= _seat_limit_for_owner(owner_plan):
return ("seat_limit", count)
target = (
session.query(User)
.filter(User.email == payload.email.strip().lower())
.first()
)
if not target:
return ("user_not_found", None)
existing = (
session.query(WorkspaceMember)
.filter(
WorkspaceMember.workspace_id == workspace_id,
WorkspaceMember.user_id == str(target.id),
)
.first()
)
if existing:
return ("already_member", None)
member = WorkspaceMember(
workspace_id=workspace_id,
user_id=str(target.id),
role=payload.role,
)
session.add(member)
session.commit()
session.refresh(member)
return ("ok", member.to_dict())
from asyncio import to_thread
status, payload_out = await to_thread(_add)
if status == "not_found":
raise HTTPException(status_code=404, detail={"error": "WORKSPACE_NOT_FOUND"})
if status == "forbidden":
raise HTTPException(status_code=403, detail={"error": "ACCESS_DENIED"})
if status == "seat_limit":
raise HTTPException(
status_code=409,
detail={
"error": "SEAT_LIMIT_REACHED",
"message": "Limite de sièges du plan atteinte. Passez au plan supérieur pour ajouter des membres.",
"details": {"seats_used": payload_out},
},
)
if status == "user_not_found":
raise HTTPException(
status_code=404,
detail={
"error": "USER_NOT_FOUND",
"message": "Aucun compte avec cet e-mail — l'utilisateur doit d'abord créer un compte.",
},
)
if status == "already_member":
raise HTTPException(status_code=409, detail={"error": "ALREADY_MEMBER"})
return JSONResponse(status_code=201, content={"data": payload_out, "meta": {}})
@router.delete("/{workspace_id}/members/{user_id}", status_code=200)
async def remove_member(
workspace_id: str,
user_id: str,
current_user=Depends(get_authenticated_user),
):
if not current_user:
raise HTTPException(status_code=401, detail={"error": "AUTH_REQUIRED"})
def _remove():
with get_sync_session() as session:
me = (
session.query(WorkspaceMember)
.filter(
WorkspaceMember.workspace_id == workspace_id,
WorkspaceMember.user_id == str(current_user.id),
)
.first()
)
if not me or me.role not in ("owner", "admin"):
return "forbidden"
target = (
session.query(WorkspaceMember)
.filter(
WorkspaceMember.workspace_id == workspace_id,
WorkspaceMember.user_id == user_id,
)
.first()
)
if not target:
return "not_found"
if target.role == "owner":
return "owner_immutable"
session.delete(target)
session.commit()
return "ok"
from asyncio import to_thread
status = await to_thread(_remove)
if status == "forbidden":
raise HTTPException(status_code=403, detail={"error": "ACCESS_DENIED"})
if status == "not_found":
raise HTTPException(status_code=404, detail={"error": "MEMBER_NOT_FOUND"})
if status == "owner_immutable":
raise HTTPException(
status_code=422,
detail={"error": "OWNER_IMMUTABLE", "message": "Le propriétaire ne peut pas être retiré."},
)
return JSONResponse(
status_code=200, content={"data": {"removed": True}, "meta": {}}
)