pending_uploads.py 11 KB

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