mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 11:47:46 +02:00
Statistics now aggregate over PrintLogEntry (one row per print event,
the same table backing the global Print Log) rather than PrintArchive
(one row per file). A reprint creates a new PrintLogEntry instead of
overwriting the source archive's runtime fields, so:
- a 100 g successful print + a 10 g failed reprint correctly sums to
110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
Prometheus /metrics endpoint (previously the failed reprint silently
replaced the source archive's data; totals dropped from 100 g to 10 g)
- the archive's card cost/energy_kwh are preserved on reprints (only
the first run writes them); per-run actuals live on PrintLogEntry
- failed/cancelled/stopped reprints record partial-aware filament: sum
of tracked spool deltas when inventory is set up, else estimate
scaled to progress%, else None — prevents the full slicer estimate
from inflating totals on a print that stopped at 10 % progress
PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
SET NULL so log entries survive archive deletion preserving #1343
soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.
New per-archive surface:
- archive list response carries run_count / last_run_at /
total_filament_actual_grams / successful_run_count / failed_run_count
via a single batch JOIN, no N+1
- new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
the archive (ARCHIVES_READ permission, newest-first ordering)
- archive cards render an orange "N prints" badge for archives with
more than one run; clicking the badge opens a dedicated PrintLogModal
with date/status/duration/filament/cost columns plus failure_reason
under failed runs. Also reachable via the context menu's new "Print
Log" entry (works for single-run archives too), and embedded at the
top of the Edit Archive modal for context.
The purge_stats=true delete path now hard-deletes linked PrintLogEntry
rows up front so the archive's contribution truly leaves the totals;
without it, ON DELETE SET NULL would orphan the runs and leave them
counting toward stats.
248 lines
9.0 KiB
Python
248 lines
9.0 KiB
Python
"""Tests for the PrintRun-based stats aggregation (#1378).
|
|
|
|
Statistics and per-archive aggregates now come from PrintLogEntry rows rather
|
|
than PrintArchive's runtime fields, so a reprint contributes new totals
|
|
instead of overwriting the source archive's first-run data.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from backend.app.models.print_log import PrintLogEntry
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_stats_count_reprints_independently(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""A reprint adds to stats instead of overwriting the source archive."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
status="completed",
|
|
filament_used_grams=100.0,
|
|
cost=2.5,
|
|
print_time_seconds=3600,
|
|
with_run=False,
|
|
)
|
|
|
|
# First run — completed, 100g.
|
|
db_session.add(
|
|
PrintLogEntry(
|
|
archive_id=archive.id,
|
|
printer_id=archive.printer_id,
|
|
status="completed",
|
|
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
|
|
completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
|
|
duration_seconds=3600,
|
|
filament_used_grams=100.0,
|
|
cost=2.5,
|
|
created_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
|
|
)
|
|
)
|
|
# Reprint — failed at 10g.
|
|
db_session.add(
|
|
PrintLogEntry(
|
|
archive_id=archive.id,
|
|
printer_id=archive.printer_id,
|
|
status="failed",
|
|
started_at=datetime(2026, 5, 5, 10, 0, tzinfo=timezone.utc),
|
|
completed_at=datetime(2026, 5, 5, 10, 5, tzinfo=timezone.utc),
|
|
duration_seconds=300,
|
|
filament_used_grams=10.0,
|
|
cost=0.25,
|
|
failure_reason="Cancelled by user",
|
|
created_at=datetime(2026, 5, 5, 10, 5, tzinfo=timezone.utc),
|
|
)
|
|
)
|
|
await db_session.commit()
|
|
|
|
response = await async_client.get("/api/v1/archives/stats")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
|
|
# Both runs counted, not the single archive row.
|
|
assert body["total_prints"] == 2
|
|
assert body["successful_prints"] == 1
|
|
assert body["failed_prints"] == 1
|
|
|
|
# 100g + 10g — NOT 10g (which is what archives.filament_used_grams alone
|
|
# would give if the archive's runtime fields were the source of truth).
|
|
assert body["total_filament_grams"] == pytest.approx(110.0)
|
|
assert body["total_cost"] == pytest.approx(2.75)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_archive_list_includes_run_aggregates(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""List response carries run_count, last_run_at, total_filament_actual_grams."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
status="completed",
|
|
filament_used_grams=100.0,
|
|
with_run=False,
|
|
)
|
|
db_session.add_all(
|
|
[
|
|
PrintLogEntry(
|
|
archive_id=archive.id,
|
|
printer_id=archive.printer_id,
|
|
status="completed",
|
|
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
|
|
completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
|
|
filament_used_grams=100.0,
|
|
created_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
|
|
),
|
|
PrintLogEntry(
|
|
archive_id=archive.id,
|
|
printer_id=archive.printer_id,
|
|
status="failed",
|
|
started_at=datetime(2026, 5, 10, 10, 0, tzinfo=timezone.utc),
|
|
completed_at=datetime(2026, 5, 10, 10, 5, tzinfo=timezone.utc),
|
|
filament_used_grams=10.0,
|
|
created_at=datetime(2026, 5, 10, 10, 5, tzinfo=timezone.utc),
|
|
),
|
|
]
|
|
)
|
|
await db_session.commit()
|
|
|
|
response = await async_client.get("/api/v1/archives/")
|
|
assert response.status_code == 200
|
|
rows = response.json()
|
|
row = next(r for r in rows if r["id"] == archive.id)
|
|
|
|
assert row["run_count"] == 2
|
|
assert row["successful_run_count"] == 1
|
|
assert row["failed_run_count"] == 1
|
|
assert row["total_filament_actual_grams"] == pytest.approx(110.0)
|
|
assert row["last_run_at"] is not None # max(started_at) populated
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_runs_endpoint_returns_runs_newest_first(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""GET /archives/{id}/runs returns each PrintLogEntry for the archive."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
status="completed",
|
|
with_run=False,
|
|
)
|
|
db_session.add_all(
|
|
[
|
|
PrintLogEntry(
|
|
archive_id=archive.id,
|
|
printer_id=archive.printer_id,
|
|
status="completed",
|
|
started_at=datetime(2026, 4, 1, 10, 0, tzinfo=timezone.utc),
|
|
completed_at=datetime(2026, 4, 1, 11, 0, tzinfo=timezone.utc),
|
|
filament_used_grams=50.0,
|
|
),
|
|
PrintLogEntry(
|
|
archive_id=archive.id,
|
|
printer_id=archive.printer_id,
|
|
status="failed",
|
|
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
|
|
completed_at=datetime(2026, 5, 1, 10, 5, tzinfo=timezone.utc),
|
|
filament_used_grams=5.0,
|
|
failure_reason="Cancelled by user",
|
|
),
|
|
]
|
|
)
|
|
await db_session.commit()
|
|
|
|
response = await async_client.get(f"/api/v1/archives/{archive.id}/runs")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["total"] == 2
|
|
# Newest first
|
|
assert body["items"][0]["status"] == "failed"
|
|
assert body["items"][0]["failure_reason"] == "Cancelled by user"
|
|
assert body["items"][1]["status"] == "completed"
|
|
assert body["items"][1]["filament_used_grams"] == pytest.approx(50.0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_purge_stats_also_deletes_linked_runs(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""``DELETE /archives/{id}?purge_stats=true`` hard-deletes linked PrintLogEntry
|
|
rows so their filament / cost / count contributions truly leave Quick Stats.
|
|
Without this, ON DELETE SET NULL on the FK would orphan the runs and they'd
|
|
keep showing up in the new aggregate-from-PrintLogEntry totals (#1378)."""
|
|
from sqlalchemy import func, select
|
|
|
|
printer = await printer_factory()
|
|
keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
|
|
purge = await archive_factory(printer.id, status="completed", filament_used_grams=100.0)
|
|
|
|
# Extra runs on the archive about to be purged, to prove they all go.
|
|
db_session.add_all(
|
|
[
|
|
PrintLogEntry(
|
|
archive_id=purge.id,
|
|
printer_id=purge.printer_id,
|
|
status="failed",
|
|
filament_used_grams=10.0,
|
|
),
|
|
PrintLogEntry(
|
|
archive_id=purge.id,
|
|
printer_id=purge.printer_id,
|
|
status="completed",
|
|
filament_used_grams=100.0,
|
|
),
|
|
]
|
|
)
|
|
await db_session.commit()
|
|
|
|
resp = await async_client.delete(f"/api/v1/archives/{purge.id}?purge_stats=true")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["purged_from_stats"] is True
|
|
|
|
remaining = await db_session.execute(
|
|
select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == purge.id)
|
|
)
|
|
assert remaining.scalar() == 0
|
|
|
|
# The OTHER archive's auto-synthesized run is still there.
|
|
keep_remaining = await db_session.execute(
|
|
select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == keep.id)
|
|
)
|
|
assert keep_remaining.scalar() == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_soft_delete_keeps_runs_for_stats(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Default soft-delete (without ``purge_stats=true``) keeps the archive's
|
|
PrintLogEntry rows so the #1343 stats-preservation contract still holds —
|
|
the archive disappears from listings, but its filament / time / cost stay
|
|
in Quick Stats."""
|
|
from sqlalchemy import func, select
|
|
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, status="completed", filament_used_grams=75.0)
|
|
|
|
resp = await async_client.delete(f"/api/v1/archives/{archive.id}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["purged_from_stats"] is False
|
|
|
|
# The run row is still there for stats aggregation.
|
|
runs = await db_session.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive.id))
|
|
assert runs.scalar() == 1
|
|
|
|
stats = (await async_client.get("/api/v1/archives/stats")).json()
|
|
assert stats["total_prints"] >= 1
|
|
assert stats["total_filament_grams"] >= 75.0
|