Files
zcglxt/backend/app/api/v1/users.py

272 lines
8.8 KiB
Python

"""
User management API routes.
"""
from typing import Optional, List, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func, or_, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.core.response import success_response, paginated_response
from app.core.security import get_password_hash
from app.models.user import User, Role, UserRole
from app.schemas.user import UserCreate, UserUpdate, ResetPasswordRequest
from app.services.auth_service import auth_service
router = APIRouter()
def _status_to_is_active(status_value: Optional[str]) -> Optional[bool]:
if not status_value:
return None
if status_value == "active":
return True
if status_value in {"disabled", "locked"}:
return False
return None
def _role_to_dict(role: Role) -> Dict:
return {
"id": role.id,
"role_name": role.role_name,
"role_code": role.role_code,
"description": role.description,
"status": role.status,
"sort_order": role.sort_order,
"created_at": role.created_at,
}
def _user_to_dict(user: User, roles: List[Role]) -> Dict:
return {
"id": user.id,
"username": user.username,
"real_name": user.full_name or user.username,
"email": user.email,
"phone": user.phone,
"avatar_url": user.avatar_url,
"status": "active" if user.is_active else "disabled",
"is_admin": user.is_superuser,
"last_login_at": user.last_login_at,
"created_at": user.created_at,
"roles": [_role_to_dict(role) for role in roles],
}
async def _ensure_roles_exist(db: AsyncSession, role_ids: List[int]) -> None:
if not role_ids:
return
result = await db.execute(
select(Role.id).where(Role.id.in_(role_ids)).where(Role.deleted_at.is_(None))
)
existing_ids = {row[0] for row in result.all()}
missing = set(role_ids) - existing_ids
if missing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid role IDs: {sorted(missing)}",
)
async def _set_user_roles(
db: AsyncSession,
user_id: int,
role_ids: List[int],
operator_id: Optional[int] = None,
) -> None:
await db.execute(delete(UserRole).where(UserRole.user_id == user_id))
if role_ids:
for role_id in role_ids:
db.add(UserRole(user_id=user_id, role_id=role_id, created_by=operator_id))
@router.get("/")
async def list_users(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Page size"),
keyword: Optional[str] = Query(None, description="Search keyword"),
status_value: Optional[str] = Query(None, alias="status", description="Status"),
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
is_active = _status_to_is_active(status_value)
query = select(User)
count_query = select(func.count(User.id))
conditions = []
if keyword:
like_value = f"%{keyword}%"
conditions.append(
or_(
User.username.ilike(like_value),
User.full_name.ilike(like_value),
User.phone.ilike(like_value),
User.email.ilike(like_value),
)
)
if is_active is not None:
conditions.append(User.is_active == is_active)
if conditions:
query = query.where(*conditions)
count_query = count_query.where(*conditions)
query = query.order_by(User.id.desc())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(query.offset(offset).limit(page_size))
users = list(result.scalars().all())
role_map: Dict[int, List[Role]] = {user.id: [] for user in users}
if users:
user_ids = [user.id for user in users]
role_result = await db.execute(
select(UserRole.user_id, Role)
.join(Role, Role.id == UserRole.role_id)
.where(UserRole.user_id.in_(user_ids))
.where(Role.deleted_at.is_(None))
)
for user_id, role in role_result.all():
role_map.setdefault(user_id, []).append(role)
items = [_user_to_dict(user, role_map.get(user.id, [])) for user in users]
return paginated_response(items, total, page, page_size)
@router.post("/", status_code=status.HTTP_201_CREATED)
async def create_user(
payload: UserCreate,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
existing = await db.execute(select(User).where(User.username == payload.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already exists")
email_value = payload.email
if not email_value:
email_value = f"{payload.username}@local.invalid"
email_check = await db.execute(select(User).where(User.email == email_value))
if email_check.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already exists")
await _ensure_roles_exist(db, payload.role_ids)
user = User(
username=payload.username,
email=email_value,
hashed_password=get_password_hash(payload.password),
full_name=payload.real_name,
phone=payload.phone,
is_active=True,
is_superuser=False,
)
db.add(user)
await db.flush()
await _set_user_roles(db, user.id, payload.role_ids, current_user.id)
await db.commit()
await db.refresh(user)
roles_result = await db.execute(
select(Role)
.join(UserRole, UserRole.role_id == Role.id)
.where(UserRole.user_id == user.id)
.where(Role.deleted_at.is_(None))
)
roles = list(roles_result.scalars().all())
return success_response(data=_user_to_dict(user, roles))
@router.put("/{user_id}")
async def update_user(
user_id: int,
payload: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
update_data = payload.model_dump(exclude_unset=True)
if "real_name" in update_data:
user.full_name = update_data.pop("real_name")
if "status" in update_data:
status_value = update_data.pop("status")
is_active = _status_to_is_active(status_value)
if is_active is not None:
user.is_active = is_active
if "email" in update_data:
email_value = update_data.pop("email")
if email_value:
email_check = await db.execute(
select(User).where(User.email == email_value).where(User.id != user_id)
)
if email_check.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already exists")
user.email = email_value
if "phone" in update_data:
user.phone = update_data.pop("phone")
role_ids = update_data.pop("role_ids", None)
if role_ids is not None:
await _ensure_roles_exist(db, role_ids)
await _set_user_roles(db, user.id, role_ids, current_user.id)
db.add(user)
await db.commit()
await db.refresh(user)
roles_result = await db.execute(
select(Role)
.join(UserRole, UserRole.role_id == Role.id)
.where(UserRole.user_id == user.id)
.where(Role.deleted_at.is_(None))
)
roles = list(roles_result.scalars().all())
return success_response(data=_user_to_dict(user, roles))
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await db.execute(delete(UserRole).where(UserRole.user_id == user_id))
await db.delete(user)
await db.commit()
return success_response(message="Deleted")
@router.post("/{user_id}/reset-password")
async def reset_password(
user_id: int,
payload: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
success = await auth_service.reset_password(db=db, user_id=user_id, new_password=payload.new_password)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return success_response(message="Password reset")