Erste Produktivversion
This commit is contained in:
212
backend/app/maintenance.py
Normal file
212
backend/app/maintenance.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""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()))
|
||||
Reference in New Issue
Block a user