users.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  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.settings import Settings
  18. from backend.app.models.user import User
  19. from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
  20. from backend.app.services.email_service import (
  21. create_welcome_email,
  22. generate_secure_password,
  23. get_smtp_settings,
  24. send_email,
  25. )
  26. from backend.app.api.routes.settings import get_setting
  27. router = APIRouter(prefix="/users", tags=["users"])
  28. def _user_to_response(user: User) -> UserResponse:
  29. """Convert a User model to UserResponse schema."""
  30. return UserResponse(
  31. id=user.id,
  32. username=user.username,
  33. email=user.email,
  34. role=user.role,
  35. is_active=user.is_active,
  36. is_admin=user.is_admin,
  37. groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
  38. permissions=sorted(user.get_permissions()),
  39. created_at=user.created_at.isoformat(),
  40. )
  41. @router.get("", response_model=list[UserResponse])
  42. @router.get("/", response_model=list[UserResponse])
  43. async def list_users(
  44. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  45. db: AsyncSession = Depends(get_db),
  46. ):
  47. """List all users."""
  48. result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
  49. users = result.scalars().all()
  50. return [_user_to_response(user) for user in users]
  51. @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  52. @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  53. async def create_user(
  54. user_data: UserCreate,
  55. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
  56. db: AsyncSession = Depends(get_db),
  57. ):
  58. """Create a new user.
  59. When advanced authentication is enabled:
  60. - Email is required
  61. - Password is auto-generated and emailed to user
  62. - Admin cannot set or see the password
  63. """
  64. import logging
  65. import os
  66. logger = logging.getLogger(__name__)
  67. # Check if advanced auth is enabled
  68. result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
  69. advanced_auth_setting = result.scalar_one_or_none()
  70. advanced_auth_enabled = advanced_auth_setting and advanced_auth_setting.value.lower() == "true"
  71. # Check if username already exists (case-insensitive)
  72. existing_user = await db.execute(
  73. select(User).where(func.lower(User.username) == func.lower(user_data.username))
  74. )
  75. if existing_user.scalar_one_or_none():
  76. raise HTTPException(
  77. status_code=status.HTTP_400_BAD_REQUEST,
  78. detail="Username already exists",
  79. )
  80. # Validate role
  81. if user_data.role not in ["admin", "user"]:
  82. raise HTTPException(
  83. status_code=status.HTTP_400_BAD_REQUEST,
  84. detail="Role must be 'admin' or 'user'",
  85. )
  86. # Advanced auth validation
  87. if advanced_auth_enabled:
  88. if not user_data.email:
  89. raise HTTPException(
  90. status_code=status.HTTP_400_BAD_REQUEST,
  91. detail="Email is required when advanced authentication is enabled",
  92. )
  93. # Check if email already exists (case-insensitive)
  94. existing_email = await db.execute(
  95. select(User).where(func.lower(User.email) == func.lower(user_data.email))
  96. )
  97. if existing_email.scalar_one_or_none():
  98. raise HTTPException(
  99. status_code=status.HTTP_400_BAD_REQUEST,
  100. detail="Email already exists",
  101. )
  102. # Generate password if advanced auth enabled, otherwise require password
  103. if advanced_auth_enabled:
  104. password = generate_secure_password()
  105. else:
  106. if not user_data.password:
  107. raise HTTPException(
  108. status_code=status.HTTP_400_BAD_REQUEST,
  109. detail="Password is required when advanced authentication is disabled",
  110. )
  111. password = user_data.password
  112. new_user = User(
  113. username=user_data.username,
  114. email=user_data.email,
  115. password_hash=get_password_hash(password),
  116. role=user_data.role,
  117. is_active=True,
  118. )
  119. # Handle group assignments
  120. if user_data.group_ids:
  121. groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
  122. groups = groups_result.scalars().all()
  123. if len(groups) != len(user_data.group_ids):
  124. raise HTTPException(
  125. status_code=status.HTTP_400_BAD_REQUEST,
  126. detail="One or more group IDs are invalid",
  127. )
  128. new_user.groups = list(groups)
  129. db.add(new_user)
  130. await db.commit()
  131. await db.refresh(new_user)
  132. # Send welcome email if advanced auth enabled
  133. if advanced_auth_enabled and new_user.email:
  134. try:
  135. smtp_settings = await get_smtp_settings(db)
  136. if smtp_settings:
  137. # Use external_url from settings if available, otherwise fall back to APP_URL env var
  138. external_url = await get_setting(db, "external_url")
  139. if external_url:
  140. external_url = external_url.rstrip("/")
  141. else:
  142. external_url = os.environ.get("APP_URL", "http://localhost:5173")
  143. login_url = external_url + "/login"
  144. subject, text_body, html_body = create_welcome_email(new_user.username, password, login_url)
  145. send_email(smtp_settings, new_user.email, subject, text_body, html_body)
  146. logger.info(f"Welcome email sent to {new_user.email}")
  147. else:
  148. logger.warning(f"SMTP not configured, could not send welcome email to {new_user.email}")
  149. except Exception as e:
  150. logger.error(f"Failed to send welcome email: {e}")
  151. # Don't fail user creation if email fails
  152. return _user_to_response(new_user)
  153. @router.get("/{user_id}", response_model=UserResponse)
  154. async def get_user(
  155. user_id: int,
  156. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  157. db: AsyncSession = Depends(get_db),
  158. ):
  159. """Get a user by ID."""
  160. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  161. user = result.scalar_one_or_none()
  162. if not user:
  163. raise HTTPException(
  164. status_code=status.HTTP_404_NOT_FOUND,
  165. detail="User not found",
  166. )
  167. return _user_to_response(user)
  168. @router.patch("/{user_id}", response_model=UserResponse)
  169. async def update_user(
  170. user_id: int,
  171. user_data: UserUpdate,
  172. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
  173. db: AsyncSession = Depends(get_db),
  174. ):
  175. """Update a user."""
  176. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  177. user = result.scalar_one_or_none()
  178. if not user:
  179. raise HTTPException(
  180. status_code=status.HTTP_404_NOT_FOUND,
  181. detail="User not found",
  182. )
  183. # Prevent deactivating the last admin
  184. if user_data.is_active is False and user.is_admin:
  185. # Count admins by role or Administrators group membership
  186. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
  187. role_admins = admin_count_result.scalars().all()
  188. # Also check for users in Administrators group
  189. admin_group_result = await db.execute(
  190. select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
  191. )
  192. admin_group = admin_group_result.scalar_one_or_none()
  193. group_admins = [u for u in (admin_group.users if admin_group else []) if u.is_active]
  194. # Combine unique admins
  195. all_admins = {u.id for u in role_admins} | {u.id for u in group_admins}
  196. if len(all_admins) <= 1 and user.id in all_admins:
  197. raise HTTPException(
  198. status_code=status.HTTP_400_BAD_REQUEST,
  199. detail="Cannot deactivate the last admin user",
  200. )
  201. # Prevent changing role of last admin
  202. if user_data.role and user_data.role != "admin" and user.role == "admin":
  203. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
  204. admin_count = len(admin_count_result.scalars().all())
  205. if admin_count <= 1:
  206. raise HTTPException(
  207. status_code=status.HTTP_400_BAD_REQUEST,
  208. detail="Cannot change role of the last admin user",
  209. )
  210. if user_data.username is not None:
  211. # Check if new username already exists (case-insensitive)
  212. existing_user = await db.execute(
  213. select(User).where(func.lower(User.username) == func.lower(user_data.username), User.id != user_id)
  214. )
  215. if existing_user.scalar_one_or_none():
  216. raise HTTPException(
  217. status_code=status.HTTP_400_BAD_REQUEST,
  218. detail="Username already exists",
  219. )
  220. user.username = user_data.username
  221. if user_data.email is not None:
  222. # Check if new email already exists (case-insensitive)
  223. existing_email = await db.execute(
  224. select(User).where(func.lower(User.email) == func.lower(user_data.email), User.id != user_id)
  225. )
  226. if existing_email.scalar_one_or_none():
  227. raise HTTPException(
  228. status_code=status.HTTP_400_BAD_REQUEST,
  229. detail="Email already exists",
  230. )
  231. user.email = user_data.email
  232. if user_data.password is not None:
  233. user.password_hash = get_password_hash(user_data.password)
  234. if user_data.role is not None:
  235. if user_data.role not in ["admin", "user"]:
  236. raise HTTPException(
  237. status_code=status.HTTP_400_BAD_REQUEST,
  238. detail="Role must be 'admin' or 'user'",
  239. )
  240. user.role = user_data.role
  241. if user_data.is_active is not None:
  242. user.is_active = user_data.is_active
  243. # Handle group assignments
  244. if user_data.group_ids is not None:
  245. groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
  246. groups = groups_result.scalars().all()
  247. if len(groups) != len(user_data.group_ids):
  248. raise HTTPException(
  249. status_code=status.HTTP_400_BAD_REQUEST,
  250. detail="One or more group IDs are invalid",
  251. )
  252. user.groups = list(groups)
  253. await db.commit()
  254. await db.refresh(user)
  255. return _user_to_response(user)
  256. @router.get("/{user_id}/items-count")
  257. async def get_user_items_count(
  258. user_id: int,
  259. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  260. db: AsyncSession = Depends(get_db),
  261. ):
  262. """Get count of items created by this user."""
  263. # Verify user exists
  264. result = await db.execute(select(User).where(User.id == user_id))
  265. if not result.scalar_one_or_none():
  266. raise HTTPException(
  267. status_code=status.HTTP_404_NOT_FOUND,
  268. detail="User not found",
  269. )
  270. # Count archives
  271. archives_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.created_by_id == user_id))
  272. archives_count = archives_result.scalar() or 0
  273. # Count queue items
  274. queue_result = await db.execute(
  275. select(func.count(PrintQueueItem.id)).where(PrintQueueItem.created_by_id == user_id)
  276. )
  277. queue_items_count = queue_result.scalar() or 0
  278. # Count library files
  279. library_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.created_by_id == user_id))
  280. library_files_count = library_result.scalar() or 0
  281. return {
  282. "archives": archives_count,
  283. "queue_items": queue_items_count,
  284. "library_files": library_files_count,
  285. }
  286. @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
  287. async def delete_user(
  288. user_id: int,
  289. delete_items: bool = Query(False, description="Delete all items created by this user"),
  290. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
  291. db: AsyncSession = Depends(get_db),
  292. ):
  293. """Delete a user.
  294. If delete_items=True, all archives, queue items, and library files created by
  295. this user will also be deleted. Otherwise, these items will become "ownerless"
  296. (created_by_id set to NULL by the foreign key constraint).
  297. """
  298. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  299. user = result.scalar_one_or_none()
  300. if not user:
  301. raise HTTPException(
  302. status_code=status.HTTP_404_NOT_FOUND,
  303. detail="User not found",
  304. )
  305. # Prevent deleting the last admin
  306. if user.is_admin:
  307. # Count admins by role or Administrators group membership
  308. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.id != user_id))
  309. other_role_admins = admin_count_result.scalars().all()
  310. # Also check for users in Administrators group
  311. admin_group_result = await db.execute(
  312. select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
  313. )
  314. admin_group = admin_group_result.scalar_one_or_none()
  315. other_group_admins = [u for u in (admin_group.users if admin_group else []) if u.id != user_id and u.is_active]
  316. # Combine unique admins
  317. all_other_admins = {u.id for u in other_role_admins} | {u.id for u in other_group_admins}
  318. if len(all_other_admins) == 0:
  319. raise HTTPException(
  320. status_code=status.HTTP_400_BAD_REQUEST,
  321. detail="Cannot delete the last admin user",
  322. )
  323. # Prevent deleting yourself (only if auth is enabled and we have a current user)
  324. if current_user and user.id == current_user.id:
  325. raise HTTPException(
  326. status_code=status.HTTP_400_BAD_REQUEST,
  327. detail="Cannot delete your own account",
  328. )
  329. if delete_items:
  330. # Delete all items created by this user
  331. await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
  332. await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
  333. await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id))
  334. else:
  335. # Explicitly set created_by_id to NULL for all items (ensures consistent behavior
  336. # across different database backends, including SQLite without foreign key support)
  337. from sqlalchemy import update
  338. await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))
  339. await db.execute(
  340. update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)
  341. )
  342. await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
  343. await db.delete(user)
  344. await db.commit()
  345. @router.post("/me/change-password", response_model=dict)
  346. async def change_own_password(
  347. password_data: ChangePasswordRequest,
  348. current_user: User | None = Depends(get_current_user_optional),
  349. db: AsyncSession = Depends(get_db),
  350. ):
  351. """Change the current user's password. Requires current password verification."""
  352. if not current_user:
  353. raise HTTPException(
  354. status_code=status.HTTP_401_UNAUTHORIZED,
  355. detail="Authentication required to change password",
  356. )
  357. # Verify current password
  358. if not verify_password(password_data.current_password, current_user.password_hash):
  359. raise HTTPException(
  360. status_code=status.HTTP_400_BAD_REQUEST,
  361. detail="Current password is incorrect",
  362. )
  363. # Validate new password
  364. if len(password_data.new_password) < 6:
  365. raise HTTPException(
  366. status_code=status.HTTP_400_BAD_REQUEST,
  367. detail="New password must be at least 6 characters",
  368. )
  369. # Fetch user from this session to ensure changes are persisted
  370. result = await db.execute(select(User).where(User.id == current_user.id))
  371. user = result.scalar_one_or_none()
  372. if not user:
  373. raise HTTPException(
  374. status_code=status.HTTP_404_NOT_FOUND,
  375. detail="User not found",
  376. )
  377. # Update password
  378. user.password_hash = get_password_hash(password_data.new_password)
  379. await db.commit()
  380. return {"message": "Password changed successfully"}