349 lines
11 KiB
Python
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()))
|