Files
bambuddy/backend/app/api/routes/print_log.py
T
maziggy cad63500a8 fix(archives): clear stale thumbnail paths on log entries when archive deleted
Archives → Print Log was 404-storming the thumbnail endpoint on every
  render: PrintLogEntry.thumbnail_path is copied by value from the archive
  at write-time, but the FK on archive_id is ON DELETE SET NULL (#1378) so
  log entries survive archive deletion to preserve stats history - and the
  cached path keeps pointing at a file that was removed when the archive's
  directory was deleted. Same shape for failed prints whose extractor never
  wrote the thumbnail.

  Two-part fix:

  1. Route self-heals: get_print_log_thumbnail NULLs thumbnail_path on the
     entry and commits before returning 404 when the file is missing on
     disk. The frontend's <img> tag is gated on entry.thumbnail_path being
     truthy, so the next fetch of the log list skips the request entirely.

  2. Eager clear on archive delete: new _null_print_log_thumbnail_paths()
     helper called from both soft_delete_archive and delete_archive before
     the on-disk files are removed. Avoids the one-time storm for future
     deletes; covers both the manual delete route and the auto-purge
     sweeper at archive_purge.py.

  Regression tests cover soft delete, hard delete via ArchiveService, and
  the route's lazy-NULL for failed-print orphans where the file was never
  written.
2026-05-16 12:43:59 +02:00

140 lines
5.2 KiB
Python

import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
from backend.app.core.config import settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.print_log import PrintLogEntry
from backend.app.models.user import User
from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/print-log", tags=["print-log"])
@router.get("/", response_model=PrintLogResponse)
async def get_print_log(
search: str | None = None,
printer_id: int | None = None,
created_by_username: str | None = None,
status: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Get the print log."""
query = select(PrintLogEntry)
count_query = select(func.count(PrintLogEntry.id))
if printer_id is not None:
query = query.where(PrintLogEntry.printer_id == printer_id)
count_query = count_query.where(PrintLogEntry.printer_id == printer_id)
if created_by_username:
query = query.where(PrintLogEntry.created_by_username == created_by_username)
count_query = count_query.where(PrintLogEntry.created_by_username == created_by_username)
if status:
query = query.where(PrintLogEntry.status == status)
count_query = count_query.where(PrintLogEntry.status == status)
if search:
query = query.where(PrintLogEntry.print_name.ilike(f"%{search}%"))
count_query = count_query.where(PrintLogEntry.print_name.ilike(f"%{search}%"))
if date_from:
query = query.where(PrintLogEntry.created_at >= date_from)
count_query = count_query.where(PrintLogEntry.created_at >= date_from)
if date_to:
query = query.where(PrintLogEntry.created_at <= date_to)
count_query = count_query.where(PrintLogEntry.created_at <= date_to)
# Get total count
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Get paginated results
query = query.order_by(PrintLogEntry.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(query)
entries = result.scalars().all()
return PrintLogResponse(
items=[
PrintLogEntrySchema(
id=e.id,
print_name=e.print_name,
printer_name=e.printer_name,
printer_id=e.printer_id,
status=e.status,
started_at=e.started_at,
completed_at=e.completed_at,
duration_seconds=e.duration_seconds,
filament_type=e.filament_type,
filament_color=e.filament_color,
filament_used_grams=e.filament_used_grams,
thumbnail_path=e.thumbnail_path,
created_by_username=e.created_by_username,
created_at=e.created_at,
)
for e in entries
],
total=total,
)
@router.get("/{entry_id}/thumbnail")
async def get_print_log_thumbnail(
entry_id: int,
db: AsyncSession = Depends(get_db),
_: None = RequireCameraStreamTokenIfAuthEnabled,
):
"""Get the thumbnail for a print log entry.
Requires a stream token query param (?token=xxx) when auth is enabled.
Self-heals stale entries: when thumbnail_path points to a file that no
longer exists on disk (archive was deleted, or print failed before the
thumbnail was ever written), NULL the path on the entry so subsequent
page renders skip the request entirely. The frontend's <img> tag is
gated on entry.thumbnail_path being truthy, so the next fetch of the
log list will simply not request this thumbnail again.
"""
entry = await db.get(PrintLogEntry, entry_id)
if not entry or not entry.thumbnail_path:
raise HTTPException(404, "Thumbnail not found")
thumb_path = settings.base_dir / entry.thumbnail_path
if not thumb_path.exists():
entry.thumbnail_path = None
await db.commit()
raise HTTPException(404, "Thumbnail file not found")
return FileResponse(
path=thumb_path,
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
@router.delete("/")
async def clear_print_log(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),
):
"""Clear the print log.
Only deletes log entries. Archives and queue items are never touched.
"""
result = await db.execute(delete(PrintLogEntry))
deleted = result.rowcount
await db.commit()
logger.info("Print log cleared: %d entries deleted", deleted)
return {"deleted": deleted}