Erste Produktivversion
This commit is contained in:
285
backend/app/users.py
Normal file
285
backend/app/users.py
Normal 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}
|
||||
Reference in New Issue
Block a user