From 46097a9ed4be90b79b66f4c93c655e31139a8eee Mon Sep 17 00:00:00 2001 From: maziggy Date: Fri, 6 Mar 2026 13:32:19 +0100 Subject: [PATCH] Fix archived files counted as printed in project statistics (#630) Files added to a project from the archive (status="archived") were incorrectly counted in completed_prints and parts_progress stats. Only status="completed" (actually printed) now counts toward completion. --- CHANGELOG.md | 1 + backend/app/api/routes/projects.py | 8 +- .../tests/integration/test_projects_api.py | 115 ++++++++++++++++++ 3 files changed, 120 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f5fcec3..39a3c119d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file. - **Print Queue Scheduler Diagnostics** ([#616](https://github.com/maziggy/bambuddy/issues/616)) — Added diagnostic logging to the print queue scheduler to help diagnose why queued prints aren't starting. After each queue check, the scheduler now logs a skip summary (how many items were skipped due to manual_start, scheduled_time, etc.) and for each busy printer, logs the exact state preventing it from being considered idle (connected status, printer state, plate_cleared flag). Previously the scheduler only logged "found N pending items" with no visibility into why items were skipped. ### Fixed +- **Project Statistics Count Archived Files as Printed** ([#630](https://github.com/maziggy/bambuddy/issues/630)) — Files added to a project from the archive were counted in project statistics (completed prints, parts progress) as if they had already been printed. Only files with `status="completed"` (actually printed via a printer) now count toward completion stats. Files with `status="archived"` (stored but not yet printed) are no longer included. Reported by @SebSeifert. - **Python 3.10 Compatibility** — Bambuddy failed to start on Python 3.10 with `ImportError: cannot import name 'StrEnum' from 'enum'` because `enum.StrEnum` was added in Python 3.11. Added a compatibility shim that falls back to `(str, Enum)` on Python < 3.11, matching the documented requirement of Python 3.10+. - **Bug Report Bubble Overlapping Toasts** — Moved toast notifications and upload progress up so they stack above the bug report bubble instead of overlapping on top of each other. - **Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x** — The TLS proxy connecting to the printer's bind port (3002) failed with `SSLV3_ALERT_HANDSHAKE_FAILURE` on systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. Added `AES256-GCM-SHA384` and `AES128-GCM-SHA256` to the client SSL context's cipher list. diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index bf31f2d87..a795d0e68 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -97,11 +97,11 @@ async def compute_project_stats( ) in_progress_prints = in_progress_result.scalar() or 0 - # Sum completed items (parts) - sum of quantities for successful prints + # Sum completed items (parts) - sum of quantities for actually printed jobs completed_items_result = await db.execute( select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where( PrintArchive.project_id == project_id, - PrintArchive.status.in_(["completed", "archived"]), + PrintArchive.status == "completed", ) ) completed_items = int(completed_items_result.scalar() or 0) @@ -192,11 +192,11 @@ async def list_projects( ) queue_count = queue_count_result.scalar() or 0 - # Sum completed parts (quantities) - includes "archived" as successful + # Sum completed parts (quantities) - only actually printed jobs completed_result = await db.execute( select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where( PrintArchive.project_id == project.id, - PrintArchive.status.in_(["completed", "archived"]), + PrintArchive.status == "completed", ) ) completed_count = int(completed_result.scalar() or 0) diff --git a/backend/tests/integration/test_projects_api.py b/backend/tests/integration/test_projects_api.py index 7ff1bc518..97b83afa0 100644 --- a/backend/tests/integration/test_projects_api.py +++ b/backend/tests/integration/test_projects_api.py @@ -262,6 +262,121 @@ class TestProjectPartsTracking: assert data["stats"]["parts_progress_percent"] == 40.0 # parts: 10/25 +class TestProjectArchivedStatusNotCounted: + """Tests for bug #630: archived files added to a project should not count as printed.""" + + @pytest.fixture + async def project_factory(self, db_session): + """Factory to create test projects.""" + + async def _create_project(**kwargs): + from backend.app.models.project import Project + + defaults = { + "name": "Archived Status Test", + "description": "Test project", + "color": "#FF0000", + } + defaults.update(kwargs) + + project = Project(**defaults) + db_session.add(project) + await db_session.commit() + await db_session.refresh(project) + return project + + return _create_project + + @pytest.fixture + async def archive_factory(self, db_session): + """Factory to create test archives.""" + + async def _create_archive(**kwargs): + from backend.app.models.archive import PrintArchive + + defaults = { + "filename": "test.3mf", + "file_path": "test/test.3mf", + "file_size": 1000, + "print_name": "Test Print", + "status": "completed", + "quantity": 1, + } + defaults.update(kwargs) + + archive = PrintArchive(**defaults) + db_session.add(archive) + await db_session.commit() + await db_session.refresh(archive) + return archive + + return _create_archive + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_archived_files_not_counted_as_completed( + self, async_client: AsyncClient, project_factory, archive_factory, db_session + ): + """Archived files added to a project should not count in completed_prints stats.""" + project = await project_factory(target_parts_count=20) + + # 2 actually printed (completed), 3 just archived (not printed yet) + await archive_factory(project_id=project.id, quantity=2, status="completed") + await archive_factory(project_id=project.id, quantity=3, status="archived") + await archive_factory(project_id=project.id, quantity=5, status="archived") + + response = await async_client.get(f"/api/v1/projects/{project.id}") + assert response.status_code == 200 + data = response.json() + + # Only the completed archive should count + assert data["stats"]["completed_prints"] == 2 + assert data["stats"]["parts_progress_percent"] == 10.0 # 2/20 = 10% + assert data["stats"]["remaining_parts"] == 18 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_archived_files_not_counted_in_project_list( + self, async_client: AsyncClient, project_factory, archive_factory, db_session + ): + """Project list endpoint should not count archived files as completed.""" + project = await project_factory(name="List Archived Test", target_parts_count=50) + + await archive_factory(project_id=project.id, quantity=4, status="completed") + await archive_factory(project_id=project.id, quantity=6, status="archived") + + response = await async_client.get("/api/v1/projects/") + assert response.status_code == 200 + data = response.json() + + our_project = next((p for p in data if p["name"] == "List Archived Test"), None) + assert our_project is not None + assert our_project["completed_count"] == 4 # Only completed, not archived + assert our_project["archive_count"] == 2 # Both archives exist as plates + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_only_completed_status_counts( + self, async_client: AsyncClient, project_factory, archive_factory, db_session + ): + """Only 'completed' status should count in stats, not archived/failed/etc.""" + project = await project_factory(target_parts_count=100) + + await archive_factory(project_id=project.id, quantity=10, status="completed") + await archive_factory(project_id=project.id, quantity=5, status="archived") + await archive_factory(project_id=project.id, quantity=3, status="failed") + await archive_factory(project_id=project.id, quantity=2, status="aborted") + + response = await async_client.get(f"/api/v1/projects/{project.id}") + assert response.status_code == 200 + data = response.json() + + assert data["stats"]["completed_prints"] == 10 # Only "completed" + assert data["stats"]["failed_prints"] == 2 # failed + aborted (count of archives, not sum) + assert data["stats"]["total_archives"] == 4 # All archives + assert data["stats"]["total_items"] == 20 # Sum of all quantities + + class TestProjectArchivesAPI: """Tests for project-archive relationships."""