Files
office_translator/routes/workspace_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

301 lines
10 KiB
Python

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