Files
einkaufsapp/backend/app/maintenance.py

349 lines
11 KiB
Python

"""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()))