fix(permissions): self-heal Administrators to ALL_PERMISSIONS on upgrade + Pipelines runs dashboard polish

Administrators system group sync
- Fresh installs already bootstrap with ALL_PERMISSIONS, so they always have
  every permission. Upgrades previously only got what one-off backfill blocks
  in seed_default_groups() explicitly listed (library:purge, archives:purge,
  the OWN/ALL read-flag block, orca_cloud:auth, pipelines:*). Any Permission
  enum member added without a matching block silently stayed missing on
  existing admin rows. The most recent gap was printer_sensor_history:read
  (Sensor History charts returned 403 for upgraded admins).
- seed_default_groups() now syncs Administrators to ALL_PERMISSIONS on every
  startup: append every Permission value that isn't already on the row.
  Additive only -- hand-added custom permissions are preserved.
- The pure-admin one-off backfills (library:purge / archives:purge block,
  the OWN/ALL + orca_cloud:auth + legacy-read-flag block, the Administrators
  branch of the pipeline backfill) are retired since the sync subsumes
  them. Non-admin backfills (Operators / Viewers OWN-tier reads, Operators
  orca_cloud:auth, pipelines for non-admin groups, makerworld:*, clear_plate
  cross-group adders) are untouched.
- Tests: test_administrators_printer_sensor_history_read_backfilled
  (regression for the reported gap),
  test_administrators_sync_covers_every_current_permission (generic
  invariant -- any future new permission lands on admin without needing
  a one-off test), test_administrators_sync_is_additive_only (custom
  permissions preserved). 12/12 backfill-migration + 102/102 broader
  permission tests green; ruff clean.

Pipelines runs dashboard
- PipelineRunsPage.tsx: the Pipeline / Status / Target filter row's three
  native <select> elements are replaced with a bambu-themed FilterDropdown
  (button trigger, floating menu, optgroup-style headers for the Target
  picker, hover + selected states with a check mark, closes on outside
  click and Escape). Same value/onChange contract -- visual only.
- SlicerPipelinesPanel.tsx: wrap list?.pipelines ?? [] in useMemo so the
  reference is stable when the data is stable. Fixes the
  react-hooks/exhaustive-deps warning where the inline fallback returned
  a fresh empty array every render, invalidating both downstream useMemo
  caches (target-options + filtered-pipelines list).
This commit is contained in:
maziggy
2026-06-28 11:18:18 +02:00
parent b5263eb5cd
commit b23cb69a66
29 changed files with 1619 additions and 392 deletions
@@ -730,6 +730,173 @@ class TestPipelineC:
assert body["parent_run_id"] == parent.id
class TestPolishFollowUp:
"""Polish-pass fixes: dashboard target filters, clear endpoint, and the
deleted-queue-entry → cancelled rollup behaviour."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_dashboard_filters_by_target_printer(
self,
async_client: AsyncClient,
pipeline_factory,
printer_factory,
library_file_factory,
db_session,
):
from backend.app.models.pipeline_run import PipelineRun
printer_a = await printer_factory()
printer_b = await printer_factory()
pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
src = await library_file_factory()
for pipe in (pipe_a, pipe_a, pipe_b):
db_session.add(
PipelineRun(
pipeline_id=pipe["id"],
source_library_file_id=src.id,
copies=1,
status="completed",
)
)
await db_session.commit()
resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_dashboard_filters_by_target_model_class(
self,
async_client: AsyncClient,
pipeline_factory,
printer_factory,
library_file_factory,
db_session,
):
from backend.app.models.pipeline_run import PipelineRun
await printer_factory(model="X1C")
await printer_factory(model="P1S")
# Two pipelines, one class-targeting X1C, one P1S.
pipe_x = await pipeline_factory()
await async_client.put(
f"/api/v1/slicer-pipelines/{pipe_x['id']}",
json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
)
pipe_p = await pipeline_factory()
await async_client.put(
f"/api/v1/slicer-pipelines/{pipe_p['id']}",
json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
)
src = await library_file_factory()
for pipe in (pipe_x, pipe_p, pipe_p):
db_session.add(
PipelineRun(
pipeline_id=pipe["id"],
source_library_file_id=src.id,
copies=1,
status="completed",
)
)
await db_session.commit()
resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert all(r["target_model_class"] == "P1S" for r in body["runs"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_clear_endpoint_deletes_terminal_runs_only(
self,
async_client: AsyncClient,
pipeline_factory,
printer_factory,
library_file_factory,
db_session,
):
from backend.app.models.pipeline_run import PipelineRun
printer = await printer_factory()
pipe = await pipeline_factory(target_printer_id=printer.id)
src = await library_file_factory()
for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
db_session.add(
PipelineRun(
pipeline_id=pipe["id"],
source_library_file_id=src.id,
copies=1,
status=status,
)
)
await db_session.commit()
resp = await async_client.post("/api/v1/pipeline-runs/clear")
assert resp.status_code == 200, resp.text
assert resp.json()["deleted"] == 4 # 4 terminal statuses cleared
# The in-flight rows survive.
survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
assert survivors["total"] == 2
assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deleted_queue_entry_rolls_up_as_cancelled(
self,
async_client: AsyncClient,
pipeline_factory,
printer_factory,
library_file_factory,
db_session,
):
"""When the queue entry that a PipelineJob is linked to gets deleted
from the print-queue page, the job's live status should roll up to
``cancelled`` so the run doesn't sit forever showing ``queued`` /
``dispatching``."""
from backend.app.models.pipeline_run import PipelineJob, PipelineRun
printer = await printer_factory()
pipe = await pipeline_factory(target_printer_id=printer.id)
src = await library_file_factory()
# Simulate the state PR C leaves a successful dispatch in: run is
# 'dispatching' and the job has a queue_entry_id pointing at a
# PrintQueueItem that no longer exists.
run = PipelineRun(
pipeline_id=pipe["id"],
source_library_file_id=src.id,
copies=1,
status="dispatching",
)
db_session.add(run)
await db_session.flush()
db_session.add(
PipelineJob(
pipeline_run_id=run.id,
copy_index=0,
queue_entry_id=999999, # Doesn't exist — simulates manual delete from queue.
assigned_printer_id=printer.id,
status="queued",
)
)
await db_session.commit()
await db_session.refresh(run)
resp = await async_client.get(f"/api/v1/pipeline-runs/{run.id}")
assert resp.status_code == 200, resp.text
body = resp.json()
# Job rolled up to cancelled because the queue entry is gone.
assert body["jobs"][0]["status"] == "cancelled"
# Run also rolls up — all jobs cancelled → run reads as cancelled.
assert body["status"] == "cancelled"
class TestCancelTerminal:
@pytest.mark.asyncio
@pytest.mark.integration
@@ -218,6 +218,63 @@ class TestReadPermissionMigration:
perms = await _get_perms("Operators")
assert "orca_cloud:auth" in perms
@pytest.mark.asyncio
@pytest.mark.integration
async def test_administrators_printer_sensor_history_read_backfilled(self, async_client: AsyncClient):
"""Admin without `printer_sensor_history:read` (older custom edit or
a DB seeded before that permission existed) gets it backfilled —
regression for the gap maziggy hit on a live install where the
per-permission admin backfills missed it."""
await seed_default_groups()
async with _database_module.async_session() as session:
grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
grp.permissions = [p for p in (grp.permissions or []) if p != "printer_sensor_history:read"]
await session.commit()
await seed_default_groups()
perms = await _get_perms("Administrators")
assert "printer_sensor_history:read" in perms
@pytest.mark.asyncio
@pytest.mark.integration
async def test_administrators_sync_covers_every_current_permission(self, async_client: AsyncClient):
"""Generic invariant: ALL_PERMISSIONS sync ensures every Permission
enum value is present on the Administrators group, no matter what
was stripped pre-backfill. Catches every future "new permission
missing on upgrade" regression without needing a one-off test."""
from backend.app.core.permissions import ALL_PERMISSIONS
await seed_default_groups()
# Wipe the admin group's permission list entirely and force the sync
# to put everything back.
async with _database_module.async_session() as session:
grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
grp.permissions = []
await session.commit()
await seed_default_groups()
perms = await _get_perms("Administrators")
missing = [p for p in ALL_PERMISSIONS if p not in perms]
assert not missing, f"Administrators missing permissions after backfill: {missing}"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_administrators_sync_is_additive_only(self, async_client: AsyncClient):
"""The sync block must never remove a permission an operator added by
hand — only add missing entries from ALL_PERMISSIONS."""
await seed_default_groups()
async with _database_module.async_session() as session:
grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
grp.permissions = [*(grp.permissions or []), "custom:plugin_permission"]
await session.commit()
await seed_default_groups()
perms = await _get_perms("Administrators")
assert "custom:plugin_permission" in perms
@pytest.mark.asyncio
@pytest.mark.integration
async def test_viewers_do_not_get_orca_cloud_auth(self, async_client: AsyncClient):