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

285
backend/app/users.py Normal file
View File

@@ -0,0 +1,285 @@
"""Benutzerverwaltung: Anlegen, Adressänderung, Deaktivieren, Löschen.
Liegt außerhalb der Router, weil dieselben Vorgänge an zwei Stellen
gebraucht werden: über die Administrationsoberfläche und über die
automatische Bereinigung.
"""
import logging
from datetime import timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.deps import get_setting
from app.models import (
EmailChange,
EmailToken,
ListMember,
ShoppingList,
User,
UserSession,
)
from app.security import hash_token, new_token, normalize_email, utcnow
log = logging.getLogger(__name__)
WELCOME_DAYS = 14
EMAIL_CHANGE_HOURS = 48
# ==========================================================================
# Einstellungen zur automatischen Bereinigung
# ==========================================================================
def months_setting(db: Session, key: str, fallback: int) -> int:
"""Liest einen Monatswert aus der Laufzeitkonfiguration.
0 bedeutet ausdrücklich "abgeschaltet" - nicht "sofort". Ein
Tippfehler soll nicht dazu führen, dass beim nächsten Durchlauf alle
Konten verschwinden.
"""
raw = get_setting(db, key, "")
if not raw:
return fallback
try:
value = int(raw)
except ValueError:
log.warning("Einstellung %s ist keine Zahl (%r) - verwende %d", key, raw, fallback)
return fallback
return max(0, min(value, 600))
# ==========================================================================
# Anlegen und Willkommensnachricht
# ==========================================================================
def issue_token(db: Session, user: User, purpose: str, hours: int) -> str:
"""Erzeugt ein Einmal-Token und entwertet ältere desselben Zwecks."""
for old in db.scalars(
select(EmailToken).where(
EmailToken.user_id == user.id,
EmailToken.purpose == purpose,
EmailToken.used_at.is_(None),
)
).all():
old.used_at = utcnow()
raw = new_token()
db.add(
EmailToken(
token_hash=hash_token(raw),
user_id=user.id,
purpose=purpose,
expires_at=utcnow() + timedelta(hours=hours),
)
)
return raw
def create_user(
db: Session, *, email: str, display_name: str | None, is_admin: bool = False
) -> tuple[User, str]:
"""Legt ein Konto ohne Passwort an.
Das Passwort setzt die eingeladene Person selbst über den Link aus
der Willkommensnachricht. Ein vom Administrator vergebenes Passwort
wäre ihm bekannt - und würde per Mail verschickt.
@returns (Konto, Klartext-Token für die Willkommensnachricht)
"""
user = User(
email=normalize_email(email),
display_name=(display_name or None),
# Platzhalter: Mit diesem Wert lässt sich nicht anmelden, weil er
# kein gültiger Argon2-Hash ist und die Prüfung fehlschlägt.
password_hash="!",
is_admin=is_admin,
is_active=True,
)
db.add(user)
db.flush()
token = issue_token(db, user, "welcome", hours=WELCOME_DAYS * 24)
return user, token
# ==========================================================================
# Adressänderung
# ==========================================================================
def start_email_change(
db: Session, user: User, new_email: str, requested_by: str | None
) -> tuple[EmailChange, str, str | None]:
"""Beginnt eine Adressänderung.
@returns (Vorgang, Token für die neue Adresse, Token für die alte
Adresse oder None)
Bei Administratorkonten wird auch von der alten Adresse eine
Bestätigung verlangt. Sonst könnte, wer Zugriff auf ein
Administratorkonto erlangt, die Adresse auf eine eigene umstellen und
sich dauerhaft einnisten - der rechtmäßige Inhaber verlöre den Weg
zurück über "Passwort vergessen".
"""
target = normalize_email(new_email)
# Offene Vorgänge desselben Kontos zurückziehen.
for pending in db.scalars(
select(EmailChange).where(
EmailChange.user_id == user.id,
EmailChange.applied_at.is_(None),
EmailChange.cancelled_at.is_(None),
)
).all():
pending.cancelled_at = utcnow()
raw_new = new_token()
raw_old = new_token() if user.is_admin else None
change = EmailChange(
user_id=user.id,
old_email=user.email,
new_email=target,
token_new_hash=hash_token(raw_new),
token_old_hash=hash_token(raw_old) if raw_old else None,
requires_old=user.is_admin,
requested_by=requested_by,
expires_at=utcnow() + timedelta(hours=EMAIL_CHANGE_HOURS),
)
db.add(change)
db.flush()
return change, raw_new, raw_old
def apply_if_complete(db: Session, change: EmailChange) -> bool:
"""Übernimmt die neue Adresse, sobald alle Bestätigungen vorliegen."""
if change.applied_at or change.cancelled_at:
return False
if change.confirmed_new_at is None:
return False
if change.requires_old and change.confirmed_old_at is None:
return False
user = db.get(User, change.user_id)
if user is None:
change.cancelled_at = utcnow()
return False
# Zwischenzeitlich vergeben? Dann Vorgang verwerfen statt eine
# Eindeutigkeitsverletzung zu produzieren.
taken = db.scalar(
select(User).where(User.email == change.new_email, User.id != user.id)
)
if taken is not None:
change.cancelled_at = utcnow()
return False
user.email = change.new_email
# Die neue Adresse hat sich gerade selbst bestätigt.
user.verified_at = utcnow()
change.applied_at = utcnow()
# Alle Sitzungen beenden: Ein Adresswechsel ist ein guter Anlass,
# sich überall neu anzumelden - besonders, wenn er nicht vom
# Kontoinhaber ausging.
for session in db.scalars(
select(UserSession).where(UserSession.user_id == user.id)
).all():
db.delete(session)
return True
# ==========================================================================
# Deaktivieren, Reaktivieren, Löschen
# ==========================================================================
def deactivate(db: Session, user: User) -> None:
"""Sperrt ein Konto und beendet alle Sitzungen."""
if not user.is_active:
return
user.is_active = False
user.deactivated_at = utcnow()
for session in db.scalars(
select(UserSession).where(UserSession.user_id == user.id)
).all():
db.delete(session)
def reactivate(db: Session, user: User) -> None:
user.is_active = True
user.deactivated_at = None
# Zähler zurücksetzen, sonst wäre das Konto beim nächsten Durchlauf
# sofort wieder fällig.
user.last_seen_at = utcnow()
def dispose_lists(db: Session, user: User) -> tuple[int, int]:
"""Regelt die Listen eines zu löschenden Kontos.
`shopping_list.owner_id` steht auf RESTRICT - ohne diese Vorarbeit
schlüge das Löschen fehl.
Geteilte Listen gehen an das dienstälteste andere Mitglied über;
Listen ohne weitere Mitglieder werden gelöscht. Eine geteilte Liste
mitzulöschen würde anderen Leuten Daten wegnehmen, mit denen das
ausscheidende Konto nichts mehr zu tun hat.
@returns (übertragen, gelöscht)
"""
transferred = 0
removed = 0
lists = db.scalars(
select(ShoppingList).where(ShoppingList.owner_id == user.id)
).all()
for lst in lists:
successor = db.scalars(
select(ListMember)
.where(ListMember.list_id == lst.id, ListMember.user_id != user.id)
.order_by(ListMember.joined_at)
).first()
if successor is None:
db.delete(lst)
removed += 1
continue
successor.role = "owner"
successor.may_share_public = True
lst.owner_id = successor.user_id
lst.rev += 1
transferred += 1
# Eigene Mitgliedschaften enden ohnehin per CASCADE.
db.flush()
return transferred, removed
def delete_user(db: Session, user: User) -> dict[str, int]:
"""Löscht ein Konto samt Vorarbeit an seinen Listen."""
transferred, removed = dispose_lists(db, user)
db.delete(user)
db.flush()
return {"übertragene Listen": transferred, "gelöschte Listen": removed}
# ==========================================================================
# Kennzahlen
# ==========================================================================
def owned_list_counts(db: Session) -> dict[str, int]:
rows = db.execute(
select(ShoppingList.owner_id, func.count())
.where(ShoppingList.deleted_at.is_(None))
.group_by(ShoppingList.owner_id)
).all()
return {owner_id: count for owner_id, count in rows}
def membership_counts(db: Session) -> dict[str, int]:
rows = db.execute(
select(ListMember.user_id, func.count()).group_by(ListMember.user_id)
).all()
return {user_id: count for user_id, count in rows}