feat(#1008): archive auto-purge + dedicated archives:purge permission

Adds an archive counterpart to the library trash sweeper shipped in the
  previous commit. Unlike the library flow, archives are hard-deleted —
  print history is a decaying timeline, so there is no trash intermediate;
  download or favourite anything you want to keep first.

  Backend
  - New ArchivePurgeService (backend/app/services/archive_purge.py) with
    its own 15-minute scheduler loop and a 24h throttle on actual purge
    runs. Delegates every delete to the existing safety-checked
    ArchiveService.delete_archive so the 3MF, thumbnail, timelapse, source
    3MF, F3D, and photo folder all get cleaned up together with the DB
    row. Per-row session via async_session() avoids commit-per-row churn
    on any caller-passed session.
  - New /archives/purge/{preview,settings} + POST /archives/purge routes
    gated on a dedicated archives:purge permission (not archives:delete_all)
    so admins can delegate bulk-delete to a role without granting
    per-archive delete on other users' rows.
  - seed_default_groups() now backfills both library:purge and
    archives:purge on the Administrators group for upgraded installs —
    the original library:purge was added after Administrators was first
    seeded so the "create if not exists" path skipped existing DBs and
    left admins without the permission.
  - 8 new integration tests (defaults, settings roundtrip, bound
    validation, preview, manual purge, auto-purge enabled path, 24h
    throttle, disabled skip).

  Frontend
  - Settings → Archives card gains an auto-purge toggle + age input (7d
    floor, 10y ceiling, 365d default), with a save-toast on every change.
    The bulk "Purge old" button lives on the Archives page header
    (rightmost, after Upload 3MF) to match the File Manager pattern —
    configuration in Settings, one-shot action on the page.
  - New PurgeArchivesModal mirrors PurgeOldFilesModal: live preview (count
    + total size freed + sample filenames) debounced at 300ms, amber
    "hard-delete, no undo" warning.
  - Admin-only UI gates on archives:purge via the standard hasPermission
    hook; Permission TS union updated.
  - i18n blocks across all 8 locales (en/de full, other 6 English
    fallback per project convention).

  Docs
  - CHANGELOG entry under 0.2.4b1 following the existing library-trash
    entry.
  - bambuddy-wiki archiving.md gains a new "Auto-Purge" section.
  - bambuddy-website features.html gets a matching bullet.

  Verification: python -m ruff check backend/app/ clean; 25 integration
  tests pass (8 archive_purge + 17 library_trash regression); npm run
  build clean.
This commit is contained in:
maziggy
2026-04-23 16:47:53 +02:00
parent e0e597271e
commit bf511c54cd
23 changed files with 1151 additions and 128 deletions
+81
View File
@@ -0,0 +1,81 @@
"""Archive auto-purge endpoints (#1008 follow-up).
Admin-only (``ARCHIVES_PURGE``). Provides:
* ``GET /archives/purge/preview`` — live count for the admin slider
* ``POST /archives/purge`` — one-shot manual bulk delete
* ``GET/PUT /archives/purge/settings`` — auto-purge toggle + threshold
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import require_permission_if_auth_enabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.user import User
from backend.app.schemas.archive_purge import (
ArchivePurgePreviewResponse,
ArchivePurgeRequest,
ArchivePurgeResponse,
ArchivePurgeSettings,
)
from backend.app.services.archive_purge import (
MAX_AUTO_PURGE_DAYS,
MIN_AUTO_PURGE_DAYS,
archive_purge_service,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/archives", tags=["archives-purge"])
@router.get("/purge/preview", response_model=ArchivePurgePreviewResponse)
async def preview_archive_purge(
older_than_days: int = Query(ge=1, le=3650),
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
):
"""Count + size of archives eligible for purge. Read-only."""
result = await archive_purge_service.preview_purge(db, older_than_days=older_than_days)
return ArchivePurgePreviewResponse(**result)
@router.post("/purge", response_model=ArchivePurgeResponse)
async def execute_archive_purge(
body: ArchivePurgeRequest,
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
):
"""Hard-delete archives older than the threshold. Irreversible."""
deleted = await archive_purge_service.purge_older_than(db, older_than_days=body.older_than_days)
return ArchivePurgeResponse(deleted=deleted)
@router.get("/purge/settings", response_model=ArchivePurgeSettings)
async def get_archive_purge_settings(
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
):
cfg = await archive_purge_service.get_settings(db)
return ArchivePurgeSettings(enabled=cfg["enabled"], days=cfg["days"])
@router.put("/purge/settings", response_model=ArchivePurgeSettings)
async def update_archive_purge_settings(
body: ArchivePurgeSettings,
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
):
if body.days < MIN_AUTO_PURGE_DAYS or body.days > MAX_AUTO_PURGE_DAYS:
raise HTTPException(
status_code=400,
detail=f"days must be between {MIN_AUTO_PURGE_DAYS} and {MAX_AUTO_PURGE_DAYS}",
)
saved = await archive_purge_service.set_settings(db, enabled=body.enabled, days=body.days)
return ArchivePurgeSettings(enabled=saved["enabled"], days=saved["days"])
+18
View File
@@ -1723,6 +1723,24 @@ async def seed_default_groups():
group.permissions = perms
await session.commit()
# Backfill library:purge + archives:purge for the Administrators group
# on existing installs. Both permissions were added after Administrators
# was first seeded, so upgrading users miss them even though the default
# config (ALL_PERMISSIONS) includes them for fresh installs.
result = await session.execute(select(Group).where(Group.name == "Administrators"))
admin_group = result.scalar_one_or_none()
if admin_group and admin_group.permissions is not None:
perms = list(admin_group.permissions)
added = False
for new_perm in ("library:purge", "archives:purge"):
if new_perm not in perms:
perms.append(new_perm)
added = True
logger.info("Added %s to Administrators group (backfill)", new_perm)
if added:
admin_group.permissions = perms
await session.commit()
# Migrate existing users to groups if they're not already in any group
if groups_created:
# Refresh to get newly created groups
+2
View File
@@ -33,6 +33,7 @@ class Permission(StrEnum):
ARCHIVES_DELETE_ALL = "archives:delete_all"
ARCHIVES_REPRINT_OWN = "archives:reprint_own"
ARCHIVES_REPRINT_ALL = "archives:reprint_all"
ARCHIVES_PURGE = "archives:purge"
# Queue
QUEUE_READ = "queue:read"
@@ -189,6 +190,7 @@ PERMISSION_CATEGORIES = {
Permission.ARCHIVES_DELETE_ALL,
Permission.ARCHIVES_REPRINT_OWN,
Permission.ARCHIVES_REPRINT_ALL,
Permission.ARCHIVES_PURGE,
],
"Queue": [
Permission.QUEUE_READ,
+7
View File
@@ -15,6 +15,7 @@ from sqlalchemy import delete, or_, select, text
from backend.app.api.routes import (
ams_history,
api_keys,
archive_purge,
archives,
auth,
background_dispatch as background_dispatch_routes,
@@ -65,6 +66,7 @@ from backend.app.core.database import async_session, engine, init_db
from backend.app.core.websocket import ws_manager
from backend.app.models.smart_plug import SmartPlug
from backend.app.services.archive import ArchiveService
from backend.app.services.archive_purge import archive_purge_service
from backend.app.services.background_dispatch import background_dispatch
from backend.app.services.bambu_ftp import (
FileNotOnPrinterError,
@@ -4200,6 +4202,9 @@ async def lifespan(app: FastAPI):
# Start the library trash sweeper (#1008)
await library_trash_service.start_scheduler()
# Start the archive auto-purge sweeper (#1008 follow-up)
await archive_purge_service.start_scheduler()
# Start AMS history recording
start_ams_history_recording()
@@ -4239,6 +4244,7 @@ async def lifespan(app: FastAPI):
github_backup_service.stop_scheduler()
local_backup_service.stop_scheduler()
library_trash_service.stop_scheduler()
archive_purge_service.stop_scheduler()
obico_detection_service.stop()
stop_ams_history_recording()
stop_runtime_tracking()
@@ -4544,6 +4550,7 @@ app.include_router(external_links.router, prefix=app_settings.api_prefix)
app.include_router(projects.router, prefix=app_settings.api_prefix)
app.include_router(library.router, prefix=app_settings.api_prefix)
app.include_router(library_trash.router, prefix=app_settings.api_prefix)
app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
app.include_router(makerworld.router, prefix=app_settings.api_prefix)
app.include_router(api_keys.router, prefix=app_settings.api_prefix)
app.include_router(webhook.router, prefix=app_settings.api_prefix)
+25
View File
@@ -0,0 +1,25 @@
"""Schemas for archive auto-purge (#1008 follow-up)."""
from __future__ import annotations
from pydantic import BaseModel, Field
class ArchivePurgePreviewResponse(BaseModel):
count: int
total_bytes: int
sample_filenames: list[str]
older_than_days: int
class ArchivePurgeRequest(BaseModel):
older_than_days: int = Field(ge=1, le=3650)
class ArchivePurgeResponse(BaseModel):
deleted: int
class ArchivePurgeSettings(BaseModel):
enabled: bool = False
days: int = Field(default=365, ge=7, le=3650)
+218
View File
@@ -0,0 +1,218 @@
"""Archive auto-purge service (#1008 follow-up).
Age-based hard-delete of print archives. Unlike the library trash flow there is
no soft-delete intermediate — archives are historical print records, so the
"undo" window the library bin provides doesn't apply here. A user who wants to
keep an archive should download or favourite it before the purge window elapses.
The sweeper runs on the same 15-minute cadence as the library trash sweeper but
throttles actual purge runs to once per 24h. Admins can also trigger a manual
purge from the Settings UI.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core import database as _database
from backend.app.models.archive import PrintArchive
from backend.app.models.settings import Settings
from backend.app.services.archive import ArchiveService
logger = logging.getLogger(__name__)
AUTO_PURGE_ENABLED_KEY = "archive_auto_purge_enabled"
AUTO_PURGE_DAYS_KEY = "archive_auto_purge_days"
AUTO_PURGE_LAST_RUN_KEY = "archive_auto_purge_last_run"
DEFAULT_AUTO_PURGE_DAYS = 365
# 7-day floor mirrors the library auto-purge; anything shorter treats archives
# as ephemeral which is rarely what anyone wants.
MIN_AUTO_PURGE_DAYS = 7
MAX_AUTO_PURGE_DAYS = 3650
def _age_cutoff(now: datetime, older_than_days: int) -> datetime:
return now - timedelta(days=older_than_days)
class ArchivePurgeService:
"""Manages archive auto-purge sweeper + admin-triggered manual purges."""
def __init__(self):
self._scheduler_task: asyncio.Task | None = None
# Match library trash cadence — the 24h throttle keeps actual work rare.
self._check_interval = 900
async def start_scheduler(self):
if self._scheduler_task is not None:
return
logger.info("Starting archive auto-purge sweeper")
self._scheduler_task = asyncio.create_task(self._scheduler_loop())
def stop_scheduler(self):
if self._scheduler_task:
self._scheduler_task.cancel()
self._scheduler_task = None
logger.info("Stopped archive auto-purge sweeper")
async def _scheduler_loop(self):
while True:
try:
await asyncio.sleep(self._check_interval)
async with _database.async_session() as db:
await self._maybe_run_auto_purge(db)
except asyncio.CancelledError:
break
except Exception as e: # pragma: no cover - defensive
logger.error("Error in archive auto-purge sweeper: %s", e)
await asyncio.sleep(60)
# ---- Settings -----------------------------------------------------
@staticmethod
async def _read_setting(db: AsyncSession, key: str) -> str | None:
result = await db.execute(select(Settings.value).where(Settings.key == key))
return result.scalar_one_or_none()
@staticmethod
async def _write_setting(db: AsyncSession, key: str, value: str) -> None:
result = await db.execute(select(Settings).where(Settings.key == key))
row = result.scalar_one_or_none()
if row is None:
db.add(Settings(key=key, value=value))
else:
row.value = value
async def get_settings(self, db: AsyncSession) -> dict:
"""Return ``{enabled, days}``. Missing keys default to disabled / 365d."""
enabled_raw = await self._read_setting(db, AUTO_PURGE_ENABLED_KEY)
days_raw = await self._read_setting(db, AUTO_PURGE_DAYS_KEY)
enabled = (enabled_raw or "false").lower() == "true"
try:
days = int(days_raw) if days_raw is not None else DEFAULT_AUTO_PURGE_DAYS
except (TypeError, ValueError):
days = DEFAULT_AUTO_PURGE_DAYS
days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, days))
return {"enabled": enabled, "days": days}
async def set_settings(self, db: AsyncSession, *, enabled: bool, days: int) -> dict:
clamped_days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, int(days)))
await self._write_setting(db, AUTO_PURGE_ENABLED_KEY, "true" if enabled else "false")
await self._write_setting(db, AUTO_PURGE_DAYS_KEY, str(clamped_days))
await db.commit()
return {"enabled": enabled, "days": clamped_days}
async def _get_last_run(self, db: AsyncSession) -> datetime | None:
raw = await self._read_setting(db, AUTO_PURGE_LAST_RUN_KEY)
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
async def _stamp_last_run(self, db: AsyncSession, when: datetime) -> None:
await self._write_setting(db, AUTO_PURGE_LAST_RUN_KEY, when.isoformat())
await db.commit()
async def _maybe_run_auto_purge(self, db: AsyncSession) -> int:
"""Run the auto-purge if enabled and >=24h has elapsed since last run."""
cfg = await self.get_settings(db)
if not cfg["enabled"]:
return 0
now = datetime.now(timezone.utc)
last = await self._get_last_run(db)
if last is not None and (now - last) < timedelta(hours=24):
return 0
deleted = await self.purge_older_than(db, older_than_days=cfg["days"])
await self._stamp_last_run(db, now)
if deleted:
logger.info(
"Archive auto-purge: hard-deleted %d archive(s) (threshold=%d days)",
deleted,
cfg["days"],
)
return deleted
# ---- Preview / purge ---------------------------------------------
async def preview_purge(
self,
db: AsyncSession,
older_than_days: int,
sample_limit: int = 5,
) -> dict:
"""Count + size of archives eligible for purge. Read-only."""
if older_than_days < 1:
return {
"count": 0,
"total_bytes": 0,
"sample_filenames": [],
"older_than_days": older_than_days,
}
now = datetime.now(timezone.utc)
cutoff = _age_cutoff(now, older_than_days)
clause = PrintArchive.created_at < cutoff
count_result = await db.execute(select(func.count(PrintArchive.id)).where(clause))
count = int(count_result.scalar() or 0)
size_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.file_size), 0)).where(clause))
total_bytes = int(size_result.scalar() or 0)
sample_result = await db.execute(
select(PrintArchive.filename).where(clause).order_by(PrintArchive.created_at).limit(sample_limit)
)
samples = [row[0] for row in sample_result.all()]
return {
"count": count,
"total_bytes": total_bytes,
"sample_filenames": samples,
"older_than_days": older_than_days,
}
async def purge_older_than(self, db: AsyncSession, older_than_days: int) -> int:
"""Hard-delete archives older than ``older_than_days``. Returns count.
Delegates to :meth:`ArchiveService.delete_archive` for every row so the
on-disk cleanup (3MF, thumbnail, timelapse, photos) goes through the
same safety-checked path as manual deletion. Each delete runs in its
own session so a commit-per-row doesn't churn the caller's session
(and matches how the sweeper uses :func:`_database.async_session` in production).
"""
if older_than_days < 1:
return 0
now = datetime.now(timezone.utc)
cutoff = _age_cutoff(now, older_than_days)
id_result = await db.execute(select(PrintArchive.id).where(PrintArchive.created_at < cutoff))
ids = [row[0] for row in id_result.all()]
if not ids:
return 0
deleted = 0
for archive_id in ids:
async with _database.async_session() as delete_db:
service = ArchiveService(delete_db)
if await service.delete_archive(archive_id):
deleted += 1
if deleted:
logger.info(
"Archive purge: hard-deleted %d archive(s) (older_than_days=%d)",
deleted,
older_than_days,
)
return deleted
archive_purge_service = ArchivePurgeService()
@@ -0,0 +1,164 @@
"""Integration tests for archive auto-purge (#1008 follow-up)."""
from datetime import datetime, timedelta, timezone
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
@pytest.mark.integration
async def test_settings_defaults_when_unset(async_client: AsyncClient):
"""GET /archives/purge/settings returns sensible defaults on a fresh install."""
resp = await async_client.get("/api/v1/archives/purge/settings")
assert resp.status_code == 200
body = resp.json()
assert body["enabled"] is False
assert body["days"] == 365
@pytest.mark.asyncio
@pytest.mark.integration
async def test_settings_roundtrip(async_client: AsyncClient):
"""PUT persists, GET returns the saved values, days is clamped."""
resp = await async_client.put(
"/api/v1/archives/purge/settings",
json={"enabled": True, "days": 180},
)
assert resp.status_code == 200
assert resp.json() == {"enabled": True, "days": 180}
resp = await async_client.get("/api/v1/archives/purge/settings")
assert resp.json() == {"enabled": True, "days": 180}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_settings_rejects_out_of_range_days(async_client: AsyncClient):
"""days below MIN or above MAX is rejected."""
resp = await async_client.put(
"/api/v1/archives/purge/settings",
json={"enabled": True, "days": 1},
)
# Pydantic validation returns 422; explicit bound check returns 400.
assert resp.status_code in (400, 422)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_preview_counts_old_archives(async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Preview returns the count + total bytes of archives older than the threshold."""
printer = await printer_factory()
old = await archive_factory(printer.id, print_name="Old", file_size=1000)
fresh = await archive_factory(printer.id, print_name="Fresh", file_size=2000)
old.created_at = datetime.now(timezone.utc) - timedelta(days=400)
fresh.created_at = datetime.now(timezone.utc) - timedelta(days=10)
await db_session.commit()
resp = await async_client.get("/api/v1/archives/purge/preview?older_than_days=365")
assert resp.status_code == 200
body = resp.json()
assert body["count"] == 1
assert body["total_bytes"] == 1000
assert "Old" in body["sample_filenames"][0] or old.filename in body["sample_filenames"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_manual_purge_deletes_old_archives(
async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""POST /archives/purge hard-deletes archives older than the threshold."""
from backend.app.models.archive import PrintArchive
printer = await printer_factory()
old = await archive_factory(printer.id, print_name="Old")
fresh = await archive_factory(printer.id, print_name="Fresh")
old_id = old.id
fresh_id = fresh.id
old.created_at = datetime.now(timezone.utc) - timedelta(days=400)
fresh.created_at = datetime.now(timezone.utc) - timedelta(days=10)
await db_session.commit()
resp = await async_client.post(
"/api/v1/archives/purge",
json={"older_than_days": 365},
)
assert resp.status_code == 200
assert resp.json()["deleted"] == 1
# Old is gone, fresh remains.
db_session.expire_all()
assert await db_session.get(PrintArchive, old_id) is None
assert await db_session.get(PrintArchive, fresh_id) is not None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_auto_purge_runs_when_enabled(async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""With the toggle on, a stale archive is hard-deleted by the sweeper.
``async_client`` is included solely so its fixture activates the module-level
``async_session`` patches that let :meth:`purge_older_than`'s per-row
delete sessions reach the in-memory test database.
"""
from backend.app.models.archive import PrintArchive
from backend.app.services.archive_purge import archive_purge_service
printer = await printer_factory()
stale = await archive_factory(printer.id, print_name="Stale")
stale_id = stale.id
stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
await db_session.commit()
await archive_purge_service.set_settings(db_session, enabled=True, days=365)
deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
assert deleted >= 1
db_session.expire_all()
assert await db_session.get(PrintArchive, stale_id) is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_auto_purge_throttles_within_24h(async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""A recent last-run timestamp blocks the sweeper for 24h."""
from backend.app.services.archive_purge import archive_purge_service
printer = await printer_factory()
stale = await archive_factory(printer.id, print_name="Stale")
stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
await db_session.commit()
await archive_purge_service.set_settings(db_session, enabled=True, days=365)
# Stamp a last-run time 1h ago — should block the sweeper for another 23h.
await archive_purge_service._stamp_last_run(db_session, datetime.now(timezone.utc) - timedelta(hours=1))
deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
assert deleted == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_auto_purge_skipped_when_disabled(
async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""When the toggle is off, old archives stay put."""
from backend.app.models.archive import PrintArchive
from backend.app.services.archive_purge import archive_purge_service
printer = await printer_factory()
stale = await archive_factory(printer.id, print_name="Stale")
stale_id = stale.id
stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
await db_session.commit()
await archive_purge_service.set_settings(db_session, enabled=False, days=365)
deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
assert deleted == 0
db_session.expire_all()
assert await db_session.get(PrintArchive, stale_id) is not None