users.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. from fastapi import APIRouter, Depends, HTTPException, status
  2. from sqlalchemy import select
  3. from sqlalchemy.ext.asyncio import AsyncSession
  4. from sqlalchemy.orm import selectinload
  5. from backend.app.core.auth import (
  6. RequirePermissionIfAuthEnabled,
  7. get_current_user_optional,
  8. get_password_hash,
  9. verify_password,
  10. )
  11. from backend.app.core.database import get_db
  12. from backend.app.core.permissions import Permission
  13. from backend.app.models.group import Group
  14. from backend.app.models.user import User
  15. from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
  16. router = APIRouter(prefix="/users", tags=["users"])
  17. def _user_to_response(user: User) -> UserResponse:
  18. """Convert a User model to UserResponse schema."""
  19. return UserResponse(
  20. id=user.id,
  21. username=user.username,
  22. role=user.role,
  23. is_active=user.is_active,
  24. is_admin=user.is_admin,
  25. groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
  26. permissions=sorted(user.get_permissions()),
  27. created_at=user.created_at.isoformat(),
  28. )
  29. @router.get("", response_model=list[UserResponse])
  30. @router.get("/", response_model=list[UserResponse])
  31. async def list_users(
  32. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  33. db: AsyncSession = Depends(get_db),
  34. ):
  35. """List all users."""
  36. result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
  37. users = result.scalars().all()
  38. return [_user_to_response(user) for user in users]
  39. @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  40. @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  41. async def create_user(
  42. user_data: UserCreate,
  43. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
  44. db: AsyncSession = Depends(get_db),
  45. ):
  46. """Create a new user."""
  47. # Check if username already exists
  48. existing_user = await db.execute(select(User).where(User.username == user_data.username))
  49. if existing_user.scalar_one_or_none():
  50. raise HTTPException(
  51. status_code=status.HTTP_400_BAD_REQUEST,
  52. detail="Username already exists",
  53. )
  54. # Validate role
  55. if user_data.role not in ["admin", "user"]:
  56. raise HTTPException(
  57. status_code=status.HTTP_400_BAD_REQUEST,
  58. detail="Role must be 'admin' or 'user'",
  59. )
  60. new_user = User(
  61. username=user_data.username,
  62. password_hash=get_password_hash(user_data.password),
  63. role=user_data.role,
  64. is_active=True,
  65. )
  66. # Handle group assignments
  67. if user_data.group_ids:
  68. groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
  69. groups = groups_result.scalars().all()
  70. if len(groups) != len(user_data.group_ids):
  71. raise HTTPException(
  72. status_code=status.HTTP_400_BAD_REQUEST,
  73. detail="One or more group IDs are invalid",
  74. )
  75. new_user.groups = list(groups)
  76. db.add(new_user)
  77. await db.commit()
  78. await db.refresh(new_user)
  79. return _user_to_response(new_user)
  80. @router.get("/{user_id}", response_model=UserResponse)
  81. async def get_user(
  82. user_id: int,
  83. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  84. db: AsyncSession = Depends(get_db),
  85. ):
  86. """Get a user by ID."""
  87. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  88. user = result.scalar_one_or_none()
  89. if not user:
  90. raise HTTPException(
  91. status_code=status.HTTP_404_NOT_FOUND,
  92. detail="User not found",
  93. )
  94. return _user_to_response(user)
  95. @router.patch("/{user_id}", response_model=UserResponse)
  96. async def update_user(
  97. user_id: int,
  98. user_data: UserUpdate,
  99. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
  100. db: AsyncSession = Depends(get_db),
  101. ):
  102. """Update a user."""
  103. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  104. user = result.scalar_one_or_none()
  105. if not user:
  106. raise HTTPException(
  107. status_code=status.HTTP_404_NOT_FOUND,
  108. detail="User not found",
  109. )
  110. # Prevent deactivating the last admin
  111. if user_data.is_active is False and user.is_admin:
  112. # Count admins by role or Administrators group membership
  113. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
  114. role_admins = admin_count_result.scalars().all()
  115. # Also check for users in Administrators group
  116. admin_group_result = await db.execute(
  117. select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
  118. )
  119. admin_group = admin_group_result.scalar_one_or_none()
  120. group_admins = [u for u in (admin_group.users if admin_group else []) if u.is_active]
  121. # Combine unique admins
  122. all_admins = {u.id for u in role_admins} | {u.id for u in group_admins}
  123. if len(all_admins) <= 1 and user.id in all_admins:
  124. raise HTTPException(
  125. status_code=status.HTTP_400_BAD_REQUEST,
  126. detail="Cannot deactivate the last admin user",
  127. )
  128. # Prevent changing role of last admin
  129. if user_data.role and user_data.role != "admin" and user.role == "admin":
  130. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
  131. admin_count = len(admin_count_result.scalars().all())
  132. if admin_count <= 1:
  133. raise HTTPException(
  134. status_code=status.HTTP_400_BAD_REQUEST,
  135. detail="Cannot change role of the last admin user",
  136. )
  137. if user_data.username is not None:
  138. # Check if new username already exists
  139. existing_user = await db.execute(select(User).where(User.username == user_data.username, User.id != user_id))
  140. if existing_user.scalar_one_or_none():
  141. raise HTTPException(
  142. status_code=status.HTTP_400_BAD_REQUEST,
  143. detail="Username already exists",
  144. )
  145. user.username = user_data.username
  146. if user_data.password is not None:
  147. user.password_hash = get_password_hash(user_data.password)
  148. if user_data.role is not None:
  149. if user_data.role not in ["admin", "user"]:
  150. raise HTTPException(
  151. status_code=status.HTTP_400_BAD_REQUEST,
  152. detail="Role must be 'admin' or 'user'",
  153. )
  154. user.role = user_data.role
  155. if user_data.is_active is not None:
  156. user.is_active = user_data.is_active
  157. # Handle group assignments
  158. if user_data.group_ids is not None:
  159. groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
  160. groups = groups_result.scalars().all()
  161. if len(groups) != len(user_data.group_ids):
  162. raise HTTPException(
  163. status_code=status.HTTP_400_BAD_REQUEST,
  164. detail="One or more group IDs are invalid",
  165. )
  166. user.groups = list(groups)
  167. await db.commit()
  168. await db.refresh(user)
  169. return _user_to_response(user)
  170. @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
  171. async def delete_user(
  172. user_id: int,
  173. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
  174. db: AsyncSession = Depends(get_db),
  175. ):
  176. """Delete a user."""
  177. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  178. user = result.scalar_one_or_none()
  179. if not user:
  180. raise HTTPException(
  181. status_code=status.HTTP_404_NOT_FOUND,
  182. detail="User not found",
  183. )
  184. # Prevent deleting the last admin
  185. if user.is_admin:
  186. # Count admins by role or Administrators group membership
  187. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.id != user_id))
  188. other_role_admins = admin_count_result.scalars().all()
  189. # Also check for users in Administrators group
  190. admin_group_result = await db.execute(
  191. select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
  192. )
  193. admin_group = admin_group_result.scalar_one_or_none()
  194. other_group_admins = [u for u in (admin_group.users if admin_group else []) if u.id != user_id and u.is_active]
  195. # Combine unique admins
  196. all_other_admins = {u.id for u in other_role_admins} | {u.id for u in other_group_admins}
  197. if len(all_other_admins) == 0:
  198. raise HTTPException(
  199. status_code=status.HTTP_400_BAD_REQUEST,
  200. detail="Cannot delete the last admin user",
  201. )
  202. # Prevent deleting yourself (only if auth is enabled and we have a current user)
  203. if current_user and user.id == current_user.id:
  204. raise HTTPException(
  205. status_code=status.HTTP_400_BAD_REQUEST,
  206. detail="Cannot delete your own account",
  207. )
  208. await db.delete(user)
  209. await db.commit()
  210. @router.post("/me/change-password", response_model=dict)
  211. async def change_own_password(
  212. password_data: ChangePasswordRequest,
  213. current_user: User | None = Depends(get_current_user_optional),
  214. db: AsyncSession = Depends(get_db),
  215. ):
  216. """Change the current user's password. Requires current password verification."""
  217. if not current_user:
  218. raise HTTPException(
  219. status_code=status.HTTP_401_UNAUTHORIZED,
  220. detail="Authentication required to change password",
  221. )
  222. # Verify current password
  223. if not verify_password(password_data.current_password, current_user.password_hash):
  224. raise HTTPException(
  225. status_code=status.HTTP_400_BAD_REQUEST,
  226. detail="Current password is incorrect",
  227. )
  228. # Validate new password
  229. if len(password_data.new_password) < 6:
  230. raise HTTPException(
  231. status_code=status.HTTP_400_BAD_REQUEST,
  232. detail="New password must be at least 6 characters",
  233. )
  234. # Fetch user from this session to ensure changes are persisted
  235. result = await db.execute(select(User).where(User.id == current_user.id))
  236. user = result.scalar_one_or_none()
  237. if not user:
  238. raise HTTPException(
  239. status_code=status.HTTP_404_NOT_FOUND,
  240. detail="User not found",
  241. )
  242. # Update password
  243. user.password_hash = get_password_hash(password_data.new_password)
  244. await db.commit()
  245. return {"message": "Password changed successfully"}