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:
@@ -15,29 +15,49 @@ from utils.exceptions import GlossaryNotFoundError
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _user_workspace_ids(session, user_id: str) -> List[str]:
|
||||
"""Workspaces the user belongs to (for shared-glossary access)."""
|
||||
from database.models import WorkspaceMember
|
||||
|
||||
rows = (
|
||||
session.query(WorkspaceMember.workspace_id)
|
||||
.filter(WorkspaceMember.user_id == user_id)
|
||||
.all()
|
||||
)
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _glossary_accessible(session, glossary: Glossary, user_id: str) -> bool:
|
||||
"""Owner always; otherwise any workspace the glossary is shared with."""
|
||||
if str(glossary.user_id) == str(user_id):
|
||||
return True
|
||||
if glossary.workspace_id:
|
||||
return glossary.workspace_id in _user_workspace_ids(session, user_id)
|
||||
return False
|
||||
|
||||
|
||||
def get_glossary_terms(glossary_id: str, user_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve glossary terms and metadata for a specific glossary owned by a user.
|
||||
Retrieve glossary terms and metadata for a glossary the user can access
|
||||
(owner, or member of the workspace the glossary is shared with).
|
||||
|
||||
Args:
|
||||
glossary_id: UUID of the glossary
|
||||
user_id: UUID of the user (must own the glossary)
|
||||
user_id: UUID of the user
|
||||
|
||||
Returns:
|
||||
Dict with 'source_language' and 'terms' (list of dicts with source, target, translations)
|
||||
|
||||
Raises:
|
||||
GlossaryNotFoundError: If glossary doesn't exist or doesn't belong to user
|
||||
GlossaryNotFoundError: If glossary doesn't exist or isn't accessible
|
||||
"""
|
||||
try:
|
||||
with get_sync_session() as session:
|
||||
glossary = (
|
||||
session.query(Glossary)
|
||||
.filter(Glossary.id == glossary_id, Glossary.user_id == user_id)
|
||||
.first()
|
||||
session.query(Glossary).filter(Glossary.id == glossary_id).first()
|
||||
)
|
||||
|
||||
if not glossary:
|
||||
if not glossary or not _glossary_accessible(session, glossary, user_id):
|
||||
raise GlossaryNotFoundError(
|
||||
message="Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
|
||||
details={"glossary_id": glossary_id}
|
||||
@@ -77,35 +97,34 @@ def get_glossary_terms(glossary_id: str, user_id: str) -> Dict[str, Any]:
|
||||
|
||||
def validate_glossary_access(glossary_id: str, user_id: str) -> bool:
|
||||
"""
|
||||
Validate that a glossary exists and belongs to the user.
|
||||
|
||||
Validate that a glossary exists and is accessible to the user
|
||||
(owner, or member of the workspace the glossary is shared with).
|
||||
|
||||
This is a lightweight check that doesn't return the terms,
|
||||
useful for early validation before starting a translation job.
|
||||
|
||||
|
||||
Args:
|
||||
glossary_id: UUID of the glossary
|
||||
user_id: UUID of the user (must own the glossary)
|
||||
|
||||
user_id: UUID of the user
|
||||
|
||||
Returns:
|
||||
True if glossary exists and belongs to user
|
||||
|
||||
True if glossary exists and is accessible
|
||||
|
||||
Raises:
|
||||
GlossaryNotFoundError: If glossary doesn't exist or doesn't belong to user
|
||||
GlossaryNotFoundError: If glossary doesn't exist or isn't accessible
|
||||
"""
|
||||
try:
|
||||
with get_sync_session() as session:
|
||||
glossary = (
|
||||
session.query(Glossary)
|
||||
.filter(Glossary.id == glossary_id, Glossary.user_id == user_id)
|
||||
.first()
|
||||
session.query(Glossary).filter(Glossary.id == glossary_id).first()
|
||||
)
|
||||
|
||||
if not glossary:
|
||||
|
||||
if not glossary or not _glossary_accessible(session, glossary, user_id):
|
||||
raise GlossaryNotFoundError(
|
||||
message="Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
|
||||
details={"glossary_id": glossary_id}
|
||||
)
|
||||
|
||||
|
||||
return True
|
||||
|
||||
except GlossaryNotFoundError:
|
||||
|
||||
Reference in New Issue
Block a user