video-accessibility/backend/app/api/v1/routes_clients.py
Vadym Samoilenko 1563714454 feat(saas): Phase 3 — membership-based authz + Mailgun + job.organization_id
authz.py (new):
- MembershipContext — per-request membership dict for the current user
- get_membership_context FastAPI dependency
- require_org_role(min_role) — dependency factory keyed off org_id path param
- require_platform_admin()
- OrgScopedQuery — adds organization_id filter; platform admin passes through
- bump_user_membership_cache — invalidates Redis key on membership writes

dependencies.py:
- get_accessible_project_ids now queries memberships collection first;
  legacy pm_client_ids / team.member_user_ids fallback retained until migration runs
  (four job-route access checks at lines 608/1054/1181/1538 are fixed via this function)

routes_clients.py:
- _assert_pm_or_admin and _assert_client_access are now async and query memberships
- All 10 call sites updated with await + db arg

emailer.py:
- Switched from SendGrid to Mailgun REST API via httpx (already in requirements)
- _send() is now fully async; same public method signatures preserved
- send_completion_email uses _send()

config.py:
- Added mailgun_api_key, mailgun_domain, mailgun_from settings
- sendgrid_api_key kept with empty default for backward compat

migration_2026-04-28-000003:
- Backfills job.organization_id from project.client_id
- Creates (organization_id, status, created_at) sparse index on jobs

routes_organizations.py / routes_invitations.py:
- Call bump_user_membership_cache after every membership write

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 16:56:42 +01:00

465 lines
16 KiB
Python

"""
Client / Team / Project management.
Access rules:
- Client CRUD → Admin only
- PM assignment on client → Admin only
- Team CRUD + membership → Admin or PM of that client
- Project CRUD → Admin or PM of that client
- List projects (read) → Admin, PM, or any team member of the client
"""
from datetime import datetime, timezone
from bson import ObjectId
from fastapi import APIRouter, Depends, HTTPException
from motor.motor_asyncio import AsyncIOMotorDatabase
from pydantic import BaseModel
from ...core.database import get_database
from ...core.dependencies import get_current_user, require_pm_for_client, require_roles
from ...models.client import (
Client,
ClientCreate,
ClientUpdate,
Project,
ProjectCreate,
ProjectUpdate,
Team,
TeamCreate,
TeamUpdate,
)
from ...models.user import User, UserRole
router = APIRouter(prefix="/clients", tags=["clients"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _now() -> datetime:
return datetime.now(timezone.utc)
async def _get_client_or_404(client_id: str, db: AsyncIOMotorDatabase) -> dict:
doc = await db.clients.find_one({"_id": client_id})
if not doc:
raise HTTPException(status_code=404, detail="Client not found")
return doc
async def _get_team_or_404(team_id: str, client_id: str, db: AsyncIOMotorDatabase) -> dict:
doc = await db.teams.find_one({"_id": team_id, "client_id": client_id})
if not doc:
raise HTTPException(status_code=404, detail="Team not found")
return doc
async def _get_project_or_404(project_id: str, client_id: str, db: AsyncIOMotorDatabase) -> dict:
doc = await db.projects.find_one({"_id": project_id, "client_id": client_id})
if not doc:
raise HTTPException(status_code=404, detail="Project not found")
return doc
def _client_from_doc(doc: dict) -> Client:
return Client(
id=str(doc["_id"]),
name=doc["name"],
slug=doc["slug"],
is_active=doc.get("is_active", True),
created_at=doc.get("created_at"),
updated_at=doc.get("updated_at"),
)
def _team_from_doc(doc: dict) -> Team:
return Team(
id=str(doc["_id"]),
name=doc["name"],
client_id=doc["client_id"],
member_user_ids=doc.get("member_user_ids", []),
created_at=doc.get("created_at"),
updated_at=doc.get("updated_at"),
)
def _project_from_doc(doc: dict) -> Project:
return Project(
id=str(doc["_id"]),
name=doc["name"],
client_id=doc["client_id"],
is_active=doc.get("is_active", True),
created_at=doc.get("created_at"),
updated_at=doc.get("updated_at"),
)
# ---------------------------------------------------------------------------
# Client endpoints (Admin only)
# ---------------------------------------------------------------------------
@router.get("", response_model=list[Client])
async def list_clients(
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
if current_user.role == UserRole.ADMIN:
docs = await db.clients.find().to_list(None)
elif current_user.role == UserRole.PROJECT_MANAGER:
client_ids = current_user.pm_client_ids or []
docs = await db.clients.find({"_id": {"$in": client_ids}}).to_list(None)
else:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return [_client_from_doc(d) for d in docs]
@router.post("", response_model=Client)
async def create_client(
body: ClientCreate,
current_user: User = Depends(require_roles(UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
if await db.clients.find_one({"slug": body.slug}):
raise HTTPException(status_code=409, detail="Slug already exists")
now = _now()
client_id = str(ObjectId())
await db.clients.insert_one({
"_id": client_id,
"name": body.name,
"slug": body.slug,
"is_active": True,
"created_at": now,
"updated_at": now,
})
doc = await db.clients.find_one({"_id": client_id})
return _client_from_doc(doc)
@router.get("/{client_id}", response_model=Client)
async def get_client(
client_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
if current_user.role not in (UserRole.ADMIN, UserRole.PROJECT_MANAGER):
raise HTTPException(status_code=403, detail="Insufficient permissions")
if current_user.role == UserRole.PROJECT_MANAGER and client_id not in (current_user.pm_client_ids or []):
raise HTTPException(status_code=403, detail="Not a manager for this client")
doc = await _get_client_or_404(client_id, db)
return _client_from_doc(doc)
@router.patch("/{client_id}", response_model=Client)
async def update_client(
client_id: str,
body: ClientUpdate,
current_user: User = Depends(require_roles(UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
update: dict = {k: v for k, v in body.model_dump(exclude_none=True).items()}
if not update:
raise HTTPException(status_code=422, detail="No fields to update")
if "slug" in update and await db.clients.find_one({"slug": update["slug"], "_id": {"$ne": client_id}}):
raise HTTPException(status_code=409, detail="Slug already exists")
update["updated_at"] = _now()
await db.clients.update_one({"_id": client_id}, {"$set": update})
doc = await db.clients.find_one({"_id": client_id})
return _client_from_doc(doc)
@router.delete("/{client_id}", status_code=204)
async def deactivate_client(
client_id: str,
current_user: User = Depends(require_roles(UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await db.clients.update_one({"_id": client_id}, {"$set": {"is_active": False, "updated_at": _now()}})
# ---------------------------------------------------------------------------
# PM assignment (Admin only)
# ---------------------------------------------------------------------------
class AssignPMRequest(BaseModel):
user_id: str
@router.post("/{client_id}/pm", status_code=204)
async def assign_pm(
client_id: str,
body: AssignPMRequest,
current_user: User = Depends(require_roles(UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
user_doc = await db.users.find_one({"_id": body.user_id})
if not user_doc:
raise HTTPException(status_code=404, detail="User not found")
await db.users.update_one(
{"_id": body.user_id},
{
"$addToSet": {"pm_client_ids": client_id},
"$set": {"role": UserRole.PROJECT_MANAGER.value, "updated_at": _now()},
},
)
@router.delete("/{client_id}/pm/{user_id}", status_code=204)
async def remove_pm(
client_id: str,
user_id: str,
current_user: User = Depends(require_roles(UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await db.users.update_one(
{"_id": user_id},
{"$pull": {"pm_client_ids": client_id}, "$set": {"updated_at": _now()}},
)
# Downgrade role if no more PM assignments
user_doc = await db.users.find_one({"_id": user_id})
if user_doc and not user_doc.get("pm_client_ids"):
await db.users.update_one(
{"_id": user_id},
{"$set": {"role": UserRole.CLIENT.value, "updated_at": _now()}},
)
@router.get("/{client_id}/pm", response_model=list[dict])
async def list_pms(
client_id: str,
current_user: User = Depends(require_roles(UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
users = await db.users.find(
{"pm_client_ids": client_id},
{"_id": 1, "email": 1, "full_name": 1, "role": 1},
).to_list(None)
return [{"id": str(u["_id"]), "email": u["email"], "full_name": u["full_name"], "role": u["role"]} for u in users]
# ---------------------------------------------------------------------------
# Team endpoints
# ---------------------------------------------------------------------------
@router.get("/{client_id}/teams", response_model=list[Team])
async def list_teams(
client_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await _assert_client_access(current_user, client_id, db)
docs = await db.teams.find({"client_id": client_id}).to_list(None)
return [_team_from_doc(d) for d in docs]
@router.post("/{client_id}/teams", response_model=Team)
async def create_team(
client_id: str,
body: TeamCreate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await _assert_pm_or_admin(current_user, client_id, db)
now = _now()
team_id = str(ObjectId())
await db.teams.insert_one({
"_id": team_id,
"name": body.name,
"client_id": client_id,
"member_user_ids": [],
"created_at": now,
"updated_at": now,
})
doc = await db.teams.find_one({"_id": team_id})
return _team_from_doc(doc)
@router.patch("/{client_id}/teams/{team_id}", response_model=Team)
async def update_team(
client_id: str,
team_id: str,
body: TeamUpdate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await _assert_pm_or_admin(current_user, client_id, db)
await _get_team_or_404(team_id, client_id, db)
update = {k: v for k, v in body.model_dump(exclude_none=True).items()}
if not update:
raise HTTPException(status_code=422, detail="No fields to update")
update["updated_at"] = _now()
await db.teams.update_one({"_id": team_id}, {"$set": update})
doc = await db.teams.find_one({"_id": team_id})
return _team_from_doc(doc)
@router.delete("/{client_id}/teams/{team_id}", status_code=204)
async def delete_team(
client_id: str,
team_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await _assert_pm_or_admin(current_user, client_id, db)
await _get_team_or_404(team_id, client_id, db)
await db.teams.delete_one({"_id": team_id})
# Team membership
class AddMemberRequest(BaseModel):
user_id: str
@router.post("/{client_id}/teams/{team_id}/members", status_code=204)
async def add_team_member(
client_id: str,
team_id: str,
body: AddMemberRequest,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await _assert_pm_or_admin(current_user, client_id, db)
await _get_team_or_404(team_id, client_id, db)
if not await db.users.find_one({"_id": body.user_id}):
raise HTTPException(status_code=404, detail="User not found")
await db.teams.update_one(
{"_id": team_id},
{"$addToSet": {"member_user_ids": body.user_id}, "$set": {"updated_at": _now()}},
)
@router.delete("/{client_id}/teams/{team_id}/members/{user_id}", status_code=204)
async def remove_team_member(
client_id: str,
team_id: str,
user_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await _assert_pm_or_admin(current_user, client_id, db)
await _get_team_or_404(team_id, client_id, db)
await db.teams.update_one(
{"_id": team_id},
{"$pull": {"member_user_ids": user_id}, "$set": {"updated_at": _now()}},
)
# ---------------------------------------------------------------------------
# Project endpoints
# ---------------------------------------------------------------------------
@router.get("/{client_id}/projects", response_model=list[Project])
async def list_projects(
client_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await _assert_client_access(current_user, client_id, db)
docs = await db.projects.find({"client_id": client_id}).to_list(None)
return [_project_from_doc(d) for d in docs]
@router.post("/{client_id}/projects", response_model=Project)
async def create_project(
client_id: str,
body: ProjectCreate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await _assert_pm_or_admin(current_user, client_id, db)
now = _now()
project_id = str(ObjectId())
await db.projects.insert_one({
"_id": project_id,
"name": body.name,
"client_id": client_id,
"is_active": True,
"created_at": now,
"updated_at": now,
})
doc = await db.projects.find_one({"_id": project_id})
return _project_from_doc(doc)
@router.patch("/{client_id}/projects/{project_id}", response_model=Project)
async def update_project(
client_id: str,
project_id: str,
body: ProjectUpdate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await _assert_pm_or_admin(current_user, client_id, db)
await _get_project_or_404(project_id, client_id, db)
update = {k: v for k, v in body.model_dump(exclude_none=True).items()}
if not update:
raise HTTPException(status_code=422, detail="No fields to update")
update["updated_at"] = _now()
await db.projects.update_one({"_id": project_id}, {"$set": update})
doc = await db.projects.find_one({"_id": project_id})
return _project_from_doc(doc)
@router.delete("/{client_id}/projects/{project_id}", status_code=204)
async def archive_project(
client_id: str,
project_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await _assert_pm_or_admin(current_user, client_id, db)
await _get_project_or_404(project_id, client_id, db)
await db.projects.update_one(
{"_id": project_id},
{"$set": {"is_active": False, "updated_at": _now()}},
)
# ---------------------------------------------------------------------------
# Internal access-check helpers
# ---------------------------------------------------------------------------
async def _assert_pm_or_admin(user: User, client_id: str, db: AsyncIOMotorDatabase) -> None:
if user.role == UserRole.ADMIN:
return
# Check memberships collection (Phase 3)
mem = await db.memberships.find_one({"user_id": str(user.id), "organization_id": client_id})
if mem and mem.get("role_in_org") in ("owner", "admin", "manager"):
return
# Legacy fallback
if user.role == UserRole.PROJECT_MANAGER and client_id in (user.pm_client_ids or []):
return
raise HTTPException(status_code=403, detail="Not a manager for this client")
async def _assert_client_access(user: User, client_id: str, db: AsyncIOMotorDatabase) -> None:
"""Allow platform staff, org members (any role), or PM of the client."""
if user.role in (UserRole.ADMIN, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.LINGUIST):
return
# Any org membership grants read access
mem = await db.memberships.find_one({"user_id": str(user.id), "organization_id": client_id})
if mem:
return
# Legacy fallback for pre-migration users
if user.role == UserRole.PROJECT_MANAGER and client_id in (user.pm_client_ids or []):
return
if user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
return
raise HTTPException(status_code=403, detail="Insufficient permissions")