Files
office_translator/routes/review_routes.py
sepehr b4e873ad2c
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 2m14s
feat(review,teams): review foundation — segments, side-by-side editor, rebuild, XLIFF, team workspaces
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)
2026-08-29 19:04:32 +02:00

448 lines
16 KiB
Python

"""
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": {},
},
)