Ver código fonte

Archives: the archive shows its latest run (archive redesign P1b)

- The archive's status, times, failure reason and plate follow its
  latest run by start, set in one place after every run is written or
  edited in the Print Log, once none of its runs is still printing
- Edit Archive writes onto that run, not the newest id
- The completion notification takes its duration and failure reason
  from the run that ended
maziggy 1 dia atrás
pai
commit
82e6533df2

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

@@ -2036,10 +2036,12 @@ async def update_archive(
     if to_mirror and previous_status != "printing":
         from backend.app.models.print_log import PrintLogEntry
 
+        # The latest by its start, the run the archive shows: ids follow the
+        # starts too, but plates on two printers can end the other way round.
         latest_entry = await db.scalar(
             select(PrintLogEntry)
             .where(PrintLogEntry.archive_id == archive_id)
-            .order_by(PrintLogEntry.id.desc())
+            .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
             .limit(1)
         )
         if latest_entry is not None:

+ 6 - 0
backend/app/api/routes/print_log.py

@@ -337,6 +337,12 @@ async def update_print_log_entry(
             raise HTTPException(400, f"Unknown status: {new_status!r}")
         entry.status = new_status
 
+    if entry.archive_id is not None:
+        from backend.app.services.print_log import mirror_latest_run
+
+        # The archive card shows its latest run; this may be it.
+        await mirror_latest_run(db, entry.archive_id)
+
     await db.commit()
     await db.refresh(entry)
 

+ 11 - 4
backend/app/main.py

@@ -10048,8 +10048,15 @@ async def _close_out_print(printer_id: int, data: dict):
 
                 archive_data = None
                 if archive_id:
+                    from backend.app.models.print_log import PrintLogEntry
+
                     archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
                     archive = archive_result.scalar_one_or_none()
+                    # The run that ended: the archive shows its latest, which
+                    # with another plate of it printing elsewhere is not this.
+                    run = await db.get(PrintLogEntry, completed_run_id) if completed_run_id is not None else None
+                    run_started = run.started_at if run is not None else archive.started_at if archive else None
+                    run_completed = run.completed_at if run is not None else archive.completed_at if archive else None
                     if archive:
                         # Actual elapsed time from started_at/completed_at when both are
                         # populated (every terminal status sets completed_at after #1198).
@@ -10062,11 +10069,11 @@ async def _close_out_print(printer_id: int, data: dict):
                         # as the duration -- 14 h for a night offline (DB-C9).
                         # An estimated end (#3310) gives the estimate.
                         if (
-                            archive.started_at
-                            and archive.completed_at
+                            run_started
+                            and run_completed
                             and (not data.get("_reconciled") or data.get("_reconciled_completed_at"))
                         ):
-                            elapsed = (archive.completed_at - archive.started_at).total_seconds()
+                            elapsed = (run_completed - run_started).total_seconds()
                             if elapsed > 0:
                                 actual_time_seconds = int(elapsed)
 
@@ -10074,7 +10081,7 @@ async def _close_out_print(printer_id: int, data: dict):
                             "print_time_seconds": archive.print_time_seconds,
                             "actual_time_seconds": actual_time_seconds,
                             "actual_filament_grams": archive.filament_used_grams,
-                            "failure_reason": archive.failure_reason,
+                            "failure_reason": run.failure_reason if run is not None else archive.failure_reason,
                             "created_by_id": archive.created_by_id,
                         }
 

+ 40 - 0
backend/app/services/print_log.py

@@ -137,9 +137,49 @@ async def write_log_entry(
     # changes nothing: the repeat no longer has the run's tracked usage, and the
     # entry may have been edited or given its energy since.
     await db.flush()
+    if archive_id is not None:
+        await mirror_latest_run(db, archive_id)
     return entry
 
 
+# What the archive card shows of its prints, its latest run's.
+_MIRRORED_FROM_RUN = ("status", "started_at", "completed_at", "failure_reason")
+
+
+async def mirror_latest_run(db: AsyncSession, archive_id: int) -> None:
+    """Give the archive its latest run's outcome, by start: status, times,
+    failure reason and plate. Only once none of its runs is printing: until
+    the print's start, end, reconcile and timelapse look their print up by
+    its run, they still go by the archive's printer, status and start, which
+    a print running elsewhere must not move under them. While one prints,
+    the archive keeps what its prints' starts and ends wrote. An archive
+    without runs keeps its own (uploaded, or printed before runs were
+    logged). Does not commit."""
+    from backend.app.models.archive import PrintArchive
+
+    archive = await db.get(PrintArchive, archive_id)
+    if archive is None:
+        return
+    runs = select(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id)
+    still_printing = await db.scalar(
+        runs.with_only_columns(PrintLogEntry.id)
+        .where(PrintLogEntry.status == RUN_OPEN)
+        .limit(1)
+        .execution_options(**{INCLUDE_OPEN_RUNS: True})
+    )
+    if still_printing is not None:
+        return
+    latest = await db.scalar(
+        runs.order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc()).limit(1)
+    )
+    if latest is None:
+        return
+    for name in _MIRRORED_FROM_RUN:
+        setattr(archive, name, getattr(latest, name))
+    if latest.plate_id is not None:
+        archive.plate_id = latest.plate_id
+
+
 async def mirror_archive_cost(
     db: AsyncSession, archive, previous_cost: float | None, *, skip_runs: AbstractSet[int] = frozenset()
 ) -> None:

+ 141 - 0
backend/tests/integration/test_run_lifecycle.py

@@ -750,3 +750,144 @@ async def test_a_late_end_of_an_earlier_job_leaves_the_running_print_alone(test_
     assert [(r.status, r.subtask_id) for r in await _all_runs(db_session, farm.archive)] == [("printing", running_job)]
     archive = await db_session.get(PrintArchive, farm.archive)
     assert archive.status == "printing"
+
+
+async def test_the_archive_shows_its_latest_run(test_engine, db_session, farm, printer_factory):
+    """Plate 1 ends while plate 2 still prints: the archive is left as plate
+    1's end wrote it -- the reconcile, the next end and the timelapse still
+    go by its printer, status and start. When plate 2 ends, the last of its
+    prints, it shows plate 2's outcome."""
+    other = await _two_printers_printing(test_engine, db_session, farm, printer_factory)
+
+    await _complete(test_engine, farm.printer, "failed", name=SUBTASK)
+    runs = {r.printer_id: r for r in await _all_runs(db_session, farm.archive)}
+    archive = await db_session.get(PrintArchive, farm.archive)
+    await db_session.refresh(archive)
+    assert (archive.status, archive.printer_id, archive.started_at) == ("failed", other, runs[other].started_at)
+
+    await _complete(test_engine, other, "completed", name=SUBTASK)
+    runs = {r.printer_id: r for r in await _all_runs(db_session, farm.archive)}
+    archive = await db_session.get(PrintArchive, farm.archive)
+    await db_session.refresh(archive)
+    assert (archive.status, archive.started_at, archive.completed_at, archive.plate_id, archive.failure_reason) == (
+        "completed",
+        runs[other].started_at,
+        runs[other].completed_at,
+        2,
+        None,
+    )
+
+
+async def test_the_end_of_an_earlier_start_does_not_take_the_card(test_engine, db_session, farm, printer_factory):
+    """Plate 2 started later and ended first; plate 1 ends after it. The
+    archive shows the latest start, plate 2, not the last end."""
+    other = await _two_printers_printing(test_engine, db_session, farm, printer_factory)
+
+    await _complete(test_engine, other, "completed", name=SUBTASK)
+    await _complete(test_engine, farm.printer, "failed", name=SUBTASK)
+
+    archive = await db_session.get(PrintArchive, farm.archive)
+    await db_session.refresh(archive)
+    assert (archive.status, archive.plate_id) == ("completed", 2)
+
+
+async def test_the_notification_of_an_end_has_its_runs_time(test_engine, db_session, farm, printer_factory):
+    """Plate 1's end is notified with plate 1's duration, not the time since
+    plate 2 started (the archive's start)."""
+    await _two_printers_printing(test_engine, db_session, farm, printer_factory)
+
+    notif = await _complete(test_engine, farm.printer, "completed", name=SUBTASK)
+
+    archive_data = notif.on_print_complete.await_args.kwargs["archive_data"]
+    assert archive_data["actual_time_seconds"] is not None and archive_data["actual_time_seconds"] >= 1
+
+
+async def test_a_run_edited_in_the_print_log_is_shown_on_its_archive(test_engine, db_session, farm, async_client):
+    await _run(test_engine, db_session, farm, plate=1, status="failed", run_id="run-1")
+    [run] = await _runs(db_session, farm.archive)
+
+    response = await async_client.patch(
+        f"/api/v1/print-log/{run.id}", json={"status": "completed", "failure_reason": ""}
+    )
+    assert response.status_code == 200, response.text
+
+    archive = await db_session.get(PrintArchive, farm.archive)
+    await db_session.refresh(archive)
+    assert (archive.status, archive.failure_reason) == ("completed", None)
+
+
+async def test_an_edit_of_the_archive_lands_on_its_latest_started_run(db_session, farm, async_client):
+    """The run the card shows is the latest start; with plates ended the
+    other way round that is not the highest id."""
+    from datetime import datetime
+
+    later = PrintLogEntry(
+        archive_id=farm.archive, printer_id=farm.printer, status="completed", started_at=datetime(2026, 10, 30, 10)
+    )
+    earlier = PrintLogEntry(
+        archive_id=farm.archive, printer_id=farm.printer, status="completed", started_at=datetime(2026, 10, 30, 8)
+    )
+    db_session.add(later)
+    await db_session.flush()
+    db_session.add(earlier)
+    await db_session.commit()
+
+    response = await async_client.patch(
+        f"/api/v1/archives/{farm.archive}", json={"status": "failed", "failure_reason": "spaghetti"}
+    )
+    assert response.status_code == 200, response.text
+
+    db_session.expire_all()
+    rows = {r.started_at.hour: (r.status, r.failure_reason) for r in await _runs(db_session, farm.archive)}
+    assert rows == {10: ("failed", "spaghetti"), 8: ("completed", None)}
+
+
+async def test_the_latest_run_is_the_latest_start(db_session, farm):
+    """Runs written at their end (before runs were opened at the start) are
+    numbered by their end; the card's run is still the latest start."""
+    from datetime import datetime
+
+    from backend.app.services.print_log import mirror_latest_run
+
+    db_session.add(
+        PrintLogEntry(
+            archive_id=farm.archive, printer_id=farm.printer, status="failed", started_at=datetime(2026, 10, 30, 10)
+        )
+    )
+    await db_session.flush()
+    db_session.add(
+        PrintLogEntry(
+            archive_id=farm.archive, printer_id=farm.printer, status="completed", started_at=datetime(2026, 10, 30, 8)
+        )
+    )
+    await db_session.flush()
+
+    await mirror_latest_run(db_session, farm.archive)
+
+    archive = await db_session.get(PrintArchive, farm.archive)
+    assert (archive.status, archive.started_at) == ("failed", datetime(2026, 10, 30, 10))
+
+
+async def test_a_later_plates_end_leaves_no_printing_archive_for_its_reconnect(
+    test_engine, db_session, farm, printer_factory
+):
+    """The later-started plate ends first. Its printer's reconnect looks for
+    printing archives of that printer to close: the archive must not be one,
+    or a second end of the print is made up."""
+    from unittest.mock import MagicMock, patch
+
+    from backend.app.main import reconcile_stale_active_prints
+
+    other = await _two_printers_printing(test_engine, db_session, farm, printer_factory)
+    await _complete(test_engine, other, "completed", name=SUBTASK)
+
+    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+    state = MagicMock(connected=True, state="FINISH", subtask_id="", progress=100, layer_num=0)
+    maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    with (
+        patch("backend.app.main.printer_manager.get_status", return_value=state),
+        patch("backend.app.main.async_session", maker),
+    ):
+        assert await reconcile_stale_active_prints(other) == 0
+    assert [r.printer_id for r in await _runs(db_session, farm.archive)] == [other]