"""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, func, select from sqlalchemy.orm import Session from app.config import settings from app.mail import send_deactivation_notice, send_list_deletion_warning from app.models import ( AppliedOp, Article, ArticleAttribute, ArticleMarket, Category, EmailChange, EmailToken, ListInvite, ListItem, ListMember, Market, ProductCache, PublicShare, RateLimit, ShoppingList, User, UserSession, ) from app.runtime_settings import get_duration 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. Alle Fristen kommen aus der Laufzeitkonfiguration, damit das, was hier geschieht, dem entspricht, was in der Verwaltung steht - und dem, was die Datenschutzerklärung behauptet. """ now = utcnow() counts: dict[str, int] = {} deleted_days = get_duration(db, "cleanup_deleted_days") ops_days = get_duration(db, "cleanup_ops_days") product_days = get_duration(db, "product_cache_days") 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=ops_days), "Outbox-Quittungen", ) # --- Abgelaufene Einladungen und öffentliche Links --- purge( ListInvite, ListInvite.expires_at < now - timedelta(days=deleted_days), "alte Einladungen", ) purge( PublicShare, PublicShare.expires_at < now - timedelta(days=deleted_days), "abgelaufene öffentliche Links", ) # --- Produktzwischenspeicher --- purge( ProductCache, ProductCache.fetched_at < now - timedelta(days=max(product_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=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=deleted_days), "alte Adressänderungen", ) db.commit() counts.update(_retire_users(db)) db.commit() counts.update(_archive_lists(db)) db.commit() return counts def _archive_lists(db: Session) -> dict[str, int]: """Archiviert lange nicht geöffnete Listen und löscht lange archivierte. Zwei Besonderheiten, die diesen Ablauf von der Kontenbereinigung unterscheiden: * Archiviert wird JE MITGLIED. Dass eine Person eine gemeinsame Liste seit einem Jahr nicht geöffnet hat, sagt nichts über die anderen - sie verschwindet nur aus ihrer eigenen Übersicht. * Gelöscht wird nur nach dem Archivdatum des EIGENTÜMERS. Sonst könnte ein eingeladenes Mitglied, das die Liste weggeräumt hat, eine Löschung auslösen, die den Eigentümer trifft. """ now = utcnow() counts: dict[str, int] = {} archive_months = months_setting(db, "auto_archive_months", 12) delete_months = months_setting(db, "archive_delete_months", 12) # --- Automatisch archivieren, je Mitgliedschaft --- if archive_months: cutoff = now - timedelta(days=archive_months * 30) stale = db.scalars( select(ListMember) .join(ShoppingList, ShoppingList.id == ListMember.list_id) .where( ListMember.archived_at.is_(None), ListMember.last_opened_at.is_not(None), ListMember.last_opened_at < cutoff, ShoppingList.deleted_at.is_(None), ) ).all() for member in stale: member.archived_at = now if stale: counts["archivierte Listen"] = len(stale) if not delete_months: return counts # --- Vorwarnen und löschen, nach dem Archivdatum des Eigentümers --- delete_after = timedelta(days=delete_months * 30) owners = db.scalars( select(ListMember) .join(ShoppingList, ShoppingList.id == ListMember.list_id) .where( ListMember.archived_at.is_not(None), ShoppingList.deleted_at.is_(None), ShoppingList.owner_id == ListMember.user_id, ) ).all() warned = 0 deleted = 0 for member in owners: lst = db.get(ShoppingList, member.list_id) if lst is None or lst.deleted_at is not None: continue due = member.archived_at + delete_after remaining = due - now # Wer die Liste zwischenzeitlich wieder geöffnet hat, soll sie # nicht verlieren - auch wenn das Archivdatum alt ist. if member.last_opened_at and member.last_opened_at > now - delete_after: continue if remaining.total_seconds() <= 0: log.info( "Lösche Liste %s (%s) - seit %s im Archiv des Eigentümers", lst.id, lst.name, member.archived_at.date(), ) # Soft Delete: Offline gebliebene Geräte sollen noch # erfahren, dass die Liste weg ist. Endgültig entfernt sie # der Durchlauf nach CLEANUP_DELETED_DAYS. lst.deleted_at = now lst.rev += 1 deleted += 1 continue stage = None if remaining <= timedelta(days=1) and lst.warn_1d_at is None: stage = 1 elif remaining <= timedelta(days=7) and lst.warn_7d_at is None: stage = 7 if stage is None: continue owner = db.get(User, lst.owner_id) if owner is None or not owner.is_active: continue item_count = db.scalar( select(func.count()) .select_from(ListItem) .where(ListItem.list_id == lst.id, ListItem.deleted_at.is_(None)) ) or 0 send_list_deletion_warning( owner.email, lst.name, stage, member.archived_at.strftime("%d.%m.%Y"), item_count, ) if stage == 1: lst.warn_1d_at = now # Falls die Sieben-Tage-Stufe übersprungen wurde, etwa weil # die Frist verkürzt wurde: als erledigt vermerken. lst.warn_7d_at = lst.warn_7d_at or now else: lst.warn_7d_at = now warned += 1 if warned: counts["Vorwarnungen zu Listen"] = warned if deleted: counts["gelöschte Listen"] = deleted 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()))