pending_uploads.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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. def _remove_waiting_file(file_path: str) -> None:
  107. """Delete an upload's waiting file, and the folder of its own it waited in.
  108. Best effort: the record is updated either way. Uploads wait in
  109. ``uploads/<vp>/pending/<id>/<name>`` (older ones directly in the upload
  110. folder), so a later upload of the same name can't replace them.
  111. """
  112. path = Path(file_path)
  113. try:
  114. path.unlink(missing_ok=True)
  115. if path.parent.parent.name == "pending":
  116. path.parent.rmdir()
  117. except OSError:
  118. pass
  119. async def _store_pending(db: AsyncSession, pending: PendingUpload, request: "ArchiveRequest | None"):
  120. """Store an approved upload in its virtual printer's File Manager folder.
  121. The file, its notes, project and tags go to the File Manager; the archive
  122. gets it once it prints (an archive is a print that happened). An upload
  123. from before the virtual printer was recorded, or whose virtual printer is
  124. gone, goes to the shared "Virtual Printer" folder. Commits.
  125. """
  126. from backend.app.services.virtual_printer.library_store import store_upload, upload_settings
  127. folder_id = None
  128. if pending.virtual_printer_id is not None:
  129. folder_id, _ = await upload_settings(db, pending.virtual_printer_id)
  130. library_file, _ = await store_upload(
  131. db,
  132. source_path=Path(pending.file_path),
  133. filename=pending.filename,
  134. folder_id=folder_id,
  135. # The sender's, not the approver's (#1430).
  136. owner_id=pending.uploaded_by_id,
  137. notes=request.notes if request else None,
  138. project_id=(request.project_id or None) if request else None,
  139. tags=request.tags if request else None,
  140. )
  141. pending.status = "approved"
  142. pending.archived_at = datetime.now(timezone.utc)
  143. if request:
  144. pending.tags = request.tags
  145. pending.notes = request.notes
  146. pending.project_id = request.project_id or None
  147. await db.commit()
  148. _remove_waiting_file(pending.file_path) # the File Manager has its copy
  149. return library_file
  150. @router.post("/archive-all")
  151. async def archive_all_pending(
  152. db: AsyncSession = Depends(get_db, scope="function"),
  153. _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE, Permission.LIBRARY_UPLOAD),
  154. ):
  155. """Approve every pending upload: each goes to the File Manager.
  156. Needs library:upload as well as queue:create: approving adds files.
  157. """
  158. # By id, each loaded fresh: a failed one is rolled back, and a rollback
  159. # expires every row the session holds.
  160. pending_ids = (await db.execute(select(PendingUpload.id).where(PendingUpload.status == "pending"))).scalars().all()
  161. stored = 0
  162. failed = 0
  163. for pending_id in pending_ids:
  164. pending = await db.get(PendingUpload, pending_id)
  165. if pending is None or pending.status != "pending":
  166. continue
  167. if not Path(pending.file_path).exists():
  168. pending.status = "discarded"
  169. await db.commit()
  170. failed += 1
  171. continue
  172. try:
  173. await _store_pending(db, pending, None)
  174. stored += 1
  175. except Exception: # noqa: BLE001 -- one bad file must not stop the rest
  176. logger.exception("Could not store pending upload %s in the File Manager", pending_id)
  177. await db.rollback()
  178. failed += 1
  179. if stored:
  180. await ws_manager.send_library_files_changed()
  181. return {"stored": stored, "failed": failed}
  182. @router.delete("/discard-all")
  183. async def discard_all_pending(
  184. db: AsyncSession = Depends(get_db, scope="function"),
  185. _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
  186. ):
  187. """Discard all pending uploads."""
  188. result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
  189. pending_uploads = result.scalars().all()
  190. discarded = 0
  191. for pending in pending_uploads:
  192. _remove_waiting_file(pending.file_path)
  193. pending.status = "discarded"
  194. discarded += 1
  195. await db.commit()
  196. return {"discarded": discarded}
  197. @router.get("/{upload_id}", response_model=PendingUploadResponse)
  198. async def get_pending_upload(
  199. upload_id: int,
  200. db: AsyncSession = Depends(get_db, scope="function"),
  201. _: tuple[User | ApiKeyActor | None, bool] = Depends(
  202. require_ownership_permission(
  203. Permission.QUEUE_READ_ALL,
  204. Permission.QUEUE_READ_OWN,
  205. )
  206. ),
  207. ):
  208. """Get a specific pending upload."""
  209. result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
  210. pending = result.scalar_one_or_none()
  211. if not pending:
  212. raise HTTPException(status_code=404, detail="Upload not found")
  213. return (await _augment_with_display_name(db, [pending]))[0]
  214. @router.post("/{upload_id}/archive")
  215. async def archive_pending_upload(
  216. upload_id: int,
  217. request: ArchiveRequest = None,
  218. db: AsyncSession = Depends(get_db, scope="function"),
  219. _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE, Permission.LIBRARY_UPLOAD),
  220. ):
  221. """Approve a pending upload: it goes to the File Manager.
  222. Needs library:upload as well as queue:create: approving adds a file.
  223. """
  224. result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
  225. pending = result.scalar_one_or_none()
  226. if not pending:
  227. raise HTTPException(status_code=404, detail="Upload not found")
  228. if pending.status != "pending":
  229. raise HTTPException(status_code=400, detail="Upload already processed")
  230. if not Path(pending.file_path).exists():
  231. raise HTTPException(status_code=404, detail="Upload file not found on disk")
  232. # The project must exist before anything is stored; 0 means none.
  233. project_id = (request.project_id or None) if request else None
  234. if project_id is not None and await db.get(Project, project_id) is None:
  235. raise HTTPException(status_code=400, detail="Project not found")
  236. try:
  237. library_file = await _store_pending(db, pending, request)
  238. except Exception as e:
  239. logger.exception("Could not store pending upload %s in the File Manager", upload_id)
  240. raise HTTPException(status_code=500, detail="Failed to store the file in the File Manager") from e
  241. await ws_manager.send_library_files_changed()
  242. return {
  243. "id": library_file.id,
  244. "filename": library_file.filename,
  245. "folder_id": library_file.folder_id,
  246. }
  247. @router.delete("/{upload_id}")
  248. async def discard_pending_upload(
  249. upload_id: int,
  250. db: AsyncSession = Depends(get_db, scope="function"),
  251. _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
  252. ):
  253. """Discard a pending upload without archiving."""
  254. result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
  255. pending = result.scalar_one_or_none()
  256. if not pending:
  257. raise HTTPException(status_code=404, detail="Upload not found")
  258. _remove_waiting_file(pending.file_path)
  259. # Update status
  260. pending.status = "discarded"
  261. await db.commit()
  262. return {"success": True}