481 lines
16 KiB
Python
481 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.runtime_settings import (
|
|
BY_KEY,
|
|
describe_durations,
|
|
get_duration,
|
|
set_duration,
|
|
)
|
|
from app.schemas_admin import (
|
|
AdminSettingsIn,
|
|
AdminSettingsOut,
|
|
AdminStatsOut,
|
|
AdminUserOut,
|
|
DeleteUserIn,
|
|
DurationOut,
|
|
EmailChangeIn,
|
|
UserCreateIn,
|
|
)
|
|
from app.security import bucket_key, 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(),
|
|
durations=[DurationOut(**entry) for entry in describe_durations(db)],
|
|
fixed={
|
|
"Zähler der Missbrauchsabwehr": "24 Stunden",
|
|
"Fehlgeschlagene Produktabfragen":
|
|
f"{settings.product_miss_days} Tage (PRODUCT_MISS_DAYS)",
|
|
"Aufräumlauf":
|
|
f"alle {settings.cleanup_interval_hours} Stunden "
|
|
"(CLEANUP_INTERVAL_HOURS)",
|
|
"Zugriffsprotokolle des Webservers":
|
|
"Sache der Protokollrotation auf dem Server, nicht der Anwendung",
|
|
},
|
|
)
|
|
|
|
|
|
@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",
|
|
)
|
|
|
|
for key, value in (payload.durations or {}).items():
|
|
if key not in BY_KEY:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST, f"Unbekannte Frist: {key}"
|
|
)
|
|
set_duration(db, key, value)
|
|
|
|
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, bucket_key(db, "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],
|
|
get_duration(db, "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, bucket_key(db, "admin-welcome", user.id), limit=5, window_minutes=60
|
|
):
|
|
db.commit()
|
|
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
|
|
|
|
days = get_duration(db, "welcome_days")
|
|
token = issue_token(db, user, "welcome", hours=days * 24)
|
|
db.commit()
|
|
|
|
background.add_task(
|
|
send_welcome, user.email, token,
|
|
admin.display_name or admin.email.split("@")[0], 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()
|
|
|
|
change_hours = get_duration(db, "email_change_hours")
|
|
background.add_task(
|
|
send_email_change_verify, target, raw_new, old_email, change_hours
|
|
)
|
|
if change.requires_old and raw_old:
|
|
background.add_task(
|
|
send_email_change_verify_old, old_email, raw_old, target, 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, 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."
|
|
)
|