Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

View File

View 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."
)

View File

@@ -0,0 +1,56 @@
"""Angaben zur Anwendung, die die Oberflaeche zur Laufzeit braucht.
Die statischen Dateien im web-Container kennen den Anwendungsnamen nicht -
er steht in der .env. Statt ihn beim Bauen einzusetzen (was ein
Neubau-Erfordernis bei jeder Umbenennung bedeutete), liefert ihn die API.
"""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from app.config import settings
router = APIRouter(tags=["app"])
@router.get("/api/config")
def public_config():
"""Ohne Anmeldung erreichbar: Das Anmeldeformular braucht den Namen,
bevor jemand angemeldet ist. Enthaelt bewusst nichts Vertrauliches."""
return {
"app_name": settings.app_name,
"app_short_name": settings.short_name,
}
@router.get("/manifest.webmanifest", include_in_schema=False)
def manifest():
"""Erzeugt statt statisch ausgeliefert, damit der Name aus der .env
auch auf dem Startbildschirm erscheint."""
return JSONResponse(
{
"name": settings.app_name,
"short_name": settings.short_name,
"description": "Gemeinsame Einkaufslisten mit Märkten, "
"Warengruppen und Preisen.",
"lang": "de",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#f6f6f4",
"theme_color": "#2f6f4e",
"categories": ["shopping", "productivity"],
"icons": [
{"src": "/icons/icon-192.png", "sizes": "192x192",
"type": "image/png", "purpose": "any"},
{"src": "/icons/icon-512.png", "sizes": "512x512",
"type": "image/png", "purpose": "any"},
{"src": "/icons/icon-maskable-512.png", "sizes": "512x512",
"type": "image/png", "purpose": "maskable"},
],
},
media_type="application/manifest+json",
headers={"Cache-Control": "no-cache"},
)

396
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,396 @@
from datetime import timedelta
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from app.config import settings
from app.deps import (
CSRF_COOKIE,
SESSION_COOKIE,
CurrentUser,
DbSession,
client_ip,
create_session,
destroy_session,
self_registration_enabled,
)
from app.mail import send_password_reset, send_verification
from app.models import EmailChange, EmailToken, User, UserSession
from app.schemas_admin import WelcomeCompleteIn, WelcomePreviewOut
from app.schemas import (
LoginIn,
MessageOut,
PasswordChangeIn,
PasswordResetIn,
PasswordResetRequestIn,
ProfileUpdateIn,
RegisterIn,
UserOut,
)
from app.users import apply_if_complete
from app.security import (
check_rate_limit,
hash_password,
hash_token,
needs_rehash,
new_token,
normalize_email,
utcnow,
verify_password,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
# Bewusst identische Antwort fuer "Konto existiert" und "Konto existiert nicht".
_NEUTRAL = "Falls die Adresse bei uns registriert ist, wurde eine E-Mail versendet."
def _issue_token(db, user: User, purpose: str, hours: int) -> str:
# Aeltere, noch offene Token desselben Zwecks entwerten.
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 _consume_token(db, raw: str, purpose: str) -> User | None:
row = db.scalar(
select(EmailToken).where(
EmailToken.token_hash == hash_token(raw),
EmailToken.purpose == purpose,
)
)
if row is None or row.used_at is not None or row.expires_at <= utcnow():
return None
row.used_at = utcnow()
return db.get(User, row.user_id)
@router.post("/register", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
def register(
payload: RegisterIn,
request: Request,
db: DbSession,
background: BackgroundTasks,
):
if not self_registration_enabled(db):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"Die Selbstregistrierung ist deaktiviert. Bitte lass dich einladen.",
)
if not check_rate_limit(
db, f"register:{client_ip(request)}", limit=5, window_minutes=60
):
db.commit()
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
email = normalize_email(payload.email)
existing = db.scalar(select(User).where(User.email == email))
if existing is None:
user = User(
email=email,
display_name=(payload.display_name or None),
password_hash=hash_password(payload.password),
is_admin=(email == normalize_email(settings.admin_email)),
)
db.add(user)
db.flush()
raw = _issue_token(db, user, "verify", hours=24)
background.add_task(send_verification, email, raw)
elif existing.verified_at is None:
# Unbestaetigtes Konto: neuen Link schicken, statt zu verraten,
# dass die Adresse schon vergeben ist.
raw = _issue_token(db, existing, "verify", hours=24)
background.add_task(send_verification, email, raw)
# Bestaetigtes Konto: nichts tun, aber gleiche Antwort geben.
db.commit()
return MessageOut(detail=_NEUTRAL)
@router.get("/verify", include_in_schema=True)
def verify(token: str, db: DbSession):
"""Ziel des Links aus der Bestätigungsmail.
Antwortet mit einer Weiterleitung auf die Oberfläche, nicht mit JSON:
Der Nutzer klickt hier im Browser, nicht per API-Aufruf.
"""
user = _consume_token(db, token, "verify")
if user is None:
db.commit()
return RedirectResponse("/?verified=invalid", status_code=303)
if user.verified_at is None:
user.verified_at = utcnow()
db.commit()
return RedirectResponse("/?verified=ok", status_code=303)
@router.post("/login", response_model=UserOut)
def login(payload: LoginIn, request: Request, response: Response, db: DbSession):
email = normalize_email(payload.email)
ip_ok = check_rate_limit(db, f"login-ip:{client_ip(request)}", limit=20, window_minutes=15)
acct_ok = check_rate_limit(db, f"login-acct:{email}", limit=8, window_minutes=15)
if not (ip_ok and acct_ok):
db.commit()
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
"Zu viele Anmeldeversuche. Bitte warte einen Moment.",
)
user = db.scalar(select(User).where(User.email == email))
# Laeuft auch ohne Treffer gegen einen Dummy-Hash - gleiche Laufzeit.
ok = verify_password(payload.password, user.password_hash if user else None)
if not ok or user is None:
db.commit()
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, "E-Mail-Adresse oder Passwort ist falsch."
)
# Erst NACH erfolgreicher Passwortprüfung über die Deaktivierung
# informieren. Vorher wäre es ein Hinweis darauf, dass es das Konto
# gibt - danach weiß die Person das ohnehin, weil sie das Passwort
# kennt. Und wer nur "falsches Passwort" liest, obwohl es stimmt,
# sucht den Fehler an der falschen Stelle.
if not user.is_active:
db.commit()
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"Dieses Konto ist deaktiviert. Deine Listen bleiben erhalten "
"wende dich an die Administration, wenn du den Zugang wieder "
"brauchst.",
)
if user.verified_at is None:
db.commit()
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"Bitte bestätige zuerst deine E-Mail-Adresse.",
)
if needs_rehash(user.password_hash):
user.password_hash = hash_password(payload.password)
# Grundlage der automatischen Deaktivierung nach langer Untätigkeit.
user.last_seen_at = utcnow()
create_session(db, user, response)
db.commit()
return UserOut.of(user)
@router.post("/logout", response_model=MessageOut)
def logout(request: Request, response: Response, db: DbSession, user: CurrentUser):
destroy_session(db, request, response)
db.commit()
return MessageOut(detail="Abgemeldet.")
@router.post("/logout-all", response_model=MessageOut)
def logout_all(request: Request, response: Response, db: DbSession, user: CurrentUser):
"""Meldet alle Geräte ab - z.B. nach Verdacht auf Kompromittierung."""
for sess in db.scalars(
select(UserSession).where(UserSession.user_id == user.id)
).all():
db.delete(sess)
response.delete_cookie(SESSION_COOKIE, path="/")
response.delete_cookie(CSRF_COOKIE, path="/")
db.commit()
return MessageOut(detail="Auf allen Geräten abgemeldet.")
@router.get("/me", response_model=UserOut)
def me(user: CurrentUser):
return UserOut.of(user)
@router.patch("/me", response_model=UserOut)
def update_me(payload: ProfileUpdateIn, db: DbSession, user: CurrentUser):
user.display_name = payload.display_name or None
db.commit()
return UserOut.of(user)
@router.post("/password/change", response_model=MessageOut)
def change_password(
payload: PasswordChangeIn, request: Request, response: Response,
db: DbSession, user: CurrentUser,
):
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Aktuelles Passwort ist falsch.")
if verify_password(payload.new_password, user.password_hash):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Das neue Passwort muss sich vom bisherigen unterscheiden.",
)
user.password_hash = hash_password(payload.new_password)
user.must_change_password = False
# Alle bestehenden Sessions verwerfen und eine neue ausstellen.
for sess in db.scalars(
select(UserSession).where(UserSession.user_id == user.id)
).all():
db.delete(sess)
db.flush()
create_session(db, user, response)
db.commit()
return MessageOut(detail="Passwort geändert. Andere Geräte wurden abgemeldet.")
@router.post("/password/reset-request", response_model=MessageOut)
def reset_request(
payload: PasswordResetRequestIn,
request: Request,
db: DbSession,
background: BackgroundTasks,
):
if not check_rate_limit(
db, f"reset:{client_ip(request)}", limit=5, window_minutes=60
):
db.commit()
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
email = normalize_email(payload.email)
user = db.scalar(select(User).where(User.email == email))
if user is not None and user.is_active and user.verified_at is not None:
raw = _issue_token(db, user, "reset", hours=1)
background.add_task(send_password_reset, email, raw)
db.commit()
return MessageOut(detail=_NEUTRAL)
# ==========================================================================
# Willkommensnachricht: Passwort setzen und Konto freischalten
# ==========================================================================
@router.get("/welcome/{token}", response_model=WelcomePreviewOut)
def welcome_preview(token: str, db: DbSession):
"""Zeigt, für welche Adresse der Link gilt - damit die Person sieht,
worauf sie sich einlässt, bevor sie ein Passwort vergibt."""
row = db.scalar(
select(EmailToken).where(
EmailToken.token_hash == hash_token(token),
EmailToken.purpose == "welcome",
)
)
if row is None or row.used_at is not None or row.expires_at <= utcnow():
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Der Link ist ungültig oder abgelaufen. Bitte die Administration "
"um eine neue Einladung.",
)
user = db.get(User, row.user_id)
if user is None or not user.is_active:
raise HTTPException(status.HTTP_410_GONE, "Das Konto steht nicht mehr bereit.")
return WelcomePreviewOut(email=user.email, display_name=user.display_name)
@router.post("/welcome/complete", response_model=MessageOut)
def welcome_complete(payload: WelcomeCompleteIn, db: DbSession):
"""Setzt das erste Passwort und bestätigt die Adresse in einem Schritt.
Der Klick auf den Link aus der Willkommensnachricht beweist, dass das
Postfach erreichbar ist - eine zusätzliche Verifikationsmail wäre nur
ein weiterer Schritt ohne Erkenntnisgewinn.
"""
user = _consume_token(db, payload.token, "welcome")
if user is None:
db.commit()
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Der Link ist ungültig oder abgelaufen."
)
if not user.is_active:
db.commit()
raise HTTPException(status.HTTP_410_GONE, "Das Konto steht nicht mehr bereit.")
user.password_hash = hash_password(payload.password)
user.must_change_password = False
user.verified_at = user.verified_at or utcnow()
user.last_seen_at = utcnow()
if payload.display_name is not None:
user.display_name = payload.display_name.strip() or None
db.commit()
return MessageOut(detail="Zugang eingerichtet. Du kannst dich jetzt anmelden.")
# ==========================================================================
# Bestätigung einer Adressänderung
# ==========================================================================
@router.get("/email-change/{token}", include_in_schema=False)
def confirm_email_change(token: str, db: DbSession):
"""Ziel der Links aus den Bestätigungsmails.
Antwortet mit einer Weiterleitung auf die Oberfläche - hier klickt
jemand im Browser, nicht per API-Aufruf.
"""
digest = hash_token(token)
change = db.scalar(
select(EmailChange).where(
(EmailChange.token_new_hash == digest)
| (EmailChange.token_old_hash == digest)
)
)
if change is None:
return RedirectResponse("/?adresswechsel=unbekannt", status_code=303)
if change.cancelled_at or change.applied_at:
return RedirectResponse("/?adresswechsel=erledigt", status_code=303)
if change.expires_at <= utcnow():
return RedirectResponse("/?adresswechsel=abgelaufen", status_code=303)
if change.token_new_hash == digest:
change.confirmed_new_at = change.confirmed_new_at or utcnow()
else:
change.confirmed_old_at = change.confirmed_old_at or utcnow()
done = apply_if_complete(db, change)
db.commit()
if done:
return RedirectResponse("/?adresswechsel=fertig", status_code=303)
# Bei Administratorkonten fehlt jetzt noch die zweite Bestätigung.
return RedirectResponse("/?adresswechsel=teilweise", status_code=303)
@router.post("/password/reset", response_model=MessageOut)
def reset_password(payload: PasswordResetIn, db: DbSession):
user = _consume_token(db, payload.token, "reset")
if user is None:
db.commit()
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Der Link ist ungültig oder abgelaufen."
)
user.password_hash = hash_password(payload.password)
user.must_change_password = False
for sess in db.scalars(
select(UserSession).where(UserSession.user_id == user.id)
).all():
db.delete(sess)
db.commit()
return MessageOut(detail="Passwort gesetzt. Du kannst dich jetzt anmelden.")

View File

@@ -0,0 +1,413 @@
"""Katalogdaten einer Liste: Märkte, Warengruppen, Artikel.
Alle Schreibzugriffe erhöhen den Revisionszähler der Liste und schreiben
den neuen Wert in `row_rev` der geänderten Zeile.
"""
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.deps import DbSession
from app.models import (
Article,
ArticleAttribute,
ArticleMarket,
Category,
ListItem,
Market,
)
from app.permissions import EditableList, ReadableList, bump_rev
from app.schemas import MessageOut
from app.product_lookup import lookup
from app.schemas_shopping import (
ArticleIn,
ArticleOut,
ArticleUpdateIn,
AttributeOut,
CategoryIn,
CategoryOut,
MarketIn,
MarketOut,
ProductLookupOut,
)
from app.security import utcnow
router = APIRouter(prefix="/api/lists/{list_id}", tags=["catalog"])
def _check_belongs(db: Session, model, obj_id: str | None, list_id: str, label: str):
"""Verhindert, dass eine Liste auf Objekte einer fremden Liste zeigt.
Ohne diese Prüfung könnte ein Mitglied durch Angabe einer fremden ID
Rückschlüsse auf andere Listen ziehen."""
if obj_id is None:
return None
obj = db.get(model, obj_id)
if obj is None or obj.list_id != list_id or obj.deleted_at is not None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{label} nicht gefunden")
return obj
# ==========================================================================
# Märkte
# ==========================================================================
@router.get("/markets", response_model=list[MarketOut])
def get_markets(lst: ReadableList, db: DbSession):
rows = db.scalars(
select(Market)
.where(Market.list_id == lst.id, Market.deleted_at.is_(None))
.order_by(Market.sort_order, Market.name)
).all()
return [MarketOut.model_validate(m) for m in rows]
@router.post("/markets", response_model=MarketOut, status_code=status.HTTP_201_CREATED)
def create_market(payload: MarketIn, lst: EditableList, db: DbSession):
rev = bump_rev(db, lst.id)
market = Market(
list_id=lst.id, name=payload.name.strip(),
sort_order=payload.sort_order, row_rev=rev,
)
db.add(market)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Markt mit diesem Namen existiert bereits."
) from None
return MarketOut.model_validate(market)
@router.put("/markets/{market_id}", response_model=MarketOut)
def update_market(market_id: str, payload: MarketIn, lst: EditableList, db: DbSession):
market = _check_belongs(db, Market, market_id, lst.id, "Markt")
market.name = payload.name.strip()
market.sort_order = payload.sort_order
market.row_rev = bump_rev(db, lst.id)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Markt mit diesem Namen existiert bereits."
) from None
return MarketOut.model_validate(market)
@router.delete("/markets/{market_id}", response_model=MessageOut)
def delete_market(market_id: str, lst: EditableList, db: DbSession):
market = _check_belongs(db, Market, market_id, lst.id, "Markt")
rev = bump_rev(db, lst.id)
market.deleted_at = utcnow()
market.row_rev = rev
# Einträge nicht mitlöschen, nur den Marktbezug lösen - sie landen
# dann in der Gruppe "Ohne Markt" und gehen nicht verloren.
for item in db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.market_id == market_id,
ListItem.deleted_at.is_(None),
)
).all():
item.market_id = None
item.row_rev = rev
db.commit()
return MessageOut(detail="Markt gelöscht. Betroffene Einträge sind ohne Markt.")
# ==========================================================================
# Warengruppen
# ==========================================================================
@router.get("/categories", response_model=list[CategoryOut])
def get_categories(lst: ReadableList, db: DbSession):
rows = db.scalars(
select(Category)
.where(Category.list_id == lst.id, Category.deleted_at.is_(None))
.order_by(Category.sort_order, Category.name)
).all()
return [CategoryOut.model_validate(c) for c in rows]
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
def create_category(payload: CategoryIn, lst: EditableList, db: DbSession):
rev = bump_rev(db, lst.id)
cat = Category(
list_id=lst.id, name=payload.name.strip(),
sort_order=payload.sort_order, row_rev=rev,
)
db.add(cat)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Diese Warengruppe existiert bereits."
) from None
return CategoryOut.model_validate(cat)
@router.put("/categories/{category_id}", response_model=CategoryOut)
def update_category(
category_id: str, payload: CategoryIn, lst: EditableList, db: DbSession
):
cat = _check_belongs(db, Category, category_id, lst.id, "Warengruppe")
cat.name = payload.name.strip()
cat.sort_order = payload.sort_order
cat.row_rev = bump_rev(db, lst.id)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Diese Warengruppe existiert bereits."
) from None
return CategoryOut.model_validate(cat)
@router.delete("/categories/{category_id}", response_model=MessageOut)
def delete_category(category_id: str, lst: EditableList, db: DbSession):
cat = _check_belongs(db, Category, category_id, lst.id, "Warengruppe")
rev = bump_rev(db, lst.id)
cat.deleted_at = utcnow()
cat.row_rev = rev
for item in db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.category_id == category_id,
ListItem.deleted_at.is_(None),
)
).all():
item.category_id = None
item.row_rev = rev
db.commit()
return MessageOut(detail="Warengruppe gelöscht.")
# ==========================================================================
# Artikel
# ==========================================================================
def _article_out(a: Article) -> ArticleOut:
return ArticleOut(
id=a.id, name=a.name, barcode=a.barcode, note=a.note,
default_market_id=a.default_market_id,
default_category_id=a.default_category_id,
attributes=[
AttributeOut(name=x.attr_name, value=x.attr_value) for x in a.attributes
],
available_market_ids=[x.market_id for x in a.availability],
row_rev=a.row_rev,
)
def _apply_attributes(db: Session, article: Article, attributes) -> None:
"""Ersetzt die Attributmenge vollständig."""
for old in list(article.attributes):
db.delete(old)
article.attributes = []
db.flush()
seen = set()
for attr in attributes:
name = attr.name.strip()
if not name or name in seen:
continue
seen.add(name)
db.add(
ArticleAttribute(
article_id=article.id, attr_name=name, attr_value=attr.value.strip()
)
)
def _apply_availability(db: Session, article: Article, market_ids, list_id: str) -> None:
for old in db.scalars(
select(ArticleMarket).where(ArticleMarket.article_id == article.id)
).all():
db.delete(old)
db.flush()
for mid in dict.fromkeys(market_ids):
_check_belongs(db, Market, mid, list_id, "Markt")
db.add(ArticleMarket(article_id=article.id, market_id=mid))
@router.get("/articles", response_model=list[ArticleOut])
def get_articles(
lst: ReadableList,
db: DbSession,
q: str | None = None,
with_barcode: bool | None = None,
):
stmt = select(Article).where(
Article.list_id == lst.id, Article.deleted_at.is_(None)
)
if q:
# Nur der LIKE-Operator, kein zusammengebautes SQL - der Suchtext
# geht als gebundener Parameter in die Abfrage. Sonderzeichen von
# LIKE werden maskiert, damit "50%" nicht alles findet.
needle = q.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
stmt = stmt.where(Article.name.like(f"%{needle}%", escape="\\"))
if with_barcode is True:
stmt = stmt.where(Article.barcode.is_not(None))
elif with_barcode is False:
stmt = stmt.where(Article.barcode.is_(None))
rows = db.scalars(stmt.order_by(Article.name).limit(500)).all()
return [_article_out(a) for a in rows]
@router.get("/barcode/{barcode}", response_model=ProductLookupOut)
def resolve_barcode(barcode: str, lst: ReadableList, db: DbSession):
"""Strichcode auflösen - erst im eigenen Bestand, dann außerhalb.
Die Reihenfolge ist wichtig: Was hier schon gepflegt wurde, ist
verlässlicher als eine Fremdquelle, und ein eigener Name soll nicht
von einer Datenbank überschrieben werden.
"""
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.barcode == barcode,
Article.deleted_at.is_(None),
)
)
if article is not None:
return ProductLookupOut(
barcode=barcode, found=True, source="catalog",
article_id=article.id, name=article.name,
)
hit = lookup(db, barcode)
if hit is None or not hit.found:
return ProductLookupOut(barcode=barcode, found=False, source="none")
return ProductLookupOut(
barcode=barcode,
found=True,
source="openfoodfacts",
name=hit.name,
brand=hit.brand,
package=hit.package,
count=hit.count,
pack_size=hit.pack_size,
pack_unit=hit.pack_unit,
)
@router.get("/articles/by-barcode/{barcode}", response_model=ArticleOut)
def article_by_barcode(barcode: str, lst: ReadableList, db: DbSession):
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.barcode == barcode,
Article.deleted_at.is_(None),
)
)
if article is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Kein Artikel mit diesem Barcode"
)
return _article_out(article)
@router.post("/articles", response_model=ArticleOut, status_code=status.HTTP_201_CREATED)
def create_article(payload: ArticleIn, lst: EditableList, db: DbSession):
_check_belongs(db, Market, payload.default_market_id, lst.id, "Markt")
_check_belongs(db, Category, payload.default_category_id, lst.id, "Warengruppe")
rev = bump_rev(db, lst.id)
article = Article(
list_id=lst.id, name=payload.name.strip(),
barcode=(payload.barcode or None), note=(payload.note or None),
default_market_id=payload.default_market_id,
default_category_id=payload.default_category_id,
row_rev=rev,
)
db.add(article)
db.flush()
_apply_attributes(db, article, payload.attributes)
_apply_availability(db, article, payload.available_market_ids, lst.id)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Artikel mit diesem Namen existiert bereits."
) from None
db.refresh(article)
return _article_out(article)
@router.patch("/articles/{article_id}", response_model=ArticleOut)
def update_article(
article_id: str, payload: ArticleUpdateIn, lst: EditableList, db: DbSession
):
article = _check_belongs(db, Article, article_id, lst.id, "Artikel")
data = payload.model_dump(exclude_unset=True)
if "default_market_id" in data:
_check_belongs(db, Market, data["default_market_id"], lst.id, "Markt")
article.default_market_id = data["default_market_id"]
if "default_category_id" in data:
_check_belongs(db, Category, data["default_category_id"], lst.id, "Warengruppe")
article.default_category_id = data["default_category_id"]
if "name" in data and data["name"]:
article.name = data["name"].strip()
if "barcode" in data:
article.barcode = data["barcode"] or None
if "note" in data:
article.note = data["note"] or None
if payload.attributes is not None:
_apply_attributes(db, article, payload.attributes)
if payload.available_market_ids is not None:
_apply_availability(db, article, payload.available_market_ids, lst.id)
article.row_rev = bump_rev(db, lst.id)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Artikel mit diesem Namen existiert bereits."
) from None
db.refresh(article)
return _article_out(article)
@router.delete("/articles/{article_id}", response_model=MessageOut)
def delete_article(article_id: str, lst: EditableList, db: DbSession):
article = _check_belongs(db, Article, article_id, lst.id, "Artikel")
rev = bump_rev(db, lst.id)
article.deleted_at = utcnow()
article.row_rev = rev
# Offene Einträge dieses Artikels verschwinden mit. Alles andere
# hinterließe Einträge, die auf einen gelöschten Artikel zeigen.
removed = 0
for item in db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.article_id == article_id,
ListItem.deleted_at.is_(None),
)
).all():
item.deleted_at = utcnow()
item.row_rev = rev
removed += 1
db.commit()
return MessageOut(
detail=f"Artikel gelöscht, dazu {removed} Eintrag/Einträge auf der Liste."
)

View File

@@ -0,0 +1,245 @@
"""Listeneinträge: anlegen, ändern, verschieben, zurückstellen, löschen.
Dazu die gruppierte Ansicht Markt -> Warengruppe -> Artikel, die sowohl
die App als auch der Druck in Phase 9 verwendet. Die Sortierlogik liegt
bewusst nur hier, damit Bildschirm und Papier nicht auseinanderlaufen.
"""
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.deps import DbSession, VerifiedUser
from app.models import Article, Category, ListItem, Market
from app.list_view import build_view, item_out
from app.print_view import render_print
from app.permissions import EditableList, ReadableList, bump_rev
from app.prices import record_price
from app.push import notify_list_changed
from app.routers.catalog import _check_belongs
from app.schemas import MessageOut
from app.schemas_shopping import (
ClearBoughtOut,
ItemCreateIn,
ItemOut,
ItemStatus,
ItemUpdateIn,
ListView,
)
from app.security import utcnow
router = APIRouter(prefix="/api/lists/{list_id}", tags=["items"])
@router.get("/items", response_model=list[ItemOut])
def get_items(
lst: ReadableList,
db: DbSession,
status_filter: ItemStatus | None = Query(default=None, alias="status"),
):
stmt = select(ListItem).where(
ListItem.list_id == lst.id, ListItem.deleted_at.is_(None)
)
if status_filter:
stmt = stmt.where(ListItem.status == status_filter)
rows = db.scalars(stmt.order_by(ListItem.created_at)).all()
return [item_out(i) for i in rows]
@router.post("/items", response_model=ItemOut, status_code=status.HTTP_201_CREATED)
def create_item(
payload: ItemCreateIn,
lst: EditableList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
if not payload.article_id and not payload.article_name:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Entweder article_id oder article_name angeben.",
)
rev = bump_rev(db, lst.id)
if payload.article_id:
article = _check_belongs(db, Article, payload.article_id, lst.id, "Artikel")
else:
name = payload.article_name.strip()
# Vorhandenen Artikel wiederverwenden. Der Vergleich ist dank
# utf8mb4_unicode_ci von Haus aus unabhaengig von Gross- und
# Kleinschreibung.
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.name == name,
Article.deleted_at.is_(None),
)
)
if article is None:
article = Article(list_id=lst.id, name=name, row_rev=rev)
db.add(article)
db.flush()
_check_belongs(db, Market, payload.market_id, lst.id, "Markt")
_check_belongs(db, Category, payload.category_id, lst.id, "Warengruppe")
item = ListItem(
list_id=lst.id,
article_id=article.id,
# Ohne Angabe die Vorgaben aus dem Artikel übernehmen.
market_id=payload.market_id or article.default_market_id,
category_id=payload.category_id or article.default_category_id,
count=payload.count,
pack_size=payload.pack_size,
pack_unit=(payload.pack_unit or None),
variant=(payload.variant or None),
note=(payload.note or None),
status="open",
created_by=user.id,
row_rev=rev,
)
db.add(item)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Eintrag konnte nicht angelegt werden."
) from None
db.refresh(item)
background.add_task(notify_list_changed, lst.id, user.id)
return item_out(item)
@router.patch("/items/{item_id}", response_model=ItemOut)
def update_item(
item_id: str,
payload: ItemUpdateIn,
lst: EditableList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
"""Deckt alles ab, was während des Einkaufs passiert: abhaken,
zurückstellen, Menge ändern, Preis erfassen, in einen anderen Markt
verschieben."""
item = db.get(ListItem, item_id)
if item is None or item.list_id != lst.id or item.deleted_at is not None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
data = payload.model_dump(exclude_unset=True)
if payload.clear_market:
item.market_id = None
elif "market_id" in data:
_check_belongs(db, Market, data["market_id"], lst.id, "Markt")
item.market_id = data["market_id"]
if payload.clear_category:
item.category_id = None
elif "category_id" in data:
_check_belongs(db, Category, data["category_id"], lst.id, "Warengruppe")
item.category_id = data["category_id"]
for field in ("count", "pack_size", "pack_unit", "variant", "note",
"status", "price_cents"):
if field in data:
setattr(item, field, data[field])
item.row_rev = bump_rev(db, lst.id)
# Nach dem Setzen der Felder: Der Preis wird nur festgehalten, wenn
# auch ein Markt zugeordnet ist - ohne den ergibt er keinen
# Vergleichswert.
record_price(db, item)
db.commit()
db.refresh(item)
background.add_task(notify_list_changed, lst.id, user.id)
return item_out(item)
@router.delete("/items/{item_id}", response_model=MessageOut)
def delete_item(
item_id: str, lst: EditableList, db: DbSession, user: VerifiedUser,
background: BackgroundTasks,
):
item = db.get(ListItem, item_id)
if item is None or item.list_id != lst.id or item.deleted_at is not None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
item.deleted_at = utcnow()
item.row_rev = bump_rev(db, lst.id)
db.commit()
background.add_task(notify_list_changed, lst.id, user.id)
return MessageOut(detail="Eintrag gelöscht.")
@router.post("/items/clear-bought", response_model=ClearBoughtOut)
def clear_bought(
lst: EditableList, db: DbSession, user: VerifiedUser,
background: BackgroundTasks,
):
"""Entfernt alle als gekauft markierten Einträge in einem Zug.
Ein einziger Revisionsschritt für den gesamten Vorgang - so sieht ein
synchronisierender Client eine geschlossene Änderung statt dutzender
einzelner.
"""
rev = bump_rev(db, lst.id)
now = utcnow()
rows = db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.status == "bought",
ListItem.deleted_at.is_(None),
)
).all()
for item in rows:
item.deleted_at = now
item.row_rev = rev
db.commit()
if rows:
background.add_task(notify_list_changed, lst.id, user.id)
return ClearBoughtOut(removed=len(rows), rev=rev)
# ==========================================================================
# Gruppierte Ansicht
# ==========================================================================
@router.get("/print", response_class=HTMLResponse, include_in_schema=True)
def print_view(
lst: ReadableList,
db: DbSession,
include_bought: bool = Query(default=True),
include_deferred: bool = Query(default=False),
):
"""Druckfertige Seite auf A4.
Standardmäßig ohne zurückgestellte Artikel: Wer ausdruckt, will die
Einkaufsliste, nicht die Merkliste.
"""
view = build_view(
db, lst, include_bought=include_bought, include_deferred=include_deferred
)
return HTMLResponse(render_print(view, include_bought=include_bought))
@router.get("/view", response_model=ListView)
def grouped_view(
lst: ReadableList,
db: DbSession,
include_bought: bool = Query(default=True),
include_deferred: bool = Query(default=True),
):
"""Markt -> Warengruppe -> Artikel, alphabetisch innerhalb der Gruppe.
Die Aufbereitung liegt in app/list_view.py, weil der oeffentliche Link
und der Ausdruck dieselbe Gliederung brauchen.
"""
return build_view(
db, lst, include_bought=include_bought, include_deferred=include_deferred
)

View File

@@ -0,0 +1,205 @@
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import func, select
from app.deps import DbSession, VerifiedUser
from app.models import Category, ListMember, Market, ShoppingList, User
from app.permissions import OwnedList, ReadableList
from app.schemas import MessageOut
from app.list_view import build_view
from app.schemas_shopping import (
CategoryOut,
ListCreateIn,
ListOut,
ListSnapshot,
ListUpdateIn,
MarketOut,
MemberOut,
MemberRoleIn,
)
from app.security import utcnow
router = APIRouter(prefix="/api/lists", tags=["lists"])
MAX_LISTS_PER_USER = 100
def _to_out(db, lst: ShoppingList, role: str, may_share: bool = False) -> ListOut:
count = db.scalar(
select(func.count()).select_from(ListMember).where(ListMember.list_id == lst.id)
)
return ListOut(
id=lst.id, name=lst.name, owner_id=lst.owner_id, rev=lst.rev,
role=role, may_share_public=(role == "owner") or may_share,
member_count=count or 0, created_at=lst.created_at,
)
@router.get("", response_model=list[ListOut])
def my_lists(db: DbSession, user: VerifiedUser):
rows = db.execute(
select(ShoppingList, ListMember.role, ListMember.may_share_public)
.join(ListMember, ListMember.list_id == ShoppingList.id)
.where(ListMember.user_id == user.id, ShoppingList.deleted_at.is_(None))
.order_by(ShoppingList.created_at)
).all()
return [_to_out(db, lst, role, may) for lst, role, may in rows]
@router.post("", response_model=ListOut, status_code=status.HTTP_201_CREATED)
def create_list(payload: ListCreateIn, db: DbSession, user: VerifiedUser):
owned = db.scalar(
select(func.count()).select_from(ShoppingList).where(
ShoppingList.owner_id == user.id, ShoppingList.deleted_at.is_(None)
)
)
if (owned or 0) >= MAX_LISTS_PER_USER:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Höchstens {MAX_LISTS_PER_USER} eigene Listen.",
)
lst = ShoppingList(name=payload.name.strip(), owner_id=user.id, rev=1)
db.add(lst)
db.flush()
db.add(ListMember(
list_id=lst.id, user_id=user.id, role="owner", may_share_public=True
))
db.commit()
return _to_out(db, lst, "owner")
@router.get("/{list_id}", response_model=ListOut)
def read_list(lst: ReadableList, db: DbSession, user: VerifiedUser):
member = db.get(ListMember, (lst.id, user.id))
return _to_out(db, lst, member.role, member.may_share_public)
@router.get("/{list_id}/snapshot", response_model=ListSnapshot)
def snapshot(lst: ReadableList, db: DbSession, user: VerifiedUser):
"""Gesamtstand in einem Aufruf: Liste, gruppierte Ansicht, Märkte,
Warengruppen. Ersetzt vier einzelne Abfragen."""
member = db.get(ListMember, (lst.id, user.id))
markets = db.scalars(
select(Market)
.where(Market.list_id == lst.id, Market.deleted_at.is_(None))
.order_by(Market.sort_order, Market.name)
).all()
categories = db.scalars(
select(Category)
.where(Category.list_id == lst.id, Category.deleted_at.is_(None))
.order_by(Category.sort_order, Category.name)
).all()
return ListSnapshot(
list=_to_out(db, lst, member.role, member.may_share_public),
view=build_view(db, lst),
markets=[MarketOut.model_validate(m) for m in markets],
categories=[CategoryOut.model_validate(c) for c in categories],
)
@router.patch("/{list_id}", response_model=ListOut)
def rename_list(payload: ListUpdateIn, lst: OwnedList, db: DbSession, user: VerifiedUser):
lst.name = payload.name.strip()
lst.rev += 1
db.commit()
return _to_out(db, lst, "owner")
@router.delete("/{list_id}", response_model=MessageOut)
def delete_list(lst: OwnedList, db: DbSession):
# Soft Delete: offline-Clients sollen das Verschwinden noch mitbekommen.
lst.deleted_at = utcnow()
lst.rev += 1
db.commit()
return MessageOut(detail="Liste gelöscht.")
# --------------------------------------------------------------------------
# Mitgliedschaften
# Einladungen per Mail und QR-Code folgen in Phase 5. Hier lassen sich
# bestehende Mitgliedschaften nur ansehen, ändern und beenden.
# --------------------------------------------------------------------------
@router.get("/{list_id}/members", response_model=list[MemberOut])
def list_members(lst: ReadableList, db: DbSession):
rows = db.execute(
select(ListMember, User)
.join(User, User.id == ListMember.user_id)
.where(ListMember.list_id == lst.id)
.order_by(ListMember.joined_at)
).all()
return [
MemberOut(
user_id=u.id, email=u.email, display_name=u.display_name,
role=m.role, may_share_public=m.may_share_public,
joined_at=m.joined_at,
)
for m, u in rows
]
@router.put("/{list_id}/members/{user_id}", response_model=MemberOut)
def set_member_role(
user_id: str, payload: MemberRoleIn, lst: OwnedList, db: DbSession,
user: VerifiedUser,
):
if user_id == lst.owner_id:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Die Rolle des Eigentümers lässt sich nicht ändern. "
"Übertrage stattdessen die Liste.",
)
member = db.get(ListMember, (lst.id, user_id))
if member is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Mitglied dieser Liste")
member.role = payload.role
if payload.may_share_public is not None:
member.may_share_public = payload.may_share_public
lst.rev += 1
db.commit()
target = db.get(User, user_id)
return MemberOut(
user_id=target.id, email=target.email, display_name=target.display_name,
role=member.role, may_share_public=member.may_share_public,
joined_at=member.joined_at,
)
@router.delete("/{list_id}/members/{user_id}", response_model=MessageOut)
def remove_member(user_id: str, list_id: str, db: DbSession, user: VerifiedUser):
"""Der Eigentümer entzieht anderen den Zugriff; jedes Mitglied kann
sich selbst entfernen (bequemer über POST /api/lists/{id}/leave).
Der Eigentümer kann sich nicht selbst entfernen - die Liste stünde
sonst ohne Verwaltung da. Dafür gibt es den Eigentümerwechsel."""
lst = db.scalar(
select(ShoppingList).where(
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
)
)
own = db.get(ListMember, (list_id, user.id))
if lst is None or own is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
if user_id != user.id and own.role != "owner":
raise HTTPException(
status.HTTP_403_FORBIDDEN, "Nur der Eigentümer kann andere entfernen."
)
if user_id == lst.owner_id:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Der Eigentümer kann die Liste nicht verlassen. Lösche sie oder "
"übertrage sie zuvor.",
)
member = db.get(ListMember, (list_id, user_id))
if member is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Mitglied dieser Liste")
db.delete(member)
lst.rev += 1
db.commit()
return MessageOut(detail="Mitgliedschaft beendet.")

View File

@@ -0,0 +1,39 @@
"""Preisvergleich: Übersicht, Verlauf je Artikel, Kurzhinweise."""
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.deps import DbSession
from app.models import Article
from app.permissions import ReadableList
from app.prices import article_prices, hints, overview
from app.schemas_shopping import ArticlePrices, PriceHint, PriceOverview
router = APIRouter(prefix="/api/lists/{list_id}", tags=["prices"])
@router.get("/prices", response_model=PriceOverview)
def price_overview(lst: ReadableList, db: DbSession):
"""Vergleichstabelle: je Artikel der jüngste Preis in jedem Markt."""
return overview(db, lst.id)
@router.get("/price-hints", response_model=list[PriceHint])
def price_hints(lst: ReadableList, db: DbSession):
"""Kompakte Fassung für die Listenansicht - nur Artikel, bei denen
sich die Märkte tatsächlich unterscheiden."""
return hints(db, lst.id)
@router.get("/articles/{article_id}/prices", response_model=ArticlePrices)
def article_price_history(article_id: str, lst: ReadableList, db: DbSession):
article = db.scalar(
select(Article).where(
Article.id == article_id,
Article.list_id == lst.id,
Article.deleted_at.is_(None),
)
)
if article is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
return article_prices(db, lst.id, article)

View File

@@ -0,0 +1,297 @@
"""Öffentliche Ansichtslinks.
Wer den Link hat, darf die Liste sehen und - sofern erlaubt - Artikel
abhaken. Sonst nichts: kein Anlegen, kein Löschen, keine Preise ändern,
keine Mitgliederliste, keine Namen.
Sicherheitsmodell: Der Link *ist* die Berechtigung. Wer ihn
weitergibt, gibt den Zugriff weiter. Deshalb ist ein Ablaufdatum Pflicht,
der Widerruf jederzeit möglich, und in der Datenbank steht nur der Hash
des Tokens.
"""
from datetime import timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from app.config import settings
from app.deps import DbSession, VerifiedUser, client_ip
from app.list_view import build_view
from app.print_view import render_print
from app.models import ListItem, ListMember, PublicShare, ShoppingList
from app.permissions import bump_rev
from app.schemas import MessageOut
from app.schemas_shopping import (
PublicShareCreatedOut,
PublicShareIn,
PublicShareOut,
PublicToggleIn,
PublicViewOut,
)
from app.security import check_rate_limit, hash_token, new_token, utcnow
router = APIRouter(tags=["public"])
MAX_DAYS = 365
MAX_ACTIVE_LINKS = 10
# ==========================================================================
# Verwaltung (angemeldet)
# ==========================================================================
def sharer(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
"""Eigentümer oder Mitglied mit dem Zusatzrecht `may_share_public`."""
lst = db.scalar(
select(ShoppingList).where(
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
)
)
member = db.get(ListMember, (list_id, user.id))
if lst is None or member is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
if member.role != "owner" and not member.may_share_public:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"Für öffentliche Links fehlt dir die Berechtigung. Der Eigentümer "
"der Liste kann sie dir erteilen.",
)
return lst
SharableList = Annotated[ShoppingList, Depends(sharer)]
def _status(share: PublicShare) -> str:
if share.revoked_at:
return "revoked"
if share.expires_at <= utcnow():
return "expired"
return "active"
def _share_out(share: PublicShare) -> PublicShareOut:
creator = share.creator
return PublicShareOut(
id=share.id,
label=share.label,
allow_check=share.allow_check,
status=_status(share),
created_by_name=(
(creator.display_name or creator.email.split("@")[0]) if creator else None
),
created_at=share.created_at,
expires_at=share.expires_at,
last_access_at=share.last_access_at,
access_count=share.access_count,
)
@router.get("/api/lists/{list_id}/public-links", response_model=list[PublicShareOut])
def get_public_links(lst: SharableList, db: DbSession):
rows = db.scalars(
select(PublicShare)
.where(PublicShare.list_id == lst.id)
.order_by(PublicShare.created_at.desc())
).all()
return [_share_out(s) for s in rows]
@router.post(
"/api/lists/{list_id}/public-links",
response_model=PublicShareCreatedOut,
status_code=status.HTTP_201_CREATED,
)
def create_public_link(
payload: PublicShareIn, lst: SharableList, db: DbSession, user: VerifiedUser
):
expires = payload.expires_at.replace(tzinfo=None)
now = utcnow()
if expires <= now:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Das Ablaufdatum muss in der Zukunft liegen."
)
if expires > now + timedelta(days=MAX_DAYS):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Ein Link darf höchstens {MAX_DAYS} Tage gültig sein.",
)
active = [
s
for s in db.scalars(
select(PublicShare).where(PublicShare.list_id == lst.id)
).all()
if _status(s) == "active"
]
if len(active) >= MAX_ACTIVE_LINKS:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Es sind bereits {MAX_ACTIVE_LINKS} Links aktiv. Widerrufe zuerst "
"einen davon.",
)
raw = new_token()
share = PublicShare(
list_id=lst.id,
token_hash=hash_token(raw),
label=(payload.label or None),
allow_check=payload.allow_check,
created_by=user.id,
expires_at=expires,
)
db.add(share)
db.commit()
db.refresh(share)
return PublicShareCreatedOut(
share=_share_out(share),
url=f"{settings.public_base_url.rstrip('/')}/s/{raw}",
)
@router.delete("/api/lists/{list_id}/public-links/{share_id}", response_model=MessageOut)
def revoke_public_link(share_id: str, lst: SharableList, db: DbSession):
share = db.get(PublicShare, share_id)
if share is None or share.list_id != lst.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Link nicht gefunden")
if share.revoked_at is None:
share.revoked_at = utcnow()
db.commit()
return MessageOut(detail="Link widerrufen. Er funktioniert ab sofort nicht mehr.")
@router.post("/api/lists/{list_id}/public-links/revoke-all", response_model=MessageOut)
def revoke_all_public_links(lst: SharableList, db: DbSession):
count = 0
for share in db.scalars(
select(PublicShare).where(
PublicShare.list_id == lst.id, PublicShare.revoked_at.is_(None)
)
).all():
share.revoked_at = utcnow()
count += 1
db.commit()
return MessageOut(detail=f"{count} Link(s) widerrufen.")
# ==========================================================================
# Zugriff ohne Konto
# ==========================================================================
def _load_share(db: DbSession, token: str, request: Request) -> PublicShare:
# Ohne Anmeldung ist der Endpunkt für jeden erreichbar - deshalb
# bremsen, bevor jemand Token durchprobiert. Bei 256 Bit Zufall ist
# Raten aussichtslos, aber die Last soll trotzdem begrenzt bleiben.
if not check_rate_limit(
db, f"public:{client_ip(request)}", limit=120, window_minutes=15
):
db.commit()
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anfragen. Bitte kurz warten."
)
share = db.scalar(
select(PublicShare).where(PublicShare.token_hash == hash_token(token))
)
db.commit()
# Gleiche Antwort für "gibt es nicht" und "abgelaufen"? Nein: Der
# Empfänger soll erfahren, warum der Link nicht mehr geht, sonst
# sucht er den Fehler bei sich.
if share is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Dieser Link ist unbekannt.")
state = _status(share)
if state == "revoked":
raise HTTPException(status.HTTP_410_GONE, "Dieser Link wurde widerrufen.")
if state == "expired":
raise HTTPException(status.HTTP_410_GONE, "Dieser Link ist abgelaufen.")
return share
@router.get("/api/public/{token}", response_model=PublicViewOut)
def public_view(token: str, request: Request, response: Response, db: DbSession):
share = _load_share(db, token, request)
lst = db.get(ShoppingList, share.list_id)
if lst is None or lst.deleted_at is not None:
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
# Grobe Nutzungsanzeige für den Eigentümer: nur Zähler und Zeitpunkt,
# keine IP-Adresse, keine Zeitreihe. Erkennbar soll sein, DASS der
# Link benutzt wird, nicht von wem.
share.access_count += 1
share.last_access_at = utcnow()
db.commit()
response.headers["X-Robots-Tag"] = "noindex, nofollow"
view = build_view(db, lst, anonymous=True)
return PublicViewOut(
list_name=view.list_name,
rev=view.rev,
allow_check=share.allow_check,
expires_at=share.expires_at,
markets=view.markets,
grand_total_cents=view.grand_total_cents,
)
@router.get("/api/public/{token}/print", response_class=HTMLResponse)
def public_print(token: str, request: Request, db: DbSession):
"""Druckansicht über den öffentlichen Link - ohne Konto.
Wie die Bildschirmansicht anonymisiert: Wer welchen Artikel
eingetragen hat, steht dort ohnehin nicht drin, und im Ausdruck
erst recht nicht.
"""
share = _load_share(db, token, request)
lst = db.get(ShoppingList, share.list_id)
if lst is None or lst.deleted_at is not None:
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
share.access_count += 1
share.last_access_at = utcnow()
db.commit()
view = build_view(db, lst, include_deferred=False, anonymous=True)
return HTMLResponse(
render_print(view),
headers={"X-Robots-Tag": "noindex, nofollow"},
)
@router.post("/api/public/{token}/items/{item_id}", response_model=MessageOut)
def public_toggle(
token: str,
item_id: str,
payload: PublicToggleIn,
request: Request,
db: DbSession,
):
"""Die einzige Änderung, die über einen öffentlichen Link möglich ist.
Absichtlich auf offen/gekauft begrenzt: Wer mit dem Link einkaufen
geht, soll abhaken können. Alles Weitere - anlegen, löschen, Preise,
Märkte - bleibt den Mitgliedern vorbehalten.
"""
share = _load_share(db, token, request)
if not share.allow_check:
raise HTTPException(
status.HTTP_403_FORBIDDEN, "Über diesen Link ist nur Ansehen erlaubt."
)
item = db.get(ListItem, item_id)
if item is None or item.list_id != share.list_id or item.deleted_at is not None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
item.status = payload.status
item.row_rev = bump_rev(db, share.list_id)
db.commit()
return MessageOut(detail="Gespeichert.")

160
backend/app/routers/push.py Normal file
View File

@@ -0,0 +1,160 @@
"""An- und Abmeldung von Geräten für Push-Benachrichtigungen."""
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
from sqlalchemy import select
from app.config import settings
from app.deps import DbSession, VerifiedUser
from app.models import PushSubscription
from app.push import send_to_user
from app.schemas import MessageOut
from app.schemas_push import (
PushConfigOut,
PushSubscribeIn,
PushSubscriptionOut,
PushUnsubscribeIn,
)
from app.security import utcnow
router = APIRouter(prefix="/api/push", tags=["push"])
MAX_DEVICES_PER_USER = 20
@router.get("/config", response_model=PushConfigOut)
def push_config():
"""Ohne Anmeldung erreichbar: Der öffentliche Schlüssel ist dafür da,
veröffentlicht zu werden."""
return PushConfigOut(
enabled=settings.push_enabled,
public_key=settings.vapid_public_key or None,
throttle_hours=settings.push_throttle_hours,
)
@router.get("/subscriptions", response_model=list[PushSubscriptionOut])
def my_subscriptions(db: DbSession, user: VerifiedUser):
rows = db.scalars(
select(PushSubscription)
.where(PushSubscription.user_id == user.id)
.order_by(PushSubscription.created_at)
).all()
return [
PushSubscriptionOut(
id=s.id, label=s.label, created_at=s.created_at,
last_success_at=s.last_success_at,
)
for s in rows
]
@router.post("/subscribe", response_model=PushSubscriptionOut,
status_code=status.HTTP_201_CREATED)
def subscribe(payload: PushSubscribeIn, db: DbSession, user: VerifiedUser):
if not settings.push_enabled:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"Push-Benachrichtigungen sind auf diesem Server nicht eingerichtet.",
)
existing = db.scalar(
select(PushSubscription).where(PushSubscription.endpoint == payload.endpoint)
)
if existing is not None:
# Derselbe Endpunkt kann nach einem Kontowechsel auf demselben
# Geraet auftauchen - dann uebernehmen statt abweisen.
existing.user_id = user.id
existing.p256dh = payload.keys.p256dh
existing.auth = payload.keys.auth
existing.label = payload.label or existing.label
existing.failure_count = 0
db.commit()
db.refresh(existing)
return PushSubscriptionOut(
id=existing.id, label=existing.label, created_at=existing.created_at,
last_success_at=existing.last_success_at,
)
count = len(db.scalars(
select(PushSubscription).where(PushSubscription.user_id == user.id)
).all())
if count >= MAX_DEVICES_PER_USER:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Höchstens {MAX_DEVICES_PER_USER} Geräte je Konto.",
)
subscription = PushSubscription(
user_id=user.id,
endpoint=payload.endpoint,
p256dh=payload.keys.p256dh,
auth=payload.keys.auth,
label=(payload.label or None),
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return PushSubscriptionOut(
id=subscription.id, label=subscription.label,
created_at=subscription.created_at, last_success_at=None,
)
@router.post("/unsubscribe", response_model=MessageOut)
def unsubscribe(payload: PushUnsubscribeIn, db: DbSession, user: VerifiedUser):
subscription = db.scalar(
select(PushSubscription).where(
PushSubscription.endpoint == payload.endpoint,
PushSubscription.user_id == user.id,
)
)
if subscription is not None:
db.delete(subscription)
db.commit()
# Auch wenn nichts gefunden wurde: Fuer den Aufrufer ist das Ziel
# erreicht - dieses Geraet bekommt keine Benachrichtigungen mehr.
return MessageOut(detail="Benachrichtigungen für dieses Gerät abgeschaltet.")
@router.delete("/subscriptions/{subscription_id}", response_model=MessageOut)
def remove_subscription(subscription_id: str, db: DbSession, user: VerifiedUser):
subscription = db.get(PushSubscription, subscription_id)
if subscription is None or subscription.user_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gerät nicht gefunden")
db.delete(subscription)
db.commit()
return MessageOut(detail="Gerät entfernt.")
@router.post("/test", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
def send_test(background: BackgroundTasks, db: DbSession, user: VerifiedUser):
"""Testnachricht an die eigenen Geräte - umgeht die Drosselung."""
if not settings.push_enabled:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"Push-Benachrichtigungen sind auf diesem Server nicht eingerichtet.",
)
count = len(db.scalars(
select(PushSubscription).where(PushSubscription.user_id == user.id)
).all())
if count == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Für dieses Konto ist kein Gerät angemeldet.",
)
def run() -> None:
from app.db import SessionLocal
with SessionLocal() as session:
send_to_user(session, user.id, {
"title": settings.app_name,
"body": "Testbenachrichtigung die Zustellung funktioniert.",
"tag": "test",
})
background.add_task(run)
return MessageOut(
detail=f"Testnachricht an {count} Gerät(e) in Auftrag gegeben. "
"Ergebnis steht im Log des api-Containers."
)

View File

@@ -0,0 +1,412 @@
"""Teilen von Listen: Einladungen, Widerruf, Eigentümerwechsel.
Ablauf einer Einladung:
1. Der Eigentümer gibt eine E-Mail-Adresse ein.
2. Es entsteht ein Datensatz mit Zufallstoken; versendet wird ein Link
auf /invite?token=…
3. Der Empfänger meldet sich an oder registriert sich und nimmt an.
4. Beim Annehmen entsteht die Mitgliedschaft.
Bewusst unabhängig davon, ob unter der Adresse schon ein Konto besteht.
Damit verrät das Einladen nicht, wer registriert ist, und neue wie
bestehende Nutzer durchlaufen denselben Weg.
"""
from datetime import timedelta
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
from sqlalchemy import select
from app.deps import DbSession, VerifiedUser, client_ip
from app.mail import send_invitation
from app.models import ListInvite, ListMember, ShoppingList, User
from app.permissions import OwnedList, ReadableList
from app.schemas import MessageOut
from app.schemas_shopping import (
InviteCreateIn,
InviteOut,
InvitePreviewOut,
MemberOut,
TransferOwnershipIn,
)
from app.security import (
check_rate_limit,
hash_token,
new_token,
normalize_email,
utcnow,
)
router = APIRouter(tags=["sharing"])
INVITE_DAYS = 14
MAX_OPEN_INVITES = 50
def _status(invite: ListInvite) -> str:
if invite.revoked_at:
return "revoked"
if invite.accepted_at:
return "accepted"
if invite.expires_at <= utcnow():
return "expired"
return "pending"
def _invite_out(invite: ListInvite) -> InviteOut:
inviter = invite.inviter
return InviteOut(
id=invite.id,
email=invite.email,
role=invite.role,
status=_status(invite),
invited_by_name=(
(inviter.display_name or inviter.email.split("@")[0]) if inviter else None
),
created_at=invite.created_at,
last_sent_at=invite.last_sent_at,
send_count=invite.send_count,
expires_at=invite.expires_at,
)
def _guard_invite_rate(db: DbSession, request: Request, list_id: str) -> None:
"""Zwei Bremsen: pro Liste und pro absendender IP.
Ohne die zweite ließe sich die Anwendung als Versandhilfe für
unerwünschte Mails missbrauchen - jede Einladung erzeugt schließlich
eine Nachricht an eine frei wählbare Adresse.
"""
ok_list = check_rate_limit(db, f"invite-list:{list_id}", limit=20, window_minutes=60)
ok_ip = check_rate_limit(db, f"invite-ip:{client_ip(request)}", limit=30, window_minutes=60)
if not (ok_list and ok_ip):
db.commit()
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
"Zu viele Einladungen in kurzer Zeit. Bitte später erneut versuchen.",
)
# ==========================================================================
# Einladungen verwalten (nur Eigentümer)
# ==========================================================================
@router.get("/api/lists/{list_id}/invites", response_model=list[InviteOut])
def get_invites(lst: OwnedList, db: DbSession):
rows = db.scalars(
select(ListInvite)
.where(ListInvite.list_id == lst.id)
.order_by(ListInvite.created_at.desc())
).all()
return [_invite_out(i) for i in rows]
@router.post(
"/api/lists/{list_id}/invites",
response_model=InviteOut,
status_code=status.HTTP_201_CREATED,
)
def create_invite(
payload: InviteCreateIn,
request: Request,
lst: OwnedList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
email = normalize_email(payload.email)
if email == normalize_email(user.email):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Du bist bereits Eigentümer dieser Liste."
)
# Ist unter der Adresse jemand bereits Mitglied?
existing_user = db.scalar(select(User).where(User.email == email))
if existing_user is not None:
member = db.get(ListMember, (lst.id, existing_user.id))
if member is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Diese Person hat bereits Zugriff auf die Liste.",
)
open_count = sum(
1
for i in db.scalars(
select(ListInvite).where(ListInvite.list_id == lst.id)
).all()
if _status(i) == "pending"
)
if open_count >= MAX_OPEN_INVITES:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Es sind bereits {MAX_OPEN_INVITES} Einladungen offen.",
)
_guard_invite_rate(db, request, lst.id)
# Eine noch offene Einladung an dieselbe Adresse ersetzen, statt eine
# zweite danebenzulegen.
for old in db.scalars(
select(ListInvite).where(
ListInvite.list_id == lst.id,
ListInvite.email == email,
ListInvite.accepted_at.is_(None),
ListInvite.revoked_at.is_(None),
)
).all():
old.revoked_at = utcnow()
raw = new_token()
invite = ListInvite(
list_id=lst.id,
email=email,
role=payload.role,
token_hash=hash_token(raw),
invited_by=user.id,
expires_at=utcnow() + timedelta(days=INVITE_DAYS),
)
db.add(invite)
db.commit()
db.refresh(invite)
background.add_task(
send_invitation, email, raw,
lst.name, user.display_name or user.email.split("@")[0], INVITE_DAYS,
)
return _invite_out(invite)
@router.post("/api/lists/{list_id}/invites/{invite_id}/resend", response_model=InviteOut)
def resend_invite(
invite_id: str,
request: Request,
lst: OwnedList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
"""Erneuter Versand erzeugt ein NEUES Token und entwertet das alte.
In der Datenbank steht nur der Hash, der Klartext des ursprünglichen
Tokens lässt sich also nicht rekonstruieren. Nebeneffekt: Ein Link,
der versehentlich weitergeleitet wurde, verliert damit seine Gültigkeit.
"""
invite = db.get(ListInvite, invite_id)
if invite is None or invite.list_id != lst.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einladung nicht gefunden")
if invite.accepted_at:
raise HTTPException(
status.HTTP_409_CONFLICT, "Diese Einladung wurde bereits angenommen."
)
_guard_invite_rate(db, request, lst.id)
raw = new_token()
invite.token_hash = hash_token(raw)
invite.revoked_at = None
invite.expires_at = utcnow() + timedelta(days=INVITE_DAYS)
invite.last_sent_at = utcnow()
invite.send_count += 1
db.commit()
db.refresh(invite)
background.add_task(
send_invitation, invite.email, raw,
lst.name, user.display_name or user.email.split("@")[0], INVITE_DAYS,
)
return _invite_out(invite)
@router.delete("/api/lists/{list_id}/invites/{invite_id}", response_model=MessageOut)
def revoke_invite(invite_id: str, lst: OwnedList, db: DbSession):
invite = db.get(ListInvite, invite_id)
if invite is None or invite.list_id != lst.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einladung nicht gefunden")
if invite.accepted_at:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Diese Einladung wurde bereits angenommen. Entziehe stattdessen "
"die Mitgliedschaft.",
)
invite.revoked_at = utcnow()
db.commit()
return MessageOut(detail="Einladung widerrufen. Der Link funktioniert nicht mehr.")
# ==========================================================================
# Freigabe insgesamt aufheben
# ==========================================================================
@router.post("/api/lists/{list_id}/unshare", response_model=MessageOut)
def unshare(lst: OwnedList, db: DbSession):
"""Entfernt alle Mitglieder außer dem Eigentümer und widerruft alle
offenen Einladungen."""
members = db.scalars(
select(ListMember).where(
ListMember.list_id == lst.id, ListMember.user_id != lst.owner_id
)
).all()
for member in members:
db.delete(member)
revoked = 0
for invite in db.scalars(
select(ListInvite).where(
ListInvite.list_id == lst.id,
ListInvite.accepted_at.is_(None),
ListInvite.revoked_at.is_(None),
)
).all():
invite.revoked_at = utcnow()
revoked += 1
lst.rev += 1
db.commit()
return MessageOut(
detail=f"{len(members)} Zugriff(e) entzogen, {revoked} offene Einladung(en) "
"widerrufen."
)
# ==========================================================================
# Eigentümerwechsel
# ==========================================================================
@router.post("/api/lists/{list_id}/transfer", response_model=list[MemberOut])
def transfer_ownership(
payload: TransferOwnershipIn, lst: OwnedList, db: DbSession, user: VerifiedUser
):
"""Der bisherige Eigentümer wird zum Bearbeiter - so verliert er nicht
versehentlich den Zugang zu einer Liste, an der er mitarbeitet."""
if payload.user_id == lst.owner_id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Diese Person ist bereits Eigentümer."
)
target = db.get(ListMember, (lst.id, payload.user_id))
if target is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"Diese Person ist kein Mitglied der Liste. Lade sie zuerst ein.",
)
previous = db.get(ListMember, (lst.id, lst.owner_id))
if previous is not None:
previous.role = "editor"
target.role = "owner"
target.may_share_public = True
lst.owner_id = payload.user_id
lst.rev += 1
db.commit()
rows = db.execute(
select(ListMember, User)
.join(User, User.id == ListMember.user_id)
.where(ListMember.list_id == lst.id)
.order_by(ListMember.joined_at)
).all()
return [
MemberOut(
user_id=u.id, email=u.email, display_name=u.display_name,
role=m.role, may_share_public=m.may_share_public,
joined_at=m.joined_at,
)
for m, u in rows
]
# ==========================================================================
# Einladung ansehen und annehmen (Empfängerseite)
# ==========================================================================
def _load_invite(db: DbSession, token: str) -> ListInvite:
invite = db.scalar(
select(ListInvite).where(ListInvite.token_hash == hash_token(token))
)
if invite is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Diese Einladung ist unbekannt."
)
state = _status(invite)
if state == "revoked":
raise HTTPException(status.HTTP_410_GONE, "Diese Einladung wurde widerrufen.")
if state == "expired":
raise HTTPException(
status.HTTP_410_GONE,
"Diese Einladung ist abgelaufen. Bitte um eine neue.",
)
if state == "accepted":
raise HTTPException(
status.HTTP_409_CONFLICT, "Diese Einladung wurde bereits angenommen."
)
return invite
@router.get("/api/invites/{token}", response_model=InvitePreviewOut)
def preview_invite(token: str, db: DbSession, user: VerifiedUser):
invite = _load_invite(db, token)
lst = db.get(ShoppingList, invite.list_id)
if lst is None or lst.deleted_at is not None:
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
inviter = invite.inviter
return InvitePreviewOut(
list_name=lst.name,
invited_by_name=(
(inviter.display_name or inviter.email.split("@")[0]) if inviter else None
),
role=invite.role,
email=invite.email,
matches_current_user=normalize_email(user.email) == invite.email,
)
@router.post("/api/invites/{token}/accept", response_model=MessageOut)
def accept_invite(token: str, db: DbSession, user: VerifiedUser):
invite = _load_invite(db, token)
# Die Adresse muss zum angemeldeten Konto passen. Sonst würde ein
# weitergeleiteter Link jedem den Zugang öffnen.
if normalize_email(user.email) != invite.email:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"Diese Einladung ist an {invite.email} gerichtet. "
"Melde dich mit diesem Konto an oder bitte um eine neue Einladung.",
)
lst = db.get(ShoppingList, invite.list_id)
if lst is None or lst.deleted_at is not None:
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
if db.get(ListMember, (lst.id, user.id)) is None:
db.add(ListMember(list_id=lst.id, user_id=user.id, role=invite.role))
lst.rev += 1
invite.accepted_at = utcnow()
invite.accepted_by = user.id
db.commit()
return MessageOut(detail=f"Du hast jetzt Zugriff auf „{lst.name}“.")
# ==========================================================================
# Eigene Mitgliedschaft beenden
# ==========================================================================
@router.post("/api/lists/{list_id}/leave", response_model=MessageOut)
def leave_list(lst: ReadableList, db: DbSession, user: VerifiedUser):
if lst.owner_id == user.id:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Als Eigentümer kannst du die Liste nicht verlassen. Übertrage sie "
"zuvor oder lösche sie.",
)
member = db.get(ListMember, (lst.id, user.id))
if member is not None:
db.delete(member)
lst.rev += 1
db.commit()
return MessageOut(detail="Du hast die Liste verlassen.")

354
backend/app/routers/sync.py Normal file
View File

@@ -0,0 +1,354 @@
"""Synchronisation: Stapelverarbeitung aus der Outbox und Ereigniskanal.
Zwei Endpunkte:
POST /api/lists/{id}/ops Der Client schickt seine gesammelten
Operationen. Jede trägt eine selbst
vergebene `op_id`; bereits verarbeitete
werden erkannt und übersprungen.
GET /api/lists/{id}/events Server-Sent Events. Meldet nur den neuen
Revisionsstand, keine Nutzdaten - der
Client holt die Ansicht dann selbst.
Warum kein Delta-Endpunkt: Eine Einkaufsliste hat Dutzende Einträge, keine
Zehntausende. Die vollständige Ansicht neu zu holen kostet ein paar
Kilobyte und spart eine ganze Klasse von Fehlern, die beim Zusammensetzen
von Teilständen entsteht. Der Revisionszähler sorgt dafür, dass das nur
passiert, wenn sich wirklich etwas geändert hat.
"""
import asyncio
import json
import logging
import anyio
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.deps import DbSession, VerifiedUser, current_user
from app.list_view import build_view
from app.models import AppliedOp, Article, Category, ListItem, ListMember, Market, ShoppingList
from app.permissions import EditableList, ReadableList, bump_rev
from app.prices import record_price
from app.push import notify_list_changed
from app.schemas_sync import OpBatchIn, OpBatchOut, OpResult
from app.security import utcnow
log = logging.getLogger(__name__)
router = APIRouter(tags=["sync"])
MAX_OPS_PER_BATCH = 200
POLL_SECONDS = 2
HEARTBEAT_SECONDS = 20
# ==========================================================================
# Operationen anwenden
# ==========================================================================
def _ref(db: Session, model, obj_id: str | None, list_id: str, label: str):
if obj_id is None:
return None
obj = db.get(model, obj_id)
if obj is None or obj.list_id != list_id or obj.deleted_at is not None:
raise ValueError(f"{label} nicht gefunden")
return obj
def _apply(db: Session, lst: ShoppingList, user_id: str, op, rev: int) -> str | None:
"""Führt eine einzelne Operation aus und gibt bei item.create die
vergebene ID zurück. Wirft ValueError bei fachlichen Fehlern - die
lässt der Aufrufer als abgelehnte Operation durchgehen, ohne den
ganzen Stapel scheitern zu lassen."""
data = op.payload
kind = op.kind
if kind == "item.create":
article = None
# Bekannter Artikel, etwa nach einem Strichcode-Treffer.
if data.get("article_id"):
article = db.get(Article, data["article_id"])
if (article is None or article.list_id != lst.id
or article.deleted_at is not None):
raise ValueError("Artikel nicht gefunden")
# Sonst über den Barcode suchen ...
if article is None and data.get("barcode"):
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.barcode == data["barcode"],
Article.deleted_at.is_(None),
)
)
# ... und zuletzt über den Namen.
if article is None:
name = (data.get("article_name") or "").strip()
if not name:
raise ValueError("Artikelname fehlt")
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.name == name,
Article.deleted_at.is_(None),
)
)
if article is None:
article = Article(
list_id=lst.id,
name=name[:200],
barcode=(data.get("barcode") or None),
row_rev=rev,
)
db.add(article)
db.flush()
elif data.get("barcode") and not article.barcode:
# Der Artikel war schon da, hatte aber noch keinen Code -
# beim nächsten Scan wird er direkt gefunden.
article.barcode = data["barcode"]
article.row_rev = rev
market = _ref(db, Market, data.get("market_id"), lst.id, "Markt")
category = _ref(db, Category, data.get("category_id"), lst.id, "Warengruppe")
item = ListItem(
list_id=lst.id,
article_id=article.id,
market_id=(market.id if market else article.default_market_id),
category_id=(category.id if category else article.default_category_id),
count=int(data.get("count") or 1),
# "quantity"/"unit" sind die alten Feldnamen. Ein Geraet, das
# zum Zeitpunkt der Umstellung noch Operationen in der Outbox
# hatte, schickt sie weiterhin - die landen jetzt als Gebinde,
# was der bisherigen Bedeutung entspricht.
pack_size=data.get("pack_size", data.get("quantity")),
pack_unit=(data.get("pack_unit") or data.get("unit") or None),
variant=(data.get("variant") or None),
note=(data.get("note") or None),
status="open",
created_by=user_id,
row_rev=rev,
)
db.add(item)
db.flush()
return item.id
if kind == "item.update":
item = db.get(ListItem, data.get("item_id"))
if item is None or item.list_id != lst.id or item.deleted_at is not None:
raise ValueError("Eintrag nicht gefunden")
if data.get("clear_market"):
item.market_id = None
elif "market_id" in data:
market = _ref(db, Market, data["market_id"], lst.id, "Markt")
item.market_id = market.id if market else None
if data.get("clear_category"):
item.category_id = None
elif "category_id" in data:
category = _ref(db, Category, data["category_id"], lst.id, "Warengruppe")
item.category_id = category.id if category else None
# Alte Feldnamen aus einer Outbox von vor der Umstellung.
if "quantity" in data and "pack_size" not in data:
data["pack_size"] = data["quantity"]
if "unit" in data and "pack_unit" not in data:
data["pack_unit"] = data["unit"]
for field in ("count", "pack_size", "pack_unit", "variant", "note",
"status", "price_cents"):
if field not in data:
continue
# count und status sind nicht leerbar - ein Eintrag ohne
# Stückzahl oder Status waere unvollstaendig.
if field in ("count", "status") and data[field] is None:
continue
setattr(item, field, data[field])
item.row_rev = rev
record_price(db, item)
return item.id
if kind == "item.delete":
item = db.get(ListItem, data.get("item_id"))
if item is None or item.list_id != lst.id:
raise ValueError("Eintrag nicht gefunden")
if item.deleted_at is None:
item.deleted_at = utcnow()
item.row_rev = rev
return item.id
if kind == "items.clear_bought":
now = utcnow()
for item in db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.status == "bought",
ListItem.deleted_at.is_(None),
)
).all():
item.deleted_at = now
item.row_rev = rev
return None
raise ValueError(f"Unbekannte Operation: {kind}")
@router.post("/api/lists/{list_id}/ops", response_model=OpBatchOut)
def apply_ops(
payload: OpBatchIn,
lst: EditableList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
"""Verarbeitet einen Stapel Operationen.
Der gesamte Stapel bekommt EINEN Revisionsschritt. So sieht ein
anderes Gerät eine geschlossene Änderung statt dutzender einzelner -
und die Anzeige flackert nicht.
Fachlich fehlgeschlagene Operationen werden einzeln als abgelehnt
zurückgemeldet, statt den ganzen Stapel scheitern zu lassen. Sonst
würde ein Eintrag, den jemand anders inzwischen gelöscht hat, alle
übrigen Änderungen des Geräts blockieren.
"""
if len(payload.ops) > MAX_OPS_PER_BATCH:
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"Höchstens {MAX_OPS_PER_BATCH} Operationen je Aufruf.",
)
results: list[OpResult] = []
rev = lst.rev
changed = False
for op in payload.ops:
known = db.get(AppliedOp, op.op_id)
if known is not None:
# Schon verarbeitet - Quittung wiederholen, nichts tun.
results.append(
OpResult(op_id=op.op_id, status="duplicate", result_id=known.result_id)
)
continue
if not changed:
rev = bump_rev(db, lst.id)
changed = True
try:
result_id = _apply(db, lst, user.id, op, rev)
except ValueError as exc:
db.rollback()
# Nach dem Rollback ist der Revisionsschritt weg - beim
# nächsten erfolgreichen Vorgang wird neu gezogen.
changed = False
results.append(OpResult(op_id=op.op_id, status="rejected", error=str(exc)))
continue
db.add(
AppliedOp(
op_id=op.op_id, list_id=lst.id, user_id=user.id,
kind=op.kind, result_id=result_id, rev=rev,
)
)
db.flush()
results.append(OpResult(op_id=op.op_id, status="applied", result_id=result_id))
db.commit()
db.refresh(lst)
# Nur benachrichtigen, wenn wirklich etwas passiert ist. Ein Stapel,
# der nur aus Wiederholungen bestand, aendert nichts.
if any(r.status == "applied" for r in results):
background.add_task(notify_list_changed, lst.id, user.id)
return OpBatchOut(rev=lst.rev, results=results)
# ==========================================================================
# Ereigniskanal
# ==========================================================================
def _read_rev(list_id: str, user_id: str) -> int | None:
"""Läuft im Threadpool: SQLAlchemy ist hier synchron konfiguriert,
und ein blockierender Aufruf in der Ereignisschleife würde den
gesamten Server anhalten."""
with SessionLocal() as db:
if db.get(ListMember, (list_id, user_id)) is None:
return None
lst = db.scalar(
select(ShoppingList).where(
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
)
)
return lst.rev if lst else None
@router.get("/api/lists/{list_id}/events")
async def events(list_id: str, request: Request, lst: ReadableList, user: VerifiedUser):
"""Meldet Änderungen an der Liste als Server-Sent Events.
Ohne Redis wird der Revisionsstand abgefragt statt verteilt - bei
dieser Größenordnung völlig ausreichend. Wichtig ist nur, dass die
Abfrage im Threadpool läuft.
Achtung beim Reverse Proxy: `proxy_buffering off` ist Pflicht, sonst
sammelt nginx die Ereignisse und liefert sie gebündelt aus.
"""
user_id = user.id
last_rev = lst.rev
async def stream():
nonlocal last_rev
# Erstes Ereignis sofort, damit der Client seinen Stand abgleicht.
yield f"event: rev\ndata: {json.dumps({'rev': last_rev})}\n\n"
# Wiederverbindungsabstand für den Browser.
yield "retry: 5000\n\n"
since_heartbeat = 0.0
while True:
if await request.is_disconnected():
break
await asyncio.sleep(POLL_SECONDS)
since_heartbeat += POLL_SECONDS
try:
rev = await anyio.to_thread.run_sync(_read_rev, list_id, user_id)
except Exception:
log.exception("Ereigniskanal: Abfrage fehlgeschlagen")
break
if rev is None:
# Liste gelöscht oder Zugriff entzogen.
yield "event: gone\ndata: {}\n\n"
break
if rev != last_rev:
last_rev = rev
since_heartbeat = 0.0
yield f"event: rev\ndata: {json.dumps({'rev': rev})}\n\n"
elif since_heartbeat >= HEARTBEAT_SECONDS:
since_heartbeat = 0.0
# Kommentarzeile: hält die Verbindung durch Proxys offen,
# löst beim Client aber kein Ereignis aus.
yield ": ping\n\n"
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)