mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Reporter IndividualGhost1905 followed up after the #1378 / #1343 backfill landed and pointed at the next inconsistency: the per- archive delete dialog has had a "Also remove this print from Quick Stats" checkbox since #1343, but the "Purge Old" button and the scheduled daily auto-purge sweeper both ignored that choice and hard-deleted unconditionally. From the user side this looked like "automatically deleted from statistics without any warning" — half- true, and the inconsistency was real either way. The actual current shape (before this fix): - POST /archives/purge -> archive_purge_service.purge_older_than -> ArchiveService.delete_archive (hard). Archive row dropped. Linked PrintLogEntry rows have ON DELETE SET NULL so they survive as orphans with archive_id=NULL. Quick Stats keeps the filament / cost / energy contribution because the log rows are still there, but the archive-list-iterating widgets (Filament Trends, By Material, Color Distribution, Printer Stats) lose the row, and Time Accuracy loses its join target. Visibly inconsistent. - Scheduled _maybe_run_auto_purge -> same code path, same effect. - Single-archive DELETE /archives/{id} -> already takes purge_stats=true|false (default false=soft) and routes either soft_delete_archive (keeps everything, flips deleted_at) or deletes PrintLogEntry rows first + hard-deletes archive. The fix threads the same purge_stats flag through every bulk surface with soft as the default, matching the single-archive default: Backend: - archive_purge_service.purge_older_than(..., purge_stats=False) -> per-row soft_delete_archive when False, per-row PrintLogEntry deletion + delete_archive when True. Each runs in its own session (same pattern the sweeper already used). - preview_purge gains the same kwarg so the eligible-count matches what an actual run would touch: soft mode excludes already-soft-deleted rows, hard mode counts them as eligible for promotion. - get_settings / set_settings now persist archive_auto_purge_stats (default False). _maybe_run_auto_purge reads it on every tick. - ArchivePurgeRequest / ArchivePurgeResponse / ArchivePurgeSettings schemas extended. - Route /archives/purge accepts the body flag, /purge/preview accepts the query param, /purge/settings GET + PUT echo the setting. Frontend: - "Purge old archives" modal: new "Also remove from statistics" checkbox under the preview, unchecked by default. Plumbed into the preview query key + the execute mutation. - Settings -> Archives auto-purge card: matching toggle next to the days slider, disabled when auto-purge itself is off. - api.previewArchivePurge / api.executeArchivePurge accept the flag; ArchivePurgeSettings type gains purge_stats. - All 8 locales (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW) get new purgeStatsLabel / purgeStatsHint / purgeStatsDescription keys, plus rewritten effect / warning copy in archivePurge and archiveAutoPurge to reflect that the default no longer "permanently removes from the database" but instead hides the row + removes files while keeping Quick Stats intact. i18n parity check clean: 4814 keys across all 8 locales, no fallback. Behaviour change for existing users on auto-purge: the sweeper used to hard-delete by default and now soft-deletes by default. After the upgrade those installs start *preserving* more data in Quick Stats rather than losing it — safer direction of the two, but worth the explicit call-out. Anyone who wants the old behaviour ticks the new toggle once and it persists.
306 lines
12 KiB
Python
306 lines
12 KiB
Python
"""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"
|
|
# #1390 follow-up: bulk and scheduled purge inherit the same "soft vs hard"
|
|
# choice the single-archive delete already exposes (#1343). When False
|
|
# (default), each purged archive goes through soft_delete_archive — files
|
|
# removed from disk, row hidden via `deleted_at`, PrintLogEntry rows
|
|
# untouched so Quick Stats keeps every contribution. When True, the linked
|
|
# log rows are deleted up front and the archive row is hard-removed,
|
|
# matching the route's `?purge_stats=true` semantics.
|
|
AUTO_PURGE_STATS_KEY = "archive_auto_purge_stats"
|
|
|
|
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)
|
|
|
|
|
|
def _last_activity_expr():
|
|
"""Most-recent timestamp on an archive row.
|
|
|
|
Reprints reuse the archive row and update ``completed_at``/``started_at`` but
|
|
leave ``created_at`` pinned to the first print, so purging on ``created_at``
|
|
would evict recently-reprinted archives. Use the latest of the three instead.
|
|
"""
|
|
return func.coalesce(
|
|
PrintArchive.completed_at,
|
|
PrintArchive.started_at,
|
|
PrintArchive.created_at,
|
|
)
|
|
|
|
|
|
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, purge_stats}``. Missing keys default to
|
|
disabled / 365d / soft-delete (Quick Stats preserved)."""
|
|
enabled_raw = await self._read_setting(db, AUTO_PURGE_ENABLED_KEY)
|
|
days_raw = await self._read_setting(db, AUTO_PURGE_DAYS_KEY)
|
|
stats_raw = await self._read_setting(db, AUTO_PURGE_STATS_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))
|
|
purge_stats = (stats_raw or "false").lower() == "true"
|
|
return {"enabled": enabled, "days": days, "purge_stats": purge_stats}
|
|
|
|
async def set_settings(self, db: AsyncSession, *, enabled: bool, days: int, purge_stats: bool = False) -> 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 self._write_setting(db, AUTO_PURGE_STATS_KEY, "true" if purge_stats else "false")
|
|
await db.commit()
|
|
return {"enabled": enabled, "days": clamped_days, "purge_stats": purge_stats}
|
|
|
|
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"],
|
|
purge_stats=cfg["purge_stats"],
|
|
)
|
|
await self._stamp_last_run(db, now)
|
|
if deleted:
|
|
logger.info(
|
|
"Archive auto-purge: %s %d archive(s) (threshold=%d days, purge_stats=%s)",
|
|
"hard-deleted" if cfg["purge_stats"] else "soft-deleted",
|
|
deleted,
|
|
cfg["days"],
|
|
cfg["purge_stats"],
|
|
)
|
|
return deleted
|
|
|
|
# ---- Preview / purge ---------------------------------------------
|
|
|
|
async def preview_purge(
|
|
self,
|
|
db: AsyncSession,
|
|
older_than_days: int,
|
|
sample_limit: int = 5,
|
|
*,
|
|
purge_stats: bool = False,
|
|
) -> dict:
|
|
"""Count + size of archives eligible for purge. Read-only.
|
|
|
|
Soft-delete mode (default) excludes already-soft-deleted rows so the
|
|
admin slider's "eligible" count matches what a fresh purge would
|
|
actually touch. Hard-delete mode counts every row past the cutoff —
|
|
already-soft-deleted rows are eligible for promotion to hard-delete.
|
|
"""
|
|
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)
|
|
last_activity = _last_activity_expr()
|
|
clause = last_activity < cutoff
|
|
|
|
count_stmt = select(func.count(PrintArchive.id)).where(clause)
|
|
size_stmt = select(func.coalesce(func.sum(PrintArchive.file_size), 0)).where(clause)
|
|
sample_stmt = select(PrintArchive.filename).where(clause).order_by(last_activity).limit(sample_limit)
|
|
if not purge_stats:
|
|
count_stmt = count_stmt.where(PrintArchive.deleted_at.is_(None))
|
|
size_stmt = size_stmt.where(PrintArchive.deleted_at.is_(None))
|
|
sample_stmt = sample_stmt.where(PrintArchive.deleted_at.is_(None))
|
|
|
|
count_result = await db.execute(count_stmt)
|
|
count = int(count_result.scalar() or 0)
|
|
|
|
size_result = await db.execute(size_stmt)
|
|
total_bytes = int(size_result.scalar() or 0)
|
|
|
|
sample_result = await db.execute(sample_stmt)
|
|
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,
|
|
*,
|
|
purge_stats: bool = False,
|
|
) -> int:
|
|
"""Bulk-delete archives older than ``older_than_days``. Returns count.
|
|
|
|
Two modes, parameter-controlled (#1390):
|
|
|
|
* ``purge_stats=False`` (default): each archive goes through
|
|
:meth:`ArchiveService.soft_delete_archive` — files removed from disk
|
|
and the row hidden via ``deleted_at``, but the linked
|
|
``PrintLogEntry`` rows are untouched so Quick Stats keeps every
|
|
contribution (filament, cost, energy, time accuracy).
|
|
* ``purge_stats=True``: linked log rows are hard-deleted up front and
|
|
the archive row is hard-removed via
|
|
:meth:`ArchiveService.delete_archive`. Matches the single-archive
|
|
``DELETE /archives/{id}?purge_stats=true`` semantics from #1343.
|
|
|
|
Each delete runs in its own session so a commit-per-row doesn't churn
|
|
the caller's session (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)
|
|
|
|
# Soft-delete mode must also skip rows already soft-deleted, otherwise
|
|
# a repeat sweeper run keeps re-touching the same rows. Hard-delete
|
|
# mode doesn't filter — already-soft-deleted rows are eligible for
|
|
# promotion to hard-delete when the user opts in.
|
|
select_stmt = select(PrintArchive.id).where(_last_activity_expr() < cutoff)
|
|
if not purge_stats:
|
|
select_stmt = select_stmt.where(PrintArchive.deleted_at.is_(None))
|
|
id_result = await db.execute(select_stmt)
|
|
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 purge_stats:
|
|
# Hard-delete linked PrintLogEntry rows first so their
|
|
# filament / cost contributions stop counting in /stats.
|
|
# FK is ON DELETE SET NULL, so without this they'd
|
|
# survive the archive row and keep showing up in totals
|
|
# (#1343 / #1378 / #1390).
|
|
from sqlalchemy import delete as sa_delete
|
|
|
|
from backend.app.models.print_log import PrintLogEntry
|
|
|
|
await delete_db.execute(sa_delete(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id))
|
|
await delete_db.commit()
|
|
if await service.delete_archive(archive_id):
|
|
deleted += 1
|
|
else:
|
|
if await service.soft_delete_archive(archive_id):
|
|
deleted += 1
|
|
if deleted:
|
|
logger.info(
|
|
"Archive purge: %s %d archive(s) (older_than_days=%d, purge_stats=%s)",
|
|
"hard-deleted" if purge_stats else "soft-deleted",
|
|
deleted,
|
|
older_than_days,
|
|
purge_stats,
|
|
)
|
|
return deleted
|
|
|
|
|
|
archive_purge_service = ArchivePurgeService()
|