pending_uploads.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. """API routes for pending uploads (virtual printer review mode)."""
  2. import logging
  3. from datetime import datetime, timezone
  4. from pathlib import Path
  5. from fastapi import APIRouter, Depends, HTTPException
  6. from pydantic import BaseModel
  7. from sqlalchemy import select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from backend.app.core.auth import ApiKeyActor, RequirePermissionIfAuthEnabled, require_ownership_permission
  10. from backend.app.core.database import get_db
  11. from backend.app.core.permissions import Permission
  12. from backend.app.core.websocket import ws_manager
  13. from backend.app.models.pending_upload import PendingUpload
  14. from backend.app.models.project import Project
  15. from backend.app.models.user import User
  16. from backend.app.services.archive import resolve_display_stem
  17. logger = logging.getLogger(__name__)
  18. router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
  19. class ArchiveRequest(BaseModel):
  20. """Request to archive a pending upload."""
  21. tags: str | None = None
  22. notes: str | None = None
  23. project_id: int | None = None
  24. class PendingUploadResponse(BaseModel):
  25. """Response model for pending upload."""
  26. id: int
  27. filename: str
  28. display_name: str # Resolved name that mirrors the eventual archive's print_name (#1152 follow-up)
  29. file_size: int
  30. source_ip: str | None
  31. status: str
  32. tags: str | None
  33. notes: str | None
  34. project_id: int | None
  35. uploaded_at: datetime
  36. class Config:
  37. from_attributes = True
  38. def _resolve_display_name(pending: PendingUpload, prefer_filename: bool) -> str:
  39. """Compute the name the review card should show, matching what archive_print
  40. will eventually write to ``PrintArchive.print_name`` so the user sees the
  41. same name in both places (#1152 follow-up).
  42. Mirrors ``ArchiveService.archive_print``:
  43. - ``prefer_filename=True`` → stripped filename stem.
  44. - ``prefer_filename=False`` → ``metadata_print_name`` if set, else stem.
  45. """
  46. stem = resolve_display_stem(pending.filename)
  47. if prefer_filename:
  48. return stem
  49. return (pending.metadata_print_name or "").strip() or stem
  50. async def _augment_with_display_name(
  51. db: AsyncSession,
  52. pendings: list[PendingUpload],
  53. ) -> list[PendingUploadResponse]:
  54. """Build response objects with display_name resolved against the toggle.
  55. Reads the ``virtual_printer_archive_name_source`` setting once per request
  56. rather than per row.
  57. """
  58. from backend.app.api.routes.settings import get_setting
  59. prefer_filename = (await get_setting(db, "virtual_printer_archive_name_source")) == "filename"
  60. return [
  61. PendingUploadResponse(
  62. id=p.id,
  63. filename=p.filename,
  64. display_name=_resolve_display_name(p, prefer_filename),
  65. file_size=p.file_size,
  66. source_ip=p.source_ip,
  67. status=p.status,
  68. tags=p.tags,
  69. notes=p.notes,
  70. project_id=p.project_id,
  71. uploaded_at=p.uploaded_at,
  72. )
  73. for p in pendings
  74. ]
  75. @router.get("/", response_model=list[PendingUploadResponse])
  76. async def list_pending_uploads(
  77. db: AsyncSession = Depends(get_db, scope="function"),
  78. _: tuple[User | ApiKeyActor | None, bool] = Depends(
  79. require_ownership_permission(
  80. Permission.QUEUE_READ_ALL,
  81. Permission.QUEUE_READ_OWN,
  82. )
  83. ),
  84. ):
  85. """List all pending uploads."""
  86. result = await db.execute(
  87. select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
  88. )
  89. return await _augment_with_display_name(db, list(result.scalars().all()))
  90. @router.get("/count")
  91. async def get_pending_count(
  92. db: AsyncSession = Depends(get_db, scope="function"),
  93. _: tuple[User | ApiKeyActor | None, bool] = Depends(
  94. require_ownership_permission(
  95. Permission.QUEUE_READ_ALL,
  96. Permission.QUEUE_READ_OWN,
  97. )
  98. ),
  99. ):
  100. """Get count of pending uploads."""
  101. result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
  102. count = len(result.scalars().all())
  103. return {"count": count}
  104. # Note: Bulk operations must be defined BEFORE parameterized routes
  105. # to prevent FastAPI from matching /archive-all as /{upload_id}
  106. async def _store_pending(db: AsyncSession, pending: PendingUpload, request: "ArchiveRequest | None"):
  107. """Store an approved upload in its virtual printer's File Manager folder.
  108. The file, its notes, project and tags go to the File Manager; the archive
  109. gets it once it prints (an archive is a print that happened). An upload
  110. from before the virtual printer was recorded, or whose virtual printer is
  111. gone, goes to the shared "Virtual Printer" folder. Commits.
  112. """
  113. from backend.app.services.virtual_printer.library_store import store_upload, upload_settings
  114. folder_id = None
  115. if pending.virtual_printer_id is not None:
  116. folder_id, _ = await upload_settings(db, pending.virtual_printer_id)
  117. library_file, _ = await store_upload(
  118. db,
  119. source_path=Path(pending.file_path),
  120. filename=pending.filename,
  121. folder_id=folder_id,
  122. notes=request.notes if request else None,
  123. project_id=(request.project_id or None) if request else None,
  124. tags=request.tags if request else None,
  125. )
  126. pending.status = "approved"
  127. pending.archived_at = datetime.now(timezone.utc)
  128. if request:
  129. pending.tags = request.tags
  130. pending.notes = request.notes
  131. pending.project_id = request.project_id or None
  132. await db.commit()
  133. try:
  134. Path(pending.file_path).unlink()
  135. except OSError:
  136. pass # Best-effort temp file cleanup; the File Manager has its copy
  137. return library_file
  138. @router.post("/archive-all")
  139. async def archive_all_pending(
  140. db: AsyncSession = Depends(get_db, scope="function"),
  141. _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE, Permission.LIBRARY_UPLOAD),
  142. ):
  143. """Approve every pending upload: each goes to the File Manager.
  144. Needs library:upload as well as queue:create: approving adds files.
  145. """
  146. # By id, each loaded fresh: a failed one is rolled back, and a rollback
  147. # expires every row the session holds.
  148. pending_ids = (await db.execute(select(PendingUpload.id).where(PendingUpload.status == "pending"))).scalars().all()
  149. stored = 0
  150. failed = 0
  151. for pending_id in pending_ids:
  152. pending = await db.get(PendingUpload, pending_id)
  153. if pending is None or pending.status != "pending":
  154. continue
  155. if not Path(pending.file_path).exists():
  156. pending.status = "discarded"
  157. await db.commit()
  158. failed += 1
  159. continue
  160. try:
  161. await _store_pending(db, pending, None)
  162. stored += 1
  163. except Exception: # noqa: BLE001 -- one bad file must not stop the rest
  164. logger.exception("Could not store pending upload %s in the File Manager", pending_id)
  165. await db.rollback()
  166. failed += 1
  167. if stored:
  168. await ws_manager.send_library_files_changed()
  169. return {"stored": stored, "failed": failed}
  170. @router.delete("/discard-all")
  171. async def discard_all_pending(
  172. db: AsyncSession = Depends(get_db, scope="function"),
  173. _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
  174. ):
  175. """Discard all pending uploads."""
  176. result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
  177. pending_uploads = result.scalars().all()
  178. discarded = 0
  179. for pending in pending_uploads:
  180. # Delete file from disk
  181. try:
  182. file_path = Path(pending.file_path)
  183. file_path.unlink(missing_ok=True)
  184. except OSError:
  185. pass # Best-effort file deletion; record is still marked discarded
  186. pending.status = "discarded"
  187. discarded += 1
  188. await db.commit()
  189. return {"discarded": discarded}
  190. @router.get("/{upload_id}", response_model=PendingUploadResponse)
  191. async def get_pending_upload(
  192. upload_id: int,
  193. db: AsyncSession = Depends(get_db, scope="function"),
  194. _: tuple[User | ApiKeyActor | None, bool] = Depends(
  195. require_ownership_permission(
  196. Permission.QUEUE_READ_ALL,
  197. Permission.QUEUE_READ_OWN,
  198. )
  199. ),
  200. ):
  201. """Get a specific pending upload."""
  202. result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
  203. pending = result.scalar_one_or_none()
  204. if not pending:
  205. raise HTTPException(status_code=404, detail="Upload not found")
  206. return (await _augment_with_display_name(db, [pending]))[0]
  207. @router.post("/{upload_id}/archive")
  208. async def archive_pending_upload(
  209. upload_id: int,
  210. request: ArchiveRequest = None,
  211. db: AsyncSession = Depends(get_db, scope="function"),
  212. _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE, Permission.LIBRARY_UPLOAD),
  213. ):
  214. """Approve a pending upload: it goes to the File Manager.
  215. Needs library:upload as well as queue:create: approving adds a file.
  216. """
  217. result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
  218. pending = result.scalar_one_or_none()
  219. if not pending:
  220. raise HTTPException(status_code=404, detail="Upload not found")
  221. if pending.status != "pending":
  222. raise HTTPException(status_code=400, detail="Upload already processed")
  223. if not Path(pending.file_path).exists():
  224. raise HTTPException(status_code=404, detail="Upload file not found on disk")
  225. # The project must exist before anything is stored; 0 means none.
  226. project_id = (request.project_id or None) if request else None
  227. if project_id is not None and await db.get(Project, project_id) is None:
  228. raise HTTPException(status_code=400, detail="Project not found")
  229. try:
  230. library_file = await _store_pending(db, pending, request)
  231. except Exception as e:
  232. logger.exception("Could not store pending upload %s in the File Manager", upload_id)
  233. raise HTTPException(status_code=500, detail="Failed to store the file in the File Manager") from e
  234. await ws_manager.send_library_files_changed()
  235. return {
  236. "id": library_file.id,
  237. "filename": library_file.filename,
  238. "folder_id": library_file.folder_id,
  239. }
  240. @router.delete("/{upload_id}")
  241. async def discard_pending_upload(
  242. upload_id: int,
  243. db: AsyncSession = Depends(get_db, scope="function"),
  244. _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
  245. ):
  246. """Discard a pending upload without archiving."""
  247. result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
  248. pending = result.scalar_one_or_none()
  249. if not pending:
  250. raise HTTPException(status_code=404, detail="Upload not found")
  251. # Delete file from disk
  252. file_path = Path(pending.file_path)
  253. try:
  254. file_path.unlink(missing_ok=True)
  255. except OSError:
  256. pass # Best-effort file deletion on discard
  257. # Update status
  258. pending.status = "discarded"
  259. await db.commit()
  260. return {"success": True}