Browse Source

fix(api): export date_to includes the whole end day; invalid print speed mode is a 422

- GET /archives/export: a date-only date_to ends with that day, as every other
  date filter reads it; date and time with an offset are converted to UTC.
- POST /printers/{id}/print-speed: a mode outside 1-4 answers 422 instead of
  reaching the printer client and answering 500.
maziggy 5 ngày trước cách đây
mục cha
commit
85097fbfda

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Archive export includes the whole end day, and an invalid print speed is refused** — `GET /api/v1/archives/export?date_to=2026-10-15` read the date as midnight and left out everything printed that day; a date on its own now ends with that day, as in every other date filter, and a date and time with a UTC offset is converted to UTC. The Archives page was not affected, because it never sends dates. `POST /api/v1/printers/{id}/print-speed` with a mode other than 1-4 answered 500 "Failed to set print speed"; it now answers 422.
 - **AMS drying now applies to "Any <model>" queue jobs** — With **Wait for drying to complete** on, a job for "Any X1C" could still start on an X1C whose AMS was drying, and its drying was never stopped for the print; queue auto-drying also never dried anything for such a job. Now a model job passes over a drying printer for another of the model, and waits only while every one of them is drying. The printer it lands on stops drying like it would for a job pinned to it, and a scheduled model job has queue auto-drying dry every printer it could run on.
 - **Every way of adding a queue job now works like the print dialog** — Jobs added from File Manager's **Add to queue**, the webhook, a virtual printer, a pipeline run or an order's **Dispatch remaining** sent no "Job added" notification and no MQTT `queue/job_added` event; now every path sends them. Several jobs added at once are one notification per printer, such as "3 jobs (bracket, hinge, lid)", and MQTT gets one event per job. File Manager, the webhook and pipeline runs ignored the default print options (timelapse, bed levelling and the rest) and now use them. The copies an order dispatches lost their cost centre, so they were billed to none and never checked against its budget; they now keep it, and with billing on, dispatching is refused when you may not print with that cost centre or its budget cannot cover every copy. A `scheduled_time` sent to the API or webhook with a UTC offset, such as `10:00+02:00`, was stored as 10:00 UTC and the job started two hours late; the offset is now converted.
 - **Long prints keep their layer timelapse** — Stitching a layer timelapse was stopped after 5 minutes and its frames were then deleted, so a print with more than a few hundred layers on a Raspberry Pi lost its timelapse for good. Stitching now gets half a second per layer, from 5 minutes up to an hour. If ffmpeg fails anyway, or Bambuddy stops while it runs, the frames are kept for about 24 hours with an ffmpeg command to make the video by hand; when ffmpeg fails, the log names the folder. Without ffmpeg the frames are still deleted.

+ 10 - 4
backend/app/api/routes/archives.py

@@ -60,6 +60,7 @@ from backend.app.services.print_storage import (
 from backend.app.services.printer_media import VIDEO_SUFFIXES, match_ipcam_chunks
 from backend.app.utils.archive_paths import archive_photos_dir, find_archive_photo
 from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
+from backend.app.utils.local_time import to_naive_utc
 from backend.app.utils.threemf_tools import (
     default_plate_gcode_name,
     expand_to_project_slots,
@@ -1175,13 +1176,18 @@ async def export_archives(
     date_to_dt = None
     if date_from:
         try:
-            date_from_dt = datetime.fromisoformat(date_from)
-        except ValueError:
+            date_from_dt = to_naive_utc(datetime.fromisoformat(date_from))
+        except (ValueError, OverflowError):
             raise HTTPException(400, "Invalid date_from format")
     if date_to:
         try:
-            date_to_dt = datetime.fromisoformat(date_to)
-        except ValueError:
+            # A date on its own ends with that day, as every other date filter
+            # here reads it; read as midnight it left out the whole day.
+            if len(date_to) == 10:
+                date_to_dt = datetime.combine(date.fromisoformat(date_to), time.max)
+            else:
+                date_to_dt = to_naive_utc(datetime.fromisoformat(date_to))
+        except (ValueError, OverflowError):
             raise HTTPException(400, "Invalid date_to format")
 
     service = ExportService(db)

+ 1 - 1
backend/app/api/routes/printers.py

@@ -3706,7 +3706,7 @@ async def resume_print(
 @router.post("/{printer_id}/print-speed")
 async def set_print_speed(
     printer_id: int,
-    mode: int = Query(..., description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
+    mode: int = Query(..., ge=1, le=4, description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):

+ 86 - 0
backend/tests/integration/test_export_date_to_and_speed_mode.py

@@ -0,0 +1,86 @@
+"""Two API answers that did not match what they were asked (F).
+
+``GET /archives/export?date_to=2026-10-15`` read the date as midnight and left
+out everything printed that day; every other date filter in Bambuddy treats a
+date-only end as the whole day. And ``POST /printers/{id}/print-speed`` with a
+mode other than 1-4 reached the printer client, which refused it, and answered
+500 "Failed to set print speed" instead of saying the request was invalid.
+"""
+
+import csv
+import io
+from datetime import datetime
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _exported_names(async_client: AsyncClient, **params) -> list[str]:
+    response = await async_client.get("/api/v1/archives/export", params={"format": "csv", **params})
+    assert response.status_code == 200, response.text
+    rows = list(csv.DictReader(io.StringIO(response.content.decode("utf-8-sig"))))
+    return sorted(row["Print Name"] for row in rows)
+
+
+@pytest.fixture
+async def three_days(archive_factory, printer_factory, db_session):
+    printer = await printer_factory()
+    for name, when in [
+        ("day before", datetime(2026, 10, 14, 23, 0)),
+        ("that morning", datetime(2026, 10, 15, 9, 30)),
+        ("that night", datetime(2026, 10, 15, 23, 59, 30)),
+        ("day after", datetime(2026, 10, 16, 0, 0, 1)),
+    ]:
+        archive = await archive_factory(printer.id, print_name=name)
+        archive.created_at = when
+    await db_session.commit()
+
+
+async def test_a_date_only_end_includes_that_whole_day(async_client: AsyncClient, three_days):
+    names = await _exported_names(async_client, date_from="2026-10-15", date_to="2026-10-15")
+
+    assert names == ["that morning", "that night"]
+
+
+async def test_an_end_with_a_time_keeps_its_exact_meaning(async_client: AsyncClient, three_days):
+    names = await _exported_names(async_client, date_to="2026-10-15T12:00:00")
+
+    assert names == ["day before", "that morning"]
+
+
+async def test_a_bound_with_an_offset_is_read_in_utc(async_client: AsyncClient, three_days):
+    """11:00+02:00 is 09:00 UTC, the time the archives are stored in."""
+    names = await _exported_names(async_client, date_from="2026-10-15T11:00:00+02:00")
+
+    assert names == ["day after", "that morning", "that night"]
+
+
+async def test_an_unreadable_end_is_still_a_400(async_client: AsyncClient):
+    response = await async_client.get("/api/v1/archives/export", params={"date_to": "15.10.2026"})
+
+    assert response.status_code == 400
+
+
+@pytest.mark.parametrize("mode", [0, 5, -1])
+async def test_a_print_speed_mode_outside_1_to_4_is_refused_as_invalid(
+    async_client: AsyncClient, printer_factory, mode
+):
+    printer = await printer_factory()
+    client = MagicMock()
+
+    with patch("backend.app.api.routes.printers.printer_manager") as pm:
+        pm.get_client.return_value = client
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/print-speed", params={"mode": mode})
+
+    assert response.status_code == 422, response.text
+    client.set_print_speed.assert_not_called()
+
+
+async def test_an_end_with_an_offset_is_read_in_utc(async_client: AsyncClient, three_days):
+    """01:00+02:00 on the 16th is 23:00 UTC on the 15th: before "that night"."""
+    names = await _exported_names(async_client, date_from="2026-10-15", date_to="2026-10-16T01:00:00+02:00")
+
+    assert names == ["that morning"]