Переглянути джерело

Archives: the API marks the per-print fields and names the latest run (archive redesign P3)

- The archive's per-print fields (status, times, failure reason,
  verdict, printer, plate, video, photos, energy) are flagged
  deprecated in the OpenAPI schema; they stay one more release, read
  them from the archive's runs instead
- latest_run_id: the latest print by start, a running one included,
  among those the caller may see
- A caller limited to some printers gets their own print whenever the
  latest is on a printer outside their scope; the archive's printer
  follows its latest print
- The favourite toggle answers with the full archive
maziggy 1 день тому
батько
коміт
d9cf4762fa

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

@@ -371,7 +371,7 @@ async def _load_run_aggregates(
     """Batch-load per-archive run aggregates from PrintLogEntry.
 
     Returns ``{archive_id: {run_count, last_run_at, total_filament_actual_grams,
-    successful_run_count, failed_run_count, unmeasured_run_count}}``. Archives with no logged runs are
+    successful_run_count, failed_run_count, unmeasured_run_count, latest_run_id}}``. Archives with no logged runs are
     absent from the map; callers should treat that as zero/none.
 
     With ``printer_scope``, only the runs on its printers count: a caller
@@ -411,6 +411,28 @@ async def _load_run_aggregates(
             "failed_run_count": int(failed or 0),
             "unmeasured_run_count": int(estimated or 0),
         }
+    # The latest print of each (by start), which the archive shows: a print
+    # running now included, as the archive shows it while it runs.
+    scope = printer_scope.where(PrintLogEntry.printer_id) if printer_scope is not None else None
+    ranked = (
+        select(
+            PrintLogEntry.archive_id,
+            PrintLogEntry.id,
+            func.row_number()
+            .over(
+                partition_by=PrintLogEntry.archive_id,
+                order_by=(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc()),
+            )
+            .label("rn"),
+        )
+        .where(PrintLogEntry.archive_id.in_(archive_ids), *([scope] if scope is not None else []))
+        .subquery()
+    )
+    latest = await db.execute(
+        select(ranked.c.archive_id, ranked.c.id).where(ranked.c.rn == 1).execution_options(**{INCLUDE_OPEN_RUNS: True})
+    )
+    for archive_id, run_id in latest.all():
+        aggregates.setdefault(archive_id, {})["latest_run_id"] = run_id
     return aggregates
 
 
@@ -558,6 +580,7 @@ def archive_to_response(
         data["total_filament_actual_grams"] = run_aggregate.get("total_filament_actual_grams")
         data["successful_run_count"] = run_aggregate.get("successful_run_count", 0)
         data["failed_run_count"] = run_aggregate.get("failed_run_count", 0)
+        data["latest_run_id"] = run_aggregate.get("latest_run_id")
 
     return data
 
@@ -2428,9 +2451,8 @@ async def toggle_favorite(
     await db.commit()
     await db.refresh(archive)
     view = await scoped_view(db, archive, printer_scope)
-    if view is None:
-        return archive
-    # As the list shows it to a caller limited to some printers.
+    # As the list shows it (its run figures; to a caller limited to some
+    # printers, their view of it).
     from sqlalchemy.orm import selectinload
 
     loaded = await db.scalar(

+ 38 - 17
backend/app/schemas/archive.py

@@ -1,5 +1,5 @@
 from datetime import datetime
-from typing import Annotated
+from typing import Annotated, Any
 
 from pydantic import BaseModel, BeforeValidator, Field, model_validator
 
@@ -65,9 +65,25 @@ class ArchiveDuplicate(BaseModel):
     match_type: str  # "exact" (hash match) or "similar" (name match)
 
 
+# Archive redesign: an archive is a file, each print of it a run. The fields
+# so marked describe one print, the latest (for a caller limited to some
+# printers, their latest): read them from GET /archives/{id}/runs, or with
+# ?run_id=, instead. Kept on the archive for one more release, flagged in the
+# OpenAPI schema only: a Pydantic deprecation would warn on every read inside
+# Bambuddy too.
+_PER_PRINT = (
+    "Deprecated: the latest print's (latest_run_id). Read it from the archive's runs (GET /archives/{id}/runs)."
+)
+_FIRST_PRINT = "Deprecated: the first print's. Read it from the archive's runs (GET /archives/{id}/runs)."
+
+
+def _per_print(default: Any = ..., description: str = _PER_PRINT) -> Any:
+    return Field(default, description=description, json_schema_extra={"deprecated": True})
+
+
 class ArchiveResponse(BaseModel):
     id: int
-    printer_id: int | None
+    printer_id: int | None = _per_print()
     project_id: int | None = None
     project_name: str | None = None  # Included for convenience
     filename: str
@@ -75,7 +91,7 @@ class ArchiveResponse(BaseModel):
     file_size: int
     content_hash: str | None
     thumbnail_path: str | None
-    timelapse_path: str | None
+    timelapse_path: str | None = _per_print()
     source_3mf_path: str | None = None  # Original project 3MF from slicer
     f3d_path: str | None = None  # Fusion 360 design file
 
@@ -89,11 +105,11 @@ class ArchiveResponse(BaseModel):
     object_count: int | None = None
 
     print_name: str | None
-    plate_id: int | None = None  # Selected plate of a multi-plate 3MF (#2603)
+    plate_id: int | None = _per_print(None)  # Selected plate of a multi-plate 3MF (#2603)
     print_time_seconds: int | None  # Estimated time from slicer
-    actual_time_seconds: int | None = None  # Computed from started_at/completed_at
+    actual_time_seconds: int | None = _per_print(None)  # Computed from started_at/completed_at
     # Percentage: 100 = perfect, >100 = faster than estimated
-    time_accuracy: float | None = None
+    time_accuracy: float | None = _per_print(None)
     filament_used_grams: float | None
     filament_type: str | None
     filament_color: str | None
@@ -106,9 +122,9 @@ class ArchiveResponse(BaseModel):
 
     sliced_for_model: str | None = None  # Printer model this file was sliced for
 
-    status: str
-    started_at: datetime | None
-    completed_at: datetime | None
+    status: str = _per_print()
+    started_at: datetime | None = _per_print()
+    completed_at: datetime | None = _per_print()
 
     extra_data: dict | None
 
@@ -121,23 +137,28 @@ class ArchiveResponse(BaseModel):
     tags: str | None
     notes: str | None
     cost: float | None
-    photos: list | None
-    failure_reason: str | None
+    photos: list | None = _per_print(
+        description="Every print's finish photos and the ones uploaded by hand; a print's own are on its run."
+    )
+    failure_reason: str | None = _per_print()
     quantity: int = 1  # Number of items printed
 
     # Post-print outcome confirmation (#1898)
-    user_verdict: str | None = None
-    user_verdict_source: str | None = None
-    user_verdict_at: datetime | None = None
+    user_verdict: str | None = _per_print(None)
+    user_verdict_source: str | None = _per_print(None)
+    user_verdict_at: datetime | None = _per_print(None)
     confirm_requested: bool = False
     # The run (print log entry) whose outcome this response shows, when it
     # shows one run of the archive rather than its latest (#1898).
     run_id: int | None = None
+    # The latest print of the archive (by start, among those the caller may
+    # see): the run the per-print fields are taken from.
+    latest_run_id: int | None = None
 
     # Energy tracking
-    energy_kwh: float | None = None
-    energy_cost: float | None = None
-    wear_cost: float | None = None  # Printer wear, #694
+    energy_kwh: float | None = _per_print(None, _FIRST_PRINT)
+    energy_cost: float | None = _per_print(None, _FIRST_PRINT)
+    wear_cost: float | None = _per_print(None, _FIRST_PRINT)  # Printer wear, #694
 
     created_at: datetime | None
 

+ 6 - 2
backend/app/services/archive_scope.py

@@ -92,11 +92,15 @@ def view_of(archive, runs: list[PrintLogEntry], scope: PrinterScope) -> ScopedVi
         return max(of, key=lambda r: (r.started_at is not None, r.started_at or r.created_at or datetime.min, r.id))
 
     run = shown = None
-    if not archive_inside:
+    newest = latest(runs) if runs else None
+    # Their latest print, when the one the archive shows is not theirs: its
+    # printer is outside their scope, or its latest print is (after a merge,
+    # say, the archive's printer and its latest print can differ).
+    if not archive_inside or (newest is not None and not scope.allows(newest.printer_id)):
         theirs = [r for r in runs if _on_printer_in(scope, r)]
         if theirs:
             run = shown = latest(theirs)
-    elif runs and scope.allows((newest := latest(runs)).printer_id):
+    elif newest is not None:
         shown = newest
     return ScopedView(run=run, shown=shown, hidden_photos=frozenset(hidden), hidden_timelapse=hidden_timelapse)
 

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

@@ -192,6 +192,14 @@ async def mirror_latest_run(db: AsyncSession, archive_id: int) -> None:
         setattr(archive, name, getattr(latest, name))
     if latest.plate_id is not None:
         archive.plate_id = latest.plate_id
+    # And its printer: after a merge, or a run deleted, the archive's would
+    # still be another print's. Not a printer since deleted: the print log
+    # keeps those ids as history, the archive's is a key.
+    if latest.printer_id is not None and latest.printer_id != archive.printer_id:
+        from backend.app.models.printer import Printer
+
+        if await db.scalar(select(Printer.id).where(Printer.id == latest.printer_id)) is not None:
+            archive.printer_id = latest.printer_id
     # Its outcome verdict too (#1898), once the run holds its own: it has one,
     # or was opened at its start with its opt-in. A run logged before then
     # may never have been given the archive's, which stays. Runs logged

+ 81 - 0
backend/tests/integration/test_archive_scope_per_run.py

@@ -425,3 +425,84 @@ async def test_the_project_list_shows_their_plate(async_client, db_session, plat
     assert [(a["id"], a["printer_id"], a["photos"]) for a in response.json()] == [
         (plates.archive, plates.a, ["finish_a.jpg"])
     ]
+
+
+class TestLatestRun:
+    """The run the archive's per-print fields come from (archive redesign P3)."""
+
+    async def test_is_the_latest_print(self, async_client, plates):
+        listed = (await async_client.get("/api/v1/archives/", headers=plates.admin)).json()
+        detail = (await async_client.get(_url(plates), headers=plates.admin)).json()
+
+        assert [a["latest_run_id"] for a in listed] == [plates.second]
+        assert detail["latest_run_id"] == plates.second
+
+    async def test_is_the_callers_latest_print(self, async_client, plates):
+        detail = (await async_client.get(_url(plates), headers=plates.of_a)).json()
+
+        assert (detail["latest_run_id"], detail["run_id"]) == (plates.first, plates.first)
+
+
+async def test_the_per_print_fields_are_flagged_deprecated(async_client):
+    schema = (await async_client.get("/openapi.json")).json()["components"]["schemas"]["ArchiveResponse"]["properties"]
+
+    deprecated = {name for name, field in schema.items() if field.get("deprecated")}
+    assert {"status", "started_at", "completed_at", "failure_reason", "user_verdict", "printer_id"} <= deprecated
+    assert not {"filename", "print_name", "notes", "tags", "latest_run_id", "run_count"} & deprecated
+
+
+class TestLatestRunFollowsTheShownPrint:
+    async def test_a_print_running_now_is_the_latest(self, async_client, db_session, plates):
+        running = PrintLogEntry(
+            archive_id=plates.archive, printer_id=plates.b, status=RUN_OPEN, started_at=T2 + timedelta(hours=1)
+        )
+        db_session.add(running)
+        await db_session.commit()
+        running_id = running.id
+
+        detail = (await async_client.get(_url(plates), headers=plates.admin)).json()
+
+        assert detail["latest_run_id"] == running_id
+
+    async def test_their_print_when_the_latest_is_not_theirs(self, async_client, db_session, plates):
+        """The archive names A (as after a merge) but its latest print ran on B."""
+        archive = await _row(db_session, PrintArchive, plates.archive)
+        archive.printer_id = plates.a
+        await db_session.commit()
+
+        body = (await async_client.get(_url(plates), headers=plates.of_a)).json()
+
+        assert (body["run_id"], body["status"], body["latest_run_id"]) == (plates.first, "completed", plates.first)
+        assert body["failure_reason"] is None
+
+    async def test_the_favourite_toggle_answers_with_the_latest_print(self, async_client, plates):
+        response = await async_client.post(_url(plates, "/favorite"), headers=plates.admin)
+
+        assert response.status_code == 200, response.text
+        assert response.json()["latest_run_id"] == plates.second
+
+
+class TestTheArchivesPrinterFollowsItsLatestPrint:
+    async def test_after_its_latest_print_moves(self, db_session, plates):
+        from backend.app.services.print_log import mirror_latest_run
+
+        archive = await _row(db_session, PrintArchive, plates.archive)
+        archive.printer_id = plates.a
+        await db_session.commit()
+
+        await mirror_latest_run(db_session, plates.archive)
+        await db_session.commit()
+
+        assert (await _row(db_session, PrintArchive, plates.archive)).printer_id == plates.b
+
+    async def test_not_to_a_printer_since_deleted(self, db_session, plates):
+        from backend.app.services.print_log import mirror_latest_run
+
+        run = await _row(db_session, PrintLogEntry, plates.second)
+        run.printer_id = 999999
+        await db_session.commit()
+
+        await mirror_latest_run(db_session, plates.archive)
+        await db_session.commit()
+
+        assert (await _row(db_session, PrintArchive, plates.archive)).printer_id == plates.b

+ 3 - 0
frontend/src/api/client.ts

@@ -923,6 +923,9 @@ export interface Archive {
   // The run whose outcome this shows, when it shows one run of the archive
   // (a plate printed on its own) rather than its latest (#1898).
   run_id?: number | null;
+  // The latest print of the archive (among those the caller may see), whose
+  // figures the per-print fields show (archive redesign).
+  latest_run_id?: number | null;
   quantity: number;
   energy_kwh: number | null;
   energy_cost: number | null;