feat: Paket 2 - Product catalog management, guest orders, sponsor rounds, password reset, user deletion, unlimited invite links and setup wizard

This commit is contained in:
2026-09-21 11:32:41 +02:00
parent 83393e363e
commit e5ca9c3cbf
7 changed files with 910 additions and 119 deletions
+341 -88
View File
@@ -1,7 +1,7 @@
import os
import secrets
from datetime import datetime, timedelta, date
from typing import Optional, Dict
from typing import Optional, Dict, List
from decimal import Decimal
from fastapi import FastAPI, Request, Depends, HTTPException, Form, status
@@ -104,9 +104,6 @@ def index(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
current_user = get_current_user(request, db)
if not current_user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
now = datetime.now()
# Aktuelle offene Runde suchen
@@ -119,6 +116,11 @@ def index(request: Request, db: Session = Depends(get_db)):
if not event:
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
# Wenn nicht eingeloggt und Event erlaubt keine Gäste -> Redirect zu Login
if not current_user:
if not event or not event.allow_guests:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
# Fristprüfung & Countdown
is_closed = True
seconds_remaining = 0
@@ -136,7 +138,7 @@ def index(request: Request, db: Session = Depends(get_db)):
# Bestehende Bestellung des Users in diesem Event
existing_order = None
user_items: Dict[int, int] = {}
if event:
if event and current_user:
existing_order = db.query(Order).filter(
Order.tenant_id == tenant.id,
Order.event_id == event.id,
@@ -175,10 +177,19 @@ def login_page(request: Request, db: Session = Depends(get_db), error: Optional[
if current_user:
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
# Prüfen, ob aktives Event Gäste erlaubt
active_event = db.query(Event).filter(
Event.tenant_id == tenant.id,
Event.status == "open",
Event.deadline > datetime.now()
).first()
allow_guests = (active_event.allow_guests if active_event else False)
return templates.TemplateResponse(request, "login.html", context={
"tenant": tenant,
"error": error,
"success": success,
"allow_guests": allow_guests,
"current_user": None
})
@@ -198,10 +209,10 @@ def login(
return templates.TemplateResponse(request, "login.html", context={
"tenant": tenant,
"error": "Ungültige E-Mail-Adresse oder falsches Passwort.",
"current_user": None
"current_user": None,
"allow_guests": False
}, status_code=400)
# Session setzen
request.session["user_id"] = user.id
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
@@ -251,9 +262,9 @@ def join_page(request: Request, token: str, db: Session = Depends(get_db)):
error = None
if not invitation:
error = "Dieser Einladungslink ist ungültig oder gehört zu einem anderen Team."
elif invitation.used_at is not None:
elif not invitation.is_reusable and invitation.used_at is not None:
error = "Dieser Einladungslink wurde bereits eingelöst. Bitte melde dich mit deinem Konto an."
elif invitation.expires_at < datetime.now():
elif invitation.expires_at and invitation.expires_at < datetime.now():
error = "Dieser Einladungslink ist leider abgelaufen. Bitte frage deinen Admin nach einem neuen Link."
return templates.TemplateResponse(request, "join.html", context={
@@ -281,8 +292,14 @@ def process_join(
Invitation.tenant_id == tenant.id
).first()
if not invitation or invitation.used_at is not None or invitation.expires_at < datetime.now():
raise HTTPException(status_code=400, detail="Ungültiger oder abgelaufener Einladungslink")
if not invitation:
raise HTTPException(status_code=400, detail="Ungültiger Einladungslink")
if not invitation.is_reusable and invitation.used_at is not None:
raise HTTPException(status_code=400, detail="Einladungslink wurde bereits verwendet")
if invitation.expires_at and invitation.expires_at < datetime.now():
raise HTTPException(status_code=400, detail="Einladungslink ist abgelaufen")
clean_email = email.strip().lower()
existing_user = db.query(User).filter(
@@ -299,7 +316,6 @@ def process_join(
"current_user": None
}, status_code=400)
# Neuen Benutzer anlegen
username = clean_email.split("@")[0]
new_user = User(
tenant_id=tenant.id,
@@ -312,12 +328,13 @@ def process_join(
)
db.add(new_user)
# Einladung als verwendet markieren
invitation.used_at = datetime.now()
# Nur Einmal-Links als verwendet markieren; wiederverwendbare Links bleiben offen!
if not invitation.is_reusable:
invitation.used_at = datetime.now()
db.commit()
db.refresh(new_user)
# Direkt einloggen
request.session["user_id"] = new_user.id
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
@@ -328,12 +345,13 @@ def process_join(
async def place_order(
request: Request,
event_id: int = Form(...),
guest_first_name: Optional[str] = Form(None),
guest_last_name: Optional[str] = Form(None),
guest_email: Optional[str] = Form(None),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
current_user = get_current_user(request, db)
if not current_user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event or event.status != "open":
@@ -342,6 +360,33 @@ async def place_order(
if event.deadline < datetime.now():
raise HTTPException(status_code=400, detail="Die Bestellfrist für diese Runde ist leider abgelaufen!")
# Falls nicht eingeloggt -> Gast-Bestellung prüfen
if not current_user:
if not event.allow_guests:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
if not guest_first_name or not guest_first_name.strip():
raise HTTPException(status_code=400, detail="Bitte gib als Gast deinen Vornamen an!")
fname = guest_first_name.strip()
lname = guest_last_name.strip() if guest_last_name else "(Gast)"
clean_email = (guest_email.strip().lower() if guest_email and guest_email.strip() else f"gast_{secrets.token_hex(4)}@{tenant.slug}.local")
guest_user = User(
tenant_id=tenant.id,
email=clean_email,
username=f"gast_{secrets.token_hex(4)}",
first_name=fname,
last_name=lname,
role="guest",
password_hash=""
)
db.add(guest_user)
db.commit()
db.refresh(guest_user)
current_user = guest_user
request.session["user_id"] = guest_user.id
form_data = await request.form()
order_items = []
total_price = Decimal("0.00")
@@ -468,12 +513,17 @@ def admin_dashboard(
# Teammitglieder & Einladungen laden
team_members = db.query(User).filter(User.tenant_id == tenant.id).order_by(User.role.asc(), User.first_name.asc()).all()
# Offene Einladungen (entweder unbegrenzt oder expires_at in der Zukunft)
now = datetime.now()
pending_invitations = db.query(Invitation).filter(
Invitation.tenant_id == tenant.id,
Invitation.used_at == None,
Invitation.expires_at > datetime.now()
(Invitation.expires_at == None) | (Invitation.expires_at > now)
).order_by(Invitation.created_at.desc()).all()
# Produkte des Mandanten laden
products = db.query(Product).filter(Product.tenant_id == tenant.id).order_by(Product.sort_order.asc(), Product.id.asc()).all()
return templates.TemplateResponse(request, "admin.html", context={
"tenant": tenant,
"current_user": current_user,
@@ -485,119 +535,90 @@ def admin_dashboard(
"total_paid": total_paid,
"team_members": team_members,
"pending_invitations": pending_invitations,
"products": products,
"active_tab": tab,
"now": datetime.now()
"now": now
})
@app.post("/admin/events/new")
def admin_create_event(
# --- ADMIN USER MANAGEMENT ---
@app.post("/admin/users/{user_id}/reset-password")
def admin_reset_password(
user_id: int,
request: Request,
title: str = Form(...),
event_date: str = Form(...),
deadline_date: str = Form(...),
deadline_time: str = Form(...),
new_password: str = Form(...),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
try:
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}"
deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M")
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
if not user:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
new_event = Event(
tenant_id=tenant.id,
title=title.strip(),
event_date=ev_date,
deadline=deadline_dt,
status="open"
)
db.add(new_event)
db.commit()
db.refresh(new_event)
if not new_password or len(new_password.strip()) < 4:
raise HTTPException(status_code=400, detail="Passwort muss mindestens 4 Zeichen lang sein")
return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/events/{event_id}/edit")
def admin_edit_event(
event_id: int,
request: Request,
title: str = Form(...),
event_date: str = Form(...),
deadline_date: str = Form(...),
deadline_time: str = Form(...),
status_val: str = Form(...),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event:
raise HTTPException(status_code=404, detail="Event nicht gefunden")
try:
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}"
deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M")
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
event.title = title.strip()
event.event_date = ev_date
event.deadline = deadline_dt
event.status = status_val
user.password_hash = hash_password(new_password.strip())
db.commit()
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/events/{event_id}/toggle-status")
def admin_toggle_event_status(
event_id: int,
@app.post("/admin/users/{user_id}/delete")
def admin_delete_user(
user_id: int,
request: Request,
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
admin = require_admin(request, db)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event:
raise HTTPException(status_code=404, detail="Event nicht gefunden")
if admin.id == user_id:
raise HTTPException(status_code=400, detail="Du kannst dein eigenes Admin-Konto nicht löschen")
event.status = "closed" if event.status == "open" else "open"
db.commit()
user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
if user:
db.delete(user)
db.commit()
return RedirectResponse(url=f"/admin?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN INVITATIONS ---
@app.post("/admin/invitations/create")
def admin_create_invitation(
request: Request,
email: Optional[str] = Form(None),
role: str = Form("user"),
valid_days: int = Form(14),
valid_days: str = Form("14"),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
admin = require_admin(request, db)
token = secrets.token_urlsafe(20)
expires_at = datetime.now() + timedelta(days=valid_days)
clean_email = email.strip().lower() if email and email.strip() else None
# Unbegrenzt prüfen
if valid_days in ["0", "unlimited", "forever"]:
expires_at = None
is_reusable = True
else:
days = int(valid_days)
expires_at = datetime.now() + timedelta(days=days)
is_reusable = False
inv = Invitation(
tenant_id=tenant.id,
token=token,
email=clean_email,
role=role,
created_by=admin.id,
expires_at=expires_at
expires_at=expires_at,
is_reusable=is_reusable
)
db.add(inv)
db.commit()
@@ -622,6 +643,238 @@ def admin_revoke_invitation(
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN EVENT MANAGEMENT ---
@app.post("/admin/events/new")
def admin_create_event(
request: Request,
title: str = Form(...),
event_date: str = Form(...),
deadline_date: str = Form(...),
deadline_time: str = Form(...),
sponsor_name: Optional[str] = Form(None),
allow_guests: bool = Form(True),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
try:
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}"
deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M")
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
new_event = Event(
tenant_id=tenant.id,
title=title.strip(),
event_date=ev_date,
deadline=deadline_dt,
status="open",
sponsor_name=sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None,
allow_guests=allow_guests
)
db.add(new_event)
db.commit()
db.refresh(new_event)
return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/events/{event_id}/edit")
def admin_edit_event(
event_id: int,
request: Request,
title: str = Form(...),
event_date: str = Form(...),
deadline_date: str = Form(...),
deadline_time: str = Form(...),
status_val: str = Form(...),
sponsor_name: Optional[str] = Form(None),
allow_guests: bool = Form(False),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event:
raise HTTPException(status_code=404, detail="Event nicht gefunden")
try:
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}"
deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M")
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
event.title = title.strip()
event.event_date = ev_date
event.deadline = deadline_dt
event.status = status_val
event.sponsor_name = sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None
event.allow_guests = allow_guests
db.commit()
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/events/{event_id}/toggle-status")
def admin_toggle_event_status(
event_id: int,
request: Request,
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event:
raise HTTPException(status_code=404, detail="Event nicht gefunden")
event.status = "closed" if event.status == "open" else "open"
db.commit()
return RedirectResponse(url=f"/admin?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN PRODUCT CATALOG MANAGEMENT ---
@app.post("/admin/products/new")
def admin_create_product(
request: Request,
name: str = Form(...),
description: str = Form(""),
price: str = Form(...),
unit: str = Form("Stück"),
sort_order: int = Form(0),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
try:
clean_price = Decimal(price.strip().replace(",", "."))
except Exception:
raise HTTPException(status_code=400, detail="Ungültiger Preis")
product = Product(
tenant_id=tenant.id,
name=name.strip(),
description=description.strip(),
price=clean_price,
unit=unit.strip(),
sort_order=sort_order,
is_active=True
)
db.add(product)
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/products/{product_id}/edit")
def admin_edit_product(
product_id: int,
request: Request,
name: str = Form(...),
description: str = Form(""),
price: str = Form(...),
unit: str = Form("Stück"),
sort_order: int = Form(0),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
prod = db.query(Product).filter(Product.id == product_id, Product.tenant_id == tenant.id).first()
if not prod:
raise HTTPException(status_code=404, detail="Produkt nicht gefunden")
try:
clean_price = Decimal(price.strip().replace(",", "."))
except Exception:
raise HTTPException(status_code=400, detail="Ungültiger Preis")
prod.name = name.strip()
prod.description = description.strip()
prod.price = clean_price
prod.unit = unit.strip()
prod.sort_order = sort_order
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/products/{product_id}/toggle")
def admin_toggle_product(
product_id: int,
request: Request,
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
prod = db.query(Product).filter(Product.id == product_id, Product.tenant_id == tenant.id).first()
if prod:
prod.is_active = not prod.is_active
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
# --- ONBOARDING / SETUP WIZARD (/setup) ---
@app.get("/setup", response_class=HTMLResponse)
def setup_page(request: Request, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db)
if not tenant:
raise HTTPException(status_code=404, detail="Mandant nicht gefunden")
current_user = get_current_user(request, db)
prod_count = db.query(Product).filter(Product.tenant_id == tenant.id).count()
user_count = db.query(User).filter(User.tenant_id == tenant.id).count()
return templates.TemplateResponse(request, "setup.html", context={
"tenant": tenant,
"current_user": current_user,
"prod_count": prod_count,
"user_count": user_count
})
@app.post("/setup/seed-products")
def seed_standard_products(request: Request, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db)
require_admin(request, db)
standard_items = [
{"name": "Original Münchner Weißwürste", "desc": "Frisch vom Metzger, traditionell hergestellt", "price": Decimal("1.40"), "unit": "Stück", "order": 1},
{"name": "Frische Laugenbreze", "desc": "Knusprig gebacken, ofenfrisch vom Bäcker", "price": Decimal("1.10"), "unit": "Stück", "order": 2},
{"name": "Händlmaier Hausmachersenf", "desc": "Original bayerischer süßer Hausmachersenf", "price": Decimal("0.40"), "unit": "Portion", "order": 3},
{"name": "Leberkassemmel (warm)", "desc": "Feiner bayerischer Leberkäse in der Kaisersemmel", "price": Decimal("2.80"), "unit": "Semmel", "order": 4},
{"name": "Wiener Würstchen", "desc": "Knackige Wiener Würstchen (Paar)", "price": Decimal("2.60"), "unit": "Paar", "order": 5},
{"name": "Alkoholfreies Weißbier (0,5l)", "desc": "Erfrischendes bayerisches Hefe-Weißbier alkoholfrei", "price": Decimal("1.80"), "unit": "Flasche", "order": 6},
]
for item in standard_items:
exists = db.query(Product).filter(Product.tenant_id == tenant.id, Product.name == item["name"]).first()
if not exists:
db.add(Product(
tenant_id=tenant.id,
name=item["name"],
description=item["desc"],
price=item["price"],
unit=item["unit"],
sort_order=item["order"],
is_active=True
))
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/toggle-paid/{order_id}")
def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
current_user = get_current_user(request, db)