"""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__) # Vorgabewerte; maßgeblich ist, was in der Verwaltung eingestellt ist. WELCOME_DAYS = 14 EMAIL_CHANGE_HOURS = 48 # ========================================================================== # Einstellungen zur automatischen Bereinigung # ========================================================================== def months_setting(db: Session, key: str, fallback: int = 0) -> int: """Monatswert aus der Laufzeitkonfiguration. Nur noch eine Weiterleitung - die Fristen liegen gesammelt in app/runtime_settings.py. Der Name bleibt, damit die vorhandenen Aufrufstellen unverändert weiterlaufen. 0 bedeutet ausdrücklich "abgeschaltet", nicht "sofort". """ from app.runtime_settings import get_duration return get_duration(db, key) # ========================================================================== # 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 welcome_days(db: Session) -> int: from app.runtime_settings import get_duration return get_duration(db, "welcome_days") 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(db) * 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() from app.runtime_settings import get_duration hours = get_duration(db, "email_change_hours") 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=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}