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