diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 0496148a4..c5eea9f86 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -85,7 +85,7 @@ jobs:
- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v4
- if: always()
+ if: always() && hashFiles('trivy-results.sarif') != ''
with:
sarif_file: trivy-results.sarif
category: trivy
@@ -102,7 +102,7 @@ jobs:
- name: Upload Trivy config results
uses: github/codeql-action/upload-sarif@v4
- if: always()
+ if: always() && hashFiles('trivy-config-results.sarif') != ''
with:
sarif_file: trivy-config-results.sarif
category: trivy-config
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce7c5e63f..2803ef7d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,10 +14,12 @@ All notable changes to Bambuddy will be documented in this file.
- **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.
### New Features
+- **Filament Cost Tracking** ([#454](https://github.com/maziggy/bambuddy/pull/454), [#452](https://github.com/maziggy/bambuddy/issues/452)) — Track per-spool filament costs and see cost breakdowns for every print. Each spool can have a `cost_per_kg` value; when a print completes, the usage tracker calculates the cost from actual filament consumption and stores it in the usage history. Archive costs are automatically aggregated from spool usage records. A global `default_filament_cost` setting (Settings → Filament) provides a fallback when spools don't have individual costs set. The print modal shows a real-time cost preview based on loaded filaments. Archive cards display the total cost. The inventory table includes a sortable cost/kg column. The recalculate-costs endpoint can retroactively update all archive costs when filament prices change. Contributed by @Keybored02.
- **Background Print Dispatch** ([#408](https://github.com/maziggy/bambuddy/pull/408), [#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
- **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
### Improved
+- **Filament Cost Tracking Test Coverage** — Added 2 backend unit tests for archive cost aggregation (zero-cost guard preserves existing costs, positive-cost updates archive correctly). Added 2 frontend unit tests for spool form cost_per_kg persistence. Fixed missing `archive_id` database migration, SQLAlchemy `is None` → `.is_(None)` in where clauses, duplicate archive cost write, and unconditional zero-cost overwrite.
- **Spool Assignment Snapshot Test Coverage** — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.
- **Background Dispatch Test Coverage** — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.
- **Frontend Pre-Commit Hooks** ([#458](https://github.com/maziggy/bambuddy/issues/458)) — Added `frontend-typecheck` (`tsc --noEmit`) and `frontend-lint` (`eslint .`) hooks to the pre-commit config. Both hooks only trigger when `frontend/src/**/*.{ts,tsx}` files are staged.
diff --git a/README.md b/README.md
index bc443052d..8ca049555 100644
--- a/README.md
+++ b/README.md
@@ -155,6 +155,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- Built-in spool inventory with AMS slot assignment, usage tracking, and remaining weight management
- Automatic filament consumption tracking: 3MF slicer estimates for all spools (primary), AMS remain% delta as fallback
- Per-layer gcode accuracy for partial prints (failed/cancelled), with linear scaling fallback
+- **Per-spool cost tracking** — Set cost/kg on each spool; costs are automatically calculated at print completion and aggregated to archives. Print modal shows real-time cost preview. Configurable default cost and currency in Settings.
- Spool catalog, color catalog, PA profile matching, and low-stock alerts
### 🔧 Integrations
diff --git a/backend/tests/integration/test_cost_statistics.py b/backend/tests/integration/test_cost_statistics.py
index 5e58cda1d..4c54dd5fa 100644
--- a/backend/tests/integration/test_cost_statistics.py
+++ b/backend/tests/integration/test_cost_statistics.py
@@ -324,7 +324,7 @@ class TestCostCalculationScenarios:
async def test_archive_cost_with_archive_id_and_print_name(
self, async_client, archive_factory, printer_factory, db_session
):
- """Test archive cost calculation using both archive_id and print_name fallback."""
+ """Test archive cost recalculation using both archive_id and print_name fallback."""
from backend.app.models.spool import Spool
from backend.app.models.spool_usage_history import SpoolUsageHistory
@@ -357,13 +357,6 @@ class TestCostCalculationScenarios:
status="completed",
cost=None,
)
- # Create dummy file for archive_new
- import os
-
- if hasattr(archive_new, "file_path") and archive_new.file_path:
- os.makedirs(os.path.dirname(archive_new.file_path), exist_ok=True)
- with open(archive_new.file_path, "w") as f:
- f.write("dummy content")
history_new = SpoolUsageHistory(
spool_id=spool_new.id,
@@ -377,19 +370,13 @@ class TestCostCalculationScenarios:
)
db_session.add(history_new)
- # Create archive with old SpoolUsageHistory (archive_id NULL)
+ # Create archive with old SpoolUsageHistory (archive_id NULL — legacy record)
archive_old = await archive_factory(
printer.id,
print_name="LegacyPrint",
status="completed",
cost=None,
)
- # Create dummy file for archive_old
- if hasattr(archive_old, "file_path") and archive_old.file_path:
- os.makedirs(os.path.dirname(archive_old.file_path), exist_ok=True)
- with open(archive_old.file_path, "w") as f:
- f.write("dummy content")
- # Explicitly set filament_used_grams for archive_old
archive_old.filament_used_grams = 30.0
await db_session.commit()
@@ -407,20 +394,19 @@ class TestCostCalculationScenarios:
await db_session.commit()
- # Rescan both archives
- response_new = await async_client.post(f"/api/v1/archives/{archive_new.id}/rescan")
- response_old = await async_client.post(f"/api/v1/archives/{archive_old.id}/rescan")
-
- assert response_new.status_code == 200
- assert response_new.json()["cost"] == 0.50
- assert response_old.status_code == 200
- # Legacy fallback: sum all SpoolUsageHistory costs for print_name/printer_id (0.45 + 0.30 = 0.75)
- assert response_old.json()["cost"] == 0.75
-
- # Check recalculate_all_costs endpoint
+ # Recalculate costs for all archives
recalc_response = await async_client.post("/api/v1/archives/recalculate-costs")
assert recalc_response.status_code == 200
- # Accept 0 or more updated archives for practical robustness
- assert recalc_response.json()["updated"] >= 0
+ assert recalc_response.json()["updated"] >= 1
+
+ # Verify archive_new cost from archive_id-linked SpoolUsageHistory
+ response_new = await async_client.get(f"/api/v1/archives/{archive_new.id}")
+ assert response_new.status_code == 200
+ assert response_new.json()["cost"] == 0.50
+
+ # Verify archive_old cost from legacy print_name fallback
+ response_old = await async_client.get(f"/api/v1/archives/{archive_old.id}")
+ assert response_old.status_code == 200
+ assert response_old.json()["cost"] == 0.45
await db_session.rollback()
diff --git a/backend/tests/unit/services/test_usage_tracker.py b/backend/tests/unit/services/test_usage_tracker.py
index 260dc1a6f..aad8cd890 100644
--- a/backend/tests/unit/services/test_usage_tracker.py
+++ b/backend/tests/unit/services/test_usage_tracker.py
@@ -27,6 +27,8 @@ def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uu
spool.tag_uid = tag_uid
spool.tray_uuid = tray_uuid
spool.last_used = None
+ spool.cost_per_kg = None
+ spool.material = "PLA"
return spool
@@ -112,6 +114,15 @@ class TestOnPrintCompleteAMSDelta:
yield
_active_sessions.clear()
+ @pytest.fixture(autouse=True)
+ def _mock_get_setting(self):
+ with patch(
+ "backend.app.api.routes.settings.get_setting",
+ new_callable=AsyncMock,
+ return_value=None,
+ ):
+ yield
+
@pytest.mark.asyncio
async def test_computes_delta_and_updates_spool(self):
"""Spool weight_used updated by remain% delta * label_weight."""
@@ -414,6 +425,15 @@ class TestSpoolAssignmentSnapshot:
yield
_active_sessions.clear()
+ @pytest.fixture(autouse=True)
+ def _mock_get_setting(self):
+ with patch(
+ "backend.app.api.routes.settings.get_setting",
+ new_callable=AsyncMock,
+ return_value=None,
+ ):
+ yield
+
@pytest.mark.asyncio
async def test_on_print_start_snapshots_assignments_with_db(self):
"""on_print_start captures spool assignments when db is provided."""
@@ -637,7 +657,7 @@ class TestSpoolAssignmentSnapshot:
filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
- # db: archive, queue_item(None), spool
+ # db: archive, queue_item(None), spool, then cost aggregation queries
# NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
db = AsyncMock()
db.execute = AsyncMock(
@@ -645,6 +665,9 @@ class TestSpoolAssignmentSnapshot:
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+ # Cost aggregation: sum query (uses .scalar()), archive lookup
+ MagicMock(scalar=MagicMock(return_value=0)),
+ MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
]
)
diff --git a/backend/tests/unit/test_cost_tracking.py b/backend/tests/unit/test_cost_tracking.py
index d579ff909..115e9ffa2 100644
--- a/backend/tests/unit/test_cost_tracking.py
+++ b/backend/tests/unit/test_cost_tracking.py
@@ -497,16 +497,13 @@ class TestCostAggregation:
"""Tests for cost aggregation to PrintArchive."""
@pytest.mark.asyncio
- async def test_costs_summed_in_archive(self):
- """Multiple spool costs are summed when aggregated to archive."""
- # This test would need to mock the full main.py flow
- # For now, we verify the results dict structure includes cost
+ async def test_costs_summed_in_results(self):
+ """Multiple spool costs are correctly summed from result dicts."""
results = [
{"spool_id": 1, "weight_used": 20.0, "cost": 0.50},
{"spool_id": 2, "weight_used": 30.0, "cost": 0.75},
]
- # Simulate aggregation logic from main.py
total_cost = sum(r.get("cost", 0) or 0 for r in results)
assert total_cost == 1.25
@@ -519,10 +516,184 @@ class TestCostAggregation:
{"spool_id": 3, "weight_used": 10.0, "cost": 0.25},
]
- # Aggregation should handle None gracefully
total_cost = sum(r.get("cost", 0) or 0 for r in results)
assert total_cost == 0.75 # Only spools 1 and 3
+ @pytest.mark.asyncio
+ async def test_archive_cost_not_overwritten_with_zero(self):
+ """archive.cost is preserved when no spool usage has cost data."""
+ # Spool without cost_per_kg, default_filament_cost also 0 → cost=None per usage
+ spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)
+ assignment = _make_assignment(spool_id=1)
+ archive = _make_archive(archive_id=10)
+ archive.cost = 5.00 # Pre-existing cost from catalog
+ archive.print_name = "TestPrint"
+ archive.printer_id = 1
+
+ _active_sessions[1] = PrintSession(
+ printer_id=1,
+ print_name="TestPrint",
+ started_at=datetime.now(timezone.utc),
+ tray_remain_start={(0, 0): 80},
+ tray_now_at_start=0,
+ )
+
+ printer_manager = MagicMock()
+ printer_manager.get_status.return_value = SimpleNamespace(
+ raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+ progress=100,
+ layer_num=50,
+ tray_now=0,
+ )
+
+ # Build mock db that returns proper scalars for the aggregation queries
+ responses = []
+ # 1. select(PrintArchive) → archive
+ responses.append(("scalar_one_or_none", archive))
+ # 2. select(PrintQueueItem) → None
+ responses.append(("scalar_one_or_none", None))
+ # 3. select(SpoolAssignment) → assignment
+ responses.append(("scalar_one_or_none", assignment))
+ # 4. select(Spool) → spool
+ responses.append(("scalar_one_or_none", spool))
+ # 5. cost aggregation: coalesce(sum(cost)) → 0 (no costs)
+ responses.append(("scalar", 0))
+ # 6. select(PrintArchive) → archive (for the guard check)
+ responses.append(("scalar_one_or_none", archive))
+ # 7. legacy fallback: coalesce(sum(cost)) → 0
+ responses.append(("scalar", 0))
+
+ db = AsyncMock()
+ call_count = [0]
+
+ async def mock_execute(*args, **kwargs):
+ idx = call_count[0]
+ call_count[0] += 1
+ result = MagicMock()
+ if idx < len(responses):
+ method, value = responses[idx]
+ if method == "scalar":
+ result.scalar.return_value = value
+ result.scalar_one_or_none.return_value = value
+ else:
+ result.scalar_one_or_none.return_value = value
+ result.scalar.return_value = value
+ else:
+ result.scalar_one_or_none.return_value = None
+ result.scalar.return_value = None
+ return result
+
+ db.execute = mock_execute
+
+ filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
+
+ with (
+ patch("backend.app.core.config.settings") as mock_settings,
+ patch("backend.app.api.routes.settings.get_setting", return_value="0.0"), # no default cost
+ patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+ ):
+ mock_settings.base_dir = MagicMock()
+ mock_path = MagicMock()
+ mock_path.exists.return_value = True
+ mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+ results = await on_print_complete(
+ printer_id=1,
+ data={"status": "completed"},
+ printer_manager=printer_manager,
+ db=db,
+ archive_id=10,
+ )
+
+ # Usage tracked but cost is None (no cost_per_kg, no default)
+ assert len(results) == 1
+ assert results[0]["cost"] is None
+
+ # Archive cost should NOT have been overwritten — still 5.00
+ assert archive.cost == 5.00
+
+ @pytest.mark.asyncio
+ async def test_archive_cost_set_when_spool_has_cost(self):
+ """archive.cost is set from spool usage when cost data exists."""
+ spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)
+ assignment = _make_assignment(spool_id=1)
+ archive = _make_archive(archive_id=10)
+ archive.cost = None # No pre-existing cost
+ archive.print_name = "TestPrint"
+ archive.printer_id = 1
+
+ _active_sessions[1] = PrintSession(
+ printer_id=1,
+ print_name="TestPrint",
+ started_at=datetime.now(timezone.utc),
+ tray_remain_start={(0, 0): 80},
+ tray_now_at_start=0,
+ )
+
+ printer_manager = MagicMock()
+ printer_manager.get_status.return_value = SimpleNamespace(
+ raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+ progress=100,
+ layer_num=50,
+ tray_now=0,
+ )
+
+ # 20g at 25/kg = 0.50
+ expected_cost = 0.50
+
+ responses = []
+ responses.append(("scalar_one_or_none", archive))
+ responses.append(("scalar_one_or_none", None)) # queue item
+ responses.append(("scalar_one_or_none", assignment))
+ responses.append(("scalar_one_or_none", spool))
+ # cost aggregation: sum returns 0.50
+ responses.append(("scalar", expected_cost))
+ # select archive for guard
+ responses.append(("scalar_one_or_none", archive))
+
+ db = AsyncMock()
+ call_count = [0]
+
+ async def mock_execute(*args, **kwargs):
+ idx = call_count[0]
+ call_count[0] += 1
+ result = MagicMock()
+ if idx < len(responses):
+ method, value = responses[idx]
+ result.scalar.return_value = value
+ result.scalar_one_or_none.return_value = value
+ else:
+ result.scalar_one_or_none.return_value = None
+ result.scalar.return_value = None
+ return result
+
+ db.execute = mock_execute
+
+ filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
+
+ with (
+ patch("backend.app.core.config.settings") as mock_settings,
+ patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
+ patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+ ):
+ mock_settings.base_dir = MagicMock()
+ mock_path = MagicMock()
+ mock_path.exists.return_value = True
+ mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+ results = await on_print_complete(
+ printer_id=1,
+ data={"status": "completed"},
+ printer_manager=printer_manager,
+ db=db,
+ archive_id=10,
+ )
+
+ assert len(results) == 1
+ assert results[0]["cost"] == expected_cost
+ # Archive cost should have been updated
+ assert archive.cost == expected_cost
+
@pytest.mark.asyncio
async def test_cost_with_archive_id(self):
"""Test cost aggregation using archive_id (3MF path)."""
diff --git a/backend/tests/unit/test_usage_tracker.py b/backend/tests/unit/test_usage_tracker.py
index 1628fe6bb..91a685222 100644
--- a/backend/tests/unit/test_usage_tracker.py
+++ b/backend/tests/unit/test_usage_tracker.py
@@ -31,6 +31,8 @@ def _make_spool(spool_id=1, label_weight=1000, weight_used=0, tag_uid=None, tray
spool.tag_uid = tag_uid
spool.tray_uuid = tray_uuid
spool.last_used = None
+ spool.cost_per_kg = None
+ spool.material = "PLA"
return spool
@@ -86,6 +88,8 @@ def _mock_db_sequential(responses):
result.scalar_one_or_none.return_value = responses[idx]
else:
result.scalar_one_or_none.return_value = None
+ # For cost aggregation queries that use .scalar() instead of .scalar_one_or_none()
+ result.scalar.return_value = None
return result
db.execute = mock_execute
@@ -167,6 +171,15 @@ class TestOnPrintComplete:
yield
_active_sessions.clear()
+ @pytest.fixture(autouse=True)
+ def _mock_get_setting(self):
+ with patch(
+ "backend.app.api.routes.settings.get_setting",
+ new_callable=AsyncMock,
+ return_value=None,
+ ):
+ yield
+
@pytest.mark.asyncio
async def test_bl_spool_uses_3mf(self):
"""BL spool (with tag_uid) is tracked via 3MF, not just AMS delta."""
diff --git a/frontend/src/__tests__/components/SpoolFormModal.test.tsx b/frontend/src/__tests__/components/SpoolFormModal.test.tsx
index 821f1ac2e..c356cda03 100644
--- a/frontend/src/__tests__/components/SpoolFormModal.test.tsx
+++ b/frontend/src/__tests__/components/SpoolFormModal.test.tsx
@@ -93,6 +93,7 @@ describe('SpoolFormModal weightTouched', () => {
isOpen={true}
onClose={vi.fn()}
spool={existingSpool}
+ currencySymbol="$"
/>
);
@@ -124,6 +125,7 @@ describe('SpoolFormModal weightTouched', () => {
isOpen={true}
onClose={vi.fn()}
spool={existingSpool}
+ currencySymbol="$"
/>
);
@@ -158,6 +160,7 @@ describe('SpoolFormModal weightTouched', () => {
);
@@ -196,6 +199,7 @@ describe('SpoolFormModal weightTouched', () => {
isOpen={true}
onClose={vi.fn()}
spool={spoolWithCatalogId}
+ currencySymbol="$"
/>
);
@@ -237,6 +241,7 @@ describe('SpoolFormModal weightTouched', () => {
);
@@ -277,6 +282,70 @@ describe('SpoolFormModal weightTouched', () => {
expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
});
+ it('preserves cost_per_kg when editing spool', async () => {
+ const spoolWithCost: InventorySpool = {
+ ...existingSpool,
+ cost_per_kg: 25.50,
+ };
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+ });
+
+ // Click Save without changing cost
+ const saveButton = screen.getByRole('button', { name: /save/i });
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(api.updateSpool).toHaveBeenCalledTimes(1);
+ });
+
+ const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+ expect(spoolId).toBe(1);
+ // cost_per_kg should be preserved in the update payload
+ expect(payload).toHaveProperty('cost_per_kg', 25.50);
+ });
+
+ it('sends null cost_per_kg when spool has no cost', async () => {
+ const spoolWithoutCost: InventorySpool = {
+ ...existingSpool,
+ cost_per_kg: null,
+ };
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+ });
+
+ const saveButton = screen.getByRole('button', { name: /save/i });
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(api.updateSpool).toHaveBeenCalledTimes(1);
+ });
+
+ const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+ // cost_per_kg should be null when not set
+ expect(payload).toHaveProperty('cost_per_kg', null);
+ });
+
it('displays correct catalog name when duplicates exist', async () => {
const spoolWithCatalogId: InventorySpool = {
...existingSpool,
@@ -297,6 +366,7 @@ describe('SpoolFormModal weightTouched', () => {
isOpen={true}
onClose={vi.fn()}
spool={spoolWithCatalogId}
+ currencySymbol="$"
/>
);