Browse Source

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.
maziggy 2 months ago
parent
commit
46097a9ed4
3 changed files with 120 additions and 4 deletions
  1. 1 0
      CHANGELOG.md
  2. 4 4
      backend/app/api/routes/projects.py
  3. 115 0
      backend/tests/integration/test_projects_api.py

+ 1 - 0
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.
 - **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
 ### 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+.
 - **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.
 - **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.
 - **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.

+ 4 - 4
backend/app/api/routes/projects.py

@@ -97,11 +97,11 @@ async def compute_project_stats(
     )
     )
     in_progress_prints = in_progress_result.scalar() or 0
     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(
     completed_items_result = await db.execute(
         select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
         select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
             PrintArchive.project_id == project_id,
             PrintArchive.project_id == project_id,
-            PrintArchive.status.in_(["completed", "archived"]),
+            PrintArchive.status == "completed",
         )
         )
     )
     )
     completed_items = int(completed_items_result.scalar() or 0)
     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
         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(
         completed_result = await db.execute(
             select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
             select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
                 PrintArchive.project_id == project.id,
                 PrintArchive.project_id == project.id,
-                PrintArchive.status.in_(["completed", "archived"]),
+                PrintArchive.status == "completed",
             )
             )
         )
         )
         completed_count = int(completed_result.scalar() or 0)
         completed_count = int(completed_result.scalar() or 0)

+ 115 - 0
backend/tests/integration/test_projects_api.py

@@ -262,6 +262,121 @@ class TestProjectPartsTracking:
         assert data["stats"]["parts_progress_percent"] == 40.0  # parts: 10/25
         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:
 class TestProjectArchivesAPI:
     """Tests for project-archive relationships."""
     """Tests for project-archive relationships."""