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

@@ -0,0 +1,105 @@
"""Segments, workspaces and shared glossaries
Revision ID: a1b2c3d4e5f6
Revises: e1f2a3b4c5d6
Create Date: 2026-08-29
New product foundations:
- translation_segments: per-job (source, translation) pairs powering the
side-by-side review editor, rebuild and XLIFF export/import
- workspaces + workspace_members: team workspaces with seat-based roles
- glossaries.workspace_id: shared glossaries inside a workspace
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f7e8d9c0b1a2"
down_revision: Union[str, None] = "e1f2a3b4c5d6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"translation_segments",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("job_id", sa.String(64), nullable=False),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("segment_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("source_text", sa.Text(), nullable=False),
sa.Column("translated_text", sa.Text(), nullable=False, server_default=""),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("reviewed_text", sa.Text(), nullable=True),
sa.Column("is_heading", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_segments_job_index", "translation_segments", ["job_id", "segment_index"])
op.create_index("ix_segments_user", "translation_segments", ["user_id"])
op.create_index("ix_segments_status", "translation_segments", ["job_id", "status"])
op.create_table(
"workspaces",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column(
"owner_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_workspaces_owner", "workspaces", ["owner_id"])
op.create_table(
"workspace_members",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"workspace_id",
sa.String(36),
sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
sa.Column("created_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_workspace_members_workspace", "workspace_members", ["workspace_id"])
op.create_index("ix_workspace_members_user", "workspace_members", ["user_id"])
with op.batch_alter_table("glossaries") as batch_op:
batch_op.add_column(sa.Column("workspace_id", sa.String(36), nullable=True))
op.create_index("ix_glossaries_workspace", "glossaries", ["workspace_id"])
def downgrade() -> None:
with op.batch_alter_table("glossaries") as batch_op:
batch_op.drop_index("ix_glossaries_workspace")
batch_op.drop_column("workspace_id")
op.drop_index("ix_workspace_members_user", table_name="workspace_members")
op.drop_index("ix_workspace_members_workspace", table_name="workspace_members")
op.drop_table("workspace_members")
op.drop_index("ix_workspaces_owner", table_name="workspaces")
op.drop_table("workspaces")
op.drop_index("ix_segments_status", table_name="translation_segments")
op.drop_index("ix_segments_user", table_name="translation_segments")
op.drop_index("ix_segments_job_index", table_name="translation_segments")
op.drop_table("translation_segments")