227 lines
8.5 KiB
Python
227 lines
8.5 KiB
Python
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}
|