mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Bulk operations on the Inventory page in both built-in and Spoolman modes.
Reporter wanted ten-of-the-same-spool edits without ten round-trips through
the per-spool editor.
Frontend
- New checkbox column on the inventory table (header / row / group). Sticky
toolbar appears when at least one row is selected with Edit / Print labels /
Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear.
Selection clears on any filter / tab / search change so the count can't
drift from what is on screen.
- BulkEditSpoolsModal is a three-state-per-field form. The user opts in per
field by ticking its checkbox or just typing into it; only ticked + non-
empty fields are sent. Clearing fields in bulk is intentionally NOT
supported per the issue discussion.
- A new SearchableSelect renders all categorical fields (material, sub-type,
brand, category, slicer preset name, slicer filament, storage location)
with the same dropdown pattern the per-spool editor uses - text input +
chevron + filtered button list, click-outside / Escape closes. No native
select anywhere in the modal. Options merge the canonical constants from
spool-form/constants.ts with whatever already exists in the user's
inventory. Slicer-preset dropdowns fetch the same sources as the per-spool
form (Bambu Cloud + Orca Cloud + local + built-in) through buildFilament
Options() and three useQuery calls gated on isOpen.
- onSuccess handlers surface three outcomes: all-succeeded (green toast),
partial-success (yellow toast with ok / failed counts), all-failed (red
toast that keeps the selection and modal open so the user can retry).
The first cut silently dropped errors / not_found arrays - audited and
fixed before merge.
- Invalid rgba hex is flagged inline with a red border + helper text and
the Apply button is gated on a hasDroppedTickedField guard, so silently
dropping a ticked field is no longer possible.
- bulkResetConsumedCounterMutation.onSuccess now closes the confirm modal +
clears selection, matching the other three bulk mutations.
Backend
- Four new endpoints per inventory mode (eight total):
POST /api/v1/inventory/spools/bulk-update INVENTORY_UPDATE
POST /api/v1/inventory/spools/bulk-delete INVENTORY_UPDATE
POST /api/v1/inventory/spools/bulk-archive INVENTORY_UPDATE
POST /api/v1/inventory/spools/bulk-restore INVENTORY_UPDATE
POST /api/v1/spoolman/inventory/spools/bulk-* FILAMENTS_UPDATE
- Built-in update runs the same prepare_internal_spool_payload(...) +
weight_used / weight_locked auto-stamp as the per-spool PATCH.
- Spoolman update loops the per-spool update_spool route function so the
filament re-linking / extra-dict / extra-lock / shared-filament rules
stay byte-identical to single-spool edits.
- Per-spool failures inside the batch are collected. Spoolman bulk-delete /
archive / restore now catch non-HTTPException too (matches bulk-update) -
a mid-batch httpx.ConnectError or TimeoutError no longer aborts the route
with a 500 and skips the WS broadcast.
- Both modes broadcast a single inventory_changed WS event at the end of
the batch.
201 lines
6.9 KiB
Python
201 lines
6.9 KiB
Python
"""Bulk Spoolman inventory endpoint coverage for the batch-edit feature (#1795).
|
|
|
|
Endpoints under test:
|
|
- POST /api/v1/spoolman/inventory/spools/bulk-update
|
|
- POST /api/v1/spoolman/inventory/spools/bulk-delete
|
|
- POST /api/v1/spoolman/inventory/spools/bulk-archive
|
|
- POST /api/v1/spoolman/inventory/spools/bulk-restore
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from httpx import AsyncClient
|
|
|
|
SAMPLE_SPOOLMAN_SPOOL = {
|
|
"id": 42,
|
|
"filament": {
|
|
"id": 7,
|
|
"name": "PLA Basic",
|
|
"material": "PLA",
|
|
"color_hex": "FF0000",
|
|
"weight": 1000,
|
|
"vendor": {"id": 3, "name": "Bambu Lab"},
|
|
},
|
|
"remaining_weight": 750.0,
|
|
"used_weight": 250.0,
|
|
"location": "Printer1 - AMS A1",
|
|
"comment": "test note",
|
|
"first_used": "2024-01-01T00:00:00+00:00",
|
|
"last_used": "2024-02-01T00:00:00+00:00",
|
|
"registered": "2024-01-01T00:00:00+00:00",
|
|
"archived": False,
|
|
"price": None,
|
|
"extra": {},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
async def spoolman_settings(db_session):
|
|
from backend.app.models.settings import Settings
|
|
|
|
db_session.add(Settings(key="spoolman_enabled", value="true"))
|
|
db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
|
|
await db_session.commit()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_spoolman_client():
|
|
mock = MagicMock()
|
|
mock.base_url = "http://localhost:7912"
|
|
mock.health_check = AsyncMock(return_value=True)
|
|
mock.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
|
|
mock.delete_spool = AsyncMock(return_value=True)
|
|
mock.set_spool_archived = AsyncMock(
|
|
side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
|
|
)
|
|
mock.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
|
|
mock.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
|
|
mock.is_filament_shared = AsyncMock(return_value=False)
|
|
mock.patch_filament = AsyncMock(return_value={"id": 7})
|
|
mock.find_or_create_filament = AsyncMock(return_value=7)
|
|
mock.find_or_create_vendor = AsyncMock(return_value=3)
|
|
mock.ensure_extra_field = AsyncMock(return_value=True)
|
|
mock.get_distinct_locations = AsyncMock(return_value=[])
|
|
|
|
class _Lock:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
return False
|
|
|
|
mock.extra_lock = lambda spool_id: _Lock()
|
|
|
|
with (
|
|
patch(
|
|
"backend.app.api.routes.spoolman_inventory.get_spoolman_client",
|
|
AsyncMock(return_value=mock),
|
|
),
|
|
patch(
|
|
"backend.app.api.routes.spoolman_inventory.init_spoolman_client",
|
|
AsyncMock(return_value=mock),
|
|
),
|
|
):
|
|
yield mock
|
|
|
|
|
|
class TestSpoolmanBulkUpdate:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_calls_per_spool_update_for_each_id(
|
|
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
|
):
|
|
resp = await async_client.post(
|
|
"/api/v1/spoolman/inventory/spools/bulk-update",
|
|
json={"ids": [42, 43, 44], "update": {"note": "From bulk edit"}},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["updated"] == 3
|
|
assert body["errors"] == []
|
|
# update_spool route loops through each, which calls update_spool_full once per ID
|
|
assert mock_spoolman_client.update_spool_full.await_count == 3
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_collects_per_spool_errors_without_aborting_batch(
|
|
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
|
):
|
|
# First two succeed, third raises
|
|
mock_spoolman_client.update_spool_full.side_effect = [
|
|
SAMPLE_SPOOLMAN_SPOOL,
|
|
SAMPLE_SPOOLMAN_SPOOL,
|
|
HTTPException(status_code=404, detail="Spool 999 not found"),
|
|
]
|
|
|
|
resp = await async_client.post(
|
|
"/api/v1/spoolman/inventory/spools/bulk-update",
|
|
json={"ids": [42, 43, 999], "update": {"note": "Batched"}},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["updated"] == 2
|
|
assert len(body["errors"]) == 1
|
|
assert body["errors"][0]["id"] == 999
|
|
assert body["errors"][0]["status"] == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_empty_update_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
|
|
resp = await async_client.post(
|
|
"/api/v1/spoolman/inventory/spools/bulk-update",
|
|
json={"ids": [42], "update": {}},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_empty_ids_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
|
|
resp = await async_client.post(
|
|
"/api/v1/spoolman/inventory/spools/bulk-update",
|
|
json={"ids": [], "update": {"note": "X"}},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
class TestSpoolmanBulkDelete:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_deletes_listed_spools(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
|
|
resp = await async_client.post(
|
|
"/api/v1/spoolman/inventory/spools/bulk-delete",
|
|
json={"ids": [42, 43, 44]},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["deleted"] == 3
|
|
assert body["errors"] == []
|
|
assert mock_spoolman_client.delete_spool.await_count == 3
|
|
|
|
|
|
class TestSpoolmanBulkArchiveRestore:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_bulk_archive_calls_per_spool(
|
|
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
|
):
|
|
resp = await async_client.post(
|
|
"/api/v1/spoolman/inventory/spools/bulk-archive",
|
|
json={"ids": [42, 43]},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["archived"] == 2
|
|
# set_spool_archived(spool_id, archived=True) called for each id
|
|
assert mock_spoolman_client.set_spool_archived.await_count == 2
|
|
for call in mock_spoolman_client.set_spool_archived.call_args_list:
|
|
assert call.kwargs.get("archived") is True
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_bulk_restore_calls_per_spool(
|
|
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
|
):
|
|
resp = await async_client.post(
|
|
"/api/v1/spoolman/inventory/spools/bulk-restore",
|
|
json={"ids": [42, 43]},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["restored"] == 2
|
|
assert mock_spoolman_client.set_spool_archived.await_count == 2
|
|
for call in mock_spoolman_client.set_spool_archived.call_args_list:
|
|
assert call.kwargs.get("archived") is False
|