286 lines
8.9 KiB
Python
286 lines
8.9 KiB
Python
"""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}
|