Quellcode durchsuchen

fix(projects): the project timeline loads again with queued or printing jobs

maziggy vor 1 Woche
Ursprung
Commit
49879b8d69

+ 0 - 16
.basedpyright/baseline.json

@@ -3151,22 +3151,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 37,
-                    "endColumn": 47,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 37,
-                    "endColumn": 47,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportAttributeAccessIssue",
                 "range": {

+ 1 - 0
CHANGELOG.md

@@ -161,6 +161,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A project's timeline stayed empty while one of its jobs was queued or printing** — The **Timeline** on a project's page failed to load as soon as the project had a pending or printing job in the queue, so it showed no events at all, not even the finished prints. It loads again, and queued and running jobs show under their print name (or file name; a job for any of several printer models under its first file).
 - **Stopping or cancelling a print through the API failed with an error** — `POST /api/v1/webhook/printer/{id}/cancel` answered with an error and left the print running. `POST /api/v1/webhook/printer/{id}/stop` stopped the print but still answered with an error. This affected Home Assistant automations and scripts that use an API key; the Stop button in Bambuddy was not affected.
   - **Now both stop the print and answer normally.** If the printer drops off before the command can be sent, they answer "Printer not connected" (503).
   - **A print stopped this way now counts as stopped, not failed,** as with the Stop button: the "print stopped" notification goes out instead of "print failed", and the print is no longer mislabelled with a printer error such as a layer shift.

+ 38 - 7
backend/app/api/routes/projects.py

@@ -24,7 +24,7 @@ from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_log import PrintLogEntry
-from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
 from backend.app.models.user import User
@@ -1797,12 +1797,32 @@ async def create_template_from_project(
 # ============ Phase 9: Timeline Endpoint ============
 
 
+def _queue_item_name(item: PrintQueueItem) -> str | None:
+    """The name a queued job goes by, from its archive or library file.
+
+    Same sources as the queue page: the archive's print name or file name, but
+    not from a deleted archive, then the library file's print name or file name,
+    then for a job for any of several models (#671) its first candidate's file.
+    """
+    if item.archive is not None and item.archive.deleted_at is None:
+        name = item.archive.print_name or item.archive.filename
+        if name:
+            return name
+    if item.library_file is not None:
+        metadata = item.library_file.file_metadata or {}
+        return metadata.get("print_name") or item.library_file.filename
+    if item.variants and item.variants[0].library_file is not None:
+        return item.variants[0].library_file.filename
+    return None
+
+
 @router.get("/{project_id}/timeline", response_model=list[TimelineEvent])
 async def get_project_timeline(
     project_id: int,
     limit: int = 50,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get timeline of events for a project."""
     # Verify project exists
@@ -1824,13 +1844,17 @@ async def get_project_timeline(
     )
 
     # Get archives and add events
-    archives_result = await db.execute(
+    archive_query = (
         select(PrintArchive)
         .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
         .order_by(PrintArchive.created_at.desc())
         .limit(limit)
     )
-    archives = archives_result.scalars().all()
+    # Only events from printers the caller may see, as the archive and queue
+    # lists of the project (#1727)
+    if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+        archive_query = archive_query.where(clause)
+    archives = (await db.execute(archive_query)).scalars().all()
 
     for archive in archives:
         if archive.status == "completed":
@@ -1859,13 +1883,20 @@ async def get_project_timeline(
             )
 
     # Get queue items
-    queue_result = await db.execute(
+    queue_query = (
         select(PrintQueueItem)
         .where(PrintQueueItem.project_id == project_id)
+        .options(
+            selectinload(PrintQueueItem.archive),
+            selectinload(PrintQueueItem.library_file),
+            selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file),
+        )
         .order_by(PrintQueueItem.created_at.desc())
         .limit(limit)
     )
-    queue_items = queue_result.scalars().all()
+    if (clause := printer_scope.where(PrintQueueItem.printer_id)) is not None:
+        queue_query = queue_query.where(clause)
+    queue_items = (await db.execute(queue_query)).scalars().all()
 
     for item in queue_items:
         if item.status == "printing":
@@ -1874,7 +1905,7 @@ async def get_project_timeline(
                     event_type="print_started",
                     timestamp=item.started_at or item.created_at,
                     title="Print started",
-                    description=item.print_name,
+                    description=_queue_item_name(item),
                     metadata={"queue_item_id": item.id},
                 )
             )
@@ -1884,7 +1915,7 @@ async def get_project_timeline(
                     event_type="queued",
                     timestamp=item.created_at,
                     title="Added to queue",
-                    description=item.print_name,
+                    description=_queue_item_name(item),
                     metadata={"queue_item_id": item.id},
                 )
             )

+ 44 - 0
backend/tests/integration/test_printer_scope_1727.py

@@ -857,3 +857,47 @@ class TestLocationsPage:
         assert (await async_client.get(f"/api/v1/printers/{theirs.id}", headers=_auth(admin))).json()[
             "location"
         ] == "Lab A"
+
+
+class TestProjectTimeline:
+    async def test_timeline_hides_other_printers(self, async_client, printer_factory, archive_factory, db_session):
+        """Like the project's archive and queue lists, the timeline shows only the
+        member's printers: neither a finished print nor a queued job on another
+        printer appears, by name or by id."""
+        from backend.app.models.print_queue import PrintQueueItem
+        from backend.app.models.project import Project
+
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        project = Project(name="Shared")
+        db_session.add(project)
+        await db_session.commit()
+        project_id = project.id
+        await archive_factory(a.id, project_id=project_id, print_name="Mine done")
+        await archive_factory(b.id, project_id=project_id, print_name="Theirs done")
+        # The queued jobs' archives belong to no project, so their names can only
+        # reach the timeline through the queue events.
+        mine = await archive_factory(a.id, print_name="Mine queued", status="archived", with_run=False)
+        theirs = await archive_factory(b.id, print_name="Theirs queued", status="archived", with_run=False)
+        db_session.add_all(
+            [
+                PrintQueueItem(project_id=project_id, printer_id=a.id, archive_id=mine.id, status="pending"),
+                PrintQueueItem(project_id=project_id, printer_id=b.id, archive_id=theirs.id, status="pending"),
+            ]
+        )
+        await db_session.commit()
+
+        admin = await _admin_token(async_client)
+        perms = await _group(async_client, admin, "perms_timeline", permissions=[*TEAM_PERMISSIONS, "projects:read"])
+        team = await _group(async_client, admin, "team_timeline", printer_ids=[a.id])
+        jwt, _ = await _user(async_client, admin, "timeline_member", [perms, team])
+
+        def events(response) -> set[tuple[str, str | None]]:
+            assert response.status_code == 200, response.text
+            return {(e["event_type"], e.get("description")) for e in response.json()}
+
+        member = events(await async_client.get(f"/api/v1/projects/{project_id}/timeline", headers=_auth(jwt)))
+        assert {("print_completed", "Mine done"), ("queued", "Mine queued")} <= member
+        assert not {d for _, d in member} & {"Theirs done", "Theirs queued"}
+
+        full = events(await async_client.get(f"/api/v1/projects/{project_id}/timeline", headers=_auth(admin)))
+        assert {("print_completed", "Theirs done"), ("queued", "Theirs queued")} <= full

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

@@ -2136,3 +2136,124 @@ class TestSubProjectRollup:
 
         assert "Wing" not in rows
         assert rows["Airframe"]["child_count"] == 1
+
+
+class TestTimelineQueueItems:
+    """Queued and printing jobs appear in the project timeline under their name.
+
+    Pre-fix the timeline read ``item.print_name``, which a queue item does not
+    have: any pending or printing job made the whole timeline answer 500, so
+    the project page showed no events at all.
+    """
+
+    @staticmethod
+    async def _timeline(async_client: AsyncClient, project_id: int) -> list[dict]:
+        response = await async_client.get(f"/api/v1/projects/{project_id}/timeline")
+        assert response.status_code == 200, response.text
+        return response.json()
+
+    @staticmethod
+    async def _project(db_session):
+        from backend.app.models.project import Project
+
+        project = Project(name="Timeline")
+        db_session.add(project)
+        await db_session.commit()
+        return project.id
+
+    @staticmethod
+    async def _queue(db_session, project_id: int, status: str, **source) -> None:
+        from backend.app.models.print_queue import PrintQueueItem
+
+        db_session.add(PrintQueueItem(project_id=project_id, status=status, **source))
+        await db_session.commit()
+
+    @staticmethod
+    async def _library_file(db_session, filename: str, print_name: str | None):
+        from backend.app.models.library import LibraryFile
+
+        lib = LibraryFile(
+            filename=filename,
+            file_path=f"library/{filename}",
+            file_type="3mf",
+            file_size=1,
+            file_metadata={"print_name": print_name} if print_name else None,
+        )
+        db_session.add(lib)
+        await db_session.commit()
+        return lib.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pending_and_printing_jobs_carry_their_name(
+        self, async_client: AsyncClient, db_session, printer_factory, archive_factory
+    ):
+        project_id = await self._project(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="Bracket", with_run=False)
+        lib_id = await self._library_file(db_session, "hinge_v2.3mf", "Hinge")
+        await self._queue(db_session, project_id, "printing", archive_id=archive.id, printer_id=printer.id)
+        await self._queue(db_session, project_id, "pending", library_file_id=lib_id)
+
+        events = {e["event_type"]: e for e in await self._timeline(async_client, project_id)}
+
+        assert events["print_started"]["description"] == "Bracket"
+        assert events["queued"]["description"] == "Hinge"
+        assert "project_created" in events
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_falls_back_to_the_file_name(self, async_client: AsyncClient, db_session):
+        project_id = await self._project(db_session)
+        lib_id = await self._library_file(db_session, "hinge_v2.3mf", None)
+        await self._queue(db_session, project_id, "pending", library_file_id=lib_id)
+
+        (queued,) = [e for e in await self._timeline(async_client, project_id) if e["event_type"] == "queued"]
+
+        assert queued["description"] == "hinge_v2.3mf"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_deleted_archive_lends_no_name(
+        self, async_client: AsyncClient, db_session, printer_factory, archive_factory
+    ):
+        from datetime import datetime, timezone
+
+        project_id = await self._project(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="Deleted", with_run=False)
+        archive_id = archive.id
+        archive.deleted_at = datetime.now(timezone.utc)
+        await db_session.commit()
+        await self._queue(db_session, project_id, "pending", archive_id=archive_id, printer_id=printer.id)
+
+        (queued,) = [e for e in await self._timeline(async_client, project_id) if e["event_type"] == "queued"]
+
+        assert queued["description"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_job_for_any_of_several_models_is_named_after_its_first_file(
+        self, async_client: AsyncClient, db_session
+    ):
+        """A cross-model job (#671) has no archive or library file until dispatch;
+        like the queue page, the timeline names it after the first candidate."""
+        from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
+
+        project_id = await self._project(db_session)
+        first = await self._library_file(db_session, "bracket_h2s.3mf", "Bracket H2S")
+        second = await self._library_file(db_session, "bracket_h2c.3mf", "Bracket H2C")
+        item = PrintQueueItem(project_id=project_id, status="pending")
+        db_session.add(item)
+        await db_session.commit()
+        db_session.add_all(
+            [
+                PrintQueueVariant(queue_item_id=item.id, library_file_id=second, target_model="H2C", position=1),
+                PrintQueueVariant(queue_item_id=item.id, library_file_id=first, target_model="H2S", position=0),
+            ]
+        )
+        await db_session.commit()
+
+        (queued,) = [e for e in await self._timeline(async_client, project_id) if e["event_type"] == "queued"]
+
+        assert queued["description"] == "bracket_h2s.3mf"