Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

212
backend/app/maintenance.py Normal file
View 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()))