Просмотр исходного кода

fix(stats): per-event data for all widgets, not just Quick Stats (#1390)

  After #1378 moved Quick Stats to print_log_entries, six widgets and
  Failure Analysis still iterated the archive list. That made reprints
  multiply event-based widgets while leaving archive-based ones unchanged,
  and made hard-deleted archives drop from archive-based widgets while
  their orphan events kept feeding Quick Stats.

  Swap the data source in two places:

  - GET /archives/slim now reads PrintLogEntry, LEFT JOINs the archive for
    the sliced print_time_seconds estimate, prefers PrintLogEntry's own
    duration_seconds as the measured-time field. StatsPage is the only
    caller -- every widget realigns in one step.
  - FailureAnalysisService swapped from PrintArchive to PrintLogEntry for
    every aggregation. project_id filter still resolves through archives
    but counts matching events.

  Conftest archive_factory now syncs the synthesized event's created_at
  with the archive's so backdated test data survives the change.
maziggy 1 неделя назад
Родитель
Сommit
f645bd2bbe

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 37 - 26
backend/app/api/routes/archives.py

@@ -415,38 +415,45 @@ async def list_archives_slim(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
-    """Lightweight archive listing for stats/dashboard widgets.
-
-    Returns only the fields needed for client-side aggregation,
-    skipping duplicate detection, file paths, and extra_data.
+    """Per-event listing for stats/dashboard widgets.
+
+    Reads from print_log_entries so reprints contribute each run and
+    orphaned events (archive deleted, log row survived via ON DELETE
+    SET NULL) still aggregate consistently with Quick Stats. The sliced
+    print_time_seconds is joined from the archive when available; for
+    orphan events it is null and downstream widgets fall back to the
+    measured duration_seconds.
     """
+    from backend.app.models.print_log import PrintLogEntry
+
     _validate_user_filter_permission(current_user, created_by_id)
     filters = []
     if date_from:
         dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
-        filters.append(PrintArchive.created_at >= dt_from)
+        filters.append(PrintLogEntry.created_at >= dt_from)
     if date_to:
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
-        filters.append(PrintArchive.created_at <= dt_to)
-    _apply_user_filter(filters, created_by_id)
+        filters.append(PrintLogEntry.created_at <= dt_to)
+    _apply_run_user_filter(filters, created_by_id)
 
     query = (
         select(
-            PrintArchive.printer_id,
-            PrintArchive.print_name,
+            PrintLogEntry.printer_id,
+            PrintLogEntry.print_name,
             PrintArchive.print_time_seconds,
-            PrintArchive.started_at,
-            PrintArchive.completed_at,
-            PrintArchive.filament_used_grams,
-            PrintArchive.filament_type,
-            PrintArchive.filament_color,
-            PrintArchive.status,
-            PrintArchive.cost,
-            PrintArchive.quantity,
-            PrintArchive.created_at,
+            PrintLogEntry.started_at,
+            PrintLogEntry.completed_at,
+            PrintLogEntry.duration_seconds,
+            PrintLogEntry.filament_used_grams,
+            PrintLogEntry.filament_type,
+            PrintLogEntry.filament_color,
+            PrintLogEntry.status,
+            PrintLogEntry.cost,
+            PrintLogEntry.created_at,
         )
+        .outerjoin(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
         .where(*filters)
-        .order_by(PrintArchive.created_at.desc())
+        .order_by(PrintLogEntry.created_at.desc())
         .limit(limit)
         .offset(offset)
     )
@@ -459,12 +466,16 @@ async def list_archives_slim(
             "print_name": r.print_name,
             "print_time_seconds": r.print_time_seconds,
             "actual_time_seconds": (
-                int((r.completed_at - r.started_at).total_seconds())
-                if r.started_at
-                and r.completed_at
-                and r.status == "completed"
-                and (r.completed_at - r.started_at).total_seconds() > 0
-                else None
+                r.duration_seconds
+                if r.duration_seconds and r.duration_seconds > 0 and r.status == "completed"
+                else (
+                    int((r.completed_at - r.started_at).total_seconds())
+                    if r.started_at
+                    and r.completed_at
+                    and r.status == "completed"
+                    and (r.completed_at - r.started_at).total_seconds() > 0
+                    else None
+                )
             ),
             "filament_used_grams": r.filament_used_grams,
             "filament_type": r.filament_type,
@@ -473,7 +484,7 @@ async def list_archives_slim(
             "started_at": r.started_at,
             "completed_at": r.completed_at,
             "cost": r.cost,
-            "quantity": r.quantity,
+            "quantity": 1,
             "created_at": r.created_at,
         }
         for r in rows

+ 71 - 61
backend/app/services/failure_analysis.py

@@ -4,12 +4,19 @@ from datetime import date, datetime, time, timedelta, timezone
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.printer import Printer
 
 
 class FailureAnalysisService:
-    """Service for analyzing print failure patterns."""
+    """Service for analyzing print failure patterns.
+
+    Reads from print_log_entries (per-event data) rather than print_archives
+    so reprints contribute each run and orphan events (archive deleted, log
+    row survived via ON DELETE SET NULL) still count consistently with
+    Quick Stats. The archive-based predecessor diverged from Quick Stats
+    after #1378 moved the rest of the page to per-event aggregation.
+    """
 
     def __init__(self, db: AsyncSession):
         self.db = db
@@ -23,54 +30,54 @@ class FailureAnalysisService:
         project_id: int | None = None,
         created_by_id: int | None = None,
     ) -> dict:
-        """Analyze failure patterns across archives.
-
-        Args:
-            days: Number of days to analyze (fallback when no date range)
-            date_from: Start date filter (inclusive)
-            date_to: End date filter (inclusive)
-            printer_id: Optional filter by printer
-            project_id: Optional filter by project
-
-        Returns:
-            Dictionary with failure analysis results
-        """
+        """Analyze failure patterns across logged print events."""
         # Build base query — separate date vs non-date filters for trend reuse
         base_filter = []
         non_date_filter = []
         if date_from or date_to:
             if date_from:
                 dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
-                base_filter.append(PrintArchive.created_at >= dt_from)
+                base_filter.append(PrintLogEntry.created_at >= dt_from)
             if date_to:
                 dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
-                base_filter.append(PrintArchive.created_at <= dt_to)
-            # Compute effective span for trend
+                base_filter.append(PrintLogEntry.created_at <= dt_to)
             range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)
             range_end = dt_to if date_to else datetime.now(timezone.utc)
             effective_days = max((range_end - range_start).days, 1)
         else:
             effective_days = days if days is not None else 30
             cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)
-            base_filter.append(PrintArchive.created_at >= cutoff_date)
+            base_filter.append(PrintLogEntry.created_at >= cutoff_date)
         if printer_id:
-            non_date_filter.append(PrintArchive.printer_id == printer_id)
+            non_date_filter.append(PrintLogEntry.printer_id == printer_id)
+        # project_id is an archive-level concept; PrintLogEntry has no project
+        # link, so we resolve it by archive_id where present.
         if project_id:
-            non_date_filter.append(PrintArchive.project_id == project_id)
+            from backend.app.models.archive import PrintArchive
+
+            project_archive_ids = await self.db.execute(
+                select(PrintArchive.id).where(PrintArchive.project_id == project_id)
+            )
+            archive_ids = [row[0] for row in project_archive_ids.fetchall()]
+            if archive_ids:
+                non_date_filter.append(PrintLogEntry.archive_id.in_(archive_ids))
+            else:
+                # No archives in this project → nothing to count
+                non_date_filter.append(PrintLogEntry.id.is_(None))
         if created_by_id is not None:
             if created_by_id == -1:
-                non_date_filter.append(PrintArchive.created_by_id.is_(None))
+                non_date_filter.append(PrintLogEntry.created_by_id.is_(None))
             else:
-                non_date_filter.append(PrintArchive.created_by_id == created_by_id)
+                non_date_filter.append(PrintLogEntry.created_by_id == created_by_id)
         base_filter.extend(non_date_filter)
 
         # Total counts
-        total_result = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*base_filter)))
+        total_result = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*base_filter)))
         total_prints = total_result.scalar() or 0
 
         failed_result = await self.db.execute(
-            select(func.count(PrintArchive.id)).where(
-                and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"]))
+            select(func.count(PrintLogEntry.id)).where(
+                and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
             )
         )
         failed_prints = failed_result.scalar() or 0
@@ -80,38 +87,42 @@ class FailureAnalysisService:
         # Failures by reason
         reason_result = await self.db.execute(
             select(
-                PrintArchive.failure_reason,
-                func.count(PrintArchive.id).label("count"),
+                PrintLogEntry.failure_reason,
+                func.count(PrintLogEntry.id).label("count"),
             )
-            .where(and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"])))
-            .group_by(PrintArchive.failure_reason)
-            .order_by(func.count(PrintArchive.id).desc())
+            .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
+            .group_by(PrintLogEntry.failure_reason)
+            .order_by(func.count(PrintLogEntry.id).desc())
         )
         failures_by_reason = {(row[0] or "Unknown"): row[1] for row in reason_result.fetchall()}
 
         # Failures by filament type
         filament_result = await self.db.execute(
             select(
-                PrintArchive.filament_type,
-                func.count(PrintArchive.id).label("count"),
+                PrintLogEntry.filament_type,
+                func.count(PrintLogEntry.id).label("count"),
             )
-            .where(and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"])))
-            .group_by(PrintArchive.filament_type)
-            .order_by(func.count(PrintArchive.id).desc())
+            .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
+            .group_by(PrintLogEntry.filament_type)
+            .order_by(func.count(PrintLogEntry.id).desc())
         )
         failures_by_filament = {(row[0] or "Unknown"): row[1] for row in filament_result.fetchall()}
 
         # Failures by printer
         printer_result = await self.db.execute(
             select(
-                PrintArchive.printer_id,
-                func.count(PrintArchive.id).label("count"),
+                PrintLogEntry.printer_id,
+                func.count(PrintLogEntry.id).label("count"),
             )
             .where(
-                and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"]), PrintArchive.printer_id.isnot(None))
+                and_(
+                    *base_filter,
+                    PrintLogEntry.status.in_(["failed", "aborted"]),
+                    PrintLogEntry.printer_id.isnot(None),
+                )
             )
-            .group_by(PrintArchive.printer_id)
-            .order_by(func.count(PrintArchive.id).desc())
+            .group_by(PrintLogEntry.printer_id)
+            .order_by(func.count(PrintLogEntry.id).desc())
         )
         failures_by_printer_id = {row[0]: row[1] for row in printer_result.fetchall()}
 
@@ -128,40 +139,39 @@ class FailureAnalysisService:
             failures_by_printer = {}
 
         # Failures by hour of day
-        failed_archives_result = await self.db.execute(
-            select(PrintArchive.started_at).where(
+        failed_events_result = await self.db.execute(
+            select(PrintLogEntry.started_at).where(
                 and_(
                     *base_filter,
-                    PrintArchive.status.in_(["failed", "aborted"]),
-                    PrintArchive.started_at.isnot(None),
+                    PrintLogEntry.status.in_(["failed", "aborted"]),
+                    PrintLogEntry.started_at.isnot(None),
                 )
             )
         )
         failures_by_hour = defaultdict(int)
-        for (started_at,) in failed_archives_result.fetchall():
+        for (started_at,) in failed_events_result.fetchall():
             if started_at:
                 hour = started_at.hour
                 failures_by_hour[hour] += 1
-        # Convert to dict with all 24 hours
         failures_by_hour_complete = {h: failures_by_hour.get(h, 0) for h in range(24)}
 
         # Recent failures
         recent_result = await self.db.execute(
-            select(PrintArchive)
-            .where(and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"])))
-            .order_by(PrintArchive.created_at.desc())
+            select(PrintLogEntry)
+            .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
+            .order_by(PrintLogEntry.created_at.desc())
             .limit(10)
         )
         recent_failures = [
             {
-                "id": a.id,
-                "print_name": a.print_name or a.filename,
-                "failure_reason": a.failure_reason,
-                "filament_type": a.filament_type,
-                "printer_id": a.printer_id,
-                "created_at": a.created_at.isoformat() if a.created_at else None,
+                "id": e.archive_id,
+                "print_name": e.print_name,
+                "failure_reason": e.failure_reason,
+                "filament_type": e.filament_type,
+                "printer_id": e.printer_id,
+                "created_at": e.created_at.isoformat() if e.created_at else None,
             }
-            for a in recent_result.scalars().all()
+            for e in recent_result.scalars().all()
         ]
 
         # Failure rate trend (by week)
@@ -172,15 +182,15 @@ class FailureAnalysisService:
             week_start = week_end - timedelta(weeks=1)
 
             week_filter = [
-                PrintArchive.created_at >= week_start,
-                PrintArchive.created_at < week_end,
+                PrintLogEntry.created_at >= week_start,
+                PrintLogEntry.created_at < week_end,
                 *non_date_filter,
             ]
 
-            week_total = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*week_filter)))
+            week_total = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*week_filter)))
             week_failed = await self.db.execute(
-                select(func.count(PrintArchive.id)).where(
-                    and_(*week_filter, PrintArchive.status.in_(["failed", "aborted"]))
+                select(func.count(PrintLogEntry.id)).where(
+                    and_(*week_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
                 )
             )
 

+ 3 - 0
backend/tests/conftest.py

@@ -559,6 +559,9 @@ def archive_factory(db_session):
                 failure_reason=archive.failure_reason,
                 print_name=archive.print_name,
                 created_by_id=archive.created_by_id,
+                # Sync the event's created_at with the archive's so date-range
+                # filtered tests that backdate an archive still find its event.
+                created_at=archive.created_at,
             )
             db_session.add(run)
             await db_session.commit()

+ 142 - 1
backend/tests/integration/test_archives_api.py

@@ -483,7 +483,10 @@ class TestArchivesSlimAPI:
         assert item["filament_used_grams"] == 50.0
         assert item["print_time_seconds"] == 3600
         assert item["cost"] == 1.50
-        assert item["quantity"] == 2
+        # quantity is per-event semantics now (each PrintLogEntry = one run);
+        # the archive's quantity field is no longer surfaced through this
+        # endpoint after the #1390 per-event migration.
+        assert item["quantity"] == 1
         assert "created_at" in item
 
         # Full archive fields must NOT be present
@@ -585,6 +588,144 @@ class TestArchivesSlimAPI:
         assert response.status_code == 200
         assert len(response.json()) == 2
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_counts_reprints_as_separate_rows(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Reprints add events even though the archive row is overwritten (#1390).
+
+        Before the per-event migration, /archives/slim returned one row per
+        archive — so an archive that had been reprinted three times appeared
+        once and undercounted Filament Used / Cost / Time. The endpoint must
+        now return one row per logged event.
+        """
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Reprinted Model",
+            filament_used_grams=50.0,
+            cost=1.50,
+        )
+        # archive_factory synthesizes one event; add two more to simulate
+        # the same archive being reprinted twice more.
+        for _ in range(2):
+            db_session.add(
+                PrintLogEntry(
+                    archive_id=archive.id,
+                    printer_id=archive.printer_id,
+                    status="completed",
+                    filament_type=archive.filament_type,
+                    filament_used_grams=archive.filament_used_grams,
+                    cost=archive.cost,
+                    print_name=archive.print_name,
+                )
+            )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 3, "Each reprint must contribute one row"
+        total_filament = sum(item["filament_used_grams"] or 0 for item in data)
+        assert total_filament == 150.0, "Sum across events must reflect all three runs"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_includes_orphan_events(self, async_client: AsyncClient, printer_factory, db_session):
+        """Events whose archive was hard-deleted still appear (#1390).
+
+        After ON DELETE SET NULL the event row survives with archive_id=NULL.
+        The slim endpoint must keep counting it so Quick Stats and the
+        archive-iterating widgets stay aligned.
+        """
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        db_session.add(
+            PrintLogEntry(
+                archive_id=None,
+                printer_id=printer.id,
+                status="completed",
+                filament_type="PETG",
+                filament_used_grams=25.0,
+                cost=0.75,
+                print_name="Orphaned Print",
+            )
+        )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["print_name"] == "Orphaned Print"
+        assert data[0]["filament_used_grams"] == 25.0
+        # print_time_seconds (sliced estimate) comes from the archive table,
+        # which orphans no longer have — must surface as null gracefully.
+        assert data[0]["print_time_seconds"] is None
+
+
+class TestFailureAnalysisAPI:
+    """Per-event failure analysis (#1390)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_failure_analysis_counts_reprints_and_orphans(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Failure analysis aggregates per event, not per archive.
+
+        Verifies the dual fix for #1390: a reprint that adds a second failed
+        event must count twice, and an orphan failed event (archive deleted)
+        must still appear in the totals.
+        """
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Failing Model",
+            status="failed",
+            failure_reason="filament_runout",
+        )
+        # Add a second failed event for the same archive (a reprint that also
+        # failed) and one orphan failed event (archive was deleted).
+        db_session.add(
+            PrintLogEntry(
+                archive_id=archive.id,
+                printer_id=printer.id,
+                status="failed",
+                failure_reason="filament_runout",
+                filament_type=archive.filament_type,
+                print_name=archive.print_name,
+            )
+        )
+        db_session.add(
+            PrintLogEntry(
+                archive_id=None,
+                printer_id=printer.id,
+                status="failed",
+                failure_reason="bed_adhesion",
+                filament_type="PETG",
+                print_name="Orphaned Failed Print",
+            )
+        )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/archives/analysis/failures")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["total_prints"] == 3
+        assert result["failed_prints"] == 3
+        assert result["failures_by_reason"]["filament_runout"] == 2
+        assert result["failures_by_reason"]["bed_adhesion"] == 1
+
 
 class TestArchiveDataIntegrity:
     """Tests for archive data integrity."""

Некоторые файлы не были показаны из-за большого количества измененных файлов