272 lines
8.8 KiB
Python
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")
|