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
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:
@@ -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
447
routes/review_routes.py
Normal 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": {},
|
||||
},
|
||||
)
|
||||
@@ -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
300
routes/workspace_routes.py
Normal 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": {}}
|
||||
)
|
||||
Reference in New Issue
Block a user