Fix API compatibility and add user/role/permission and asset import/export
This commit is contained in:
271
backend/app/api/v1/users.py
Normal file
271
backend/app/api/v1/users.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
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")
|
||||
Reference in New Issue
Block a user