Explorar o código

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 hai 2 meses
pai
achega
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,
         "auto_off_after": item.auto_off_after,
         "manual_start": item.manual_start,
         "manual_start": item.manual_start,
         "filament_short": bool(item.filament_short),
         "filament_short": bool(item.filament_short),
+        "skip_filament_check": bool(item.skip_filament_check),
         "ams_mapping": ams_mapping_parsed,
         "ams_mapping": ams_mapping_parsed,
         "plate_id": item.plate_id,
         "plate_id": item.plate_id,
         "bed_levelling": item.bed_levelling,
         "bed_levelling": item.bed_levelling,
@@ -539,6 +540,7 @@ async def add_to_queue(
             require_previous_success=data.require_previous_success,
             require_previous_success=data.require_previous_success,
             auto_off_after=data.auto_off_after,
             auto_off_after=data.auto_off_after,
             manual_start=data.manual_start,
             manual_start=data.manual_start,
+            skip_filament_check=data.skip_filament_check,
             ams_mapping=ams_mapping_json,
             ams_mapping=ams_mapping_json,
             plate_id=data.plate_id,
             plate_id=data.plate_id,
             bed_levelling=data.bed_levelling,
             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.
     # Print Anyway / no deficit: clear the flags and let the scheduler dispatch.
     item.manual_start = False
     item.manual_start = False
     item.filament_short = 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 —
     # 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
     # VP-uploaded queue items arrive over FTP unattributed, so without this
     # the print log's User column stays blank even when auth is on
     # 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:
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT false")
         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).
     # 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
     # 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
     # 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.
     # block automatically.
     filament_short: Mapped[bool] = mapped_column(Boolean, default=False)
     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
     # Tracking
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     completed_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
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
     auto_off_after: bool = False  # Power off printer after print completes
     manual_start: bool = False  # Requires manual trigger to start (staged)
     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
     # 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)
     # Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)
     ams_mapping: list[int] | None = None
     ams_mapping: list[int] | None = None
@@ -96,6 +100,9 @@ class PrintQueueItemResponse(BaseModel):
     # (#1496). Display-only — the ▶ click recomputes deficit against live
     # (#1496). Display-only — the ▶ click recomputes deficit against live
     # spool state.
     # spool state.
     filament_short: bool = False
     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
     ams_mapping: list[int] | None = None
     plate_id: int | None = None  # Plate ID for multi-plate 3MF files
     plate_id: int | None = None  # Plate ID for multi-plate 3MF files
     # Print options
     # 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
         since been swapped to one with enough material clears the flag here
         so the next scheduler tick dispatches it.
         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:
         try:
             deficit = await compute_deficit_for_queue_item(db, item)
             deficit = await compute_deficit_for_queue_item(db, item)
         except Exception as e:
         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["status"] == "pending"
         assert result["manual_start"] is True
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_add_to_queue_with_project_id(
     async def test_add_to_queue_with_project_id(
@@ -583,6 +617,53 @@ class TestQueueStartEndpoint:
         # decision to print anyway.
         # decision to print anyway.
         assert called_with == {}
         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:
 class TestQueueCancelEndpoint:
     """Tests for the /queue/{item_id}/cancel endpoint."""
     """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
     assert blocked is False
     await db_session.refresh(item)
     await db_session.refresh(item)
     assert item.filament_short is False
     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
   // 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" badge; cleared on a successful ▶ click (live recheck).
   filament_short: boolean;
   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
   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
   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
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
@@ -1966,6 +1971,9 @@ export interface PrintQueueItemCreate {
   require_previous_success?: boolean;
   require_previous_success?: boolean;
   auto_off_after?: boolean;
   auto_off_after?: boolean;
   manual_start?: boolean;  // Requires manual trigger to start (staged)
   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
   ams_mapping?: number[] | null;  // AMS slot mapping for multi-color prints
   plate_id?: number | null;  // Plate ID for multi-plate 3MF files
   plate_id?: number | null;  // Plate ID for multi-plate 3MF files
   // Print options
   // Print options

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

@@ -653,6 +653,10 @@ export function PrintModal({
       auto_off_after: scheduleOptions.autoOffAfter,
       auto_off_after: scheduleOptions.autoOffAfter,
       gcode_injection: scheduleOptions.gcodeInjection,
       gcode_injection: scheduleOptions.gcodeInjection,
       manual_start: scheduleOptions.scheduleType === 'manual',
       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,
       ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
       plate_id: plateOverride !== undefined ? plateOverride : selectedPlate,
       plate_id: plateOverride !== undefined ? plateOverride : selectedPlate,
       scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
       scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-Dx3eHcCx.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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">
     <link rel="stylesheet" crossorigin href="/assets/index-BvmIMSUd.css">
   </head>
   </head>
   <body>
   <body>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio