Преглед изворни кода

fix(projects): adding archives or queue jobs to a project keeps to the user's printers and prints

add-archives, remove-archives and add-queue loaded each archive or job by
id alone and needed only projects:update. A member of a group limited to
some printers could link another team's archives and jobs to a project or
unlink them, and the "Added N" count told them which hidden ids exist. Any
project editor could also relink other users' prints, which the archive and
queue edits refuse.

The routes now change only rows the caller could change directly: on their
printers, and only their own unless they hold the update_all permission.
Other rows are skipped like missing ones. An API key acts with its owner's
rights. A project key still needs no archive or queue flag (#1893), but it
never reaches a row its owner could not change; a legacy key without an owner
keeps every row of its printers. The responses carry the number changed as
"count", and Assign to project reports it instead of the number selected.
maziggy пре 1 недеља
родитељ
комит
fbd506557a
59 измењених фајлова са 370 додато и 44 уклоњено
  1. 1 0
      CHANGELOG.md
  2. 85 32
      backend/app/api/routes/projects.py
  3. 219 0
      backend/tests/integration/test_project_batch_scope.py
  4. 54 0
      frontend/src/__tests__/components/BatchProjectModal.test.tsx
  5. 3 3
      frontend/src/api/client.ts
  6. 5 4
      frontend/src/components/BatchProjectModal.tsx
  7. 0 0
      static/assets/AdditionalSection-DOaA1ogX.js
  8. 0 0
      static/assets/AmsUnitCard-CH5Gi1nq.js
  9. 0 0
      static/assets/ArchivesPage-BtNYc-Nc.js
  10. 0 0
      static/assets/AssignToAmsModal-DhXXEo3X.js
  11. 0 0
      static/assets/CamWallPage-Au49U00l.js
  12. 0 0
      static/assets/CameraDiagnoseModal-U9eyEtl-.js
  13. 0 0
      static/assets/CameraPage-Dkg-9uUn.js
  14. 0 0
      static/assets/CameraWall-DgnWcKDA.js
  15. 0 0
      static/assets/ColumnConfigModal-BTS1yD9I.js
  16. 0 0
      static/assets/ConfigureAmsSlotModal-B5pE6Zfr.js
  17. 0 0
      static/assets/ConnectAuthorizePage-D9RPGvWH.js
  18. 0 0
      static/assets/ContextMenu-DMk9p_XF.js
  19. 0 0
      static/assets/ExternalLinkPage-1RdxOGZl.js
  20. 0 1
      static/assets/FileManagerPage-Dnaslp0X.js
  21. 0 0
      static/assets/FileUploadModal-BXCTtyUn.js
  22. 0 0
      static/assets/FinancePage-Vt1lDiLj.js
  23. 0 0
      static/assets/GCodeViewerPage-CqX9z-_z.js
  24. 0 0
      static/assets/GroupEditPage-CZ2hu2kI.js
  25. 0 0
      static/assets/ImagePreviewModal-BGamb9b3.js
  26. 0 0
      static/assets/InventoryPage-CcaydE-S.js
  27. 0 0
      static/assets/LibraryTrashPage-BRlliPjC.js
  28. 0 0
      static/assets/LoginPage-CMYyUAzm.js
  29. 0 0
      static/assets/MaintenancePage-D3DLDIvy.js
  30. 0 0
      static/assets/ModelSourcesPage-BCvfxJ4I.js
  31. 0 0
      static/assets/NotificationsPage-BJhtRnqP.js
  32. 0 1
      static/assets/PdfPreviewModal-Bcc7uHle.js
  33. 0 0
      static/assets/PhotoGalleryModal-BWbC1uoq.js
  34. 0 0
      static/assets/PrinterLocationsPage-CapmAvY4.js
  35. 0 0
      static/assets/PrintersPage-CcwtkuMj.js
  36. 0 0
      static/assets/ProfilesPage-BlN_eyk3.js
  37. 0 0
      static/assets/ProjectDetailPage-CDWilXQD.js
  38. 0 0
      static/assets/ProjectsPage-C6wlEQvM.js
  39. 0 0
      static/assets/QueuePage-CkeOWEYK.js
  40. 0 0
      static/assets/RichTextEditor-AOxRdNUX.js
  41. 0 0
      static/assets/SettingsPage-rwaWS9xc.js
  42. 0 0
      static/assets/SetupPage-DhZzhseq.js
  43. 0 0
      static/assets/SliceModal-LzMNWWiQ.js
  44. 0 0
      static/assets/SpoolBuddyAmsPage-g56fHshE.js
  45. 0 0
      static/assets/SpoolBuddyCalibrationPage-Bz42Mk4a.js
  46. 0 0
      static/assets/SpoolBuddyDashboard-JEnI2rHd.js
  47. 0 0
      static/assets/SpoolBuddyInventoryPage-CCiY-_Zy.js
  48. 1 1
      static/assets/SpoolBuddySettingsPage-XCnoWigr.js
  49. 0 0
      static/assets/SpoolBuddyWriteTagPage-BBj1PtVy.js
  50. 0 0
      static/assets/SpreadsheetPreviewModal-CZbfnqXn.js
  51. 0 0
      static/assets/StatsPage-CNRzp-YL.js
  52. 0 0
      static/assets/StreamOverlayPage-C1H7Ytkr.js
  53. 0 0
      static/assets/SystemInfoPage-BWv43tk8.js
  54. 0 0
      static/assets/index-Cd25WO_Z.js
  55. 1 1
      static/assets/inventorySearch-CdflUdPi.js
  56. 0 0
      static/assets/locationSensorDefaults-DY_WP5e0.js
  57. 0 0
      static/assets/pdf-CaysVT1M.js
  58. 0 0
      static/assets/utils-kA_cA74a.js
  59. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,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
+- **Adding archives or queue jobs to a project keeps to the user's own printers and prints** — Adding archives to a project (**Assign to project**), removing them, and adding queue jobs took any archive or job by its id. A member of a group limited to some printers could link another team's archives and jobs to a project or unlink them, and the reply's count told them which hidden archives exist; any project editor could also relink other users' prints. These now change only the archives and jobs the user could edit directly: on their printers, and only their own unless they may edit everyone's (Operators: their own). Others are skipped like missing ones, and **Assign to project** now reports how many were actually added. An API key with project access still needs no archive or queue access for this, but changes only the archives and jobs of its printers that its owner could change.
 - **Editing an archive can no longer move it to a printer outside the user's groups** — A member of a group limited to some printers could move an archive of one of their printers to any other printer through the archive edit, into the history of a team they have no access to, although uploading an archive to such a printer was already refused. Any edit could also link an archive to a printer or project that does not exist (a server error on PostgreSQL, a broken link on SQLite). Such an edit is now refused with "Printer not found" or "Project not found". Moving an archive between the user's own printers, and removing its printer or project, work as before.
 - **A smart plug or virtual printer can no longer be linked to printer 0** — Adding or editing a smart plug and adding a virtual printer skipped the "Printer not found" check when the printer id was `0`. On PostgreSQL the save then failed with a server error; on SQLite the plug or virtual printer was saved linked to a printer that does not exist. Such a request is now refused with "Printer not found", as for any other unknown printer.
 - **No more firmware-check errors in the log while the internet is down** — Without an internet connection, every firmware check of every printer logged a full error for each Bambu Lab page it tried (measured: 12 errors and 9 warnings for three printers checked three times). The outage is now logged once per Bambu Lab site as a warning, the checks keep retrying quietly, and a line in the log says when the site is reachable again.

+ 85 - 32
backend/app/api/routes/projects.py

@@ -16,7 +16,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.library import get_library_dir
-from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled, require_media_token_permission
+from backend.app.core.auth import (
+    ApiKeyActor,
+    RequestActor,
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    require_media_token_permission,
+)
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -1075,12 +1081,46 @@ async def get_project_file_progress(
     return [ProjectFileProgress(file_id=fid, completed_count=n) for fid, n in sorted(counts.items())]
 
 
+def _editable_rows(
+    model: type[PrintArchive] | type[PrintQueueItem],
+    ids: list[int],
+    printer_scope: PrinterScope,
+    actor: User | ApiKeyActor | None,
+    all_permission: Permission,
+    own_permission: Permission,
+):
+    """Select the rows of ``ids`` the caller may relink, or None for none of them.
+
+    The same rows the caller could change through the archive or queue edit:
+    on their printers, and their own unless they may change everyone's. Rows
+    outside are skipped like missing ones, so the count names no hidden id. An
+    API key changes what its owner could. Auth off (no actor) changes all.
+    """
+    query = select(model).where(model.id.in_(ids))
+    if (clause := printer_scope.where(model.printer_id)) is not None:
+        query = query.where(clause)
+    if actor is None:
+        return query
+    # A key acts with its owner's rights to these rows, not with its own scope
+    # flags (a project key needs no archive or queue flag, #1893), so it never
+    # reaches a row its owner could not change. A legacy key without an owner
+    # keeps every row of its printers.
+    user = actor.owner if isinstance(actor, ApiKeyActor) else actor
+    if user is None or user.has_permission(all_permission.value):
+        return query
+    if user.has_permission(own_permission.value):
+        return query.where(model.created_by_id == user.id)
+    return None
+
+
 @router.post("/{project_id}/add-archives")
 async def add_archives_to_project(
     project_id: int,
     data: BatchAddArchives,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
     """Batch add archives to a project."""
     # Verify project exists
@@ -1088,16 +1128,19 @@ async def add_archives_to_project(
     if not result.scalar_one_or_none():
         raise HTTPException(status_code=404, detail="Project not found")
 
-    # Update archives
-    updated = 0
-    for archive_id in data.archive_ids:
-        result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-        archive = result.scalar_one_or_none()
-        if archive:
-            archive.project_id = project_id
-            updated += 1
+    query = _editable_rows(
+        PrintArchive,
+        data.archive_ids,
+        printer_scope,
+        actor,
+        Permission.ARCHIVES_UPDATE_ALL,
+        Permission.ARCHIVES_UPDATE_OWN,
+    )
+    archives = (await db.execute(query)).scalars().all() if query is not None else []
+    for archive in archives:
+        archive.project_id = project_id
 
-    return {"message": f"Added {updated} archives to project"}
+    return {"message": f"Added {len(archives)} archives to project", "count": len(archives)}
 
 
 @router.post("/{project_id}/add-queue")
@@ -1106,6 +1149,8 @@ async def add_queue_items_to_project(
     data: BatchAddQueueItems,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
     """Batch add queue items to a project."""
     # Verify project exists
@@ -1113,16 +1158,19 @@ async def add_queue_items_to_project(
     if not result.scalar_one_or_none():
         raise HTTPException(status_code=404, detail="Project not found")
 
-    # Update queue items
-    updated = 0
-    for item_id in data.queue_item_ids:
-        result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
-        item = result.scalar_one_or_none()
-        if item:
-            item.project_id = project_id
-            updated += 1
+    query = _editable_rows(
+        PrintQueueItem,
+        data.queue_item_ids,
+        printer_scope,
+        actor,
+        Permission.QUEUE_UPDATE_ALL,
+        Permission.QUEUE_UPDATE_OWN,
+    )
+    items = (await db.execute(query)).scalars().all() if query is not None else []
+    for item in items:
+        item.project_id = project_id
 
-    return {"message": f"Added {updated} queue items to project"}
+    return {"message": f"Added {len(items)} queue items to project", "count": len(items)}
 
 
 @router.post("/{project_id}/remove-archives")
@@ -1131,22 +1179,27 @@ async def remove_archives_from_project(
     data: BatchAddArchives,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
     """Remove archives from a project (sets project_id to NULL)."""
-    updated = 0
-    for archive_id in data.archive_ids:
-        result = await db.execute(
-            select(PrintArchive).where(
-                PrintArchive.id == archive_id,
-                PrintArchive.project_id == project_id,
-            )
-        )
-        archive = result.scalar_one_or_none()
-        if archive:
-            archive.project_id = None
-            updated += 1
+    query = _editable_rows(
+        PrintArchive,
+        data.archive_ids,
+        printer_scope,
+        actor,
+        Permission.ARCHIVES_UPDATE_ALL,
+        Permission.ARCHIVES_UPDATE_OWN,
+    )
+    archives = (
+        (await db.execute(query.where(PrintArchive.project_id == project_id))).scalars().all()
+        if query is not None
+        else []
+    )
+    for archive in archives:
+        archive.project_id = None
 
-    return {"message": f"Removed {updated} archives from project"}
+    return {"message": f"Removed {len(archives)} archives from project", "count": len(archives)}
 
 
 def get_project_attachments_dir(project_id: int) -> Path:

+ 219 - 0
backend/tests/integration/test_project_batch_scope.py

@@ -0,0 +1,219 @@
+"""Adding archives or queue jobs to a project keeps to the caller's printers and rows.
+
+The project batch routes (add-archives, remove-archives, add-queue) loaded
+each row by id alone. A member of a group limited to printer A could link
+another team's archives and jobs to a project, or unlink them, and the
+"Added N" count told them which hidden ids exist. They also skipped the
+ownership rule of the archive and queue edits, so an Operator could relink
+other users' prints. Rows outside the caller's printers or ownership are now
+skipped like missing ones. An API key changes what its owner could, on its
+own printers.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.tests.integration.test_printer_scope_1727 import TEAM_PERMISSIONS, _admin_token, _auth, _group, _user
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+EDITOR = [*TEAM_PERMISSIONS, "projects:read", "projects:update", "archives:update_all", "queue:update_all"]
+
+
+async def _member(client: AsyncClient, admin: str, name: str, printer_ids: list[int] | None, permissions: list[str]):
+    groups = [await _group(client, admin, f"perms_{name}", permissions=permissions)]
+    if printer_ids is not None:
+        groups.append(await _group(client, admin, f"team_{name}", printer_ids=printer_ids))
+    jwt, user_id = await _user(client, admin, name, groups)
+    return _auth(jwt), user_id
+
+
+async def _project(client: AsyncClient, admin: str) -> int:
+    response = await client.post("/api/v1/projects/", headers=_auth(admin), json={"name": "P"})
+    assert response.status_code == 200, response.text
+    return response.json()["id"]
+
+
+async def _archive_projects(db_session, *ids: int) -> list[int | None]:
+    db_session.expire_all()
+    rows = dict((await db_session.execute(select(PrintArchive.id, PrintArchive.project_id))).all())
+    return [rows[i] for i in ids]
+
+
+async def _queue_item(db_session, printer_id: int, archive_id: int, created_by_id: int | None = None) -> int:
+    item = PrintQueueItem(printer_id=printer_id, archive_id=archive_id, created_by_id=created_by_id)
+    db_session.add(item)
+    await db_session.commit()
+    return item.id
+
+
+async def test_a_member_adds_only_archives_of_the_team_printers(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session
+):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    admin = await _admin_token(async_client)
+    member, _ = await _member(async_client, admin, "member", [a.id], EDITOR)
+    project = await _project(async_client, admin)
+    on_a, on_b = await archive_factory(a.id), await archive_factory(b.id)
+
+    response = await async_client.post(
+        f"/api/v1/projects/{project}/add-archives", headers=member, json={"archive_ids": [on_a.id, on_b.id]}
+    )
+
+    assert response.status_code == 200, response.text
+    assert response.json()["message"] == "Added 1 archives to project"
+    assert await _archive_projects(db_session, on_a.id, on_b.id) == [project, None]
+
+
+async def test_a_member_cannot_remove_archives_of_other_printers(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session
+):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    admin = await _admin_token(async_client)
+    member, _ = await _member(async_client, admin, "member", [a.id], EDITOR)
+    project = await _project(async_client, admin)
+    on_a, on_b = await archive_factory(a.id), await archive_factory(b.id)
+    added = await async_client.post(
+        f"/api/v1/projects/{project}/add-archives", headers=_auth(admin), json={"archive_ids": [on_a.id, on_b.id]}
+    )
+    assert added.json()["message"] == "Added 2 archives to project"
+
+    response = await async_client.post(
+        f"/api/v1/projects/{project}/remove-archives", headers=member, json={"archive_ids": [on_a.id, on_b.id]}
+    )
+
+    assert response.json()["message"] == "Removed 1 archives from project"
+    assert await _archive_projects(db_session, on_a.id, on_b.id) == [None, project]
+
+
+async def test_a_member_adds_only_queue_jobs_of_the_team_printers(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session
+):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    admin = await _admin_token(async_client)
+    member, _ = await _member(async_client, admin, "member", [a.id], EDITOR)
+    project = await _project(async_client, admin)
+    job_a = await _queue_item(db_session, a.id, (await archive_factory(a.id)).id)
+    job_b = await _queue_item(db_session, b.id, (await archive_factory(b.id)).id)
+
+    response = await async_client.post(
+        f"/api/v1/projects/{project}/add-queue", headers=member, json={"queue_item_ids": [job_a, job_b]}
+    )
+
+    assert response.json()["message"] == "Added 1 queue items to project"
+    db_session.expire_all()
+    rows = dict((await db_session.execute(select(PrintQueueItem.id, PrintQueueItem.project_id))).all())
+    assert [rows[job_a], rows[job_b]] == [project, None]
+
+
+async def test_with_own_permissions_only_own_rows_are_linked(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session
+):
+    a = await printer_factory(name="A")
+    admin = await _admin_token(async_client)
+    own_perms = [*TEAM_PERMISSIONS, "projects:read", "projects:update", "archives:update_own", "queue:update_own"]
+    operator, operator_id = await _member(async_client, admin, "operator", None, own_perms)
+    project = await _project(async_client, admin)
+    mine = await archive_factory(a.id, created_by_id=operator_id)
+    theirs = await archive_factory(a.id)
+    my_job = await _queue_item(db_session, a.id, mine.id, created_by_id=operator_id)
+    their_job = await _queue_item(db_session, a.id, theirs.id)
+
+    archives = await async_client.post(
+        f"/api/v1/projects/{project}/add-archives", headers=operator, json={"archive_ids": [mine.id, theirs.id]}
+    )
+    jobs = await async_client.post(
+        f"/api/v1/projects/{project}/add-queue", headers=operator, json={"queue_item_ids": [my_job, their_job]}
+    )
+
+    assert archives.json()["message"] == "Added 1 archives to project"
+    assert jobs.json()["message"] == "Added 1 queue items to project"
+    assert await _archive_projects(db_session, mine.id, theirs.id) == [project, None]
+
+
+async def test_without_archive_permissions_nothing_is_linked(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session
+):
+    a = await printer_factory(name="A")
+    admin = await _admin_token(async_client)
+    editor, editor_id = await _member(
+        async_client, admin, "editor", None, [*TEAM_PERMISSIONS, "projects:read", "projects:update"]
+    )
+    project = await _project(async_client, admin)
+    own = await archive_factory(a.id, created_by_id=editor_id)
+
+    response = await async_client.post(
+        f"/api/v1/projects/{project}/add-archives", headers=editor, json={"archive_ids": [own.id]}
+    )
+
+    assert response.json()["message"] == "Added 0 archives to project"
+    assert await _archive_projects(db_session, own.id) == [None]
+
+
+async def test_a_project_key_links_archives_of_its_printers(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session
+):
+    # A key has no per-row owner: past its project gate it may link any archive
+    # of its printers, as API keys did before (#1893).
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    admin = await _admin_token(async_client)
+    project = await _project(async_client, admin)
+    on_a, on_b = await archive_factory(a.id), await archive_factory(b.id)
+    created = await async_client.post(
+        "/api/v1/api-keys/",
+        headers=_auth(admin),
+        json={"name": "k", "can_manage_projects": True, "printer_ids": [a.id]},
+    )
+    assert created.status_code in (200, 201), created.text
+    key = {"X-API-Key": created.json()["key"]}
+
+    response = await async_client.post(
+        f"/api/v1/projects/{project}/add-archives", headers=key, json={"archive_ids": [on_a.id, on_b.id]}
+    )
+
+    assert response.status_code == 200, response.text
+    assert response.json()["message"] == "Added 1 archives to project"
+    assert await _archive_projects(db_session, on_a.id, on_b.id) == [project, None]
+
+
+async def test_auth_off_links_every_archive(async_client: AsyncClient, printer_factory, archive_factory, db_session):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    project = (await async_client.post("/api/v1/projects/", json={"name": "P"})).json()["id"]
+    on_a, on_b = await archive_factory(a.id), await archive_factory(b.id)
+
+    response = await async_client.post(
+        f"/api/v1/projects/{project}/add-archives", json={"archive_ids": [on_a.id, on_b.id]}
+    )
+
+    assert response.json()["message"] == "Added 2 archives to project"
+
+
+async def test_a_key_links_only_what_its_owner_could(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session
+):
+    # A key acts with its owner's rights: an owner who may change only their own
+    # archives cannot relink everyone's through a project key.
+    a = await printer_factory(name="A")
+    admin = await _admin_token(async_client)
+    own_perms = [*TEAM_PERMISSIONS, "projects:read", "projects:update", "archives:update_own"]
+    operator, operator_id = await _member(async_client, admin, "operator", None, own_perms)
+    project = await _project(async_client, admin)
+    mine, theirs = await archive_factory(a.id, created_by_id=operator_id), await archive_factory(a.id)
+    created = await async_client.post(
+        "/api/v1/api-keys/", headers=operator, json={"name": "k", "can_manage_projects": True}
+    )
+    assert created.status_code in (200, 201), created.text
+    key = {"X-API-Key": created.json()["key"]}
+
+    response = await async_client.post(
+        f"/api/v1/projects/{project}/add-archives", headers=key, json={"archive_ids": [mine.id, theirs.id]}
+    )
+
+    assert response.status_code == 200, response.text
+    assert response.json()["count"] == 1
+    assert await _archive_projects(db_session, mine.id, theirs.id) == [project, None]

+ 54 - 0
frontend/src/__tests__/components/BatchProjectModal.test.tsx

@@ -0,0 +1,54 @@
+/**
+ * BatchProjectModal: the "Added N" toast counts what the server added. The
+ * server skips archives the user may not change (other teams' printers, other
+ * users' prints), so the number selected can be higher.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BatchProjectModal } from '../../components/BatchProjectModal';
+import { api } from '../../api/client';
+
+const mockShowToast = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getProjects: vi.fn(),
+    addArchivesToProject: vi.fn(),
+    updateArchive: vi.fn(),
+  },
+}));
+
+vi.mock('../../contexts/ToastContext', () => ({
+  useToast: () => ({ showToast: mockShowToast }),
+}));
+
+describe('BatchProjectModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    (api.getProjects as ReturnType<typeof vi.fn>).mockResolvedValue([
+      { id: 7, name: 'Voron', status: 'active', parent_id: null },
+    ]);
+  });
+
+  it('reports the number of archives the server added, not the number selected', async () => {
+    (api.addArchivesToProject as ReturnType<typeof vi.fn>).mockResolvedValue({
+      message: 'Added 1 archives to project',
+      count: 1,
+    });
+    const user = userEvent.setup();
+    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    render(
+      <QueryClientProvider client={client}>
+        <BatchProjectModal selectedIds={[1, 2, 3]} onClose={vi.fn()} />
+      </QueryClientProvider>,
+    );
+
+    await user.click(await screen.findByText('Voron'));
+
+    await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Added 1 archive to "Voron"'));
+    expect(api.addArchivesToProject).toHaveBeenCalledWith(7, [1, 2, 3]);
+  });
+});

+ 3 - 3
frontend/src/api/client.ts

@@ -7592,17 +7592,17 @@ export const api = {
   getProjectFileProgress: (id: number) =>
     request<ProjectFileProgress[]>(`/projects/${id}/file-progress`),
   addArchivesToProject: (projectId: number, archiveIds: number[]) =>
-    request<{ message: string }>(`/projects/${projectId}/add-archives`, {
+    request<{ message: string; count: number }>(`/projects/${projectId}/add-archives`, {
       method: 'POST',
       body: JSON.stringify({ archive_ids: archiveIds }),
     }),
   removeArchivesFromProject: (projectId: number, archiveIds: number[]) =>
-    request<{ message: string }>(`/projects/${projectId}/remove-archives`, {
+    request<{ message: string; count: number }>(`/projects/${projectId}/remove-archives`, {
       method: 'POST',
       body: JSON.stringify({ archive_ids: archiveIds }),
     }),
   addQueueItemsToProject: (projectId: number, queueItemIds: number[]) =>
-    request<{ message: string }>(`/projects/${projectId}/add-queue`, {
+    request<{ message: string; count: number }>(`/projects/${projectId}/add-queue`, {
       method: 'POST',
       body: JSON.stringify({ queue_item_ids: queueItemIds }),
     }),

+ 5 - 4
frontend/src/components/BatchProjectModal.tsx

@@ -54,13 +54,14 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro
   // Assign to project mutation (uses bulk API)
   const assignMutation = useMutation({
     mutationFn: async (projectId: number) => {
-      await api.addArchivesToProject(projectId, selectedIds);
-      return projectId;
+      // The server skips archives the user may not change, so count what it added.
+      const { count } = await api.addArchivesToProject(projectId, selectedIds);
+      return { projectId, count };
     },
-    onSuccess: (projectId) => {
+    onSuccess: ({ projectId, count }) => {
       const project = projects?.find(p => p.id === projectId);
       invalidateProjectQueries();
-      showToast(`Added ${selectedIds.length} archive${selectedIds.length !== 1 ? 's' : ''} to "${project?.name}"`);
+      showToast(`Added ${count} archive${count !== 1 ? 's' : ''} to "${project?.name}"`);
       onClose();
     },
     onError: () => {

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/AdditionalSection-DOaA1ogX.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/AmsUnitCard-CH5Gi1nq.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ArchivesPage-BtNYc-Nc.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/AssignToAmsModal-DhXXEo3X.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CamWallPage-Au49U00l.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CameraDiagnoseModal-U9eyEtl-.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CameraPage-Dkg-9uUn.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CameraWall-DgnWcKDA.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ColumnConfigModal-BTS1yD9I.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ConfigureAmsSlotModal-B5pE6Zfr.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ConnectAuthorizePage-D9RPGvWH.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ContextMenu-DMk9p_XF.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ExternalLinkPage-1RdxOGZl.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
static/assets/FileManagerPage-Dnaslp0X.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/FileUploadModal-BXCTtyUn.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/FinancePage-Vt1lDiLj.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/GCodeViewerPage-CqX9z-_z.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/GroupEditPage-CZ2hu2kI.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ImagePreviewModal-BGamb9b3.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/InventoryPage-CcaydE-S.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/LibraryTrashPage-BRlliPjC.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/LoginPage-CMYyUAzm.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/MaintenancePage-D3DLDIvy.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ModelSourcesPage-BCvfxJ4I.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/NotificationsPage-BJhtRnqP.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
static/assets/PdfPreviewModal-Bcc7uHle.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/PhotoGalleryModal-BWbC1uoq.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/PrinterLocationsPage-CapmAvY4.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/PrintersPage-CcwtkuMj.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ProfilesPage-BlN_eyk3.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ProjectDetailPage-CDWilXQD.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ProjectsPage-C6wlEQvM.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/QueuePage-CkeOWEYK.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/RichTextEditor-AOxRdNUX.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SettingsPage-rwaWS9xc.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SetupPage-DhZzhseq.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SliceModal-LzMNWWiQ.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpoolBuddyAmsPage-g56fHshE.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-Bz42Mk4a.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpoolBuddyDashboard-JEnI2rHd.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-CCiY-_Zy.js


Разлика између датотеке није приказан због своје велике величине
+ 1 - 1
static/assets/SpoolBuddySettingsPage-XCnoWigr.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-BBj1PtVy.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpreadsheetPreviewModal-CZbfnqXn.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/StatsPage-CNRzp-YL.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/StreamOverlayPage-C1H7Ytkr.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SystemInfoPage-BWv43tk8.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-Cd25WO_Z.js


+ 1 - 1
static/assets/inventorySearch-BXbelz8C.js → static/assets/inventorySearch-CdflUdPi.js

@@ -1 +1 @@
-import{ft as e}from"./index-fwznsy2U.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-Cd25WO_Z.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/locationSensorDefaults-DY_WP5e0.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/pdf-CaysVT1M.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/utils-kA_cA74a.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-fwznsy2U.js"></script>
+    <script type="module" crossorigin src="/assets/index-Cd25WO_Z.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-C3LdD0iY.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-DzWmuIjj.js">

Неке датотеке нису приказане због велике количине промена