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)
301 lines
10 KiB
Python
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": {}}
|
|
)
|