Przeglądaj źródła

fix(queue): persist "Print Anyway" so scheduler stops re-flagging it

  When the user clicked Print Anyway on a filament-deficit warning, the
  acknowledgement was one-shot. The route cleared manual_start and
  filament_short, then the next scheduler tick re-ran
  compute_deficit_for_queue_item against identical spool state, found
  the same deficit, and re-set both flags. The item bounced between
  "user said anyway" and "scheduler re-blocked" — every Play click
  returned 409, every confirm got rolled back on the next tick.

  Add a persistent acknowledgement flag on the queue item:

  - New column `skip_filament_check` on print_queue. SQLite + Postgres
    migration branched on is_sqlite() so Postgres doesn't reject
    DEFAULT 0 on BOOLEAN.

  - PrintQueueItemCreate + PrintQueueItemResponse schemas + the
    TypeScript types carry the field.

  - POST /print-queue/{id}/start with skip_filament_check=true now
    ALSO sets item.skip_filament_check = True (not just clearing
    manual_start / filament_short).

  - PrintScheduler._block_on_filament_deficit short-circuits to
    False — no compute, no flag-setting, no notification — when
    item.skip_filament_check is True. We trust the operator's
    decision and stop fighting them.

  - PrintModal at queue-creation time threads
    skip_filament_check=true into the create payload when the user
    clicks Print Anyway on the frontend deficit warning, so a print
    that was warned-then-acknowledged at add-to-queue time goes in
    pre-acknowledged — scheduler never blocks it on first tick.

  Flag is not auto-cleared on spool swap by design: if remaining is
  now sufficient, the check returns no deficit anyway, so the flag
  is moot. Auto-clearing would add lifecycle complexity without
  changing behaviour.

  AMS Backup awareness (the other half of the discussion) intentionally
  NOT included — verified the H2D's bit-26 of print.cfg toggles with
  the printer-side AMS Backup setting, but the X1C's cfg has a
  different shape entirely and verifying every model family isn't
  realistic. Silently under-warning would be worse than always
  per-slot. The check stays single-slot for now.
maziggy 2 miesięcy temu
rodzic
commit
85fbd7fc35

+ 8 - 0
backend/app/api/routes/print_queue.py

@@ -199,6 +199,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "auto_off_after": item.auto_off_after,
         "manual_start": item.manual_start,
         "filament_short": bool(item.filament_short),
+        "skip_filament_check": bool(item.skip_filament_check),
         "ams_mapping": ams_mapping_parsed,
         "plate_id": item.plate_id,
         "bed_levelling": item.bed_levelling,
@@ -539,6 +540,7 @@ async def add_to_queue(
             require_previous_success=data.require_previous_success,
             auto_off_after=data.auto_off_after,
             manual_start=data.manual_start,
+            skip_filament_check=data.skip_filament_check,
             ams_mapping=ams_mapping_json,
             plate_id=data.plate_id,
             bed_levelling=data.bed_levelling,
@@ -1096,6 +1098,12 @@ async def start_queue_item(
     # Print Anyway / no deficit: clear the flags and let the scheduler dispatch.
     item.manual_start = False
     item.filament_short = False
+    # Persist the user's "Print Anyway" decision so the scheduler does not
+    # immediately re-flag this item on the next tick (#1698-followup). The
+    # pre-fix behaviour bounced between "user said anyway" and
+    # "scheduler re-blocked on same deficit" forever.
+    if skip_filament_check:
+        item.skip_filament_check = True
     # Credit the clicker as the item's owner when no prior owner is set —
     # VP-uploaded queue items arrive over FTP unattributed, so without this
     # the print log's User column stays blank even when auth is on

+ 11 - 0
backend/app/core/database.py

@@ -935,6 +935,17 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT false")
 
+    # Migration: skip_filament_check flag on print_queue (#1698-followup).
+    # Persists the user's "Print Anyway" acknowledgement so the scheduler
+    # doesn't re-flag the item every tick after they've confirmed dispatch
+    # despite the deficit warning. Set from the start route's skip_filament_check
+    # query param and from PrintModal at queue-creation time. Postgres / SQLite
+    # boolean default branch matches filament_short above.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT false")
+
     # Migration: Add queue_force_color_match column to virtual_printers (#1188).
     # Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
     # from the 3MF onto the queue item's filament_overrides so the scheduler

+ 8 - 0
backend/app/models/print_queue.py

@@ -85,6 +85,14 @@ class PrintQueueItem(Base):
     # block automatically.
     filament_short: Mapped[bool] = mapped_column(Boolean, default=False)
 
+    # User has acknowledged the filament-shortage warning for this item
+    # ("Print Anyway"). Set by the start route when the user passes
+    # skip_filament_check=true, or at queue-creation time if PrintModal's
+    # frontend deficit warning was acknowledged. Survives scheduler ticks so
+    # the dispatch no longer bounces between "user said anyway" and
+    # "scheduler re-flagged" (#1698-followup).
+    skip_filament_check: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # Tracking
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 7 - 0
backend/app/schemas/print_queue.py

@@ -28,6 +28,10 @@ class PrintQueueItemCreate(BaseModel):
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
     manual_start: bool = False  # Requires manual trigger to start (staged)
+    # Persistent "Print Anyway" acknowledgement (#1698-followup). When set,
+    # PrintModal already showed the deficit warning and the user confirmed,
+    # so the scheduler does not re-flag this item on the next tick.
+    skip_filament_check: bool = False
     # AMS mapping: list of global tray IDs for each filament slot
     # Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)
     ams_mapping: list[int] | None = None
@@ -96,6 +100,9 @@ class PrintQueueItemResponse(BaseModel):
     # (#1496). Display-only — the ▶ click recomputes deficit against live
     # spool state.
     filament_short: bool = False
+    # User has acknowledged "Print Anyway" — scheduler skips the deficit check
+    # for this item (#1698-followup).
+    skip_filament_check: bool = False
     ams_mapping: list[int] | None = None
     plate_id: int | None = None  # Plate ID for multi-plate 3MF files
     # Print options

+ 8 - 0
backend/app/services/print_scheduler.py

@@ -1846,6 +1846,14 @@ class PrintScheduler:
         since been swapped to one with enough material clears the flag here
         so the next scheduler tick dispatches it.
         """
+        # User has explicitly acknowledged the deficit ("Print Anyway") —
+        # don't re-flag, don't even compute. Without this short-circuit the
+        # scheduler bounces between "user said anyway" (route clears
+        # manual_start) and "scheduler re-blocked" (this method re-flags it
+        # on identical spool state) (#1698-followup).
+        if item.skip_filament_check:
+            return False
+
         try:
             deficit = await compute_deficit_for_queue_item(db, item)
         except Exception as e:

+ 81 - 0
backend/tests/integration/test_print_queue_api.py

@@ -147,6 +147,40 @@ class TestPrintQueueAPI:
         assert result["status"] == "pending"
         assert result["manual_start"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_skip_filament_check(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """PrintModal "Print Anyway" persists skip_filament_check on creation (#1698-followup)."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "skip_filament_check": True,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["skip_filament_check"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_skip_filament_check_defaults_false(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Default add-to-queue has skip_filament_check=False — no silent bypass."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {"printer_id": printer.id, "archive_id": archive.id}
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["skip_filament_check"] is False
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_add_to_queue_with_project_id(
@@ -583,6 +617,53 @@ class TestQueueStartEndpoint:
         # decision to print anyway.
         assert called_with == {}
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_with_skip_flag_persists_acknowledgement(
+        self,
+        async_client: AsyncClient,
+        queue_item_factory,
+        db_session,
+    ):
+        """skip_filament_check=true sets the persistent flag on the queue item
+        so the scheduler doesn't re-flag it on the next tick (#1698-followup).
+
+        Without persistence the route's flag-clearing only survives until the
+        next scheduler tick re-runs the deficit check on identical spool
+        state and re-promotes the item — the user has to click Play+Confirm
+        every single tick.
+        """
+        item = await queue_item_factory(manual_start=True, filament_short=True)
+        assert item.skip_filament_check is False
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start?skip_filament_check=true")
+        assert response.status_code == 200
+        body = response.json()
+        assert body["skip_filament_check"] is True
+
+        await db_session.refresh(item)
+        assert item.skip_filament_check is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_without_skip_flag_does_not_set_acknowledgement(
+        self,
+        async_client: AsyncClient,
+        queue_item_factory,
+        db_session,
+    ):
+        """A successful Play click with no deficit must NOT silently set the
+        acknowledgement flag — only an explicit Print Anyway should.
+        """
+        item = await queue_item_factory(manual_start=False, filament_short=False)
+        assert item.skip_filament_check is False
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+        assert response.status_code == 200
+
+        await db_session.refresh(item)
+        assert item.skip_filament_check is False
+
 
 class TestQueueCancelEndpoint:
     """Tests for the /queue/{item_id}/cancel endpoint."""

+ 38 - 0
backend/tests/unit/test_scheduler_filament_deficit.py

@@ -117,3 +117,41 @@ async def test_helper_exception_does_not_wedge_dispatch(scheduler, db_session, q
     assert blocked is False
     await db_session.refresh(item)
     assert item.filament_short is False
+
+
+@pytest.mark.asyncio
+async def test_skip_filament_check_short_circuits_without_compute(scheduler, db_session, queue_item):
+    """User clicked Print Anyway (skip_filament_check=True): no compute, no flag (#1698-followup).
+
+    Pre-fix the scheduler re-ran the deficit check on every tick, re-set
+    manual_start/filament_short to True, and the item bounced between
+    "user said anyway" (route clears flags) and "scheduler re-blocked"
+    forever. With the persistent acknowledgement flag the scheduler bails
+    early without even touching the deficit helper.
+    """
+    item = await queue_item(skip_filament_check=True)
+    compute_mock = AsyncMock(
+        return_value=[
+            FilamentDeficit(
+                slot_id=1,
+                ams_id=0,
+                tray_id=0,
+                filament_type="PLA",
+                required_grams=270.0,
+                remaining_grams=200.0,
+            ),
+        ]
+    )
+    with patch(
+        "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
+        compute_mock,
+    ):
+        blocked = await scheduler._block_on_filament_deficit(db_session, item)
+
+    assert blocked is False
+    compute_mock.assert_not_awaited()
+    await db_session.refresh(item)
+    # Flags must not get re-set by the scheduler now that the user has
+    # acknowledged the deficit.
+    assert item.filament_short is False
+    assert item.manual_start is False

+ 8 - 0
frontend/src/api/client.ts

@@ -1898,6 +1898,11 @@ export interface PrintQueueItem {
   // any required slot's grams (#1496). Surfaced on the queue row as a
   // "filament short" badge; cleared on a successful ▶ click (live recheck).
   filament_short: boolean;
+  // Persistent "Print Anyway" acknowledgement — once true the scheduler
+  // skips the deficit check for this item (#1698-followup). Set by the
+  // start route when skip_filament_check=true, or at queue creation if
+  // PrintModal's deficit warning was acknowledged.
+  skip_filament_check: boolean;
   ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
   filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
@@ -1966,6 +1971,9 @@ export interface PrintQueueItemCreate {
   require_previous_success?: boolean;
   auto_off_after?: boolean;
   manual_start?: boolean;  // Requires manual trigger to start (staged)
+  // PrintModal "Print Anyway" on the deficit warning — persisted so the
+  // scheduler doesn't immediately re-flag this item (#1698-followup).
+  skip_filament_check?: boolean;
   ams_mapping?: number[] | null;  // AMS slot mapping for multi-color prints
   plate_id?: number | null;  // Plate ID for multi-plate 3MF files
   // Print options

+ 4 - 0
frontend/src/components/PrintModal/index.tsx

@@ -653,6 +653,10 @@ export function PrintModal({
       auto_off_after: scheduleOptions.autoOffAfter,
       gcode_injection: scheduleOptions.gcodeInjection,
       manual_start: scheduleOptions.scheduleType === 'manual',
+      // When the user clicks "Print Anyway" on the frontend deficit warning,
+      // persist that acknowledgement so the scheduler doesn't immediately
+      // re-flag the item on its first dispatch tick (#1698-followup).
+      skip_filament_check: options?.skipFilamentCheck === true ? true : undefined,
       ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
       plate_id: plateOverride !== undefined ? plateOverride : selectedPlate,
       scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime

Plik diff jest za duży
+ 0 - 0
static/assets/index-Dx3eHcCx.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-lEQPGYKn.js"></script>
+    <script type="module" crossorigin src="/assets/index-Dx3eHcCx.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BvmIMSUd.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików