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:
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