Files
einkaufsapp/backend/app/routers/admin.py

471 lines
16 KiB
Python

"""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
DEFAULT_ARCHIVE_MONTHS = 12
DEFAULT_ARCHIVE_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
),
auto_archive_months=months_setting(
db, "auto_archive_months", DEFAULT_ARCHIVE_MONTHS
),
archive_delete_months=months_setting(
db, "archive_delete_months", DEFAULT_ARCHIVE_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))
if payload.auto_archive_months is not None:
set_setting(db, "auto_archive_months", str(payload.auto_archive_months))
if payload.archive_delete_months is not None:
set_setting(db, "archive_delete_months", str(payload.archive_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."
)