print_log.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  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, select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from backend.app.core.auth import (
  8. RequireCameraStreamTokenIfAuthEnabled,
  9. RequirePermissionIfAuthEnabled,
  10. require_ownership_permission,
  11. )
  12. from backend.app.core.config import settings
  13. from backend.app.core.database import get_db
  14. from backend.app.core.permissions import Permission
  15. from backend.app.models.print_log import PrintLogEntry
  16. from backend.app.models.user import User
  17. from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogEntryUpdate, PrintLogResponse
  18. logger = logging.getLogger(__name__)
  19. router = APIRouter(prefix="/print-log", tags=["print-log"])
  20. @router.get("/", response_model=PrintLogResponse)
  21. async def get_print_log(
  22. search: str | None = None,
  23. printer_id: int | None = None,
  24. created_by_username: str | None = None,
  25. status: str | None = None,
  26. date_from: datetime | None = None,
  27. date_to: datetime | None = None,
  28. limit: int = Query(default=50, ge=1, le=500),
  29. offset: int = Query(default=0, ge=0),
  30. db: AsyncSession = Depends(get_db),
  31. _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
  32. ):
  33. """Get the print log."""
  34. query = select(PrintLogEntry)
  35. count_query = select(func.count(PrintLogEntry.id))
  36. if printer_id is not None:
  37. query = query.where(PrintLogEntry.printer_id == printer_id)
  38. count_query = count_query.where(PrintLogEntry.printer_id == printer_id)
  39. if created_by_username:
  40. query = query.where(PrintLogEntry.created_by_username == created_by_username)
  41. count_query = count_query.where(PrintLogEntry.created_by_username == created_by_username)
  42. if status:
  43. query = query.where(PrintLogEntry.status == status)
  44. count_query = count_query.where(PrintLogEntry.status == status)
  45. if search:
  46. query = query.where(PrintLogEntry.print_name.ilike(f"%{search}%"))
  47. count_query = count_query.where(PrintLogEntry.print_name.ilike(f"%{search}%"))
  48. if date_from:
  49. query = query.where(PrintLogEntry.created_at >= date_from)
  50. count_query = count_query.where(PrintLogEntry.created_at >= date_from)
  51. if date_to:
  52. query = query.where(PrintLogEntry.created_at <= date_to)
  53. count_query = count_query.where(PrintLogEntry.created_at <= date_to)
  54. # Get total count
  55. total_result = await db.execute(count_query)
  56. total = total_result.scalar() or 0
  57. # Get paginated results
  58. query = query.order_by(PrintLogEntry.created_at.desc()).offset(offset).limit(limit)
  59. result = await db.execute(query)
  60. entries = result.scalars().all()
  61. return PrintLogResponse(
  62. items=[
  63. PrintLogEntrySchema(
  64. id=e.id,
  65. archive_id=e.archive_id,
  66. print_name=e.print_name,
  67. printer_name=e.printer_name,
  68. printer_id=e.printer_id,
  69. status=e.status,
  70. started_at=e.started_at,
  71. completed_at=e.completed_at,
  72. duration_seconds=e.duration_seconds,
  73. filament_type=e.filament_type,
  74. filament_color=e.filament_color,
  75. filament_used_grams=e.filament_used_grams,
  76. # failure_reason was silently dropped by the GET serialiser
  77. # before #1687 part 4 — without it the Print Log table couldn't
  78. # surface what the Failure Analysis widget already groups by.
  79. failure_reason=e.failure_reason,
  80. thumbnail_path=e.thumbnail_path,
  81. created_by_id=e.created_by_id,
  82. created_by_username=e.created_by_username,
  83. created_at=e.created_at,
  84. )
  85. for e in entries
  86. ],
  87. total=total,
  88. )
  89. @router.get("/{entry_id}/thumbnail")
  90. async def get_print_log_thumbnail(
  91. entry_id: int,
  92. db: AsyncSession = Depends(get_db),
  93. _: None = RequireCameraStreamTokenIfAuthEnabled,
  94. ):
  95. """Get the thumbnail for a print log entry.
  96. Requires a stream token query param (?token=xxx) when auth is enabled.
  97. Self-heals stale entries: when thumbnail_path points to a file that no
  98. longer exists on disk (archive was deleted, or print failed before the
  99. thumbnail was ever written), NULL the path on the entry so subsequent
  100. page renders skip the request entirely. The frontend's <img> tag is
  101. gated on entry.thumbnail_path being truthy, so the next fetch of the
  102. log list will simply not request this thumbnail again.
  103. """
  104. entry = await db.get(PrintLogEntry, entry_id)
  105. if not entry or not entry.thumbnail_path:
  106. raise HTTPException(404, "Thumbnail not found")
  107. thumb_path = settings.base_dir / entry.thumbnail_path
  108. if not thumb_path.exists():
  109. entry.thumbnail_path = None
  110. await db.commit()
  111. raise HTTPException(404, "Thumbnail file not found")
  112. return FileResponse(
  113. path=thumb_path,
  114. media_type="image/png",
  115. headers={"Cache-Control": "public, max-age=86400"},
  116. )
  117. @router.delete("/")
  118. async def clear_print_log(
  119. db: AsyncSession = Depends(get_db),
  120. _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),
  121. ):
  122. """Clear the print log.
  123. Only deletes log entries. Archives and queue items are never touched.
  124. """
  125. result = await db.execute(delete(PrintLogEntry))
  126. deleted = result.rowcount
  127. await db.commit()
  128. logger.info("Print log cleared: %d entries deleted", deleted)
  129. return {"deleted": deleted}
  130. @router.delete("/{entry_id}")
  131. async def delete_print_log_entry(
  132. entry_id: int,
  133. db: AsyncSession = Depends(get_db),
  134. auth_result: tuple[User | None, bool] = Depends(
  135. require_ownership_permission(
  136. Permission.ARCHIVES_DELETE_ALL,
  137. Permission.ARCHIVES_DELETE_OWN,
  138. )
  139. ),
  140. ):
  141. """Delete a single print-log entry (#1687).
  142. Removes the row entirely. Because /archives/stats aggregates over
  143. PrintLogEntry, the deleted row's filament / cost / duration / count
  144. contributions drop out of the totals in the same response cycle.
  145. The linked archive (if any) is untouched — the FK on the archive row
  146. is from PrintLogEntry, not the other way around.
  147. """
  148. user, can_modify_all = auth_result
  149. entry = await db.get(PrintLogEntry, entry_id)
  150. if not entry:
  151. raise HTTPException(404, "Print log entry not found")
  152. if not can_modify_all:
  153. if entry.created_by_id is None or (user is not None and entry.created_by_id != user.id):
  154. raise HTTPException(403, "You can only delete your own print log entries")
  155. await db.delete(entry)
  156. await db.commit()
  157. logger.info("Print log entry %d deleted", entry_id)
  158. return {"status": "deleted", "id": entry_id}
  159. # Canonical failure-reason vocabulary. Mirrors the frontend dropdown in
  160. # EditArchiveModal.tsx; the empty string is the "clear classification" value.
  161. # The catch-all "other" is the escape hatch for failures that don't fit the
  162. # enumerated list. Keep these two lists in sync if the EditArchiveModal options
  163. # ever change.
  164. _FAILURE_REASON_KEYS = frozenset(
  165. {
  166. "",
  167. "adhesionFailure",
  168. "spaghettiDetached",
  169. "layerShift",
  170. "cloggedNozzle",
  171. "filamentRunout",
  172. "warping",
  173. "stringing",
  174. "underExtrusion",
  175. "powerFailure",
  176. "userCancelled",
  177. "other",
  178. }
  179. )
  180. # Same status vocabulary the print-log column already filters by.
  181. _STATUS_KEYS = frozenset({"completed", "failed", "stopped", "cancelled", "skipped"})
  182. @router.patch("/{entry_id}", response_model=PrintLogEntrySchema)
  183. async def update_print_log_entry(
  184. entry_id: int,
  185. update: PrintLogEntryUpdate,
  186. db: AsyncSession = Depends(get_db),
  187. auth_result: tuple[User | None, bool] = Depends(
  188. require_ownership_permission(
  189. Permission.ARCHIVES_UPDATE_ALL,
  190. Permission.ARCHIVES_UPDATE_OWN,
  191. )
  192. ),
  193. ):
  194. """Edit a single Print Log row's classification (#1687 part 4, reporter
  195. IndividualGhost1905).
  196. Lets the user set ``failure_reason`` (and optionally re-classify ``status``)
  197. directly on a Print Log row — including orphan entries that have no
  198. archive to edit through. The Failure Analysis widget already groups by
  199. ``PrintLogEntry.failure_reason`` (see ``archives.py:1421`` for the
  200. archive-side mirror); this endpoint is the missing edit affordance for the
  201. log-side, mirror-less case.
  202. Ownership semantics mirror the per-row delete: archives:update_all sees
  203. everything; archives:update_own sees only rows it owns.
  204. """
  205. user, can_modify_all = auth_result
  206. entry = await db.get(PrintLogEntry, entry_id)
  207. if not entry:
  208. raise HTTPException(404, "Print log entry not found")
  209. if not can_modify_all:
  210. if entry.created_by_id is None or (user is not None and entry.created_by_id != user.id):
  211. raise HTTPException(403, "You can only update your own print log entries")
  212. payload = update.model_dump(exclude_unset=True)
  213. # Validate against the canonical vocabularies. Reject unknown values rather
  214. # than silently storing them — the Failure Analysis widget renders the
  215. # values back as i18n keys, and an unrecognised value would surface as a
  216. # raw string in the UI.
  217. if "failure_reason" in payload:
  218. new_reason = payload["failure_reason"] or ""
  219. if new_reason not in _FAILURE_REASON_KEYS:
  220. raise HTTPException(400, f"Unknown failure_reason: {new_reason!r}")
  221. # Store empty string back as NULL so the column's nullable=True intent
  222. # is preserved end-to-end.
  223. entry.failure_reason = new_reason or None
  224. if "status" in payload and payload["status"] is not None:
  225. new_status = payload["status"]
  226. if new_status not in _STATUS_KEYS:
  227. raise HTTPException(400, f"Unknown status: {new_status!r}")
  228. entry.status = new_status
  229. await db.commit()
  230. await db.refresh(entry)
  231. logger.info(
  232. "Print log entry %d updated (failure_reason=%r, status=%r)",
  233. entry_id,
  234. entry.failure_reason,
  235. entry.status,
  236. )
  237. return PrintLogEntrySchema(
  238. id=entry.id,
  239. archive_id=entry.archive_id,
  240. print_name=entry.print_name,
  241. printer_name=entry.printer_name,
  242. printer_id=entry.printer_id,
  243. status=entry.status,
  244. started_at=entry.started_at,
  245. completed_at=entry.completed_at,
  246. duration_seconds=entry.duration_seconds,
  247. filament_type=entry.filament_type,
  248. filament_color=entry.filament_color,
  249. filament_used_grams=entry.filament_used_grams,
  250. failure_reason=entry.failure_reason,
  251. thumbnail_path=entry.thumbnail_path,
  252. created_by_id=entry.created_by_id,
  253. created_by_username=entry.created_by_username,
  254. created_at=entry.created_at,
  255. )