"""Administration: Einstellungen, Mailprüfung, Benutzerverwaltung.""" from datetime import timedelta from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status from sqlalchemy import select from app.config import settings from app.deps import ( AdminUser, DbSession, client_ip, get_setting, registration_locked_by_env, self_registration_enabled, set_setting, ) from app.mail import ( check_connection, send_email_change_notice, send_email_change_verify, send_email_change_verify_old, send_test_mail, send_welcome, ) from app.maintenance import describe, run_cleanup from app.models import EmailChange, User from app.schemas import MailCheckOut, MailTestIn, MessageOut from app.schemas_admin import ( AdminSettingsIn, AdminSettingsOut, AdminStatsOut, AdminUserOut, DeleteUserIn, EmailChangeIn, UserCreateIn, ) from app.security import check_rate_limit, normalize_email, utcnow from app.users import ( EMAIL_CHANGE_HOURS, WELCOME_DAYS, create_user, deactivate, delete_user, issue_token, membership_counts, months_setting, owned_list_counts, reactivate, start_email_change, ) router = APIRouter(prefix="/api/admin", tags=["admin"]) DEFAULT_DEACTIVATE_MONTHS = 12 DEFAULT_DELETE_MONTHS = 12 # ========================================================================== # Einstellungen # ========================================================================== def _settings_out(db) -> AdminSettingsOut: return AdminSettingsOut( allow_self_registration=self_registration_enabled(db), locked_by_env=registration_locked_by_env(), auto_deactivate_months=months_setting( db, "auto_deactivate_months", DEFAULT_DEACTIVATE_MONTHS ), auto_delete_months=months_setting( db, "auto_delete_months", DEFAULT_DELETE_MONTHS ), ) @router.get("/settings", response_model=AdminSettingsOut) def read_settings(db: DbSession, admin: AdminUser): return _settings_out(db) @router.put("/settings", response_model=AdminSettingsOut) def write_settings(payload: AdminSettingsIn, db: DbSession, admin: AdminUser): if payload.allow_self_registration is not None: if registration_locked_by_env(): raise HTTPException( status.HTTP_409_CONFLICT, "Die Selbstregistrierung ist über die Umgebungsvariable " f"ALLOW_SELF_REGISTRATION={settings.allow_self_registration} " "festgelegt und lässt sich hier nicht ändern. Setze die Variable " "auf 'admin', um sie über diese Schnittstelle steuerbar zu machen.", ) set_setting( db, "allow_self_registration", "true" if payload.allow_self_registration else "false", ) if payload.auto_deactivate_months is not None: set_setting(db, "auto_deactivate_months", str(payload.auto_deactivate_months)) if payload.auto_delete_months is not None: set_setting(db, "auto_delete_months", str(payload.auto_delete_months)) db.commit() return _settings_out(db) # ========================================================================== # Benutzerübersicht # ========================================================================== def _user_out( user: User, owned: dict[str, int], memberships: dict[str, int], pending: dict[str, str], ) -> AdminUserOut: return AdminUserOut( id=user.id, email=user.email, display_name=user.display_name, is_admin=user.is_admin, is_active=user.is_active, verified=user.verified_at is not None, last_seen_at=user.last_seen_at, deactivated_at=user.deactivated_at, created_at=user.created_at, owned_lists=owned.get(user.id, 0), memberships=memberships.get(user.id, 0), pending_email=pending.get(user.id), ) def _pending_changes(db) -> dict[str, str]: rows = db.scalars( select(EmailChange).where( EmailChange.applied_at.is_(None), EmailChange.cancelled_at.is_(None), EmailChange.expires_at > utcnow(), ) ).all() return {row.user_id: row.new_email for row in rows} @router.get("/users", response_model=list[AdminUserOut]) def list_users(db: DbSession, admin: AdminUser): users = db.scalars(select(User).order_by(User.created_at)).all() owned = owned_list_counts(db) memberships = membership_counts(db) pending = _pending_changes(db) return [_user_out(u, owned, memberships, pending) for u in users] @router.get("/stats", response_model=AdminStatsOut) def stats(db: DbSession, admin: AdminUser): users = db.scalars(select(User)).all() now = utcnow() deactivate_months = months_setting( db, "auto_deactivate_months", DEFAULT_DEACTIVATE_MONTHS) delete_months = months_setting(db, "auto_delete_months", DEFAULT_DELETE_MONTHS) due_deactivation = 0 due_deletion = 0 for user in users: # Administratorkonten unterliegen der Automatik nicht. if user.is_admin: continue if ( deactivate_months and user.is_active and user.last_seen_at and user.last_seen_at < now - timedelta(days=deactivate_months * 30) ): due_deactivation += 1 if ( delete_months and not user.is_active and user.deactivated_at and user.deactivated_at < now - timedelta(days=delete_months * 30) ): due_deletion += 1 return AdminStatsOut( total=len(users), active=sum(1 for u in users if u.is_active), inactive=sum(1 for u in users if not u.is_active), unverified=sum(1 for u in users if u.verified_at is None), admins=sum(1 for u in users if u.is_admin), due_deactivation=due_deactivation, due_deletion=due_deletion, ) # ========================================================================== # Benutzer anlegen # ========================================================================== def _target(db, user_id: str) -> User: user = db.get(User, user_id) if user is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Konto nicht gefunden") return user @router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED) def add_user( payload: UserCreateIn, request: Request, db: DbSession, admin: AdminUser, background: BackgroundTasks, ): if not check_rate_limit( db, f"admin-create:{client_ip(request)}", limit=30, window_minutes=60 ): db.commit() raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anlagen.") email = normalize_email(payload.email) if db.scalar(select(User).where(User.email == email)) is not None: raise HTTPException( status.HTTP_409_CONFLICT, "Unter dieser Adresse existiert bereits ein Konto." ) user, token = create_user( db, email=email, display_name=payload.display_name, is_admin=payload.is_admin ) db.commit() db.refresh(user) background.add_task( send_welcome, email, token, admin.display_name or admin.email.split("@")[0], WELCOME_DAYS, ) return _user_out(user, {}, {}, {}) @router.post("/users/{user_id}/welcome", response_model=MessageOut) def resend_welcome( user_id: str, db: DbSession, admin: AdminUser, background: BackgroundTasks, ): """Erneuter Versand der Willkommensnachricht. Nur solange das Konto noch nicht bestätigt ist. Danach wäre es kein Willkommensgruß mehr, sondern ein vom Administrator ausgelöstes Zurücksetzen des Passworts - das soll vom Kontoinhaber ausgehen und läuft über "Passwort vergessen". """ user = _target(db, user_id) if user.verified_at is not None: raise HTTPException( status.HTTP_409_CONFLICT, "Dieses Konto ist bereits eingerichtet. Für ein neues Passwort " "nutzt die Person „Passwort vergessen“ auf der Anmeldeseite.", ) if not check_rate_limit(db, f"admin-welcome:{user.id}", limit=5, window_minutes=60): db.commit() raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.") token = issue_token(db, user, "welcome", hours=WELCOME_DAYS * 24) db.commit() background.add_task( send_welcome, user.email, token, admin.display_name or admin.email.split("@")[0], WELCOME_DAYS, ) return MessageOut(detail=f"Willkommensnachricht erneut an {user.email} versendet.") # ========================================================================== # Adressänderung # ========================================================================== @router.post("/users/{user_id}/email", response_model=MessageOut) def change_email( user_id: str, payload: EmailChangeIn, db: DbSession, admin: AdminUser, background: BackgroundTasks, ): user = _target(db, user_id) target = normalize_email(payload.new_email) if target == user.email: raise HTTPException( status.HTTP_400_BAD_REQUEST, "Das ist bereits die aktuelle Adresse." ) if db.scalar(select(User).where(User.email == target)) is not None: raise HTTPException( status.HTTP_409_CONFLICT, "Unter dieser Adresse existiert bereits ein Konto." ) old_email = user.email change, raw_new, raw_old = start_email_change(db, user, target, admin.id) db.commit() background.add_task( send_email_change_verify, target, raw_new, old_email, EMAIL_CHANGE_HOURS ) if change.requires_old and raw_old: background.add_task( send_email_change_verify_old, old_email, raw_old, target, EMAIL_CHANGE_HOURS ) detail = ( f"Bestätigungslinks an {target} und {old_email} versendet. " "Bei Administratorkonten müssen beide Adressen zustimmen; die " "Änderung wird erst danach wirksam." ) else: background.add_task( send_email_change_notice, old_email, target, EMAIL_CHANGE_HOURS ) detail = ( f"Bestätigungslink an {target} versendet, Hinweis an {old_email}. " "Die Änderung wird wirksam, sobald die neue Adresse bestätigt hat." ) return MessageOut(detail=detail) @router.delete("/users/{user_id}/email", response_model=MessageOut) def cancel_email_change(user_id: str, db: DbSession, admin: AdminUser): user = _target(db, user_id) count = 0 for change in db.scalars( select(EmailChange).where( EmailChange.user_id == user.id, EmailChange.applied_at.is_(None), EmailChange.cancelled_at.is_(None), ) ).all(): change.cancelled_at = utcnow() count += 1 db.commit() return MessageOut( detail=f"{count} offene(r) Adresswechsel zurückgezogen." if count else "Es lief kein Adresswechsel." ) # ========================================================================== # Deaktivieren, Reaktivieren, Löschen # ========================================================================== def _guard_admin_target(user: User, admin: User, action: str) -> None: """Administratorkonten sind vor Deaktivierung und Löschung geschützt. Sonst könnte ein Administrator alle anderen aussperren - oder sich selbst, und dann käme niemand mehr an die Verwaltung. Wer einen Administrator entfernen will, nimmt ihm zuerst die Rechte; das geht bewusst nur direkt in der Datenbank und ist damit ein Schritt, den man nicht versehentlich tut. """ if user.is_admin: raise HTTPException( status.HTTP_409_CONFLICT, f"Administratorkonten können nicht {action} werden. Entziehe die " "Administratorrechte zuerst - das geht bewusst nur direkt in der " "Datenbank.", ) if user.id == admin.id: raise HTTPException( status.HTTP_409_CONFLICT, "Das eigene Konto lässt sich nicht ändern." ) @router.post("/users/{user_id}/deactivate", response_model=AdminUserOut) def deactivate_user(user_id: str, db: DbSession, admin: AdminUser): user = _target(db, user_id) _guard_admin_target(user, admin, "deaktiviert") deactivate(db, user) db.commit() db.refresh(user) return _user_out(user, owned_list_counts(db), membership_counts(db), {}) @router.post("/users/{user_id}/activate", response_model=AdminUserOut) def activate_user(user_id: str, db: DbSession, admin: AdminUser): user = _target(db, user_id) reactivate(db, user) db.commit() db.refresh(user) return _user_out(user, owned_list_counts(db), membership_counts(db), {}) @router.post("/users/{user_id}/delete", response_model=MessageOut) def remove_user(user_id: str, payload: DeleteUserIn, db: DbSession, admin: AdminUser): """Löschen als POST mit Rumpf, nicht als DELETE. Der Vorgang braucht zwei Angaben: was mit den Listen geschehen soll und eine Bestätigung der Adresse. Ein DELETE mit Rumpf ist in Zwischenschichten unzuverlässig. """ user = _target(db, user_id) _guard_admin_target(user, admin, "gelöscht") if normalize_email(payload.confirm_email) != user.email: raise HTTPException( status.HTTP_400_BAD_REQUEST, "Die Bestätigungsadresse stimmt nicht mit dem Konto überein.", ) owned = owned_list_counts(db).get(user.id, 0) if owned and payload.lists == "refuse": raise HTTPException( status.HTTP_409_CONFLICT, f"Diesem Konto gehören {owned} Liste(n). Wähle „übergeben“, um " "geteilte Listen an das dienstälteste andere Mitglied zu " "übertragen und die übrigen zu löschen.", ) email = user.email counts = delete_user(db, user) db.commit() return MessageOut( detail=f"Konto {email} gelöscht. " f"{counts['übertragene Listen']} Liste(n) übertragen, " f"{counts['gelöschte Listen']} gelöscht." ) # ========================================================================== # Betrieb # ========================================================================== @router.post("/cleanup", response_model=MessageOut) def trigger_cleanup(db: DbSession, admin: AdminUser): """Räumt sofort auf, statt auf den täglichen Durchlauf zu warten.""" counts = run_cleanup(db) return MessageOut(detail=f"Aufgeräumt: {describe(counts)}.") @router.get("/mail/check", response_model=MailCheckOut) def mail_check(admin: AdminUser): """Verbindungstest zum Relay, ohne eine Nachricht zu versenden.""" ok, detail = check_connection() return MailCheckOut( ok=ok, detail=detail, host=settings.smtp_host, port=settings.smtp_port, security=settings.smtp_security, envelope_from=settings.envelope_from, ) @router.post("/mail/test", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED) def mail_test(payload: MailTestIn, background: BackgroundTasks, admin: AdminUser): background.add_task(send_test_mail, str(payload.to)) return MessageOut( detail=f"Testnachricht an {payload.to} in Auftrag gegeben. " "Ergebnis steht im Log des api-Containers." )