library_trash.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. """Library trash bin + admin purge endpoints (#1008).
  2. Permission model:
  3. * **Admin purge** (``/library/purge/*``) and **retention settings**
  4. (``/library/trash/settings``) require :attr:`Permission.LIBRARY_PURGE` —
  5. admin-only.
  6. * **Per-user trash** (list / restore / hard-delete / empty own trash) is
  7. gated by the existing :attr:`Permission.LIBRARY_DELETE_ALL` /
  8. :attr:`Permission.LIBRARY_DELETE_OWN` ownership pair, so a regular user
  9. sees their own trashed files and an admin sees everyone's.
  10. """
  11. from __future__ import annotations
  12. import logging
  13. from datetime import timedelta
  14. from fastapi import APIRouter, Depends, HTTPException, Query
  15. from sqlalchemy import func, select
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
  18. from backend.app.core.database import get_db
  19. from backend.app.core.permissions import Permission
  20. from backend.app.models.library import LibraryFile, LibraryFolder
  21. from backend.app.models.user import User
  22. from backend.app.schemas.library_trash import (
  23. EmptyTrashResponse,
  24. PurgePreviewResponse,
  25. PurgeRequest,
  26. PurgeResponse,
  27. TrashFile,
  28. TrashListResponse,
  29. TrashSettings,
  30. )
  31. from backend.app.services.library_trash import (
  32. MAX_RETENTION_DAYS,
  33. MIN_RETENTION_DAYS,
  34. library_trash_service,
  35. )
  36. logger = logging.getLogger(__name__)
  37. router = APIRouter(prefix="/library", tags=["library-trash"])
  38. # ===================== Admin purge =====================
  39. @router.get("/purge/preview", response_model=PurgePreviewResponse)
  40. async def preview_purge(
  41. older_than_days: int = Query(ge=1, le=3650),
  42. include_never_printed: bool = True,
  43. db: AsyncSession = Depends(get_db),
  44. _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
  45. ):
  46. """Preview how many files would move to trash for the given age threshold.
  47. Read-only — safe to call repeatedly as the admin adjusts the slider.
  48. """
  49. result = await library_trash_service.preview_purge(
  50. db,
  51. older_than_days=older_than_days,
  52. include_never_printed=include_never_printed,
  53. )
  54. return PurgePreviewResponse(**result)
  55. @router.post("/purge", response_model=PurgeResponse)
  56. async def execute_purge(
  57. body: PurgeRequest,
  58. db: AsyncSession = Depends(get_db),
  59. _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
  60. ):
  61. """Move matching files to trash. Idempotent — already-trashed rows skip."""
  62. moved = await library_trash_service.purge_older_than(
  63. db,
  64. older_than_days=body.older_than_days,
  65. include_never_printed=body.include_never_printed,
  66. )
  67. return PurgeResponse(moved_to_trash=moved)
  68. # ===================== Trash list + per-item ops =====================
  69. @router.get("/trash", response_model=TrashListResponse)
  70. async def list_trash(
  71. limit: int = Query(default=100, ge=1, le=500),
  72. offset: int = Query(default=0, ge=0),
  73. db: AsyncSession = Depends(get_db),
  74. auth_result: tuple[User | None, bool] = Depends(
  75. require_ownership_permission(
  76. Permission.LIBRARY_DELETE_ALL,
  77. Permission.LIBRARY_DELETE_OWN,
  78. )
  79. ),
  80. ):
  81. """List trashed files.
  82. Admins (``LIBRARY_DELETE_ALL``) see every user's trash; regular users
  83. (``LIBRARY_DELETE_OWN``) see only rows they created.
  84. """
  85. user, can_modify_all = auth_result
  86. retention_days = await library_trash_service.get_retention_days(db)
  87. # Base query: trashed files + their folder name (for the UI) + creator.
  88. base_conditions = [LibraryFile.deleted_at.isnot(None)]
  89. if not can_modify_all:
  90. if user is None:
  91. # Defensive: ownership checker only returns user=None when auth is off,
  92. # in which case can_modify_all=True. If we somehow land here, err safe.
  93. raise HTTPException(status_code=403, detail="Authentication required")
  94. base_conditions.append(LibraryFile.created_by_id == user.id)
  95. total_result = await db.execute(select(func.count(LibraryFile.id)).where(*base_conditions))
  96. total = int(total_result.scalar() or 0)
  97. rows_result = await db.execute(
  98. select(LibraryFile, LibraryFolder.name, User.username)
  99. .outerjoin(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
  100. .outerjoin(User, LibraryFile.created_by_id == User.id)
  101. .where(*base_conditions)
  102. .order_by(LibraryFile.deleted_at.desc())
  103. .limit(limit)
  104. .offset(offset)
  105. )
  106. items: list[TrashFile] = []
  107. for file, folder_name, username in rows_result.all():
  108. # deleted_at is not-null by construction above; narrow for the typechecker.
  109. assert file.deleted_at is not None
  110. auto_purge_at = file.deleted_at + timedelta(days=retention_days)
  111. items.append(
  112. TrashFile(
  113. id=file.id,
  114. filename=file.filename,
  115. file_size=file.file_size,
  116. thumbnail_path=file.thumbnail_path,
  117. folder_id=file.folder_id,
  118. folder_name=folder_name,
  119. created_by_id=file.created_by_id,
  120. created_by_username=username,
  121. deleted_at=file.deleted_at,
  122. auto_purge_at=auto_purge_at,
  123. )
  124. )
  125. return TrashListResponse(items=items, total=total, retention_days=retention_days)
  126. async def _load_trashed_file(
  127. db: AsyncSession,
  128. file_id: int,
  129. user: User | None,
  130. can_modify_all: bool,
  131. ) -> LibraryFile:
  132. """Fetch a trashed file, enforcing ownership for non-admins."""
  133. result = await db.execute(
  134. select(LibraryFile).where(
  135. LibraryFile.id == file_id,
  136. LibraryFile.deleted_at.isnot(None),
  137. )
  138. )
  139. file = result.scalar_one_or_none()
  140. if file is None:
  141. raise HTTPException(status_code=404, detail="Trashed file not found")
  142. if not can_modify_all:
  143. if user is None or file.created_by_id != user.id:
  144. raise HTTPException(status_code=403, detail="You can only manage your own trashed files")
  145. return file
  146. @router.post("/trash/{file_id}/restore")
  147. async def restore_from_trash(
  148. file_id: int,
  149. db: AsyncSession = Depends(get_db),
  150. auth_result: tuple[User | None, bool] = Depends(
  151. require_ownership_permission(
  152. Permission.LIBRARY_DELETE_ALL,
  153. Permission.LIBRARY_DELETE_OWN,
  154. )
  155. ),
  156. ):
  157. user, can_modify_all = auth_result
  158. file = await _load_trashed_file(db, file_id, user, can_modify_all)
  159. await library_trash_service.restore(db, file)
  160. return {"status": "success", "id": file.id}
  161. @router.delete("/trash/{file_id}")
  162. async def hard_delete_from_trash(
  163. file_id: int,
  164. db: AsyncSession = Depends(get_db),
  165. auth_result: tuple[User | None, bool] = Depends(
  166. require_ownership_permission(
  167. Permission.LIBRARY_DELETE_ALL,
  168. Permission.LIBRARY_DELETE_OWN,
  169. )
  170. ),
  171. ):
  172. """Permanently delete a single trashed file + its bytes. Irreversible."""
  173. user, can_modify_all = auth_result
  174. file = await _load_trashed_file(db, file_id, user, can_modify_all)
  175. await library_trash_service.hard_delete_now(db, file)
  176. return {"status": "success"}
  177. @router.delete("/trash", response_model=EmptyTrashResponse)
  178. async def empty_trash(
  179. db: AsyncSession = Depends(get_db),
  180. auth_result: tuple[User | None, bool] = Depends(
  181. require_ownership_permission(
  182. Permission.LIBRARY_DELETE_ALL,
  183. Permission.LIBRARY_DELETE_OWN,
  184. )
  185. ),
  186. ):
  187. """Permanently delete all trashed files in the caller's scope.
  188. Regular users empty only their own trash; admins empty everyone's.
  189. """
  190. user, can_modify_all = auth_result
  191. conditions = [LibraryFile.deleted_at.isnot(None)]
  192. if not can_modify_all:
  193. if user is None:
  194. raise HTTPException(status_code=403, detail="Authentication required")
  195. conditions.append(LibraryFile.created_by_id == user.id)
  196. rows_result = await db.execute(select(LibraryFile).where(*conditions))
  197. rows = rows_result.scalars().all()
  198. deleted = 0
  199. for row in rows:
  200. await library_trash_service.hard_delete_now(db, row)
  201. deleted += 1
  202. return EmptyTrashResponse(deleted=deleted)
  203. # ===================== Retention settings (admin only) =====================
  204. @router.get("/trash/settings", response_model=TrashSettings)
  205. async def get_trash_settings(
  206. db: AsyncSession = Depends(get_db),
  207. _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
  208. ):
  209. retention = await library_trash_service.get_retention_days(db)
  210. auto = await library_trash_service.get_auto_purge_settings(db)
  211. return TrashSettings(
  212. retention_days=retention,
  213. auto_purge_enabled=auto["enabled"],
  214. auto_purge_days=auto["days"],
  215. auto_purge_include_never_printed=auto["include_never_printed"],
  216. )
  217. @router.put("/trash/settings", response_model=TrashSettings)
  218. async def update_trash_settings(
  219. body: TrashSettings,
  220. db: AsyncSession = Depends(get_db),
  221. _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
  222. ):
  223. if body.retention_days < MIN_RETENTION_DAYS or body.retention_days > MAX_RETENTION_DAYS:
  224. raise HTTPException(
  225. status_code=400,
  226. detail=f"retention_days must be between {MIN_RETENTION_DAYS} and {MAX_RETENTION_DAYS}",
  227. )
  228. saved_retention = await library_trash_service.set_retention_days(db, body.retention_days)
  229. saved_auto = await library_trash_service.set_auto_purge_settings(
  230. db,
  231. enabled=body.auto_purge_enabled,
  232. days=body.auto_purge_days,
  233. include_never_printed=body.auto_purge_include_never_printed,
  234. )
  235. return TrashSettings(
  236. retention_days=saved_retention,
  237. auto_purge_enabled=saved_auto["enabled"],
  238. auto_purge_days=saved_auto["days"],
  239. auto_purge_include_never_printed=saved_auto["include_never_printed"],
  240. )