213 lines
6.9 KiB
Python
213 lines
6.9 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, 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()))
|