"""Regelmäßiges Aufräumen. Die Anwendung sammelt an mehreren Stellen bewusst mehr Daten, als für den Augenblick nötig sind - Soft Delete, damit offline gebliebene Geräte vom Löschen erfahren; Quittungen für die Outbox, damit Wiederholungen erkannt werden; Zwischenspeicher für Produktabfragen. Ohne Aufräumen wächst das unbegrenzt. Läuft ohne zusätzlichen Dienst: eine Hintergrundaufgabe im api-Container, einmal beim Start und danach täglich. Kein Cron-Container, kein Redis, und damit auch keine weitere Stelle, an der etwas kaputtgehen kann. """ import logging from datetime import timedelta from sqlalchemy import delete, select from sqlalchemy.orm import Session from app.config import settings from app.mail import send_deactivation_notice from app.models import ( AppliedOp, Article, ArticleAttribute, ArticleMarket, Category, EmailChange, EmailToken, ListInvite, ListItem, Market, ProductCache, PublicShare, RateLimit, ShoppingList, User, UserSession, ) from app.security import utcnow from app.users import deactivate, delete_user, months_setting log = logging.getLogger(__name__) def run_cleanup(db: Session) -> dict[str, int]: """Räumt auf und gibt zurück, was entfernt wurde.""" now = utcnow() counts: dict[str, int] = {} def purge(model, condition, label: str) -> None: result = db.execute(delete(model).where(condition)) if result.rowcount: counts[label] = result.rowcount # --- Sitzungen und Token --- purge(UserSession, UserSession.expires_at <= now, "abgelaufene Sitzungen") # Verbrauchte oder abgelaufene Mail-Token: nach sieben Tagen weg. Der # Puffer erlaubt es, im Zweifelsfall nachzusehen, warum ein Link nicht # mehr ging. purge( EmailToken, EmailToken.expires_at <= now - timedelta(days=7), "abgelaufene Mail-Token", ) # --- Rate-Limit-Zähler --- purge( RateLimit, RateLimit.window_start < now - timedelta(days=1), "Rate-Limit-Zähler", ) # --- Quittungen der Outbox --- # Sie verhindern doppeltes Ausführen. Ein Gerät, das länger als die # Aufbewahrungsfrist offline war, hätte seine Operationen ohnehin # längst über die Wiederholungsgrenze hinaus versucht. purge( AppliedOp, AppliedOp.created_at < now - timedelta(days=settings.cleanup_ops_days), "Outbox-Quittungen", ) # --- Abgelaufene Einladungen und öffentliche Links --- purge( ListInvite, ListInvite.expires_at < now - timedelta(days=settings.cleanup_deleted_days), "alte Einladungen", ) purge( PublicShare, PublicShare.expires_at < now - timedelta(days=settings.cleanup_deleted_days), "abgelaufene öffentliche Links", ) # --- Produktzwischenspeicher --- purge( ProductCache, ProductCache.fetched_at < now - timedelta(days=max(settings.product_cache_days * 2, 365)), "Produktzwischenspeicher", ) # --- Endgültiges Löschen weicher Löschungen --- # Erst jetzt verschwinden die Daten wirklich. Bis dahin konnten # offline gebliebene Geräte erfahren, dass es sie nicht mehr gibt. cutoff = now - timedelta(days=settings.cleanup_deleted_days) purge(ListItem, ListItem.deleted_at < cutoff, "gelöschte Einträge") # Artikel erst nach ihren Einträgen: Die Fremdschlüssel räumen zwar # per CASCADE mit auf, aber die Reihenfolge macht die Zählung ehrlich. stale_articles = db.scalars( select(Article.id).where(Article.deleted_at < cutoff) ).all() if stale_articles: db.execute( delete(ArticleAttribute).where( ArticleAttribute.article_id.in_(stale_articles)) ) db.execute( delete(ArticleMarket).where(ArticleMarket.article_id.in_(stale_articles)) ) db.execute(delete(Article).where(Article.id.in_(stale_articles))) counts["gelöschte Artikel"] = len(stale_articles) purge(Market, Market.deleted_at < cutoff, "gelöschte Märkte") purge(Category, Category.deleted_at < cutoff, "gelöschte Warengruppen") # Gelöschte Listen zuletzt - daran hängt per CASCADE alles Übrige. purge(ShoppingList, ShoppingList.deleted_at < cutoff, "gelöschte Listen") # --- Abgeschlossene Adressänderungen --- purge( EmailChange, EmailChange.expires_at < now - timedelta(days=settings.cleanup_deleted_days), "alte Adressänderungen", ) db.commit() counts.update(_retire_users(db)) db.commit() return counts def _retire_users(db: Session) -> dict[str, int]: """Deaktiviert lange untätige Konten und löscht lange deaktivierte. Administratorkonten bleiben ausgenommen - sonst könnte sich die Verwaltung selbst aussperren, und zwar unbemerkt, weil niemand hinsieht, solange alles läuft. Ein Wert von 0 bedeutet "abgeschaltet", nicht "sofort". """ now = utcnow() counts: dict[str, int] = {} deactivate_months = months_setting(db, "auto_deactivate_months", 12) delete_months = months_setting(db, "auto_delete_months", 12) if deactivate_months: cutoff = now - timedelta(days=deactivate_months * 30) candidates = db.scalars( select(User).where( User.is_active.is_(True), User.is_admin.is_(False), User.last_seen_at.is_not(None), User.last_seen_at < cutoff, ) ).all() for user in candidates: log.info( "Deaktiviere %s - seit %s nicht mehr gesehen", user.email, user.last_seen_at.date(), ) address = user.email deactivate(db, user) # Der Versand blockiert; hier ist das vertretbar, weil der # Aufräumlauf ohnehin im Hintergrund läuft und selten # mehr als eine Handvoll Konten betrifft. send_deactivation_notice(address, deactivate_months) if candidates: counts["deaktivierte Konten"] = len(candidates) if delete_months: cutoff = now - timedelta(days=delete_months * 30) candidates = db.scalars( select(User).where( User.is_active.is_(False), User.is_admin.is_(False), User.deactivated_at.is_not(None), User.deactivated_at < cutoff, ) ).all() for user in candidates: log.info( "Lösche %s - seit %s deaktiviert", user.email, user.deactivated_at.date(), ) delete_user(db, user) if candidates: counts["gelöschte Konten"] = len(candidates) return counts def describe(counts: dict[str, int]) -> str: if not counts: return "nichts zu tun" return ", ".join(f"{value} {label}" for label, value in sorted(counts.items()))