소스 검색

Fix: Failure Analysis widget honours edited failure_reason / status (#1444)

  PrintLogEntry.failure_reason is captured once at print-completion time
  (main.py:3641) by copying archive.failure_reason — which is NULL while
  the user hasn't classified the failure yet. The PATCH /archives/{id}
  route then writes only to print_archives via a generic setattr loop,
  so the log entry stays NULL and failure_analysis.py keeps grouping the
  print as "Unknown". Same desync hits status — flipping it in the modal
  never reached the entry either.

  Mirror failure_reason and status from the PATCH payload to the latest
  PrintLogEntry for that archive (highest id). Latest-only because
  archive.failure_reason / status already reflect the latest run's outcome
  (each reprint clears the archive value at main.py:2195 and rewrites it
  at completion), so the Edit Archive modal is implicitly editing the
  latest run — reprints of an archive that succeeded on the second attempt
  keep the earlier failed run's original classification intact.

  Scoped to those two fields only. cost / print_name / printer_id stay
  unmirrored because per-run values legitimately diverge from archive
  ones (partial-print cost on a failed run vs source archive's full-print
  cost — see _compute_run_filament_grams at main.py:596).
maziggy 1 주 전
부모
커밋
badf0bed04
3개의 변경된 파일123개의 추가작업 그리고 1개의 파일을 삭제
  1. 0 0
      CHANGELOG.md
  2. 27 1
      backend/app/api/routes/archives.py
  3. 96 0
      backend/tests/integration/test_archives_api.py

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
CHANGELOG.md


+ 27 - 1
backend/app/api/routes/archives.py

@@ -1343,9 +1343,35 @@ async def update_archive(
         if archive.created_by_id != user.id:
             raise HTTPException(403, "You can only update your own archives")
 
-    for field, value in update_data.model_dump(exclude_unset=True).items():
+    update_payload = update_data.model_dump(exclude_unset=True)
+    for field, value in update_payload.items():
         setattr(archive, field, value)
 
+    # #1444: Mirror per-run classification fields to the most recent
+    # PrintLogEntry for this archive. PrintLogEntry.failure_reason is captured
+    # once at print-completion time from archive.failure_reason — which is
+    # NULL until the user classifies the failure via the Edit Archive modal.
+    # Without this mirror the Failure Analysis widget (which groups by
+    # print_log_entries.failure_reason) keeps showing "Unknown" forever.
+    # Same desync hits status: flipping it in the modal wouldn't update the
+    # entry either. Only the latest entry is touched because that's the run
+    # the modal is implicitly showing (archive.failure_reason / status are
+    # overwritten on each reprint to reflect the latest run's outcome).
+    mirror_fields = {"failure_reason", "status"}
+    to_mirror = {k: v for k, v in update_payload.items() if k in mirror_fields}
+    if to_mirror:
+        from backend.app.models.print_log import PrintLogEntry
+
+        latest_entry = await db.scalar(
+            select(PrintLogEntry)
+            .where(PrintLogEntry.archive_id == archive_id)
+            .order_by(PrintLogEntry.id.desc())
+            .limit(1)
+        )
+        if latest_entry is not None:
+            for field, value in to_mirror.items():
+                setattr(latest_entry, field, value)
+
     await db.commit()
 
     # Re-fetch with relationships loaded after commit

+ 96 - 0
backend/tests/integration/test_archives_api.py

@@ -168,6 +168,102 @@ class TestArchivesAPI:
         assert response.status_code == 200
         assert response.json()["external_url"] is None
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_archive_failure_reason_mirrors_to_print_log_entry(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1444: PATCH /archives/{id} with failure_reason must mirror to the
+        latest PrintLogEntry so the Stats page Failure Analysis widget
+        (which reads PrintLogEntry.failure_reason) reflects the user's
+        reclassification instead of showing "Unknown" forever.
+        """
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        # archive_factory auto-creates a matching PrintLogEntry (failure_reason
+        # carried from the archive, which is NULL here — same shape as the bug
+        # repro: print completed → log entry written with NULL → user goes to
+        # classify the failure afterwards).
+        archive = await archive_factory(printer.id, print_name="Failed Print", status="failed", run_status="failed")
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"failure_reason": "Adhesion failure"},
+        )
+        assert response.status_code == 200
+
+        result = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        mirrored = result.scalar_one()
+        assert mirrored.failure_reason == "Adhesion failure"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_archive_status_mirrors_to_print_log_entry(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1444: PATCH /archives/{id} with status must mirror to the latest
+        PrintLogEntry so stats that filter on PrintLogEntry.status see the
+        user's reclassification.
+        """
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, run_status="completed")
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"status": "failed"},
+        )
+        assert response.status_code == 200
+
+        result = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        mirrored = result.scalar_one()
+        assert mirrored.status == "failed"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_archive_failure_reason_only_touches_latest_entry(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1444: For an archive with multiple runs (reprints), only the
+        latest PrintLogEntry should receive the reclassification. Earlier
+        runs were classified at their own time and must not be retroactively
+        overwritten.
+        """
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        # First run — created by the factory's auto-run with its own reason.
+        archive = await archive_factory(printer.id, status="failed", run_status="failed")
+        from sqlalchemy import select
+
+        first_run = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        first_run.failure_reason = "Filament tangle"
+        await db_session.commit()
+
+        # Second run — the reprint that just finished with NULL classification.
+        latest_run = PrintLogEntry(archive_id=archive.id, status="failed", failure_reason=None)
+        db_session.add(latest_run)
+        await db_session.commit()
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"failure_reason": "Adhesion failure"},
+        )
+        assert response.status_code == 200
+
+        await db_session.refresh(first_run)
+        await db_session.refresh(latest_run)
+        assert first_run.failure_reason == "Filament tangle"
+        assert latest_run.failure_reason == "Adhesion failure"
+
     # ========================================================================
     # Delete endpoints
     # ========================================================================

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.