Ctrl K
ComunidadGuías…

app/maintenance.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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())