users.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. from fastapi import APIRouter, Depends, HTTPException, Query, status
  2. from sqlalchemy import delete, func, 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.archive import PrintArchive
  14. from backend.app.models.group import Group
  15. from backend.app.models.library import LibraryFile
  16. from backend.app.models.print_queue import PrintQueueItem
  17. from backend.app.models.user import User
  18. from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
  19. router = APIRouter(prefix="/users", tags=["users"])
  20. def _user_to_response(user: User) -> UserResponse:
  21. """Convert a User model to UserResponse schema."""
  22. return UserResponse(
  23. id=user.id,
  24. username=user.username,
  25. role=user.role,
  26. is_active=user.is_active,
  27. is_admin=user.is_admin,
  28. groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
  29. permissions=sorted(user.get_permissions()),
  30. created_at=user.created_at.isoformat(),
  31. )
  32. @router.get("", response_model=list[UserResponse])
  33. @router.get("/", response_model=list[UserResponse])
  34. async def list_users(
  35. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  36. db: AsyncSession = Depends(get_db),
  37. ):
  38. """List all users."""
  39. result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
  40. users = result.scalars().all()
  41. return [_user_to_response(user) for user in users]
  42. @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  43. @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  44. async def create_user(
  45. user_data: UserCreate,
  46. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
  47. db: AsyncSession = Depends(get_db),
  48. ):
  49. """Create a new user."""
  50. # Check if username already exists
  51. existing_user = await db.execute(select(User).where(User.username == user_data.username))
  52. if existing_user.scalar_one_or_none():
  53. raise HTTPException(
  54. status_code=status.HTTP_400_BAD_REQUEST,
  55. detail="Username already exists",
  56. )
  57. # Validate role
  58. if user_data.role not in ["admin", "user"]:
  59. raise HTTPException(
  60. status_code=status.HTTP_400_BAD_REQUEST,
  61. detail="Role must be 'admin' or 'user'",
  62. )
  63. new_user = User(
  64. username=user_data.username,
  65. password_hash=get_password_hash(user_data.password),
  66. role=user_data.role,
  67. is_active=True,
  68. )
  69. # Handle group assignments
  70. if user_data.group_ids:
  71. groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
  72. groups = groups_result.scalars().all()
  73. if len(groups) != len(user_data.group_ids):
  74. raise HTTPException(
  75. status_code=status.HTTP_400_BAD_REQUEST,
  76. detail="One or more group IDs are invalid",
  77. )
  78. new_user.groups = list(groups)
  79. db.add(new_user)
  80. await db.commit()
  81. await db.refresh(new_user)
  82. return _user_to_response(new_user)
  83. @router.get("/{user_id}", response_model=UserResponse)
  84. async def get_user(
  85. user_id: int,
  86. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  87. db: AsyncSession = Depends(get_db),
  88. ):
  89. """Get a user by ID."""
  90. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  91. user = result.scalar_one_or_none()
  92. if not user:
  93. raise HTTPException(
  94. status_code=status.HTTP_404_NOT_FOUND,
  95. detail="User not found",
  96. )
  97. return _user_to_response(user)
  98. @router.patch("/{user_id}", response_model=UserResponse)
  99. async def update_user(
  100. user_id: int,
  101. user_data: UserUpdate,
  102. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
  103. db: AsyncSession = Depends(get_db),
  104. ):
  105. """Update a user."""
  106. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  107. user = result.scalar_one_or_none()
  108. if not user:
  109. raise HTTPException(
  110. status_code=status.HTTP_404_NOT_FOUND,
  111. detail="User not found",
  112. )
  113. # Prevent deactivating the last admin
  114. if user_data.is_active is False and user.is_admin:
  115. # Count admins by role or Administrators group membership
  116. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
  117. role_admins = admin_count_result.scalars().all()
  118. # Also check for users in Administrators group
  119. admin_group_result = await db.execute(
  120. select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
  121. )
  122. admin_group = admin_group_result.scalar_one_or_none()
  123. group_admins = [u for u in (admin_group.users if admin_group else []) if u.is_active]
  124. # Combine unique admins
  125. all_admins = {u.id for u in role_admins} | {u.id for u in group_admins}
  126. if len(all_admins) <= 1 and user.id in all_admins:
  127. raise HTTPException(
  128. status_code=status.HTTP_400_BAD_REQUEST,
  129. detail="Cannot deactivate the last admin user",
  130. )
  131. # Prevent changing role of last admin
  132. if user_data.role and user_data.role != "admin" and user.role == "admin":
  133. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
  134. admin_count = len(admin_count_result.scalars().all())
  135. if admin_count <= 1:
  136. raise HTTPException(
  137. status_code=status.HTTP_400_BAD_REQUEST,
  138. detail="Cannot change role of the last admin user",
  139. )
  140. if user_data.username is not None:
  141. # Check if new username already exists
  142. existing_user = await db.execute(select(User).where(User.username == user_data.username, User.id != user_id))
  143. if existing_user.scalar_one_or_none():
  144. raise HTTPException(
  145. status_code=status.HTTP_400_BAD_REQUEST,
  146. detail="Username already exists",
  147. )
  148. user.username = user_data.username
  149. if user_data.password is not None:
  150. user.password_hash = get_password_hash(user_data.password)
  151. if user_data.role is not None:
  152. if user_data.role not in ["admin", "user"]:
  153. raise HTTPException(
  154. status_code=status.HTTP_400_BAD_REQUEST,
  155. detail="Role must be 'admin' or 'user'",
  156. )
  157. user.role = user_data.role
  158. if user_data.is_active is not None:
  159. user.is_active = user_data.is_active
  160. # Handle group assignments
  161. if user_data.group_ids is not None:
  162. groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
  163. groups = groups_result.scalars().all()
  164. if len(groups) != len(user_data.group_ids):
  165. raise HTTPException(
  166. status_code=status.HTTP_400_BAD_REQUEST,
  167. detail="One or more group IDs are invalid",
  168. )
  169. user.groups = list(groups)
  170. await db.commit()
  171. await db.refresh(user)
  172. return _user_to_response(user)
  173. @router.get("/{user_id}/items-count")
  174. async def get_user_items_count(
  175. user_id: int,
  176. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  177. db: AsyncSession = Depends(get_db),
  178. ):
  179. """Get count of items created by this user."""
  180. # Verify user exists
  181. result = await db.execute(select(User).where(User.id == user_id))
  182. if not result.scalar_one_or_none():
  183. raise HTTPException(
  184. status_code=status.HTTP_404_NOT_FOUND,
  185. detail="User not found",
  186. )
  187. # Count archives
  188. archives_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.created_by_id == user_id))
  189. archives_count = archives_result.scalar() or 0
  190. # Count queue items
  191. queue_result = await db.execute(
  192. select(func.count(PrintQueueItem.id)).where(PrintQueueItem.created_by_id == user_id)
  193. )
  194. queue_items_count = queue_result.scalar() or 0
  195. # Count library files
  196. library_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.created_by_id == user_id))
  197. library_files_count = library_result.scalar() or 0
  198. return {
  199. "archives": archives_count,
  200. "queue_items": queue_items_count,
  201. "library_files": library_files_count,
  202. }
  203. @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
  204. async def delete_user(
  205. user_id: int,
  206. delete_items: bool = Query(False, description="Delete all items created by this user"),
  207. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
  208. db: AsyncSession = Depends(get_db),
  209. ):
  210. """Delete a user.
  211. If delete_items=True, all archives, queue items, and library files created by
  212. this user will also be deleted. Otherwise, these items will become "ownerless"
  213. (created_by_id set to NULL by the foreign key constraint).
  214. """
  215. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  216. user = result.scalar_one_or_none()
  217. if not user:
  218. raise HTTPException(
  219. status_code=status.HTTP_404_NOT_FOUND,
  220. detail="User not found",
  221. )
  222. # Prevent deleting the last admin
  223. if user.is_admin:
  224. # Count admins by role or Administrators group membership
  225. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.id != user_id))
  226. other_role_admins = admin_count_result.scalars().all()
  227. # Also check for users in Administrators group
  228. admin_group_result = await db.execute(
  229. select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
  230. )
  231. admin_group = admin_group_result.scalar_one_or_none()
  232. other_group_admins = [u for u in (admin_group.users if admin_group else []) if u.id != user_id and u.is_active]
  233. # Combine unique admins
  234. all_other_admins = {u.id for u in other_role_admins} | {u.id for u in other_group_admins}
  235. if len(all_other_admins) == 0:
  236. raise HTTPException(
  237. status_code=status.HTTP_400_BAD_REQUEST,
  238. detail="Cannot delete the last admin user",
  239. )
  240. # Prevent deleting yourself (only if auth is enabled and we have a current user)
  241. if current_user and user.id == current_user.id:
  242. raise HTTPException(
  243. status_code=status.HTTP_400_BAD_REQUEST,
  244. detail="Cannot delete your own account",
  245. )
  246. if delete_items:
  247. # Delete all items created by this user
  248. await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
  249. await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
  250. await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id))
  251. else:
  252. # Explicitly set created_by_id to NULL for all items (ensures consistent behavior
  253. # across different database backends, including SQLite without foreign key support)
  254. from sqlalchemy import update
  255. await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))
  256. await db.execute(
  257. update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)
  258. )
  259. await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
  260. await db.delete(user)
  261. await db.commit()
  262. @router.post("/me/change-password", response_model=dict)
  263. async def change_own_password(
  264. password_data: ChangePasswordRequest,
  265. current_user: User | None = Depends(get_current_user_optional),
  266. db: AsyncSession = Depends(get_db),
  267. ):
  268. """Change the current user's password. Requires current password verification."""
  269. if not current_user:
  270. raise HTTPException(
  271. status_code=status.HTTP_401_UNAUTHORIZED,
  272. detail="Authentication required to change password",
  273. )
  274. # Verify current password
  275. if not verify_password(password_data.current_password, current_user.password_hash):
  276. raise HTTPException(
  277. status_code=status.HTTP_400_BAD_REQUEST,
  278. detail="Current password is incorrect",
  279. )
  280. # Validate new password
  281. if len(password_data.new_password) < 6:
  282. raise HTTPException(
  283. status_code=status.HTTP_400_BAD_REQUEST,
  284. detail="New password must be at least 6 characters",
  285. )
  286. # Fetch user from this session to ensure changes are persisted
  287. result = await db.execute(select(User).where(User.id == current_user.id))
  288. user = result.scalar_one_or_none()
  289. if not user:
  290. raise HTTPException(
  291. status_code=status.HTTP_404_NOT_FOUND,
  292. detail="User not found",
  293. )
  294. # Update password
  295. user.password_hash = get_password_hash(password_data.new_password)
  296. await db.commit()
  297. return {"message": "Password changed successfully"}