mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 11:25:48 +02:00
(#1687 part 4, reported by @IndividualGhost1905) Reporter clarified after part 1 shipped that point 2 wasn't about archive `tags` (which describe the model — home decor, toys), but about failure-cause classification on the *log* row itself: spaghetti, jam, bed-adhesion, etc. Different surface, different lifetime. The data field he wanted already existed. PrintLogEntry.failure_reason is a String(100); the Failure Analysis widget already groups by it; the Archive Edit modal already mirrors archive.failure_reason into the most recent log entry (archives.py:1421, shipped with #1444). The only gaps were: 1. The GET endpoint silently dropped failure_reason (and archive_id and created_by_id) from PrintLogEntrySchema construction even when set in the DB — so the Print Log table couldn't render what the Failure Analysis widget grouped by. Fixed independently of the editor; regression test added. 2. Orphan log entries (no archive — dispatch errors, aborts before archive creation, manual entries) had no edit path at all because the Archive Edit modal cannot reach them. The new endpoint is the only way to classify those rows. Changes: - Backend: new PATCH /print-log/{entry_id} taking {failure_reason, status}, gated on require_ownership_permission( ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN) — same ownership shape as the per-row DELETE. Validates against the same 11-key failure vocabulary and 5-key status set the Archive Edit modal uses; unknown values return 400 rather than getting stored as raw text (the i18n layer maps the value back through the vocabulary, unrecognised values would render as literal strings). Empty-string failure_reason stores back as NULL so the column's nullable=True intent is preserved end-to-end. GET endpoint now surfaces failure_reason, archive_id, created_by_id. - Frontend: FAILURE_REASON_KEYS moved to an export from EditArchiveModal.tsx so the new editor reuses the exact same vocabulary — backend and frontend stay in lockstep. Pencil icon beside the existing trash icon on every Print Log row, opens a compact two-field modal (status + failure reason). Save invalidates print-log and archives-stats query keys so the Failure Analysis widget reflects the re-classification on the same response cycle. Failure reason rendered as a sub-label under the status badge, matching PrintLogTable.tsx's convention. - i18n: 10 new keys (editEntryTitle, editEntryDescription, entryUpdated, entryUpdateFailed, archives.permission.noEdit, plus a 5-key statuses block) translated across all 11 locales. No English fallbacks. - Wiki: features/print-log.md gains per-row actions section, updated permissions table, PATCH/single-DELETE endpoint docs.
298 lines
11 KiB
Python
298 lines
11 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,
|
|
require_ownership_permission,
|
|
)
|
|
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, PrintLogEntryUpdate, 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,
|
|
archive_id=e.archive_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,
|
|
# failure_reason was silently dropped by the GET serialiser
|
|
# before #1687 part 4 — without it the Print Log table couldn't
|
|
# surface what the Failure Analysis widget already groups by.
|
|
failure_reason=e.failure_reason,
|
|
thumbnail_path=e.thumbnail_path,
|
|
created_by_id=e.created_by_id,
|
|
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}
|
|
|
|
|
|
@router.delete("/{entry_id}")
|
|
async def delete_print_log_entry(
|
|
entry_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
auth_result: tuple[User | None, bool] = Depends(
|
|
require_ownership_permission(
|
|
Permission.ARCHIVES_DELETE_ALL,
|
|
Permission.ARCHIVES_DELETE_OWN,
|
|
)
|
|
),
|
|
):
|
|
"""Delete a single print-log entry (#1687).
|
|
|
|
Removes the row entirely. Because /archives/stats aggregates over
|
|
PrintLogEntry, the deleted row's filament / cost / duration / count
|
|
contributions drop out of the totals in the same response cycle.
|
|
The linked archive (if any) is untouched — the FK on the archive row
|
|
is from PrintLogEntry, not the other way around.
|
|
"""
|
|
user, can_modify_all = auth_result
|
|
|
|
entry = await db.get(PrintLogEntry, entry_id)
|
|
if not entry:
|
|
raise HTTPException(404, "Print log entry not found")
|
|
|
|
if not can_modify_all:
|
|
if entry.created_by_id is None or (user is not None and entry.created_by_id != user.id):
|
|
raise HTTPException(403, "You can only delete your own print log entries")
|
|
|
|
await db.delete(entry)
|
|
await db.commit()
|
|
|
|
logger.info("Print log entry %d deleted", entry_id)
|
|
return {"status": "deleted", "id": entry_id}
|
|
|
|
|
|
# Canonical failure-reason vocabulary. Mirrors the frontend dropdown in
|
|
# EditArchiveModal.tsx; the empty string is the "clear classification" value.
|
|
# The catch-all "other" is the escape hatch for failures that don't fit the
|
|
# enumerated list. Keep these two lists in sync if the EditArchiveModal options
|
|
# ever change.
|
|
_FAILURE_REASON_KEYS = frozenset(
|
|
{
|
|
"",
|
|
"adhesionFailure",
|
|
"spaghettiDetached",
|
|
"layerShift",
|
|
"cloggedNozzle",
|
|
"filamentRunout",
|
|
"warping",
|
|
"stringing",
|
|
"underExtrusion",
|
|
"powerFailure",
|
|
"userCancelled",
|
|
"other",
|
|
}
|
|
)
|
|
|
|
# Same status vocabulary the print-log column already filters by.
|
|
_STATUS_KEYS = frozenset({"completed", "failed", "stopped", "cancelled", "skipped"})
|
|
|
|
|
|
@router.patch("/{entry_id}", response_model=PrintLogEntrySchema)
|
|
async def update_print_log_entry(
|
|
entry_id: int,
|
|
update: PrintLogEntryUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
auth_result: tuple[User | None, bool] = Depends(
|
|
require_ownership_permission(
|
|
Permission.ARCHIVES_UPDATE_ALL,
|
|
Permission.ARCHIVES_UPDATE_OWN,
|
|
)
|
|
),
|
|
):
|
|
"""Edit a single Print Log row's classification (#1687 part 4, reporter
|
|
IndividualGhost1905).
|
|
|
|
Lets the user set ``failure_reason`` (and optionally re-classify ``status``)
|
|
directly on a Print Log row — including orphan entries that have no
|
|
archive to edit through. The Failure Analysis widget already groups by
|
|
``PrintLogEntry.failure_reason`` (see ``archives.py:1421`` for the
|
|
archive-side mirror); this endpoint is the missing edit affordance for the
|
|
log-side, mirror-less case.
|
|
|
|
Ownership semantics mirror the per-row delete: archives:update_all sees
|
|
everything; archives:update_own sees only rows it owns.
|
|
"""
|
|
user, can_modify_all = auth_result
|
|
|
|
entry = await db.get(PrintLogEntry, entry_id)
|
|
if not entry:
|
|
raise HTTPException(404, "Print log entry not found")
|
|
|
|
if not can_modify_all:
|
|
if entry.created_by_id is None or (user is not None and entry.created_by_id != user.id):
|
|
raise HTTPException(403, "You can only update your own print log entries")
|
|
|
|
payload = update.model_dump(exclude_unset=True)
|
|
|
|
# Validate against the canonical vocabularies. Reject unknown values rather
|
|
# than silently storing them — the Failure Analysis widget renders the
|
|
# values back as i18n keys, and an unrecognised value would surface as a
|
|
# raw string in the UI.
|
|
if "failure_reason" in payload:
|
|
new_reason = payload["failure_reason"] or ""
|
|
if new_reason not in _FAILURE_REASON_KEYS:
|
|
raise HTTPException(400, f"Unknown failure_reason: {new_reason!r}")
|
|
# Store empty string back as NULL so the column's nullable=True intent
|
|
# is preserved end-to-end.
|
|
entry.failure_reason = new_reason or None
|
|
|
|
if "status" in payload and payload["status"] is not None:
|
|
new_status = payload["status"]
|
|
if new_status not in _STATUS_KEYS:
|
|
raise HTTPException(400, f"Unknown status: {new_status!r}")
|
|
entry.status = new_status
|
|
|
|
await db.commit()
|
|
await db.refresh(entry)
|
|
|
|
logger.info(
|
|
"Print log entry %d updated (failure_reason=%r, status=%r)",
|
|
entry_id,
|
|
entry.failure_reason,
|
|
entry.status,
|
|
)
|
|
|
|
return PrintLogEntrySchema(
|
|
id=entry.id,
|
|
archive_id=entry.archive_id,
|
|
print_name=entry.print_name,
|
|
printer_name=entry.printer_name,
|
|
printer_id=entry.printer_id,
|
|
status=entry.status,
|
|
started_at=entry.started_at,
|
|
completed_at=entry.completed_at,
|
|
duration_seconds=entry.duration_seconds,
|
|
filament_type=entry.filament_type,
|
|
filament_color=entry.filament_color,
|
|
filament_used_grams=entry.filament_used_grams,
|
|
failure_reason=entry.failure_reason,
|
|
thumbnail_path=entry.thumbnail_path,
|
|
created_by_id=entry.created_by_id,
|
|
created_by_username=entry.created_by_username,
|
|
created_at=entry.created_at,
|
|
)
|