Erste Produktivversion
This commit is contained in:
458
backend/app/routers/admin.py
Normal file
458
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,458 @@
|
||||
"""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."
|
||||
)
|
||||
Reference in New Issue
Block a user