From 2bf397e33e009a26465047600e6afdec4a641e20 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 19 Apr 2026 12:15:25 +0200 Subject: [PATCH] fix(queue): update LibraryFile.print_count and last_printed_at on completion (#1008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both fields have existed on the model and been shown in the File Manager for some time, but nothing ever wrote to them — every file in every library appeared to have never been printed. Now on_print_complete's queue-status update path calls a small _bump_library_file_usage_if_completed() helper that increments print_count and stamps last_printed_at on the source library file whenever a queued print completes successfully. Failed, cancelled and user-aborted prints are intentionally skipped so the fields represent successful usage rather than attempt count. Unblocks sorting the File Manager by last-printed date and is a prerequisite for the scheduled-purge feature requested in #1008, which is held until we see whether manual sort+bulk-delete covers the use case. --- CHANGELOG.md | 3 + backend/app/main.py | 23 +++ .../tests/integration/test_print_queue_api.py | 140 ++++++++++++++++++ 3 files changed, 166 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a09fd4f5..a1e14f237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.4b1] - Unreleased +### Fixed +- **Library File Print-Usage Tracking** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — `LibraryFile.print_count` and `last_printed_at` are now updated on every successful queued print completion. Previously both fields were defined on the model and displayed in the File Manager, but nothing ever wrote to them — every file in every library showed as never printed. Now counts increment cumulatively and `last_printed_at` stamps the completion timestamp (UTC). Failed, cancelled and user-aborted prints are intentionally excluded, so the fields represent "successful usage" rather than "attempted usage." This unblocks sorting the File Manager by last-printed date and is a prerequisite for the scheduled-purge feature requested in #1008. Thanks to @cadtoolbox for the report. + ### Improved - **File Manager: Collapse Folders by Default** ([#996](https://github.com/maziggy/bambuddy/issues/996)) — Added a **Collapse** toggle next to **Wrap** in the File Manager sidebar header. When enabled, the folder tree opens with only top-level folders visible on every page load; disabling it restores the previous fully-expanded default. Toggling the preference also immediately re-collapses/re-expands the current tree — no reload required. Persisted to localStorage under `library-collapse-folders`, matching the existing `library-*` preference pattern. Thanks to @AshieTashi for the request. diff --git a/backend/app/main.py b/backend/app/main.py index b66d5298a..c9cc0205c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -431,6 +431,23 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No return stored_ams_mapping +async def _bump_library_file_usage_if_completed(db, item, queue_status: str) -> None: + """Increment LibraryFile.print_count and stamp last_printed_at when a queued + print completes successfully. Gated to status=='completed': failed, cancelled + and aborted prints do not count as usage. Caller is responsible for committing + the session. No-op when the queue item has no linked library file (e.g. reprints + from an archive). See #1008.""" + if queue_status != "completed" or item.library_file_id is None: + return + from backend.app.models.library import LibraryFile + + lib_file = await db.scalar(select(LibraryFile).where(LibraryFile.id == item.library_file_id)) + if lib_file is None: + return + lib_file.print_count = (lib_file.print_count or 0) + 1 + lib_file.last_printed_at = datetime.now(timezone.utc) + + def mark_printer_stopped_by_user(printer_id: int) -> None: """Mark that the active print on this printer was stopped by the user from the queue UI. @@ -2649,6 +2666,12 @@ async def on_print_complete(printer_id: int, data: dict): queue_status = "cancelled" item.status = queue_status item.completed_at = datetime.now(timezone.utc) + + # Bump usage counters on the source library file so admins can + # sort by "last printed" and (eventually) auto-purge stale + # files — #1008. + await _bump_library_file_usage_if_completed(db, item, queue_status) + await db.commit() queue_item_id = item.id queue_auto_off = item.auto_off_after diff --git a/backend/tests/integration/test_print_queue_api.py b/backend/tests/integration/test_print_queue_api.py index bd135e710..fbffe6458 100644 --- a/backend/tests/integration/test_print_queue_api.py +++ b/backend/tests/integration/test_print_queue_api.py @@ -1482,6 +1482,146 @@ class TestAbortedStatusNormalisation: assert item.status == "completed" + # ======================================================================== + # Library file usage tracking on print completion (#1008) + # + # These exercise the _bump_library_file_usage_if_completed helper directly + # rather than invoking the whole on_print_complete handler — that path + # spawns background asyncio tasks (notifications, MQTT relay, smart-plug) + # that are expensive to mock and have nothing to do with the bump logic. + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bump_library_file_usage_on_completed(self, printer_factory, db_session): + """Successful completion increments print_count and stamps last_printed_at.""" + from datetime import datetime, timezone + + from backend.app.main import _bump_library_file_usage_if_completed + from backend.app.models.library import LibraryFile + from backend.app.models.print_queue import PrintQueueItem + + printer = await printer_factory() + lib_file = LibraryFile( + filename="benchy.gcode.3mf", + file_path="/data/library/benchy.gcode.3mf", + file_type="gcode.3mf", + file_size=1024, + print_count=0, + last_printed_at=None, + ) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + + item = PrintQueueItem( + printer_id=printer.id, + library_file_id=lib_file.id, + status="printing", + position=1, + ) + + before = datetime.now(timezone.utc).replace(tzinfo=None) + await _bump_library_file_usage_if_completed(db_session, item, "completed") + await db_session.commit() + await db_session.refresh(lib_file) + + assert lib_file.print_count == 1 + assert lib_file.last_printed_at is not None + assert lib_file.last_printed_at >= before + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bump_library_file_usage_repeated_prints_increment_count(self, printer_factory, db_session): + """Each successful completion bumps print_count cumulatively.""" + from backend.app.main import _bump_library_file_usage_if_completed + from backend.app.models.library import LibraryFile + from backend.app.models.print_queue import PrintQueueItem + + printer = await printer_factory() + lib_file = LibraryFile( + filename="repeat.gcode.3mf", + file_path="/data/library/repeat.gcode.3mf", + file_type="gcode.3mf", + file_size=1024, + print_count=0, + ) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + + item = PrintQueueItem( + printer_id=printer.id, + library_file_id=lib_file.id, + status="printing", + position=1, + ) + + for _ in range(3): + await _bump_library_file_usage_if_completed(db_session, item, "completed") + + await db_session.commit() + await db_session.refresh(lib_file) + assert lib_file.print_count == 3 + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize("terminal_status", ["failed", "cancelled"]) + async def test_bump_library_file_usage_skips_non_completed(self, printer_factory, db_session, terminal_status): + """Failed and cancelled prints must NOT count as usage.""" + from backend.app.main import _bump_library_file_usage_if_completed + from backend.app.models.library import LibraryFile + from backend.app.models.print_queue import PrintQueueItem + + printer = await printer_factory() + lib_file = LibraryFile( + filename="broken.gcode.3mf", + file_path="/data/library/broken.gcode.3mf", + file_type="gcode.3mf", + file_size=1024, + print_count=0, + last_printed_at=None, + ) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + + item = PrintQueueItem( + printer_id=printer.id, + library_file_id=lib_file.id, + status="printing", + position=1, + ) + + await _bump_library_file_usage_if_completed(db_session, item, terminal_status) + await db_session.commit() + await db_session.refresh(lib_file) + + assert lib_file.print_count == 0 + assert lib_file.last_printed_at is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bump_library_file_usage_skips_when_no_library_file_id( + self, printer_factory, archive_factory, db_session + ): + """Queue items without library_file_id (e.g. archive reprints) are a no-op.""" + from backend.app.main import _bump_library_file_usage_if_completed + from backend.app.models.print_queue import PrintQueueItem + + printer = await printer_factory() + archive = await archive_factory() + item = PrintQueueItem( + printer_id=printer.id, + library_file_id=None, + archive_id=archive.id, + status="printing", + position=1, + ) + + # Must not raise. + await _bump_library_file_usage_if_completed(db_session, item, "completed") + # ======================================================================== # Batch quantity tests # ========================================================================