Browse Source

Add tests, docs, and changelog for #920 + #932

  Audit PRs #920 (printers search/filter) and #932 (print from project
  view) for regressions, i18n coverage, test gaps, and docs.

  No regressions found: existing callers of the changed signatures
  (archive_print, getLibraryFiles, filteredPrinters chain) are unaffected;
  i18n is complete in all 7 locales for both features.

  Backend tests (4 new, all passing):
  - test_list_files_by_project_id — bulk JOIN returns files across all
    linked folders, excludes unlinked ones
  - test_list_files_folder_id_takes_precedence_over_project_id — guards
    the documented precedence folder_id > project_id > include_root
  - test_add_to_queue_with_project_id — project_id is persisted on the
    queue row for later archive linkage
  - test_add_to_queue_invalid_project_id_returns_404 — regression guard
    for the validation pre-check on the queue path (mirrors the one on
    the direct-print path in library.py)
maziggy 1 month ago
parent
commit
b01b5e05fd

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **AMS Drying Support for P2S** — Remote AMS drying and queue auto-drying now work on P2S printers with firmware 01.02.00.00 or later. Previously P2S was hard-blocked from the drying feature.
 
 ### New Features
+- **Print Files Directly from Project View** ([#930](https://github.com/maziggy/bambuddy/issues/930)) — The project detail page now lists the printable files from every linked library folder inline, with Play (Print Now) and CalendarPlus (Add to Queue) action buttons on each sliced file (`.gcode` and `.gcode.3mf`). No more round-tripping through File Manager to reprint project files. Prints triggered from the project view are automatically associated with the originating project, so the resulting archive shows up in that project's history without any manual assignment. Backend adds a `project_id` query parameter to `GET /library/files` that returns all files across linked folders in a single query (replacing the prior one-request-per-folder pattern) and validates `project_id` on both the direct-print and queue paths so a stale ID yields a 404 instead of a FK-constraint 500. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.
 - **Printers Page Search and Filters** ([#852](https://github.com/maziggy/bambuddy/issues/852)) — The Printers page now has a live search bar and two filter dropdowns (status and location) to make finding specific printers in large setups easier, especially on mobile where Ctrl+F is impractical. Search matches printer name, model, location, and serial number (case-insensitive, whitespace-trimmed) and has a clear button. The status filter covers All / Printing / Paused / Idle / Finished / Error / Offline and is reactive to WebSocket status updates via a React Query cache subscription — so a print finishing while "Printing" is selected immediately removes the printer from the filtered list. The location filter is only shown when at least one printer has a location configured. All three filters are combinable; the controls are hidden when no printers are configured yet; and an empty-state message appears when no printer matches the current search/filters. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.
 - **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments.
 

+ 2 - 0
README.md

@@ -87,6 +87,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Fan status monitoring (part cooling, auxiliary, chamber)
 - Printer control (stop, pause, resume, chamber light, print speed)
 - Bulk printer actions (multi-select cards, then stop/pause/resume/clear all — select by state or location)
+- Printer search and filters — live search by name/model/location/serial plus status and location dropdown filters (WebSocket-reactive, mobile-friendly)
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - AMS slot RFID re-read
@@ -150,6 +151,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Color-coded project badges
 - Bulk assign archives via multi-select toolbar
 - Import/Export projects as ZIP (includes files) or JSON
+- Print or queue files from linked library folders directly in the project view (resulting archive auto-linked to the project)
 
 </td>
 <td width="50%" valign="top">

+ 62 - 0
backend/tests/integration/test_library_api.py

@@ -181,6 +181,68 @@ class TestLibraryFilesAPI:
         assert len(result) == 1
         assert result[0]["id"] == file1.id
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_by_project_id(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
+        """#932: project_id filter returns files across all folders linked to the project.
+
+        Replaces the prior N+1 pattern where the frontend fired one request per
+        linked folder. A single JOIN query must return every file in folders whose
+        project_id matches, while excluding files from unlinked folders.
+        """
+        from backend.app.models.project import Project
+
+        project = Project(name="Test Project for Files", color="#00ff00")
+        db_session.add(project)
+        await db_session.commit()
+        await db_session.refresh(project)
+
+        folder_a = await folder_factory(name="Folder A", project_id=project.id)
+        folder_b = await folder_factory(name="Folder B", project_id=project.id)
+        other_folder = await folder_factory(name="Unlinked")
+
+        linked_a = await file_factory(folder_id=folder_a.id, filename="a.3mf")
+        linked_b = await file_factory(folder_id=folder_b.id, filename="b.3mf")
+        await file_factory(folder_id=other_folder.id, filename="unlinked.3mf")
+        await file_factory(filename="root.3mf")  # no folder → not part of any project
+
+        response = await async_client.get(f"/api/v1/library/files?project_id={project.id}")
+        assert response.status_code == 200
+        result = response.json()
+        ids = {f["id"] for f in result}
+        assert ids == {linked_a.id, linked_b.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_folder_id_takes_precedence_over_project_id(
+        self, async_client: AsyncClient, folder_factory, file_factory, db_session
+    ):
+        """When both folder_id and project_id are passed, folder_id wins.
+
+        Documented precedence in list_files(): folder_id > project_id > include_root.
+        This guards the behavior so a future refactor can't silently flip it.
+        """
+        from backend.app.models.project import Project
+
+        project = Project(name="Precedence Project")
+        db_session.add(project)
+        await db_session.commit()
+        await db_session.refresh(project)
+
+        folder_linked = await folder_factory(name="Linked", project_id=project.id)
+        folder_other = await folder_factory(name="Other")
+
+        await file_factory(folder_id=folder_linked.id, filename="linked.3mf")
+        other_file = await file_factory(folder_id=folder_other.id, filename="other.3mf")
+
+        # folder_id points at a folder that is NOT in the project — must return
+        # that folder's contents and ignore project_id entirely.
+        response = await async_client.get(f"/api/v1/library/files?folder_id={folder_other.id}&project_id={project.id}")
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result) == 1
+        assert result[0]["id"] == other_file.id
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_get_file(self, async_client: AsyncClient, file_factory, db_session):

+ 56 - 0
backend/tests/integration/test_print_queue_api.py

@@ -147,6 +147,62 @@ class TestPrintQueueAPI:
         assert result["status"] == "pending"
         assert result["manual_start"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_project_id(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """#932: queue items created from the project view carry project_id forward."""
+        from backend.app.models.project import Project
+
+        printer = await printer_factory()
+        archive = await archive_factory()
+        project = Project(name="Queue Project")
+        db_session.add(project)
+        await db_session.commit()
+        await db_session.refresh(project)
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "project_id": project.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        # The response schema may or may not echo project_id; the stored row is
+        # what matters, so verify via DB.
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        row = (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == result["id"]))).scalar_one()
+        assert row.project_id == project.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_invalid_project_id_returns_404(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """#932: bogus project_id must be rejected before the FK constraint fires.
+
+        Regression guard for the pre-check added to add_to_queue. Without the
+        validation, a nonexistent project_id would reach db.commit() and raise
+        an IntegrityError → 500. The pre-check must convert that to a 404 so
+        the UI gets a clean error it can surface.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "project_id": 999999,  # nonexistent
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 404
+        assert "project" in response.json()["detail"].lower()
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_add_to_queue_with_ams_mapping(

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BpSYztnP.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CihbWKPG.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-dE7PVwWf.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DlWXszIh.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CihbWKPG.css">
+    <script type="module" crossorigin src="/assets/index-BpSYztnP.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-dE7PVwWf.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff