feat(diagnostic, archives): install step 4 — proactive check + reactive banner

Two complementary surfaces for the most-missed install step ("Store sent
  files on external storage"):

  1. Connection diagnostic check (printer-side variant)
     - Reads state.store_to_sdcard, parsed from MQTT home_flag bit 11.
     - Pass / fail / skip; instant, no I/O.
     - Catches the newer-firmware variant where the toggle moved onto the
       printer itself (P2S 01.02 / Studio 2.6+).

     An FTP upload-and-verify probe was tried first and rejected. /cache
     is always writable from Bambuddy regardless of the slicer setting;
     only BambuStudio's own behaviour changes when the toggle flips.
     Empirically confirmed against X1C + H2D with the slicer option
     toggled off: probe still succeeded, home_flag bit 11 stayed True.

  2. Archives-page banner (slicer-side variant)
     - The slicer-side toggle is invisible to the printer — older
       BambuStudio doesn't push the change to the printer. The diagnostic
       can't see it.
     - Symptom is deterministic: archiver creates rows with
       extra_data.no_3mf_available=True (main.py:2770) when it can't pull
       the 3MF from /cache after a slicer-initiated print.
     - New endpoint GET /archives/no-3mf-warning returns whether any
       archive in the last 30 days has the flag (excluding soft-deleted).
     - Amber dismissible banner at the top of /archives; one-shot
       localStorage dismissal (matches Layout.tsx update-banner pattern,
       but persistent across sessions).
     - React-Query disabled after dismissal so the endpoint isn't polled
       once the user has been told.
This commit is contained in:
maziggy
2026-06-09 12:20:01 +02:00
parent 60e31634b8
commit f87b5bb3b8
20 changed files with 362 additions and 123 deletions
+33
View File
@@ -427,6 +427,39 @@ async def list_archives(
return result
@router.get("/no-3mf-warning")
async def no_3mf_warning(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Whether to nudge the user about install step 4 ("Store sent files on
external storage"). True iff any archive in the last 30 days was created
via the no-3MF fallback path — that's the deterministic symptom of the
slicer-side variant of the setting being off.
Complements the connection-diagnostic ``external_storage`` check, which
only catches the printer-side variant of the setting. On older slicers
where the toggle lives only in BambuStudio, the printer never reports it
and the diagnostic passes — this endpoint surfaces the symptom instead.
Dismissal is handled client-side via localStorage (one-shot): once the
user has been told, no further nudge until they clear browser storage.
The backend stays stateless.
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
result = await db.execute(
select(PrintArchive.extra_data).where(
PrintArchive.created_at >= cutoff,
PrintArchive.deleted_at.is_(None),
PrintArchive.extra_data.isnot(None),
)
)
for (extra_data,) in result.all():
if extra_data and extra_data.get("no_3mf_available"):
return {"has_fallback": True}
return {"has_fallback": False}
@router.get("/slim", response_model=list[ArchiveSlim])
async def list_archives_slim(
date_from: date | None = Query(None),
@@ -534,6 +534,96 @@ class TestArchivesAPI:
assert "successful_prints" in result
class TestNo3MFWarning:
"""`GET /archives/no-3mf-warning` — install step 4 reactive nudge.
The connection diagnostic's external_storage check only catches the
printer-side variant of the setting (newer firmware). For older slicers
where the toggle lives only in BambuStudio, the printer never reports
it. The fallback path in main.py creates the archive with
extra_data.no_3mf_available=True; this endpoint exposes that as a
boolean so the frontend can surface a one-time banner.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_returns_true_when_recent_fallback_exists(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
printer = await printer_factory()
await archive_factory(printer.id, extra_data={"no_3mf_available": True})
response = await async_client.get("/api/v1/archives/no-3mf-warning")
assert response.status_code == 200
assert response.json() == {"has_fallback": True}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_returns_false_when_no_archives(self, async_client: AsyncClient):
response = await async_client.get("/api/v1/archives/no-3mf-warning")
assert response.status_code == 200
assert response.json() == {"has_fallback": False}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_returns_false_when_only_normal_archives(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
printer = await printer_factory()
# extra_data has other keys but no_3mf_available is absent — normal
# archives must not trigger the nudge.
await archive_factory(printer.id, extra_data={"makerworld_url": "https://example"})
await archive_factory(printer.id, extra_data=None)
response = await async_client.get("/api/v1/archives/no-3mf-warning")
assert response.status_code == 200
assert response.json() == {"has_fallback": False}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_ignores_archives_older_than_30_days(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
from datetime import datetime, timedelta, timezone
from backend.app.models.archive import PrintArchive
printer = await printer_factory()
archive = await archive_factory(printer.id, extra_data={"no_3mf_available": True})
# Backdate past the 30-day window — old fallbacks are forgiven.
archive.created_at = datetime.now(timezone.utc) - timedelta(days=45)
await db_session.commit()
response = await async_client.get("/api/v1/archives/no-3mf-warning")
assert response.status_code == 200
assert response.json() == {"has_fallback": False}
# Sanity: row really is in the DB, we just don't surface it.
assert (await db_session.get(PrintArchive, archive.id)) is not None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_ignores_soft_deleted_fallbacks(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
from datetime import datetime, timezone
printer = await printer_factory()
archive = await archive_factory(printer.id, extra_data={"no_3mf_available": True})
archive.deleted_at = datetime.now(timezone.utc)
await db_session.commit()
response = await async_client.get("/api/v1/archives/no-3mf-warning")
assert response.status_code == 200
# Soft-deleted fallbacks have been actioned (user clearing the
# evidence). Stop nudging.
assert response.json() == {"has_fallback": False}
class TestPrintLogEntryDelete:
"""#1687: per-row delete on the Print Log page.