import uuid from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.security import get_current_user from app.database import get_db from app.models.agent import Agent from app.models.user import User router = APIRouter(prefix="/users", tags=["users"]) class UserQuotaUpdate(BaseModel): quota_message_limit: int | None = None quota_message_period: str | None = None quota_max_agents: int | None = None quota_agent_ttl_hours: int | None = None class UserOut(BaseModel): id: uuid.UUID # username/email/display_name can be None for SSO-created users whose Identity # was created without explicit values (e.g., DingTalk/Feishu OAuth flow). # The frontend should handle None gracefully. username: str | None = None email: str | None = None display_name: str | None = None role: str is_active: bool # Quota fields quota_message_limit: int quota_message_period: str quota_messages_used: int quota_max_agents: int quota_agent_ttl_hours: int # Computed agents_count: int = 0 # Source info created_at: str | None = None source: str = 'registered' # 'registered' | 'feishu' | 'dingtalk' | 'wecom' | etc. model_config = {"from_attributes": True} @router.get("/", response_model=list[UserOut]) async def list_users( tenant_id: str | None = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List all users in the specified tenant (admin only).""" if current_user.role not in ("platform_admin", "org_admin"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") # Platform admins can view any tenant; org_admins only their own tid = tenant_id if tenant_id and current_user.role == "platform_admin" else str(current_user.tenant_id) # Filter users by tenant — platform_admins only shown in their own tenant result = await db.execute( select(User).options(selectinload(User.identity)).where( User.tenant_id == tid ).order_by(User.created_at.asc()) ) users = result.scalars().all() out = [] for u in users: # Count non-expired agents count_result = await db.execute( select(func.count()).select_from(Agent).where( Agent.creator_id == u.id, Agent.is_expired == False, ) ) agents_count = count_result.scalar() or 0 user_dict = { "id": u.id, # Fallback to empty string if username/email/display_name is None to prevent # serialization errors for SSO-created users with incomplete Identity records. "username": u.username or u.email or f"{u.registration_source or 'user'}_{str(u.id)[:8]}", "email": u.email or "", "display_name": u.display_name or u.username or "", "role": u.role, "is_active": u.is_active, "quota_message_limit": u.quota_message_limit, "quota_message_period": u.quota_message_period, "quota_messages_used": u.quota_messages_used, "quota_max_agents": u.quota_max_agents, "quota_agent_ttl_hours": u.quota_agent_ttl_hours, "agents_count": agents_count, "created_at": u.created_at.isoformat() if u.created_at else None, "source": (u.registration_source or 'registered'), } out.append(UserOut(**user_dict)) return out @router.patch("/{user_id}/quota", response_model=UserOut) async def update_user_quota( user_id: uuid.UUID, data: UserQuotaUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Update a user's quota settings (admin only).""" if current_user.role not in ("platform_admin", "org_admin"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") result = await db.execute( select(User).options(selectinload(User.identity)).where(User.id == user_id) ) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") if user.tenant_id != current_user.tenant_id: raise HTTPException(status_code=403, detail="Cannot modify users outside your organization") if data.quota_message_limit is not None: user.quota_message_limit = data.quota_message_limit if data.quota_message_period is not None: if data.quota_message_period not in ("permanent", "daily", "weekly", "monthly"): raise HTTPException(status_code=400, detail="Invalid period. Use: permanent, daily, weekly, monthly") user.quota_message_period = data.quota_message_period if data.quota_max_agents is not None: user.quota_max_agents = data.quota_max_agents if data.quota_agent_ttl_hours is not None: user.quota_agent_ttl_hours = data.quota_agent_ttl_hours await db.commit() await db.refresh(user) # Count agents count_result = await db.execute( select(func.count()).select_from(Agent).where( Agent.creator_id == user.id, Agent.is_expired == False, ) ) agents_count = count_result.scalar() or 0 return UserOut( id=user.id, username=user.username, email=user.email, display_name=user.display_name, role=user.role, is_active=user.is_active, quota_message_limit=user.quota_message_limit, quota_message_period=user.quota_message_period, quota_messages_used=user.quota_messages_used, quota_max_agents=user.quota_max_agents, quota_agent_ttl_hours=user.quota_agent_ttl_hours, agents_count=agents_count, ) # ─── Role Management ─────────────────────────────────── class RoleUpdate(BaseModel): role: str @router.patch("/{user_id}/role") async def update_user_role( user_id: uuid.UUID, data: RoleUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Change a user's role within the same company. Permissions: - org_admin: can set roles to org_admin / member within own tenant. Cannot assign platform_admin. - platform_admin: can set any valid role. Safety: - If the target is the ONLY remaining org_admin in the company, demoting them is blocked to prevent orphaned companies. """ if current_user.role not in ("platform_admin", "org_admin"): raise HTTPException(status_code=403, detail="Admin access required") # Validate target role value allowed_roles = ("org_admin", "member") if current_user.role == "platform_admin": allowed_roles = ("platform_admin", "org_admin", "member") if data.role not in allowed_roles: raise HTTPException(status_code=400, detail=f"Invalid role. Allowed: {', '.join(allowed_roles)}") # Find target user result = await db.execute( select(User).options(selectinload(User.identity)).where(User.id == user_id) ) target_user = result.scalar_one_or_none() if not target_user: raise HTTPException(status_code=404, detail="User not found") # org_admin can only modify users in the same tenant if current_user.role == "org_admin" and target_user.tenant_id != current_user.tenant_id: raise HTTPException(status_code=403, detail="Cannot modify users outside your organization") # No-op shortcut if target_user.role == data.role: return {"status": "ok", "user_id": str(user_id), "role": data.role} # Last-admin protection: if demoting an org_admin, check they are not the only one if target_user.role in ("org_admin", "platform_admin") and data.role not in ("org_admin", "platform_admin"): admin_count_result = await db.execute( select(func.count()).select_from(User).where( User.tenant_id == target_user.tenant_id, User.role.in_(["org_admin", "platform_admin"]), ) ) admin_count = admin_count_result.scalar() or 0 if admin_count <= 1: raise HTTPException( status_code=400, detail="Cannot demote the only administrator. Promote another user first." ) target_user.role = data.role await db.commit() return {"status": "ok", "user_id": str(user_id), "role": data.role}