mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
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:
@@ -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"])
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user