"""Daily housekeeping. Deliberately its own scheduled job rather than a passenger on the price feed. The two answer to different things — one to market hours, the other to how long you want records kept — and bolting the second onto the first would mean turning off the feed silently stopped the cleanup as well. Each step is small, independent, and wrapped: a step that fails logs and the rest still run, because a stuck staging directory is no reason to stop expiring sessions. Everything runs on an unscoped session, which is correct rather than convenient — sessions and login attempts belong to no portfolio. """ from __future__ import annotations import datetime as dt import logging import time from sqlalchemy import delete, select from . import tenancy from .models import LoginAttempt, UserSession log = logging.getLogger(__name__) def purge_expired_sessions(session, settings) -> int: """Drop sessions that have already expired. `load_session` deletes an expired row when it happens to be presented, so without this the table keeps every session nobody ever returned to — which is most of them. """ now = dt.datetime.now(dt.timezone.utc) result = session.execute(delete(UserSession).where(UserSession.expires_at < now)) return result.rowcount or 0 def purge_old_login_attempts(session, settings) -> int: """Forget failed sign-ins past the retention window. They exist to drive lockout, not as an audit log, so keeping them longer than the lockout window serves nobody and quietly accumulates a list of which addresses tried which email. """ cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta( days=settings.maintenance.attempt_retention_days ) result = session.execute(delete(LoginAttempt).where(LoginAttempt.created_at < cutoff)) return result.rowcount or 0 def purge_passkey_challenges(session, settings) -> int: """Ceremonies nobody came back from. A challenge is consumed on use, so these are only the abandoned ones — a prompt dismissed, a tab closed. They expire in minutes and are useless afterwards, but nothing deletes a row that is never presented again. """ from . import passkeys # noqa: PLC0415 - avoids a circular import return passkeys.purge_expired_challenges(session) def purge_oidc_states(session, settings) -> int: """Sign-ins that went to a provider and never came back.""" from . import federation # noqa: PLC0415 - avoids a circular import return federation.purge_expired_states(session) def sweep_staged_uploads(session, settings) -> int: """Remove half-finished CSV imports left in the staging area. A preview writes the parsed rows to a file and waits for someone to press commit. When they don't, the file stays — holding the trades they were about to import, which is personal data with no owner watching it. """ from .imports_web import staging folder = staging(settings) if not folder.exists(): return 0 cutoff = time.time() - settings.maintenance.staged_upload_hours * 3600 removed = 0 for path in folder.glob("*.json"): try: if path.stat().st_mtime < cutoff: path.unlink() removed += 1 except OSError as exc: # already gone, or not ours log.warning("could not sweep %s: %s", path, exc) return removed def sweep_visual_mapper(session, settings) -> int: """Rendered page images from the visual statement mapper. Belt to the lazy sweep's braces. `pagemap.create` clears cold sessions whenever somebody uploads, which covers every install where the feature is used more than once — this is for the one where it is used exactly once and the last session's images would otherwise sit on the volume forever. """ from . import pagemap return pagemap.sweep(settings) # Order is not significant — each step stands alone. Add one here and it runs # with the rest, inside the same error handling. STEPS = ( ("expired sessions", purge_expired_sessions), ("stale passkey challenges", purge_passkey_challenges), ("stale sign-in attempts", purge_oidc_states), ("old login attempts", purge_old_login_attempts), ("staged uploads", sweep_staged_uploads), ("visual mapper pages", sweep_visual_mapper), ) # `STEPS` under the name the tests look it up by. JOBS = STEPS def run(session_factory, settings) -> dict[str, int | str]: """Run every step once. Returns what each one did, for the log.""" counts: dict[str, int | str] = {} for name, step in STEPS: try: with tenancy.unscoped_session(session_factory) as session: counts[name] = step(session, settings) except Exception as exc: # One failing step must not take the others with it. log.exception("maintenance step %r failed", name) counts[name] = f"failed: {type(exc).__name__}" log.info("maintenance run: %s", counts) return counts def expired_session_count(session) -> int: """Used by the tests, and handy in a shell when wondering if this works.""" now = dt.datetime.now(dt.timezone.utc) return len(session.scalars(select(UserSession).where(UserSession.expires_at < now)).all())