fix(archives): bulk and auto purge now honour the soft / hard delete choice from #1343 (#1390 follow-up)

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.
This commit is contained in:
maziggy
2026-05-17 10:54:28 +02:00
parent 0ddf0925b7
commit e7045597bc
18 changed files with 513 additions and 259 deletions
+20 -3
View File
@@ -21,6 +21,9 @@ export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalP
const { showToast } = useToast();
const [days, setDays] = useState(initialDays ?? DEFAULT_DAYS);
// #1390: matches the single-archive delete dialog's "Also remove from
// statistics" checkbox. Default off — soft-delete, Quick Stats preserved.
const [purgeStats, setPurgeStats] = useState(false);
const [debouncedDays, setDebouncedDays] = useState(days);
useEffect(() => {
@@ -29,13 +32,13 @@ export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalP
}, [days]);
const previewQuery = useQuery({
queryKey: ['archive-purge-preview', debouncedDays],
queryFn: () => api.previewArchivePurge(debouncedDays),
queryKey: ['archive-purge-preview', debouncedDays, purgeStats],
queryFn: () => api.previewArchivePurge(debouncedDays, purgeStats),
enabled: debouncedDays >= 1,
});
const purgeMutation = useMutation({
mutationFn: () => api.executeArchivePurge(days),
mutationFn: () => api.executeArchivePurge(days, purgeStats),
onSuccess: (res) => {
showToast(t('archivePurge.toast.success', { count: res.deleted }), 'success');
queryClient.invalidateQueries({ queryKey: ['archives'] });
@@ -141,6 +144,20 @@ export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalP
)}
</div>
<label className="flex gap-2 items-start rounded border border-gray-200 dark:border-gray-700 px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50">
<input
type="checkbox"
checked={purgeStats}
onChange={(e) => setPurgeStats(e.target.checked)}
disabled={purgeMutation.isPending}
className="mt-0.5 shrink-0"
/>
<span className="text-xs text-gray-700 dark:text-gray-300">
<span className="font-medium block mb-0.5">{t('archivePurge.purgeStatsLabel')}</span>
<span className="text-gray-500 dark:text-gray-400">{t('archivePurge.purgeStatsHint')}</span>
</span>
</label>
<div className="flex gap-2 items-start text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded px-3 py-2">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>{t('archivePurge.warning')}</span>