diff --git a/bambu_run/mcp/filaments.py b/bambu_run/mcp/filaments.py index 9900ff4..4c916fe 100644 --- a/bambu_run/mcp/filaments.py +++ b/bambu_run/mcp/filaments.py @@ -80,3 +80,36 @@ def _spool_summary(f, include_history): for u in f.print_usages.select_related("print_job").order_by("-print_job__start_time")[:3] ] return entry + + +def get_filament(filament_id: int) -> dict: + """One spool in full, plus its complete print-usage history. + + Usage rows with corrupt consumption (negative, or derived from the -1 + "unread" sentinel) are returned with consumed_grams null and + data_quality "unreliable" rather than dropped -- the print still + happened, and hiding it would misstate the spool's history. Raises + Filament.DoesNotExist if filament_id is unknown. + """ + f = Filament.objects.get(id=filament_id) + summary = _spool_summary(f, include_history=False) + + usages = [] + for u in f.print_usages.select_related("print_job").order_by("-print_job__start_time"): + consumed_percent, consumed_grams, quality = normalize_consumed( + u.starting_percent, u.ending_percent, f.initial_weight_grams + ) + usages.append({ + "job_id": u.print_job_id, + "job_name": u.print_job.project_name, + "started_at": u.print_job.start_time.isoformat(), + "consumed_percent": consumed_percent, + "consumed_grams": consumed_grams, + "data_quality": quality, + }) + + return { + **summary, + "purchase_date": f.purchase_date.isoformat() if f.purchase_date else None, + "usage_history": usages, + } diff --git a/tests/test_mcp_filaments.py b/tests/test_mcp_filaments.py index c44d38d..a21b87d 100644 --- a/tests/test_mcp_filaments.py +++ b/tests/test_mcp_filaments.py @@ -2,7 +2,7 @@ import pytest from django.utils import timezone from bambu_run.models import Filament, Printer, PrintJob, FilamentUsage -from bambu_run.mcp.filaments import list_filaments +from bambu_run.mcp.filaments import list_filaments, get_filament @pytest.mark.django_db @@ -97,3 +97,39 @@ def test_list_filaments_never_exposes_rfid_identifiers(): entry_str = repr(result["filaments"][0]) assert "SECRET-UUID" not in entry_str assert "SECRET-TAG" not in entry_str + + +@pytest.mark.django_db +def test_get_filament_returns_full_usage_history_with_data_quality(): + p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True) + f = Filament.objects.create(type="ABS", brand="Bambu Lab", color="Black", initial_weight_grams=1000) + job1 = PrintJob.objects.create(device=p, project_name="Bracket", start_time=timezone.now(), final_status="FINISH") + job2 = PrintJob.objects.create(device=p, project_name="Vase", start_time=timezone.now(), final_status="FINISH") + FilamentUsage.objects.create(print_job=job1, filament=f, tray_id=0, starting_percent=80, ending_percent=70) + FilamentUsage.objects.create(print_job=job2, filament=f, tray_id=0, starting_percent=-1, ending_percent=100) + + result = get_filament(f.id) + + assert len(result["usage_history"]) == 2 + reliable = next(u for u in result["usage_history"] if u["job_name"] == "Bracket") + unreliable = next(u for u in result["usage_history"] if u["job_name"] == "Vase") + assert reliable["data_quality"] == "ok" + assert reliable["consumed_grams"] == 100 + assert unreliable["data_quality"] == "unreliable" + assert unreliable["consumed_grams"] is None + + +@pytest.mark.django_db +def test_get_filament_never_exposes_purchase_price_or_cost(): + f = Filament.objects.create(type="PLA", brand="Bambu Lab", color="White", purchase_price=25.50) + + result = get_filament(f.id) + + assert "purchase_price" not in result + assert "cost" not in result + + +@pytest.mark.django_db +def test_get_filament_unknown_id_raises(): + with pytest.raises(Filament.DoesNotExist): + get_filament(999999)