diff --git a/CHANGELOG.md b/CHANGELOG.md index d72e46175..b060c9d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ All notable changes to Bambuddy will be documented in this file. ### Security - **Path-traversal hardening across the upload / import / file-write surface (routes + services); fifth CI backstop ships alongside** — A private path-traversal report against `POST /api/v1/projects/import/file` traced two attacker-controlled strings being joined to `library_dir` with no resolve + containment check: (a) `linked_folders[*].name` from the request's `project.json` ("Vector A" — an absolute path in this field collapsed `library_dir / "/anywhere"` to `Path("/anywhere")` because pathlib discards the left side when the right is absolute, letting the next `write_bytes` land anywhere the backend could write), and (b) per-entry `zf.namelist()` paths from the ZIP itself ("Vector B" — ZIP filenames carry `..` segments by spec and the join `library_dir / folder_name / relative_path` had no per-component check). Concrete escalation: drop a `.pth` file into the venv's `site-packages` directory for code execution on next service restart; overwrite the JWT signing-secret file to forge an admin token; overwrite `~/.ssh/authorized_keys` or `~/.bashrc` on native installs. **Fix is structural, not just patch the diff** (per [[feedback_dont_dismiss_preexisting]]). New `backend/app/utils/safe_path.py::safe_join_under(parent, *parts)` helper joins under a trusted parent, resolves both sides, asserts `is_relative_to(parent.resolve())`, and rejects up-front empty / null-byte / absolute path components. Wired into `import_project_file` at both vectors. **Adjacent fix from the routes audit**: `GET /api/v1/archives/{id}/photos/{filename}` had NO validation on `filename` and FileResponse-served arbitrary paths — the existing DELETE endpoint at least had a membership check against `archive.photos` (which is UUID-generated on upload), but GET shared neither the check nor any traversal guard. Both GET and DELETE now route through `safe_join_under` for defence-in-depth on top of the membership check. **Second adjacent fix from the services audit**: `ArchiveService.attach_timelapse(archive_id, data, filename)` in `backend/app/services/archive.py:1456` wrote `archive_dir / filename` where `filename` ultimately comes from either a printer's FTP listing (compromised-printer threat model — the printer is part of the trust surface) or the `?filename=...` query param on `POST /api/v1/archives/{id}/timelapse/select`. A malicious printer that returns a directory listing entry with `..` segments could write the timelapse bytes outside the archive directory; the `f.get("name") == filename` gate in the route did not prevent it because the gate is satisfied by whatever the printer claims is on disk. `attach_timelapse` now routes through `safe_join_under(..., http=False)` and returns `False` (logging the rejection) when the join would escape — matching the existing not-found contract of the function rather than raising 400 from inside a background task. **Audit sweep methodology**: AST-walked every Python file under `backend/app/api/routes/` AND `backend/app/services/` for `Path / Name` shapes (the exact shape that produced the original report). 25 additional route-layer sites and 8 additional service-layer sites confirmed safe case-by-case (UUID-generated filenames written by Bambuddy itself, `_safe_filename(...)` / `Path(arg).name` basename-stripped inputs, `os.walk`-discovered names, denylist + format-validated backup names, hardcoded constants iterated through a tuple, DB-stored paths whose write origin already goes through a resolved-and-containment-checked helper). Each safe site got a `# SEC-PATH-OK: ` marker so future audits can trust the inline guard at a glance. Six pre-existing safe-with-marker sites (`library.py` external upload, `archives.py` timelapse output, `projects.py` attachment download/delete, `settings.py` backup extractall) carry the same marker shape. **Fifth CI backstop** `test_route_path_arithmetic_is_safe_joined_or_marked` (`backend/tests/unit/test_no_unsafe_path_joins.py`) AST-walks every Python file in `backend/app/api/routes/` AND `backend/app/services/` and fails the build on any ` / ` join that doesn't either route through `safe_join_under` or carry the marker on the join line. Joins matching the higher-structure shapes (Attribute access, Subscript, f-string, `str(...)` call) are categorically different and out of scope — those are caught by the broader audit sweep, not the regression backstop. The services layer is in scope because it receives values from the routes verbatim AND from external sources Bambuddy has no control over (the printer FTP-listing case above). **Tests**: 17 unit tests for `safe_join_under` covering every escape vector (absolute path, Windows abs path, `..` segments, embedded `..`, null byte, empty string, no parts, non-str, plus legitimate nested-path round-trip); 4 integration tests against `POST /api/v1/projects/import/file` exercising the full FastAPI stack with the verbatim shape from the report (absolute path in `folder_name` → 400 + filesystem assertion that the target file doesn't exist; `..` in `folder_name` → 400; `..` in `relative_path` → 400; legitimate nested ZIP still imports cleanly to guard against the fix being over-strict); 3 unit tests against `ArchiveService.attach_timelapse` exercising the compromised-printer threat model (filename with `..` segments → returns False + no file at the escape target; absolute filename → returns False + no file at `/tmp`; legitimate `timelapse_YYYY-MM-DD_HH-MM-SS.mp4` → returns True + file lands inside archive_dir, guarding against the fix being over-strict). **SECURITY.md** gains a fifth rule + a fifth row in the CI-test mapping table; the rule explicitly names the printer FTP-listing case as in-scope to set the expectation for future services-layer audits. Full 5500+ test backend suite green; ruff clean. ### Fixed +- **Multi-plate `.gcode.3mf` archives + reprints no longer under-report filament, time, and cost — project stats and parser both fixed (#1593, reported by @needo37)** — Reporter printed 3 plates of a multi-plate file: Archive Print Log correctly recorded 3 completed runs at distinct durations and filament weights; Project page showed `Print Jobs: 1 / 1 parts printed`, plate-1's `1h53m / 58g / $1.09`; Archive card said `3 prints` but rendered plate-1's `57.6g / 1h45m / 1 object`. Two distinct causes stacked. **Root cause 1 — 3MF parser only read the first plate**: `ThreeMFParser._parse_slice_info` (`backend/app/services/archive.py:191`) called `root.find(".//plate")` and pulled `prediction` / `weight` from that one element — so for any multi-plate file the archive's file-level `print_time_seconds` / `filament_used_grams` reflected plate 1 alone. The per-plate `/plates` endpoint already looped `findall(".//plate")` and was correct, which is why the plate carousel showed the right numbers while the archive card was wrong. **Root cause 2 — project rollup aggregated `PrintArchive`, not the per-run log**: `compute_project_stats` and the `list_projects` quick-stats block (`backend/app/api/routes/projects.py`) summed `PrintArchive.print_time_seconds / filament_used_grams / cost / energy_*` `WHERE project_id = X`. A reprint reuses the source archive row and only adds a new `PrintLogEntry`, so 3 sequential runs of one file collapsed to 1 archive — and that archive's numbers were already plate-1-only because of root cause 1. The Archive Print Log path was correct because it already drove off `print_log_entries` (`archives.py:420` — *"Reads from print_log_entries so reprints contribute each run"*); project stats just hadn't been pointed at the same source. **Parser fix**: `_parse_slice_info` now loops `findall(".//plate")` and sums `prediction` → `print_time_seconds` and `weight` → `filament_used_grams` across all plates. Per-plate concepts (`plate_number`, `_plate_index`, `printable_objects`) are only set when there's exactly one plate — for multi-plate exports the archive represents all plates and a single plate index is meaningless at the file level. `bed_type` keeps the first plate's value as a best-effort archive default. Malformed `prediction` / `weight` values on individual plates skip cleanly rather than poison the sum. **Stats fix**: `compute_project_stats` and the `list_projects` quick-stats block both switch to an inner join `print_log_entries → print_archives` `WHERE archives.project_id = X`. `total_archives` becomes `COUNT(PrintLogEntry.id)` (actual runs, not files); `failed_prints` becomes the count of runs in `failed/aborted/cancelled/stopped`; `completed_items` becomes `SUM(PrintArchive.quantity)` filtered to runs with `status='completed'` (each run contributes its archive's quantity); `total_print_time_hours / total_filament_grams / estimated_cost / total_energy_*` come from `PrintLogEntry` columns. Orphan log rows (`archive_id IS NULL` after archive deletion via `ON DELETE SET NULL`) are excluded by the inner join — they can't be attributed to any project. **Backfill behaviour** (intentional, matches the reporter's "forward-only" note): users with AMS spool tracking — the reporter's case — have per-run `PrintLogEntry.filament_used_grams` from the tracked spool delta, not the plate-1 estimate, so project stats become correct *immediately* after the rollup fix with no reslice required. Users without tracking fall back to the archive estimate; their stats undercount until they reprint with the fixed parser. The Archive **card** still reads `PrintArchive.filament_used_grams` directly, so old archives keep their plate-1-only numbers until a reslice/rescan repopulates `file_metadata`. **Same-shape fix carried forward**: `system.py::system_info` (the System Info page's lifetime totals) summed `PrintArchive.print_time_seconds` / `filament_used_grams` with the identical bug — reprints collapsed to one archive, multi-plate files reported plate-1-only. The route now sums from `PrintLogEntry.duration_seconds` / `filament_used_grams` like the project rollup, so every run contributes its measured per-run actual. **Same-shape fix in the time-accuracy metric** (`archives.py::get_archive_stats`): the metric computed `estimate / actual` per run where `estimate = PrintArchive.print_time_seconds`. Post-parser-fix multi-plate archives have file-level estimate but per-run actual = one plate's duration → ratio ≈ N×100% for an N-plate file (300% for the reporter's 3-plate case), which would drag the printer-level average to noise. The calc now clamps each row to the [50%, 200%] plausibility band before contributing to the average; single-plate accuracy is fully included (the case the metric is designed for), multi-plate plate-by-plate runs and one-off outliers (manual intervention, purge waste blowing the estimate) are excluded. **Tests**: 4 new in `test_archive_service.py::TestMultiPlateSliceInfoSum` — three-plate file sums prediction + weight (the reporter's exact numerics: 7140+6000+6300 → 19440s, 19.2+20.0+18.8 → 58.0g); single-plate path preserves `plate_number` + objects + bed_type; multi-plate ignores per-plate object lists; malformed per-plate values are skipped without poisoning the sum. 4 new in `test_projects_api.py::TestProjectStatsPerRun` — 3 reprints show as 3 jobs with summed totals (matches the reporter's exact 3-run scenario); orphan log entries don't bleed into any project; mixed-outcome archive splits cleanly between `completed_prints` (quantity-weighted) and `failed_prints` (run-counted); list-view quick stats agree with per-project stats. 1 new in `test_archive_run_aggregation.py` — the accuracy band filter excludes multi-plate plate-by-plate runs (estimate 18000s / actual 6000s = 300%) so a single-plate file's near-100% reading stays the printer's average. Two pre-existing assertions updated to reflect the corrected semantics: `archive_count` and `total_archives` now count runs, so files attached but never printed (status `"archived"`) contribute 0 — that's the right answer, not a regression. Full backend suite + ruff clean. - **Webhook printer-status / stop / cancel routes 500'd on every connected printer because the route treated the PrinterState dataclass as a dict (#1584, reported via in-app bug report)** — Reporter saw `GET /api/v1/webhook/printer/{id}/status` return `500 Internal Server Error` with a valid API key carrying the `read_status` scope, while `GET /api/v1/system/info` returned 200 with the same key — so auth and routing were fine, the handler itself was crashing. Cause: `printer_manager.get_status(printer_id)` returns a `PrinterState` dataclass (`backend/app/services/bambu_mqtt.py`), not a dict. The route at `webhook.py:266-270` called `status.get("connected", False)`, `status.get("state")`, `status.get("current_print")`, `status.get("progress")`, `status.get("remaining_time")` — every one raised `AttributeError`, which Starlette surfaced as a generic 500. Reporter's id-1 (printer exists) returned 500; non-existent ids returned 404 — exactly because the early `Printer not found` branch fired before reaching the crash. Same shape in two adjacent routes: `webhook_stop_print` (`POST /printer/{id}/stop`) and `webhook_cancel_print` (`POST /printer/{id}/cancel`) checked `status.get("connected")` / `status.get("state")` for their precondition gates. 8 crash sites total across the three routes. **Fix**: every `status.get("X", default)` replaced with attribute access (`status.X if status else default`); Pydantic response schema unchanged. `PrinterState`'s dataclass defaults cleanly cover the `status is None` branch (printer registered but never connected — the route now returns 200 with `connected=false, state=null, …` rather than crashing). **Tests** (`backend/tests/integration/test_webhook_printer_status.py`): 7 new — status route returns 200 with the dataclass attributes mapped into the response (regression for the exact #1584 shape); status route returns 200 with sensible defaults when `get_status()` returns None; status route returns 404 for a non-existent printer (control case proving the auth path is unaffected); stop route returns 503 when disconnected (pre-fix would have 500'd here); stop route returns 409 when state is not `RUNNING`; cancel route returns 503 when disconnected; cancel route returns 409 when state is not `RUNNING`/`PAUSE`. Runtime-verified end-to-end against a live PG-backed instance before and after: same key + same printer id, 500 before the patch and 200 with the correct payload after. Full backend suite + ruff clean. - **Path-traversal CI backstop now recognises markers on the closing-paren line (project-wide convention)** — `test_no_unsafe_path_joins.py::test_route_path_arithmetic_is_safe_joined_or_marked` AST-walks every Path-arithmetic site in `api/routes/` + `services/` and demands either `safe_join_under(...)` or a `# SEC-PATH-OK: ` marker. The marker-detection helper only scanned the BinOp's own line range (`lineno..end_lineno`), but the project's convention puts the marker on the line of the wrapping closing paren — one past `end_lineno`. The backstop flagged 30 already-marked, already-safe sites as findings, masking the fact that the post-GHSA marker work is complete. The helper now peeks one line past `end_lineno` IF that line begins with a continuation token (`)`, `]`, `}`, `,`), capturing exactly this convention without giving a free pass to a marker on a wholly unrelated next statement. 5 new tests in `TestMarkerDetection` pin the contract: marker on the BinOp line recognised; marker on the closing-paren line recognised; an unrelated marker on a later statement does NOT silence; a marker on a non-continuation line right after the BinOp does NOT silence; no marker anywhere is still flagged. Integration test now passes against the existing tree — 30 findings → 0 — with no changes to any guard / sanitisation in routes or services. - **Deleted local profiles no longer linger in the SliceModal preset dropdown; new manual "Refresh" button surfaces cloud-side deletions without waiting for the 5-minute cache (#1581, reported by @lloydjohnson)** — Reporter saw deleted local AND cloud profiles still appearing in the slice menu after removing them. Two distinct causes wired together. **Local half (real bug)**: `LocalProfilesView`'s import and delete mutations invalidated `['localPresets']` (the Local Profiles management view's own query) but not `['slicerPresets']` — the SliceModal reads from the unified `/slicer/presets` endpoint via a separate React Query key (`SliceModal.tsx:425`, `staleTime: 60_000`), so a freshly-deleted preset kept rendering in the dropdown until the modal's 60 s staleTime elapsed plus a refocus / remount. The backend was correct end-to-end (`delete_local_preset` removes the DB row, `get_db()` auto-commits, `_fetch_local_presets` reads fresh from DB with no backend cache). Both mutations now also invalidate `['slicerPresets']` so the next modal open shows the current set. **Cloud half (by-design backend cache + new opt-in bypass)**: `_fetch_cloud_presets` keeps a 5-minute per-(user, token) in-process cache balancing "users see their freshly-saved presets quickly" against "a busy install doesn't hit Bambu Cloud once per modal open" (`slicer_presets.py:69`). The user deletes cloud presets in Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook to invalidate on — the cache only refreshes when the TTL expires. Rather than shorten the TTL (which would effectively rate-limit the cloud for every user), the listing endpoint gains an opt-in `?refresh=true` query param that bypasses BOTH the cloud cache and the 1-hour bundled-preset cache for that one call; the fresh result is still written back so subsequent normal callers still hit cached responses. **New SliceModal "Refresh" button**: lives in the preset section header next to the cloud-status banner, calls `getSlicerPresets({refresh: true})` and writes the fresh slots into the `['slicerPresets']` cache via `queryClient.setQueryData` (so the spinner disappears immediately rather than triggering a second refetch). Spins the `RefreshCw` icon while in-flight; disabled during a slice enqueue so users can't fire it twice. **i18n**: real translations for `slice.refreshPresets` + `slice.refreshPresetsTitle` (action label + tooltip) across all 9 locales per the [[feedback_translate_dont_fallback]] HARD RULE; parity script green at 5007 leaves × 9 locales. **Tests**: 2 new backend in `test_slicer_presets.py` (`refresh=True` re-hits Bambu Cloud even with a warm cache + still writes the fresh result back for the next normal call; same shape for `_fetch_bundled_presets`); 1 new frontend in `LocalProfilesView.test.tsx` asserts the delete flow invalidates `['slicerPresets']` in addition to `['localPresets']` via a spied QueryClient. Full backend suite + frontend vitest + ruff + eslint + i18n parity green. diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 21914bf33..43adf4a8b 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -948,6 +948,23 @@ async def get_archive_stats( *base_conditions, ) ) + # Accuracy is meaningful only when the estimate roughly describes the + # work the run actually performed. Two shapes produce wildly-off ratios + # that are pure noise: + # - multi-plate ``.gcode.3mf`` printed plate-by-plate: each run's + # actual is one plate, the archive's estimate is the sum across + # plates (post-#1593 parser fix), so the ratio is roughly N×100% + # for an N-plate file. Pre-fix this shape was also broken, just + # less dramatically — the estimate was plate-1-only so the ratio + # was meaningless rather than N×. + # - manual interventions / purge waste blowing the actual far past + # the estimate. + # Clamp to the [50%, 200%] band so the printer-level average reflects + # real slicer-vs-reality drift, not multi-plate accounting or one-off + # outliers. Single-plate archives — the case the metric is actually + # designed for — stay fully included. + _ACCURACY_BAND_LO = 50.0 + _ACCURACY_BAND_HI = 200.0 average_accuracy = None accuracy_by_printer: dict[str, float] = {} accuracies: list[float] = [] @@ -960,6 +977,8 @@ async def get_archive_stats( if not actual_seconds or not estimate_seconds: continue accuracy = (estimate_seconds / actual_seconds) * 100 + if accuracy < _ACCURACY_BAND_LO or accuracy > _ACCURACY_BAND_HI: + continue accuracies.append(accuracy) printer_key = str(run_printer_id) if run_printer_id else "unknown" printer_accuracies.setdefault(printer_key, []).append(accuracy) diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index 6ecd2e628..1b44287c6 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -20,6 +20,7 @@ from backend.app.core.database import get_db from backend.app.core.permissions import Permission from backend.app.models.archive import PrintArchive from backend.app.models.library import LibraryFile, LibraryFolder +from backend.app.models.print_log import PrintLogEntry from backend.app.models.print_queue import PrintQueueItem from backend.app.models.project import Project from backend.app.models.project_bom import ProjectBOMItem @@ -48,40 +49,66 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/projects", tags=["projects"]) +_FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped") + + async def compute_project_stats( db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None ) -> ProjectStats: - """Compute statistics for a project.""" - # Count total archives (distinct print jobs) - total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id)) - total_archives = total_result.scalar() or 0 + """Compute statistics for a project. - # Sum total items (using quantity field) - total_items_result = await db.execute( - select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project_id) - ) - total_items = total_items_result.scalar() or 0 + Aggregates from ``print_log_entries`` joined to ``print_archives`` so + every actual run contributes — pre-fix this counted ``print_archives`` + (one row per file), which under-reported every reprint by collapsing + runs back into the source file (#1593). The Archive Print Log view + already drives off the same source (``archives.py::list_archives_slim``), + so project stats now stay aligned with the per-archive numbers. - # Count failed archives (number of print jobs) - includes all failure states - failed_result = await db.execute( - select(func.count(PrintArchive.id)).where( - PrintArchive.project_id == project_id, - PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]), - ) - ) - failed_prints = failed_result.scalar() or 0 - - # Sum print time, filament, and energy - sums_result = await db.execute( + Orphan log entries (``archive_id IS NULL`` after archive deletion via + ``ON DELETE SET NULL``) are excluded by the inner join — they can't + be attributed to a project. + """ + # Per-run aggregates from print_log_entries joined on archive_id so + # the WHERE filters by archives.project_id. Each run's duration, + # filament, cost, and energy come from the log row, not the source + # archive — so multi-plate 3MFs and reprints both count correctly. + log_stats_result = await db.execute( select( - func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"), - func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"), - func.coalesce(func.sum(PrintArchive.cost), 0).label("total_filament_cost"), - func.coalesce(func.sum(PrintArchive.energy_kwh), 0).label("total_energy"), - func.coalesce(func.sum(PrintArchive.energy_cost), 0).label("total_energy_cost"), - ).where(PrintArchive.project_id == project_id) + func.count(PrintLogEntry.id).label("total_runs"), + func.coalesce(func.sum(PrintLogEntry.duration_seconds), 0).label("total_time"), + func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0).label("total_filament"), + func.coalesce(func.sum(PrintLogEntry.cost), 0).label("total_filament_cost"), + func.coalesce(func.sum(PrintLogEntry.energy_kwh), 0).label("total_energy"), + func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"), + ) + .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id) + .where(PrintArchive.project_id == project_id) ) - sums = sums_result.first() + log_stats = log_stats_result.first() + total_archives = int(log_stats.total_runs or 0) + + # Total items the project has produced or attempted: sum of quantity + # per run (each run contributes its archive's quantity). The total/ + # completed/failed splits are all per-run, not per-file. + items_split_result = await db.execute( + select( + func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"), + func.coalesce( + func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)), + 0, + ).label("completed_items"), + func.coalesce( + func.sum(case((PrintLogEntry.status.in_(_FAILURE_STATUSES), 1), else_=0)), + 0, + ).label("failed_runs"), + ) + .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id) + .where(PrintArchive.project_id == project_id) + ) + items_split = items_split_result.first() + total_items = int(items_split.total_items or 0) + completed_items = int(items_split.completed_items or 0) + failed_prints = int(items_split.failed_runs or 0) # Count queued items queued_result = await db.execute( @@ -99,15 +126,6 @@ async def compute_project_stats( ) in_progress_prints = in_progress_result.scalar() or 0 - # Sum completed items (parts) - sum of quantities for actually printed jobs - completed_items_result = await db.execute( - select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where( - PrintArchive.project_id == project_id, - PrintArchive.status == "completed", - ) - ) - completed_items = int(completed_items_result.scalar() or 0) - # Calculate progress for plates (target_count vs total_archives) progress_percent = None remaining_prints = None @@ -141,13 +159,13 @@ async def compute_project_stats( failed_prints=int(failed_prints), queued_prints=queued_prints, in_progress_prints=in_progress_prints, - total_print_time_hours=round((sums.total_time or 0) / 3600, 2), - total_filament_grams=round(sums.total_filament or 0, 2), + total_print_time_hours=round((log_stats.total_time or 0) / 3600, 2), + total_filament_grams=round(log_stats.total_filament or 0, 2), progress_percent=progress_percent, parts_progress_percent=parts_progress_percent, - estimated_cost=round((sums.total_filament_cost or 0), 2), - total_energy_kwh=round((sums.total_energy or 0), 3), - total_energy_cost=round((sums.total_energy_cost or 0), 3), + estimated_cost=round((log_stats.total_filament_cost or 0), 2), + total_energy_kwh=round((log_stats.total_energy or 0), 3), + total_energy_cost=round((log_stats.total_energy_cost or 0), 3), remaining_prints=remaining_prints, remaining_parts=remaining_parts, bom_total_items=bom_stats.total or 0, @@ -172,20 +190,34 @@ async def list_projects( result = await db.execute(query) projects = result.scalars().all() - # Compute quick stats for each project + # Compute quick stats for each project. Same per-run aggregation as + # ``compute_project_stats`` — counts and quantities come from + # ``print_log_entries`` joined to ``print_archives`` so reprints and + # multi-plate prints contribute every run, not just the source file + # (#1593). Quick stats and the full stats endpoint must agree. response = [] for project in projects: - # Get archive count (number of print jobs) - archive_count_result = await db.execute( - select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id) + log_quick_result = await db.execute( + select( + func.count(PrintLogEntry.id).label("archive_count"), + func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"), + func.coalesce( + func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)), + 0, + ).label("completed_count"), + func.coalesce( + func.sum(case((PrintLogEntry.status.in_(_FAILURE_STATUSES), 1), else_=0)), + 0, + ).label("failed_count"), + ) + .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id) + .where(PrintArchive.project_id == project.id) ) - archive_count = archive_count_result.scalar() or 0 - - # Get total items (sum of quantities) - total_items_result = await db.execute( - select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project.id) - ) - total_items = int(total_items_result.scalar() or 0) + log_quick = log_quick_result.first() + archive_count = int(log_quick.archive_count or 0) + total_items = int(log_quick.total_items or 0) + completed_count = int(log_quick.completed_count or 0) + failed_count = int(log_quick.failed_count or 0) # Get queue count queue_count_result = await db.execute( @@ -196,24 +228,6 @@ async def list_projects( ) queue_count = queue_count_result.scalar() or 0 - # Sum completed parts (quantities) - only actually printed jobs - completed_result = await db.execute( - select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where( - PrintArchive.project_id == project.id, - PrintArchive.status == "completed", - ) - ) - completed_count = int(completed_result.scalar() or 0) - - # Sum failed parts (quantities) - includes all failure states - failed_result = await db.execute( - select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where( - PrintArchive.project_id == project.id, - PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]), - ) - ) - failed_count = int(failed_result.scalar() or 0) - # Plates progress: archive_count / target_count progress_percent = None if project.target_count and project.target_count > 0: diff --git a/backend/app/api/routes/system.py b/backend/app/api/routes/system.py index 5f9531e22..1a563f92a 100644 --- a/backend/app/api/routes/system.py +++ b/backend/app/api/routes/system.py @@ -19,6 +19,7 @@ from backend.app.core.database import get_db from backend.app.core.permissions import Permission from backend.app.models.archive import PrintArchive from backend.app.models.filament import Filament +from backend.app.models.print_log import PrintLogEntry from backend.app.models.printer import Printer from backend.app.models.project import Project from backend.app.models.smart_plug import SmartPlug @@ -416,18 +417,22 @@ async def get_system_info( failed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed")) printing_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing")) - # Total print time + # System-wide totals aggregate per-run from ``print_log_entries`` so + # reprints contribute each run and multi-plate sums are pulled from the + # measured per-run actuals — same source the per-archive stats and the + # project rollup use (#1593). Pre-fix this summed ``PrintArchive`` directly, + # which under-reported the same way the project page did (3 reprints of + # one file showed as one file's worth of filament/time). total_print_time = ( await db.scalar( - select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.print_time_seconds.isnot(None)) + select(func.sum(PrintLogEntry.duration_seconds)).where(PrintLogEntry.duration_seconds.isnot(None)) ) or 0 ) - # Total filament used total_filament = ( await db.scalar( - select(func.sum(PrintArchive.filament_used_grams)).where(PrintArchive.filament_used_grams.isnot(None)) + select(func.sum(PrintLogEntry.filament_used_grams)).where(PrintLogEntry.filament_used_grams.isnot(None)) ) or 0 ) diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index fc80741d8..410b65e28 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -188,49 +188,78 @@ class ThreeMFParser: self.metadata["sliced_for_model"] = normalized break - # Find the plate element (single-plate exports only have one plate) - plate = root.find(".//plate") + # Loop every so multi-plate exports get summed file-level + # totals. Pre-fix, this used `root.find(".//plate")` which + # returned only the first plate — file-level `print_time_seconds` + # / `filament_used_grams` reflected plate 1 alone, and the + # archive card / project rollup under-reported by the number + # of plates (#1593). Per-plate breakdown is still served by + # the dedicated `/plates` endpoint. + plates = root.findall(".//plate") + summed_time = 0 + summed_grams = 0.0 + any_time_seen = False + any_grams_seen = False - if plate is not None: - # Extract metadata from plate element + for plate in plates: + # Plate-level fields that only make sense at the file + # level when there's exactly one plate. ``plate_number`` + # / ``_plate_index`` describe which plate the export + # represents — meaningless for an all-plates 3MF, so we + # only record them in the single-plate case. ``bed_type`` + # is also single-valued; we take the first plate's value + # as a best-effort default for the archive metadata. + plate_index_value: int | None = None for meta in plate.findall("metadata"): key = meta.get("key") value = meta.get("value") if key == "index" and value: - # Extract plate index - this tells us which plate was exported try: - extracted_index = int(value) - # Set plate_number if not already set from filename - if not self.plate_number: - self.plate_number = extracted_index - # Store in metadata for print_name generation - self.metadata["_plate_index"] = extracted_index + plate_index_value = int(value) except ValueError: pass # Skip non-numeric plate index elif key == "prediction" and value: - self.metadata["print_time_seconds"] = int(value) + try: + summed_time += int(value) + any_time_seen = True + except ValueError: + pass elif key == "weight" and value: - self.metadata["filament_used_grams"] = float(value) - elif key == "curr_bed_type" and value: + try: + summed_grams += float(value) + any_grams_seen = True + except ValueError: + pass + elif key == "curr_bed_type" and value and "bed_type" not in self.metadata: self.metadata["bed_type"] = value - # Extract printable objects for skip object functionality - # Objects are stored as - printable_objects = {} - for obj in plate.findall("object"): - identify_id = obj.get("identify_id") - name = obj.get("name") - skipped = obj.get("skipped", "false") + # Per-plate object lists are only kept at the file level + # when there's one plate — the skip-object affordance + # operates on the plate being printed, which is the + # `/plates` endpoint's job for multi-plate exports. + if len(plates) == 1: + if plate_index_value is not None: + if not self.plate_number: + self.plate_number = plate_index_value + self.metadata["_plate_index"] = plate_index_value - # Only include objects that are not pre-skipped - if identify_id and name and skipped.lower() != "true": - try: - printable_objects[int(identify_id)] = name - except ValueError: - pass # Skip objects with non-numeric identify_id + printable_objects: dict[int, str] = {} + for obj in plate.findall("object"): + identify_id = obj.get("identify_id") + name = obj.get("name") + skipped = obj.get("skipped", "false") + if identify_id and name and skipped.lower() != "true": + try: + printable_objects[int(identify_id)] = name + except ValueError: + pass # Skip objects with non-numeric identify_id + if printable_objects: + self.metadata["printable_objects"] = printable_objects - if printable_objects: - self.metadata["printable_objects"] = printable_objects + if any_time_seen: + self.metadata["print_time_seconds"] = summed_time + if any_grams_seen: + self.metadata["filament_used_grams"] = round(summed_grams, 2) # Get filament info from filaments ACTUALLY USED in the print # slice_info has diff --git a/backend/tests/integration/test_projects_api.py b/backend/tests/integration/test_projects_api.py index fb58b3d14..a9540d12e 100644 --- a/backend/tests/integration/test_projects_api.py +++ b/backend/tests/integration/test_projects_api.py @@ -287,10 +287,19 @@ class TestProjectPartsTracking: @pytest.fixture async def archive_factory(self, db_session): - """Factory to create test archives.""" + """Factory to create a test archive plus a matching PrintLogEntry. + + Project stats aggregate from ``print_log_entries`` (#1593), so a + test that only writes archives wouldn't exercise the production + path — production always writes one log entry per run. The + factory mirrors that: every archive whose status is anything other + than ``"archived"`` (file shelved without printing) gets a log + entry whose status matches the archive. + """ async def _create_archive(**kwargs): from backend.app.models.archive import PrintArchive + from backend.app.models.print_log import PrintLogEntry defaults = { "filename": "test.3mf", @@ -306,6 +315,16 @@ class TestProjectPartsTracking: db_session.add(archive) await db_session.commit() await db_session.refresh(archive) + + if archive.status != "archived": + db_session.add( + PrintLogEntry( + archive_id=archive.id, + print_name=archive.print_name, + status=archive.status, + ) + ) + await db_session.commit() return archive return _create_archive @@ -436,10 +455,12 @@ class TestProjectArchivedStatusNotCounted: @pytest.fixture async def archive_factory(self, db_session): - """Factory to create test archives.""" + """Factory to create a test archive plus a matching PrintLogEntry — + see TestProjectPartsTracking.archive_factory for rationale (#1593).""" async def _create_archive(**kwargs): from backend.app.models.archive import PrintArchive + from backend.app.models.print_log import PrintLogEntry defaults = { "filename": "test.3mf", @@ -455,6 +476,16 @@ class TestProjectArchivedStatusNotCounted: db_session.add(archive) await db_session.commit() await db_session.refresh(archive) + + if archive.status != "archived": + db_session.add( + PrintLogEntry( + archive_id=archive.id, + print_name=archive.print_name, + status=archive.status, + ) + ) + await db_session.commit() return archive return _create_archive @@ -499,7 +530,10 @@ class TestProjectArchivedStatusNotCounted: our_project = next((p for p in data if p["name"] == "List Archived Test"), None) assert our_project is not None assert our_project["completed_count"] == 4 # Only completed, not archived - assert our_project["archive_count"] == 2 # Both archives exist as plates + # Post-#1593: archive_count is "print runs", not "files attached". An + # ``archived``-status file (shelved without printing) has no + # PrintLogEntry and doesn't count — only the actual printed run does. + assert our_project["archive_count"] == 1 @pytest.mark.asyncio @pytest.mark.integration @@ -519,9 +553,201 @@ class TestProjectArchivedStatusNotCounted: data = response.json() assert data["stats"]["completed_prints"] == 10 # Only "completed" - assert data["stats"]["failed_prints"] == 2 # failed + aborted (count of archives, not sum) - assert data["stats"]["total_archives"] == 4 # All archives - assert data["stats"]["total_items"] == 20 # Sum of all quantities + assert data["stats"]["failed_prints"] == 2 # failed + aborted (count of runs) + # Post-#1593: total_archives counts runs from print_log_entries, not + # files. The ``archived`` row is a shelved file with no run, so it + # contributes 0; the other three (completed, failed, aborted) each + # produced a run. + assert data["stats"]["total_archives"] == 3 + # total_items sums quantity per run: 10 (completed) + 3 (failed) + 2 (aborted) = 15 + assert data["stats"]["total_items"] == 15 + + +class TestProjectStatsPerRun: + """Project stats aggregate per-run from ``print_log_entries`` so + reprints and multi-plate prints count every run (#1593). Pre-fix the + stats counted ``print_archives`` (one row per file), so 3 reprints of + one file showed as 1 job with plate-1-only filament/time/cost. + """ + + @pytest.fixture + async def project_factory(self, db_session): + async def _create_project(**kwargs): + from backend.app.models.project import Project + + defaults = {"name": "Per-Run Stats Project", "color": "#FF0000"} + defaults.update(kwargs) + project = Project(**defaults) + db_session.add(project) + await db_session.commit() + await db_session.refresh(project) + return project + + return _create_project + + @pytest.fixture + async def archive_with_runs(self, db_session): + """Build a single archive + N PrintLogEntry rows. + + Models the reporter's case: one source file (archive) is reprinted + N times, each run with its own duration / filament / cost. + """ + + async def _create(*, project_id: int, runs: list[dict], archive_status: str = "completed", quantity: int = 1): + from backend.app.models.archive import PrintArchive + from backend.app.models.print_log import PrintLogEntry + + archive = PrintArchive( + filename="reprinted.3mf", + file_path="test/reprinted.3mf", + file_size=1000, + print_name="Reprinted Print", + status=archive_status, + quantity=quantity, + project_id=project_id, + ) + db_session.add(archive) + await db_session.commit() + await db_session.refresh(archive) + + for run in runs: + db_session.add( + PrintLogEntry( + archive_id=archive.id, + print_name=archive.print_name, + status=run.get("status", "completed"), + duration_seconds=run.get("duration_seconds"), + filament_used_grams=run.get("filament_used_grams"), + cost=run.get("cost"), + energy_kwh=run.get("energy_kwh"), + energy_cost=run.get("energy_cost"), + ) + ) + await db_session.commit() + return archive + + return _create + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_three_reprints_count_as_three_jobs_with_summed_totals( + self, async_client: AsyncClient, project_factory, archive_with_runs + ): + """Reporter's case: 3 runs of one multi-plate file should report + 3 jobs and summed time / filament / cost — pre-fix it reported 1 + job with plate-1-only totals.""" + project = await project_factory() + await archive_with_runs( + project_id=project.id, + runs=[ + {"duration_seconds": 7140, "filament_used_grams": 19.2, "cost": 0.40}, + {"duration_seconds": 6000, "filament_used_grams": 20.0, "cost": 0.40}, + {"duration_seconds": 6300, "filament_used_grams": 18.8, "cost": 0.40}, + ], + ) + + response = await async_client.get(f"/api/v1/projects/{project.id}") + assert response.status_code == 200 + stats = response.json()["stats"] + + assert stats["total_archives"] == 3, "3 runs must show as 3 jobs" + assert stats["completed_prints"] == 3, "Each run with quantity=1 contributes 1 part" + assert stats["total_filament_grams"] == round(19.2 + 20.0 + 18.8, 2) + assert stats["total_print_time_hours"] == round((7140 + 6000 + 6300) / 3600, 2) + # Cost rounds at 2 decimals — 3 * 0.40 = 1.20 + assert stats["estimated_cost"] == 1.20 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_orphan_log_entries_do_not_bleed_into_projects( + self, async_client: AsyncClient, project_factory, db_session + ): + """Log rows whose ``archive_id`` is NULL (archive deleted via + ON DELETE SET NULL) must not leak into any project — the inner + join filters them out by construction.""" + from backend.app.models.print_log import PrintLogEntry + + project = await project_factory() + + # Orphan log entries — no archive_id. + for _ in range(5): + db_session.add( + PrintLogEntry( + archive_id=None, + print_name="Orphan Run", + status="completed", + duration_seconds=3600, + filament_used_grams=20.0, + cost=0.5, + ) + ) + await db_session.commit() + + response = await async_client.get(f"/api/v1/projects/{project.id}") + assert response.status_code == 200 + stats = response.json()["stats"] + + # None of the orphan rows are attributable to this project. + assert stats["total_archives"] == 0 + assert stats["completed_prints"] == 0 + assert stats["total_filament_grams"] == 0 + assert stats["total_print_time_hours"] == 0 + assert stats["estimated_cost"] == 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_mixed_run_outcomes_split_completed_and_failed( + self, async_client: AsyncClient, project_factory, archive_with_runs + ): + """A multi-run archive with mixed outcomes splits cleanly between + completed_prints (per-quantity) and failed_prints (per-run).""" + project = await project_factory() + await archive_with_runs( + project_id=project.id, + quantity=2, + runs=[ + {"status": "completed", "filament_used_grams": 30.0}, + {"status": "completed", "filament_used_grams": 30.0}, + {"status": "failed", "filament_used_grams": 5.0}, + {"status": "aborted", "filament_used_grams": 2.0}, + ], + ) + + response = await async_client.get(f"/api/v1/projects/{project.id}") + stats = response.json()["stats"] + + assert stats["total_archives"] == 4 + # 2 completed runs × quantity=2 each = 4 parts + assert stats["completed_prints"] == 4 + # 2 failure runs (failed + aborted) count as 2, not 2*quantity + assert stats["failed_prints"] == 2 + # All 4 runs contribute filament: 30 + 30 + 5 + 2 = 67 + assert stats["total_filament_grams"] == 67.0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_quick_stats_in_list_view_agree_with_per_project_stats( + self, async_client: AsyncClient, project_factory, archive_with_runs + ): + """The /projects list view's quick stats must agree with + /projects/{id}'s detailed stats — both come from the same per-run + aggregation.""" + project = await project_factory(name="Quick-Stats Alignment") + await archive_with_runs( + project_id=project.id, + quantity=1, + runs=[ + {"status": "completed"}, + {"status": "completed"}, + {"status": "failed"}, + ], + ) + + list_resp = await async_client.get("/api/v1/projects/") + ours = next(p for p in list_resp.json() if p["name"] == "Quick-Stats Alignment") + assert ours["archive_count"] == 3 + assert ours["completed_count"] == 2 + assert ours["failed_count"] == 1 class TestProjectArchivesAPI: diff --git a/backend/tests/integration/test_system_api.py b/backend/tests/integration/test_system_api.py index e445b15f5..c22120fce 100644 --- a/backend/tests/integration/test_system_api.py +++ b/backend/tests/integration/test_system_api.py @@ -219,10 +219,32 @@ class TestSystemAPI: @pytest.mark.asyncio @pytest.mark.integration async def test_system_info_with_archives(self, async_client: AsyncClient, printer_factory, archive_factory): - """Verify database stats include archive counts.""" + """Verify database stats include archive counts. + + Post-#1593 `total_print_time_seconds` is summed from + `PrintLogEntry.duration_seconds` (the *actual* per-run duration), + not `PrintArchive.print_time_seconds` (the slicer estimate). The + archive_factory derives the run's duration from + ``completed_at - started_at`` on the archive, so the test sets + those so each run carries a duration the system route can sum. + """ + from datetime import datetime, timezone + printer = await printer_factory() - await archive_factory(printer.id, status="completed", print_time_seconds=3600) - await archive_factory(printer.id, status="failed", print_time_seconds=1800) + await archive_factory( + printer.id, + status="completed", + print_time_seconds=3600, + started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc), + completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc), + ) + await archive_factory( + printer.id, + status="failed", + print_time_seconds=1800, + started_at=datetime(2026, 5, 2, 10, 0, tzinfo=timezone.utc), + completed_at=datetime(2026, 5, 2, 10, 30, tzinfo=timezone.utc), + ) with ( patch("backend.app.api.routes.system.psutil") as mock_psutil, diff --git a/backend/tests/unit/services/test_archive_service.py b/backend/tests/unit/services/test_archive_service.py index 6da5f4fa5..844328119 100644 --- a/backend/tests/unit/services/test_archive_service.py +++ b/backend/tests/unit/services/test_archive_service.py @@ -729,3 +729,149 @@ class TestGcodeHeaderFilamentUsage: meta = ThreeMFParser(self._make_3mf("; total layer number: 10\n")).parse() assert "filament_used_grams" not in meta assert "filament_used_mm" not in meta + + +class TestMultiPlateSliceInfoSum: + """Multi-plate ``.gcode.3mf`` exports must produce file-level totals that + are the SUM of every plate's prediction + weight, not plate-1 only. + + Pre-fix the parser used ``root.find(".//plate")`` and only read the + first plate's metadata, so the archive card and project rollup + under-reported by roughly the number of plates (#1593). + """ + + @staticmethod + def _make_3mf_with_slice_info(slice_info_xml: str) -> str: + """Write a minimal .3mf with the given slice_info.config payload. + + Bambu Studio's slice_info.config is the file the parser reads for + file-level `prediction` / `weight`; the rest of the 3MF members + aren't required for this test. + """ + import os + import tempfile + import zipfile + + fd, path = tempfile.mkstemp(suffix=".3mf") + os.close(fd) + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("3D/3dmodel.model", "") + zf.writestr("Metadata/slice_info.config", slice_info_xml) + return path + + def test_three_plate_file_sums_prediction_and_weight(self): + """The reporter's case: three plates with distinct prediction + + weight values must yield file-level totals that are the sum. + """ + from backend.app.services.archive import ThreeMFParser + + slice_info_xml = """ + + + + + + + + + + + + + + + + + + """ + parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml)) + meta = parser.parse() + assert meta["print_time_seconds"] == 7140 + 6000 + 6300 # 19440 + assert meta["filament_used_grams"] == round(19.2 + 20.0 + 18.8, 2) # 58.0 + # Multi-plate file: no single plate index should be claimed at the + # file level — the archive represents all plates, not a specific one. + assert parser.plate_number is None + + def test_single_plate_file_preserves_plate_index_and_objects(self): + """The single-plate path must still set ``_plate_index`` and pick + up printable objects — these only make sense when the archive + represents exactly one plate. + """ + from backend.app.services.archive import ThreeMFParser + + slice_info_xml = """ + + + + + + + + + + + """ + parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml)) + meta = parser.parse() + assert meta["print_time_seconds"] == 3600 + assert meta["filament_used_grams"] == 50.5 + # Single-plate exports surface the plate index via ``plate_number`` + # (``_plate_index`` is an internal key cleared at the end of parse). + assert parser.plate_number == 2 + assert meta["bed_type"] == "textured_pei" + assert meta["printable_objects"] == {1: "Part_A"} + + def test_multi_plate_ignores_per_plate_objects(self): + """Multi-plate exports must NOT carry a single plate's objects at + the file level — the ``/plates`` endpoint surfaces them per-plate. + Conflating them would attach plate-1's parts to the whole archive. + """ + from backend.app.services.archive import ThreeMFParser + + slice_info_xml = """ + + + + + + + + + + + + + + + """ + parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml)) + meta = parser.parse() + assert meta["print_time_seconds"] == 2500 + assert meta["filament_used_grams"] == 25.0 + # No archive-level object list when there's more than one plate. + assert "printable_objects" not in meta + assert parser.plate_number is None + + def test_missing_or_malformed_values_are_skipped(self): + """A plate with a malformed prediction/weight string must skip + that field, not poison the sum or raise — defensive parsing was + already present per-field; the sum loop must preserve it. + """ + from backend.app.services.archive import ThreeMFParser + + slice_info_xml = """ + + + + + + + + + + + """ + meta = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml)).parse() + assert meta["print_time_seconds"] == 300 + # Only the second plate's weight contributed. + assert meta["filament_used_grams"] == 5.0 diff --git a/backend/tests/unit/test_archive_run_aggregation.py b/backend/tests/unit/test_archive_run_aggregation.py index 57302a8e3..cac502672 100644 --- a/backend/tests/unit/test_archive_run_aggregation.py +++ b/backend/tests/unit/test_archive_run_aggregation.py @@ -245,3 +245,70 @@ async def test_soft_delete_keeps_runs_for_stats( stats = (await async_client.get("/api/v1/archives/stats")).json() assert stats["total_prints"] >= 1 assert stats["total_filament_grams"] >= 75.0 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_time_accuracy_excludes_multi_plate_plate_by_plate_outliers( + async_client: AsyncClient, archive_factory, printer_factory, db_session +): + """Per-run accuracy clamps to a plausible 50%-200% band so multi-plate + archives printed plate-by-plate don't poison the printer-level average. + + Pre-#1593 the parser stored plate-1-only time in + ``PrintArchive.print_time_seconds``, so a plate-by-plate run produced a + near-100% ratio by accident. Post-#1593 the field is the sum across + plates, so each plate-by-plate run produces estimate/actual = N×100% + for an N-plate file. Without the band filter a single 3-plate file + printed plate-by-plate would drag the printer's accuracy reading to + ~300%, which is pure noise. The metric is designed for the + single-plate-file case and should reflect real slicer drift there. + """ + printer = await printer_factory() + + # Archive 1: single-plate file. Estimate 3600s, actual 3700s + # → ratio 97.3% (well within band). + single = await archive_factory( + printer.id, + print_time_seconds=3600, + with_run=False, + ) + db_session.add( + PrintLogEntry( + archive_id=single.id, + printer_id=printer.id, + status="completed", + duration_seconds=3700, + ) + ) + + # Archive 2: multi-plate file (3 plates totaling 18000s). Two runs + # printed plate-by-plate at ~6000s each — ratio 18000/6000 = 300%. + # Both must be filtered out so the printer average stays at the + # single-plate file's 97.3% reading. + multi = await archive_factory( + printer.id, + print_time_seconds=18000, + with_run=False, + ) + db_session.add( + PrintLogEntry( + archive_id=multi.id, + printer_id=printer.id, + status="completed", + duration_seconds=6000, + ) + ) + db_session.add( + PrintLogEntry( + archive_id=multi.id, + printer_id=printer.id, + status="completed", + duration_seconds=6100, + ) + ) + await db_session.commit() + + body = (await async_client.get("/api/v1/archives/stats")).json() + assert body["average_time_accuracy"] == pytest.approx(97.3, abs=0.1) + assert body["time_accuracy_by_printer"][str(printer.id)] == pytest.approx(97.3, abs=0.1)