""" Retention: purge generated export workbooks older than AR_RETENTION_DAYS. EXPORTS ONLY, by design. Uploaded source files are never auto-deleted — they are the audit source for every published figure, and re-generating the full workbook re-parses them. Export files are pure derivatives: anything purged can be regenerated with one click, and the DB row is kept so the Exports list still shows what was generated and when (`available: false` once the file is gone). AR_RETENTION_DAYS=0 keeps everything forever. """ from __future__ import annotations import asyncio import datetime as dt import logging import os from ..config import RETENTION_DAYS from ..db import models from ..db.database import SessionLocal logger = logging.getLogger(__name__) _SWEEP_INTERVAL_S = 24 * 3600 def purge_old_exports(retention_days: int | None = None) -> int: """Delete export files older than the retention window. Returns files removed.""" days = RETENTION_DAYS if retention_days is None else retention_days if days <= 0: return 0 cutoff = dt.datetime.utcnow() - dt.timedelta(days=days) removed = 0 db = SessionLocal() try: rows = db.query(models.ExportRecord).filter( models.ExportRecord.generated_at < cutoff).all() for r in rows: if not r.path: continue try: if os.path.exists(r.path): os.remove(r.path) removed += 1 except OSError: logger.warning("retention: could not remove %s", r.path) if removed: logger.info("retention: removed %d export file(s) older than %d days", removed, days) except Exception: # noqa: BLE001 — a failed sweep must never take the app down logger.exception("retention sweep failed") finally: db.close() return removed async def retention_loop() -> None: """Daily sweep, started from the app lifespan. Cancelled cleanly on shutdown.""" while True: await asyncio.to_thread(purge_old_exports) await asyncio.sleep(_SWEEP_INTERVAL_S)