print_log.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import logging
  2. from datetime import datetime
  3. from fastapi import APIRouter, Depends, HTTPException, Query
  4. from fastapi.responses import FileResponse
  5. from sqlalchemy import delete, func, nullslast, select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from backend.app.core.auth import (
  8. ApiKeyActor,
  9. MediaOrRequestPrinterScope,
  10. RequestPrinterScope,
  11. RequirePermissionIfAuthEnabled,
  12. require_media_token_ownership,
  13. require_ownership_permission,
  14. )
  15. from backend.app.core.config import settings
  16. from backend.app.core.database import get_db
  17. from backend.app.core.permissions import Permission
  18. from backend.app.core.printer_scope import PrinterScope
  19. from backend.app.models.print_log import PrintLogEntry
  20. from backend.app.models.user import User
  21. from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogEntryUpdate, PrintLogResponse
  22. logger = logging.getLogger(__name__)
  23. router = APIRouter(prefix="/print-log", tags=["print-log"])
  24. # Sortable columns, keyed by the id the Print Log table uses for its columns
  25. # (#2636). An explicit map rather than getattr on a caller-supplied string:
  26. # the client picks the key, so anything else would let a request order by any
  27. # attribute it can name.
  28. #
  29. # ``date`` coalesces because the column renders ``started_at or created_at`` —
  30. # sorting on started_at alone would scatter the rows that have no start time
  31. # (queue-skipped entries) instead of interleaving them where the user sees
  32. # them.
  33. _SORTABLE_COLUMNS = {
  34. "date": func.coalesce(PrintLogEntry.started_at, PrintLogEntry.created_at),
  35. "print_name": PrintLogEntry.print_name,
  36. "printer": PrintLogEntry.printer_name,
  37. "user": PrintLogEntry.created_by_username,
  38. "status": PrintLogEntry.status,
  39. "duration": PrintLogEntry.duration_seconds,
  40. "completed_at": PrintLogEntry.completed_at,
  41. "filament": PrintLogEntry.filament_type,
  42. "filament_used": PrintLogEntry.filament_used_grams,
  43. "cost": PrintLogEntry.cost,
  44. "energy": PrintLogEntry.energy_kwh,
  45. "energy_cost": PrintLogEntry.energy_cost,
  46. "wear_cost": PrintLogEntry.wear_cost,
  47. }
  48. @router.get("/", response_model=PrintLogResponse)
  49. async def get_print_log(
  50. search: str | None = None,
  51. printer_id: int | None = None,
  52. created_by_username: str | None = None,
  53. status: str | None = None,
  54. date_from: datetime | None = None,
  55. date_to: datetime | None = None,
  56. limit: int = Query(default=50, ge=1, le=500),
  57. offset: int = Query(default=0, ge=0),
  58. sort_by: str = Query(default="date"),
  59. sort_dir: str = Query(default="desc", pattern="^(asc|desc)$"),
  60. db: AsyncSession = Depends(get_db, scope="function"),
  61. auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
  62. require_ownership_permission(
  63. Permission.ARCHIVES_READ_ALL,
  64. Permission.ARCHIVES_READ_OWN,
  65. )
  66. ),
  67. printer_scope: PrinterScope = RequestPrinterScope,
  68. ):
  69. """Get the print log."""
  70. user, can_read_all = auth_result
  71. query = select(PrintLogEntry)
  72. count_query = select(func.count(PrintLogEntry.id))
  73. if user is not None and not can_read_all:
  74. query = query.where(PrintLogEntry.created_by_id == user.id)
  75. count_query = count_query.where(PrintLogEntry.created_by_id == user.id)
  76. # Only prints on printers the caller may see (#1727)
  77. if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
  78. query = query.where(clause)
  79. count_query = count_query.where(clause)
  80. if printer_id is not None:
  81. query = query.where(PrintLogEntry.printer_id == printer_id)
  82. count_query = count_query.where(PrintLogEntry.printer_id == printer_id)
  83. if created_by_username:
  84. query = query.where(PrintLogEntry.created_by_username == created_by_username)
  85. count_query = count_query.where(PrintLogEntry.created_by_username == created_by_username)
  86. if status:
  87. query = query.where(PrintLogEntry.status == status)
  88. count_query = count_query.where(PrintLogEntry.status == status)
  89. if search:
  90. query = query.where(PrintLogEntry.print_name.ilike(f"%{search}%"))
  91. count_query = count_query.where(PrintLogEntry.print_name.ilike(f"%{search}%"))
  92. if date_from:
  93. query = query.where(PrintLogEntry.created_at >= date_from)
  94. count_query = count_query.where(PrintLogEntry.created_at >= date_from)
  95. if date_to:
  96. query = query.where(PrintLogEntry.created_at <= date_to)
  97. count_query = count_query.where(PrintLogEntry.created_at <= date_to)
  98. # Get total count
  99. total_result = await db.execute(count_query)
  100. total = total_result.scalar() or 0
  101. # Sorting happens here rather than in the browser because the table is
  102. # paginated server-side: ordering the 25 rows the client happens to hold
  103. # would answer "the most expensive print on this page", which is not what
  104. # clicking a column header means.
  105. sort_column = _SORTABLE_COLUMNS.get(sort_by)
  106. if sort_column is None:
  107. raise HTTPException(400, f"Cannot sort by {sort_by!r}")
  108. ordering = sort_column.asc() if sort_dir == "asc" else sort_column.desc()
  109. # NULLs last in both directions, so a column that is empty for half the
  110. # rows (cost before a spool is priced, energy without a smart plug) never
  111. # buries the rows that do have values. Left to the database this differs
  112. # per backend — Postgres sorts NULLs high, SQLite sorts them low — so the
  113. # same click would give two different first pages depending on deployment.
  114. query = query.order_by(nullslast(ordering), PrintLogEntry.id.desc())
  115. # id.desc() above is the tiebreaker: without it, rows sharing a value
  116. # (every "completed" when sorting by status) come back in whatever order
  117. # the planner picks, which can differ between pages and duplicate or drop
  118. # a row as the user pages through.
  119. query = query.offset(offset).limit(limit)
  120. result = await db.execute(query)
  121. entries = result.scalars().all()
  122. # Validate straight off the ORM rows rather than naming each field: the
  123. # hand-written version dropped whatever it forgot to mention, and a
  124. # forgotten field is indistinguishable from a NULL column on the wire.
  125. # It lost failure_reason that way (#1687 part 4), then cost / energy_kwh /
  126. # energy_cost, which were written to the table but never sent — so the
  127. # Print Log's cost and energy columns read empty for every run (#2636).
  128. # Archives of these prints that were deleted (soft, #1343: their prints
  129. # stay logged), so the log doesn't link to an archive page that is gone.
  130. from backend.app.models.archive import PrintArchive
  131. archive_ids = {e.archive_id for e in entries if e.archive_id is not None}
  132. deleted = (
  133. await db.scalars(
  134. select(PrintArchive.id).where(PrintArchive.id.in_(archive_ids), PrintArchive.deleted_at.is_not(None))
  135. )
  136. if archive_ids
  137. else None
  138. )
  139. return PrintLogResponse(
  140. items=[PrintLogEntrySchema.model_validate(e) for e in entries],
  141. total=total,
  142. deleted_archive_ids=sorted(deleted.all()) if deleted is not None else [],
  143. )
  144. @router.get("/{entry_id}/thumbnail")
  145. async def get_print_log_thumbnail(
  146. entry_id: int,
  147. db: AsyncSession = Depends(get_db, scope="function"),
  148. auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
  149. require_media_token_ownership(
  150. Permission.ARCHIVES_READ_ALL,
  151. Permission.ARCHIVES_READ_OWN,
  152. )
  153. ),
  154. printer_scope: PrinterScope = MediaOrRequestPrinterScope,
  155. ):
  156. """Get the thumbnail for a print log entry.
  157. Requires a media token query param (?token=xxx) when auth is enabled, and
  158. is scoped to the rows the caller can see in the log itself (#3025) -- the
  159. same ``created_by_id`` filter ``get_print_log`` applies, including its
  160. treatment of an ownerless entry as not-yours.
  161. Self-heals stale entries: when thumbnail_path points to a file that no
  162. longer exists on disk (archive was deleted, or print failed before the
  163. thumbnail was ever written), NULL the path on the entry so subsequent
  164. page renders skip the request entirely. The frontend's <img> tag is
  165. gated on entry.thumbnail_path being truthy, so the next fetch of the
  166. log list will simply not request this thumbnail again.
  167. """
  168. user, can_read_all = auth_result
  169. entry = await db.get(PrintLogEntry, entry_id)
  170. if not entry or not entry.thumbnail_path or not printer_scope.allows(entry.printer_id):
  171. raise HTTPException(404, "Thumbnail not found")
  172. if not can_read_all and (user is None or entry.created_by_id != user.id):
  173. raise HTTPException(404, "Thumbnail not found")
  174. thumb_path = settings.base_dir / entry.thumbnail_path
  175. if not thumb_path.exists():
  176. entry.thumbnail_path = None
  177. await db.commit()
  178. raise HTTPException(404, "Thumbnail file not found")
  179. return FileResponse(
  180. path=thumb_path,
  181. media_type="image/png",
  182. headers={"Cache-Control": "public, max-age=86400"},
  183. )
  184. @router.delete("/")
  185. async def clear_print_log(
  186. db: AsyncSession = Depends(get_db, scope="function"),
  187. _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),
  188. printer_scope: PrinterScope = RequestPrinterScope,
  189. ):
  190. """Clear the print log.
  191. Only deletes log entries. Archives and queue items are never touched.
  192. """
  193. # Entries for printers the caller can't see stay (#1727)
  194. statement = delete(PrintLogEntry)
  195. if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
  196. statement = statement.where(clause)
  197. result = await db.execute(statement)
  198. deleted = result.rowcount
  199. await db.commit()
  200. logger.info("Print log cleared: %d entries deleted", deleted)
  201. return {"deleted": deleted}
  202. @router.delete("/{entry_id}")
  203. async def delete_print_log_entry(
  204. entry_id: int,
  205. db: AsyncSession = Depends(get_db, scope="function"),
  206. auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
  207. require_ownership_permission(
  208. Permission.ARCHIVES_DELETE_ALL,
  209. Permission.ARCHIVES_DELETE_OWN,
  210. )
  211. ),
  212. printer_scope: PrinterScope = RequestPrinterScope,
  213. ):
  214. """Delete a single print-log entry (#1687).
  215. Removes the row entirely. Because /archives/stats aggregates over
  216. PrintLogEntry, the deleted row's filament / cost / duration / count
  217. contributions drop out of the totals in the same response cycle.
  218. The linked archive (if any) is untouched — the FK on the archive row
  219. is from PrintLogEntry, not the other way around.
  220. """
  221. user, can_modify_all = auth_result
  222. entry = await db.get(PrintLogEntry, entry_id)
  223. if not entry or not printer_scope.allows(entry.printer_id):
  224. raise HTTPException(404, "Print log entry not found")
  225. if not can_modify_all:
  226. if entry.created_by_id is None or (user is not None and entry.created_by_id != user.id):
  227. raise HTTPException(403, "You can only delete your own print log entries")
  228. await db.delete(entry)
  229. await db.commit()
  230. logger.info("Print log entry %d deleted", entry_id)
  231. return {"status": "deleted", "id": entry_id}
  232. # Canonical failure-reason vocabulary. Mirrors the frontend dropdown in
  233. # EditArchiveModal.tsx; the empty string is the "clear classification" value.
  234. # The catch-all "other" is the escape hatch for failures that don't fit the
  235. # enumerated list. Keep these two lists in sync if the EditArchiveModal options
  236. # ever change.
  237. _FAILURE_REASON_KEYS = frozenset(
  238. {
  239. "",
  240. "adhesionFailure",
  241. "spaghettiDetached",
  242. "layerShift",
  243. "cloggedNozzle",
  244. "filamentRunout",
  245. "warping",
  246. "stringing",
  247. "underExtrusion",
  248. "powerFailure",
  249. "userCancelled",
  250. # Written by the two stale-archive paths in main.py when no end-of-print
  251. # status ever arrived (issue #2974). It has to be in the vocabulary, not
  252. # just tolerated: the archive editor clears any stored value it does not
  253. # recognise, so leaving it out would delete the classification on the
  254. # next save of such an archive.
  255. "noStatusUpdate",
  256. # A print the printer kill switch stopped (written at its completion).
  257. "killSwitch",
  258. "other",
  259. }
  260. )
  261. # Same status vocabulary the print-log column already filters by.
  262. _STATUS_KEYS = frozenset({"completed", "failed", "stopped", "cancelled", "skipped"})
  263. @router.patch("/{entry_id}", response_model=PrintLogEntrySchema)
  264. async def update_print_log_entry(
  265. entry_id: int,
  266. update: PrintLogEntryUpdate,
  267. db: AsyncSession = Depends(get_db, scope="function"),
  268. auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
  269. require_ownership_permission(
  270. Permission.ARCHIVES_UPDATE_ALL,
  271. Permission.ARCHIVES_UPDATE_OWN,
  272. )
  273. ),
  274. printer_scope: PrinterScope = RequestPrinterScope,
  275. ):
  276. """Edit a single Print Log row's classification (#1687 part 4, reporter
  277. IndividualGhost1905).
  278. Lets the user set ``failure_reason`` (and optionally re-classify ``status``)
  279. directly on a Print Log row — including orphan entries that have no
  280. archive to edit through. The Failure Analysis widget already groups by
  281. ``PrintLogEntry.failure_reason`` (see ``archives.py:1421`` for the
  282. archive-side mirror); this endpoint is the missing edit affordance for the
  283. log-side, mirror-less case.
  284. Ownership semantics mirror the per-row delete: archives:update_all sees
  285. everything; archives:update_own sees only rows it owns.
  286. """
  287. user, can_modify_all = auth_result
  288. entry = await db.get(PrintLogEntry, entry_id)
  289. if not entry or not printer_scope.allows(entry.printer_id):
  290. raise HTTPException(404, "Print log entry not found")
  291. if not can_modify_all:
  292. if entry.created_by_id is None or (user is not None and entry.created_by_id != user.id):
  293. raise HTTPException(403, "You can only update your own print log entries")
  294. payload = update.model_dump(exclude_unset=True)
  295. # Validate against the canonical vocabularies. Reject unknown values rather
  296. # than silently storing them — the Failure Analysis widget renders the
  297. # values back as i18n keys, and an unrecognised value would surface as a
  298. # raw string in the UI.
  299. if "failure_reason" in payload:
  300. new_reason = payload["failure_reason"] or ""
  301. if new_reason not in _FAILURE_REASON_KEYS:
  302. raise HTTPException(400, f"Unknown failure_reason: {new_reason!r}")
  303. # Store empty string back as NULL so the column's nullable=True intent
  304. # is preserved end-to-end.
  305. entry.failure_reason = new_reason or None
  306. if "status" in payload and payload["status"] is not None:
  307. new_status = payload["status"]
  308. if new_status not in _STATUS_KEYS:
  309. raise HTTPException(400, f"Unknown status: {new_status!r}")
  310. entry.status = new_status
  311. if "notes" in payload:
  312. entry.notes = (payload["notes"] or "").strip() or None
  313. if entry.archive_id is not None:
  314. from backend.app.services.print_log import mirror_latest_run
  315. # The archive card shows its latest run; this may be it.
  316. await mirror_latest_run(db, entry.archive_id)
  317. await db.commit()
  318. await db.refresh(entry)
  319. logger.info(
  320. "Print log entry %d updated (failure_reason=%r, status=%r)",
  321. entry_id,
  322. entry.failure_reason,
  323. entry.status,
  324. )
  325. # Same field-by-field trap as the list route: this one also omitted cost
  326. # and the energy pair, so the row the client merged back after an edit
  327. # blanked whichever columns it was showing for them.
  328. return PrintLogEntrySchema.model_validate(entry)