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

@@ -331,6 +331,8 @@ class Glossary(Base):
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# Optional: workspace this glossary is shared with (owner still administers it)
workspace_id = Column(String(36), nullable=True)
name = Column(String(255), nullable=False)
source_language = Column(String(10), nullable=False, default="fr")
target_language = Column(String(10), nullable=True, default="en")
@@ -347,12 +349,14 @@ class Glossary(Base):
__table_args__ = (
Index("ix_glossaries_user_id", "user_id"),
Index("ix_glossaries_template_id", "template_id"),
Index("ix_glossaries_workspace", "workspace_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"workspace_id": self.workspace_id,
"name": self.name,
"source_language": self.source_language,
"target_language": self.target_language,
@@ -423,3 +427,116 @@ class CustomPrompt(Base):
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class TranslationSegment(Base):
"""One translatable unit of a finished job (review foundation).
Segments persist (source, translation) pairs per job so reviewers can
edit/approve them side-by-side, rebuild the document with the reviewed
text, and export/import XLIFF.
"""
__tablename__ = "translation_segments"
id = Column(String(36), primary_key=True, default=generate_uuid)
job_id = Column(String(64), nullable=False)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
segment_index = Column(Integer, nullable=False, default=0)
source_text = Column(Text, nullable=False)
translated_text = Column(Text, nullable=False, default="")
# pending | approved | edited
status = Column(String(20), nullable=False, default="pending")
# Reviewer's edit (used for rebuild when status == "edited")
reviewed_text = Column(Text, nullable=True)
is_heading = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
__table_args__ = (
Index("ix_segments_job_index", "job_id", "segment_index"),
Index("ix_segments_user", "user_id"),
Index("ix_segments_status", "job_id", "status"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"job_id": self.job_id,
"segment_index": self.segment_index,
"source_text": self.source_text,
"translated_text": self.translated_text,
"status": self.status,
"reviewed_text": self.reviewed_text,
"is_heading": self.is_heading,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class Workspace(Base):
"""Team workspace: shared glossaries + seat-based membership.
Seats are enforced from the owner's plan (Business = 5 seats).
"""
__tablename__ = "workspaces"
id = Column(String(36), primary_key=True, default=generate_uuid)
name = Column(String(255), nullable=False)
owner_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
created_at = Column(DateTime, default=_utcnow)
members = relationship(
"WorkspaceMember", back_populates="workspace", cascade="all, delete-orphan"
)
__table_args__ = (Index("ix_workspaces_owner", "owner_id"),)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"owner_id": self.owner_id,
"member_count": len(self.members) if self.members else 0,
"members": [m.to_dict() for m in self.members] if self.members else [],
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class WorkspaceMember(Base):
"""Membership row linking a user to a workspace with a role."""
__tablename__ = "workspace_members"
id = Column(String(36), primary_key=True, default=generate_uuid)
workspace_id = Column(
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# owner | admin | member
role = Column(String(20), nullable=False, default="member")
created_at = Column(DateTime, default=_utcnow)
workspace = relationship("Workspace", back_populates="members")
__table_args__ = (
Index("ix_workspace_members_workspace", "workspace_id"),
Index("ix_workspace_members_user", "user_id"),
# UniqueConstraint imported lazily below if needed; enforced in app layer
)
def to_dict(self) -> dict:
return {
"id": self.id,
"workspace_id": self.workspace_id,
"user_id": self.user_id,
"role": self.role,
"created_at": self.created_at.isoformat() if self.created_at else None,
}