Add notification for no spool assigned for active trays, improve usage tracker logic in edge cases (#789)

Add notification for no spool assigned for active trays, improve usage tracker logic in edge cases (#789)
This commit is contained in:
Keybored
2026-03-24 11:31:32 +01:00
committed by GitHub
parent dd75c8f0c9
commit 3f7d0e2d55
28 changed files with 726 additions and 70 deletions
+1
View File
@@ -427,6 +427,7 @@ def notification_provider_factory(db_session):
"on_print_failed": True,
"on_print_stopped": True,
"on_print_progress": False,
"on_print_missing_spool_assignment": False,
"on_printer_offline": False,
"on_printer_error": False,
"on_filament_low": False,
@@ -391,6 +391,42 @@ class TestNotificationsAPI:
assert result["on_bed_cooled"] is False
assert result["on_first_layer_complete"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_provider_with_missing_spool_assignment_toggle(self, async_client: AsyncClient):
"""Verify missing spool assignment toggle persists on create."""
data = {
"name": "Missing Spool Assignment Test",
"provider_type": "ntfy",
"config": {"server": "https://ntfy.sh", "topic": "test"},
"on_print_missing_spool_assignment": True,
}
response = await async_client.post("/api/v1/notifications/", json=data)
assert response.status_code == 200
result = response.json()
assert result["on_print_missing_spool_assignment"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_missing_spool_assignment_toggle(
self, async_client: AsyncClient, notification_provider_factory, db_session
):
"""CRITICAL: Verify missing spool assignment toggle persists correctly."""
provider = await notification_provider_factory(on_print_missing_spool_assignment=False)
response = await async_client.patch(
f"/api/v1/notifications/{provider.id}",
json={"on_print_missing_spool_assignment": True},
)
assert response.status_code == 200
assert response.json()["on_print_missing_spool_assignment"] is True
response = await async_client.get(f"/api/v1/notifications/{provider.id}")
assert response.json()["on_print_missing_spool_assignment"] is True
class TestNotificationTemplatesAPI:
"""Integration tests for /api/v1/notification-templates/ endpoints."""
@@ -0,0 +1,77 @@
"""Unit tests for spool assignment notification service."""
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.services.spool_assignment_notifications import notify_missing_spool_assignments_on_print_start
class _FakeAssignmentsResult:
def __init__(self, rows):
self._rows = rows
def fetchall(self):
return self._rows
class _FakeSession:
def __init__(self, printer_name: str, assignments: list[SimpleNamespace]):
self._printer = SimpleNamespace(name=printer_name)
self._assignments = assignments
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, model, key):
return self._printer
async def execute(self, statement):
return _FakeAssignmentsResult(self._assignments)
@pytest.mark.asyncio
async def test_missing_assignment_broadcasts_websocket_event_and_push_notification():
"""When a mapped tray is unassigned, service emits websocket and notification events."""
logger = logging.getLogger(__name__)
data = {
"ams_mapping": [1],
"raw_data": {},
}
# Assignment exists for A1 (global tray 0), but print uses A2 (global tray 1).
assignments = [SimpleNamespace(ams_id=0, tray_id=0)]
with (
patch(
"backend.app.services.spool_assignment_notifications.async_session",
return_value=_FakeSession("Printer A", assignments),
),
patch("backend.app.services.spool_assignment_notifications.printer_manager.get_status", return_value=None),
patch(
"backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
new_callable=AsyncMock,
) as mock_ws,
patch(
"backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment",
new_callable=AsyncMock,
) as mock_notify,
):
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_awaited_once()
ws_kwargs = mock_ws.await_args.kwargs
assert ws_kwargs["printer_id"] == 1
assert ws_kwargs["printer_name"] == "Printer A"
assert ws_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
mock_notify.assert_awaited_once()
notify_kwargs = mock_notify.await_args.kwargs
assert notify_kwargs["printer_id"] == 1
assert notify_kwargs["printer_name"] == "Printer A"
assert notify_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
@@ -32,13 +32,14 @@ def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uu
return spool
def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0):
def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0, created_at=None):
"""Create a mock SpoolAssignment object."""
assignment = MagicMock()
assignment.spool_id = spool_id
assignment.printer_id = printer_id
assignment.ams_id = ams_id
assignment.tray_id = tray_id
assignment.created_at = created_at or datetime.now(timezone.utc)
return assignment
@@ -570,10 +571,11 @@ class TestSpoolAssignmentSnapshot:
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
pm = _make_printer_manager(_make_printer_state(ams_data))
# db only returns spool (NO assignment query)
# db returns no live assignment, then spool from snapshot spool_id
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
@@ -657,13 +659,15 @@ class TestSpoolAssignmentSnapshot:
filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
# db: archive, queue_item(None), spool, then cost aggregation queries
# db: archive, queue_item(None), live assignment(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(
side_effect=[
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=None)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
# Cost aggregation: sum query (uses .scalar()), archive lookup
MagicMock(scalar=MagicMock(return_value=0)),
+102 -1
View File
@@ -5,7 +5,7 @@ AMS remain% delta as fallback, per-layer gcode for partial prints,
slot-to-tray mapping resolution, and notification variable formatting.
"""
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@@ -327,6 +327,107 @@ class TestOnPrintComplete:
class TestTrackFrom3mf:
"""Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping."""
@pytest.mark.asyncio
async def test_prefers_live_assignment_when_reassigned_mid_print(self):
"""If tray assignment changed during print, track usage on the new spool."""
spool_old = _make_spool(spool_id=1, label_weight=1000)
spool_new = _make_spool(spool_id=2, label_weight=1000)
archive = _make_archive(archive_id=80)
live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
started_at = datetime.now(timezone.utc)
live_assignment.created_at = started_at + timedelta(seconds=5)
# db: archive, queue_item(None), live assignment lookup, spool_new lookup
db = _mock_db_sequential([archive, None, live_assignment, spool_new])
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
progress=100,
layer_num=50,
tray_now=0,
)
filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
handled_trays: set[tuple[int, int]] = set()
with (
patch("backend.app.core.config.settings") as mock_settings,
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 _track_from_3mf(
printer_id=1,
archive_id=80,
status="completed",
print_name="MidPrintReassign",
handled_trays=handled_trays,
printer_manager=printer_manager,
db=db,
spool_assignments={(0, 0): spool_old.id},
print_started_at=started_at,
)
assert len(results) == 1
assert results[0]["spool_id"] == spool_new.id
@pytest.mark.asyncio
async def test_keeps_snapshot_when_live_assignment_predates_print(self):
"""If live assignment predates print start, preserve snapshot spool mapping."""
spool_old = _make_spool(spool_id=1, label_weight=1000)
archive = _make_archive(archive_id=81)
live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
started_at = datetime.now(timezone.utc)
live_assignment.created_at = started_at - timedelta(seconds=5)
# db: archive, queue_item(None), live assignment lookup, spool_old lookup
db = _mock_db_sequential([archive, None, live_assignment, spool_old])
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
progress=100,
layer_num=50,
tray_now=0,
)
filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
handled_trays: set[tuple[int, int]] = set()
with (
patch("backend.app.core.config.settings") as mock_settings,
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 _track_from_3mf(
printer_id=1,
archive_id=81,
status="completed",
print_name="SnapshotPreserved",
handled_trays=handled_trays,
printer_manager=printer_manager,
db=db,
spool_assignments={(0, 0): spool_old.id},
print_started_at=started_at,
)
assert len(results) == 1
assert results[0]["spool_id"] == spool_old.id
@pytest.mark.asyncio
async def test_linear_fallback_for_partial_print(self):
"""Falls back to linear scaling when gcode layer data unavailable."""