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"])