feat: token-secured guest invites, product freeze lock during active events, Outlook ics export and template, realistic test data

This commit is contained in:
2026-09-21 14:12:59 +02:00
parent 913aa646ea
commit 2001ffb831
5 changed files with 328 additions and 55 deletions
+201 -43
View File
@@ -2,10 +2,10 @@ import os
import re
import secrets
from datetime import datetime, timedelta, date
from typing import Optional, Dict, List
from typing import Optional, Dict, List, Set
from decimal import Decimal
from fastapi import FastAPI, Request, Depends, HTTPException, Form, status
from fastapi import FastAPI, Request, Depends, HTTPException, Form, status, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -68,6 +68,19 @@ def require_admin(request: Request, db: Session) -> User:
return current_user
def is_product_locked_in_active_event(product_id: int, tenant_id: int, db: Session) -> bool:
"""Prüft, ob ein Produkt in einer offenen Runde bereits bestellt wurde."""
open_events = db.query(Event).filter(Event.tenant_id == tenant_id, Event.status == "open").all()
if not open_events:
return False
open_ids = [e.id for e in open_events]
has_item = db.query(OrderItem).join(Order).filter(
Order.event_id.in_(open_ids),
OrderItem.product_id == product_id
).first()
return has_item is not None
def seed_demo_users_if_needed(tenant: Tenant, db: Session):
"""Stellt sicher, dass die Standard-Accounts für die Demo existieren."""
demo_user = db.query(User).filter(User.tenant_id == tenant.id, User.email == "maxi@waiswuaschd.de").first()
@@ -99,7 +112,7 @@ def seed_demo_users_if_needed(tenant: Tenant, db: Session):
@app.get("/", response_class=HTMLResponse)
def index(request: Request, event_id: Optional[int] = None, db: Session = Depends(get_db)):
def index(request: Request, event_id: Optional[int] = None, token: Optional[str] = None, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db)
if not tenant:
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
@@ -120,16 +133,16 @@ def index(request: Request, event_id: Optional[int] = None, db: Session = Depend
if not event and open_events:
event = open_events[0]
if not event:
# Neuestes Event überhaupt anzeigen
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
# Gast-Modus
is_guest_mode = (request.query_params.get("guest") == "1")
# Sicherer Gast-Check: Nur wenn token übereinstimmt, ist Gast-Modus aktiv
is_valid_guest = False
if event and token and event.guest_token and token == event.guest_token:
is_valid_guest = True
# 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)
# Wenn nicht eingeloggt und kein gültiger Gast-Token -> Redirect zu Login
if not current_user and not is_valid_guest:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
# Fristprüfung & Countdown
is_closed = True
@@ -168,7 +181,8 @@ def index(request: Request, event_id: Optional[int] = None, db: Session = Depend
"current_user": current_user,
"event": event,
"open_events": open_events,
"is_guest_mode": is_guest_mode,
"is_valid_guest": is_valid_guest,
"guest_token": token or (event.guest_token if event else ""),
"is_closed": is_closed,
"seconds_remaining": seconds_remaining,
"products": products,
@@ -189,19 +203,10 @@ 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)
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,
"active_event": active_event,
"current_user": None
})
@@ -221,8 +226,7 @@ def login(
return templates.TemplateResponse(request, "login.html", context={
"tenant": tenant,
"error": "Ungültige E-Mail-Adresse oder falsches Passwort.",
"current_user": None,
"allow_guests": False
"current_user": None
}, status_code=400)
request.session["user_id"] = user.id
@@ -356,6 +360,7 @@ def process_join(
async def place_order(
request: Request,
event_id: int = Form(...),
guest_token: Optional[str] = Form(None),
guest_first_name: Optional[str] = Form(None),
guest_last_name: Optional[str] = Form(None),
guest_email: Optional[str] = Form(None),
@@ -373,8 +378,9 @@ async def place_order(
guest_name_str = None
if not current_user:
if not event.allow_guests:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
# Gast-Token prüfen
if not guest_token or not event.guest_token or guest_token != event.guest_token:
raise HTTPException(status_code=403, detail="Ungültiger oder fehlender Gast-Token. Bitte nutze den Einladungslink.")
if not guest_first_name or not guest_first_name.strip():
raise HTTPException(status_code=400, detail="Bitte gib als Gast deinen Namen an!")
@@ -478,6 +484,49 @@ def order_success(order_id: int, request: Request, db: Session = Depends(get_db)
})
# --- OUTLOOK & CALENDAR (.ics) ---
@app.get("/events/{event_id}/calendar.ics")
def download_calendar_ics(event_id: int, request: Request, db: Session = Depends(get_db)):
tenant = resolve_tenant(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")
dt_start = datetime.combine(event.event_date, datetime.min.time()).replace(hour=9, minute=30)
dt_end = dt_start + timedelta(hours=1)
dt_stamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
dt_start_str = dt_start.strftime("%Y%m%dT%H%M%S")
dt_end_str = dt_end.strftime("%Y%m%dT%H%M%S")
host = request.headers.get("host", "demo.dev.waiswuaschd.de")
order_url = f"https://{host}/?event_id={event.id}"
ics_content = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Schmankerl//Brotzeitportal//DE\r\n"
"CALSCALE:GREGORIAN\r\n"
"METHOD:PUBLISH\r\n"
"BEGIN:VEVENT\r\n"
f"UID:schmankerl-event-{event.id}@{host}\r\n"
f"DTSTAMP:{dt_stamp}\r\n"
f"DTSTART:{dt_start_str}\r\n"
f"DTEND:{dt_end_str}\r\n"
f"SUMMARY:🥨 {event.title}\r\n"
f"DESCRIPTION:Gemeinsame Brotzeit / Weißwurstfrühstück.\\nBestellschluss: {event.deadline.strftime('%d.%m.%Y um %H:%M Uhr')}\\nBestell-Link: {order_url}\r\n"
f"LOCATION:Kantine / Teeküche ({tenant.name})\r\n"
"STATUS:CONFIRMED\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
return Response(
content=ics_content,
media_type="text/calendar",
headers={"Content-Disposition": f'attachment; filename="brotzeit-{event.event_date.strftime("%Y-%m-%d")}.ics"'}
)
# --- ADMIN COCKPIT (/admin) ---
@app.get("/admin", response_class=HTMLResponse)
@@ -487,6 +536,7 @@ def admin_dashboard(
tab: str = "orders",
warn_active_id: Optional[int] = None,
success: Optional[str] = None,
error: Optional[str] = None,
bulk_count: Optional[int] = None,
db: Session = Depends(get_db)
):
@@ -503,13 +553,12 @@ def admin_dashboard(
# Alle Events des Mandanten
all_events = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.event_date.desc(), Event.id.desc()).all()
# Ausgewähltes Event bestimmen: Wenn event_id übergeben, nehmen wir das, sonst das erste offene, sonst das neueste
# Ausgewähltes Event bestimmen
selected_event = None
if event_id:
selected_event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not selected_event:
# Bevorzuge aktives offenes Event
for ev in all_events:
if ev.status == "open":
selected_event = ev
@@ -536,7 +585,7 @@ def admin_dashboard(
item_sums[pname] = {"quantity": 0, "unit": unit}
item_sums[pname]["quantity"] += it.quantity
# Teammitglieder laden (Gäste filtern oder am Ende aufführen)
# Teammitglieder laden
team_members = db.query(User).filter(
User.tenant_id == tenant.id,
User.role != "guest"
@@ -558,6 +607,14 @@ def admin_dashboard(
# Produkte laden
products = db.query(Product).filter(Product.tenant_id == tenant.id).order_by(Product.sort_order.asc(), Product.id.asc()).all()
# Welche Produkte sind durch Bestellungen in offenen Events gesperrt?
locked_product_ids: Set[int] = set()
open_events = [e for e in all_events if e.status == "open"]
if open_events:
open_ev_ids = [e.id for e in open_events]
ordered_pids = db.query(OrderItem.product_id).join(Order).filter(Order.event_id.in_(open_ev_ids)).distinct().all()
locked_product_ids = {p[0] for p in ordered_pids}
# Warnung bei existierendem aktivem Event
active_warning_event = None
if warn_active_id:
@@ -576,9 +633,11 @@ def admin_dashboard(
"active_guests": active_guests,
"pending_invitations": pending_invitations,
"products": products,
"locked_product_ids": locked_product_ids,
"active_tab": tab,
"warn_active_event": active_warning_event,
"success": success,
"error": error,
"bulk_count": bulk_count,
"now": now
})
@@ -606,7 +665,7 @@ def admin_reset_password(
user.password_hash = hash_password(new_password.strip())
db.commit()
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=team&success=Passwort erfolgreich aktualisiert", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/users/{user_id}/delete")
@@ -623,7 +682,6 @@ def admin_delete_user(
user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
if user:
# Eventuelle Bestellungen von user_id entkoppeln, damit Beträge im Archiv nicht verloren gehen
orders = db.query(Order).filter(Order.user_id == user.id).all()
for ord_obj in orders:
ord_obj.guest_display_name = f"{user.first_name} {user.last_name}"
@@ -631,10 +689,10 @@ def admin_delete_user(
db.delete(user)
db.commit()
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=team&success=Benutzer gelöscht", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN INVITATIONS (EINZELN & BULK) ---
# --- ADMIN INVITATIONS ---
@app.post("/admin/invitations/create")
def admin_create_invitation(
@@ -684,7 +742,6 @@ def admin_bulk_invitations(
tenant = resolve_tenant(request, db)
admin = require_admin(request, db)
# E-Mails per Regex extrahieren (unterstützt Komma, Semikolon, Umbrüche etc.)
email_pattern = r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+'
found_emails = list(set([e.lower().strip() for e in re.findall(email_pattern, emails_text)]))
@@ -758,7 +815,6 @@ def admin_create_event(
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
# Prüfung: Gibt es bereits ein aktives offenes Event in der Zukunft?
if not force:
active_existing = db.query(Event).filter(
Event.tenant_id == tenant.id,
@@ -766,7 +822,6 @@ def admin_create_event(
Event.event_date >= date.today()
).first()
if active_existing:
# Weiterleitung mit Warn-Hinweis
return RedirectResponse(
url=f"/admin?tab=event&warn_active_id={active_existing.id}",
status_code=status.HTTP_303_SEE_OTHER
@@ -779,7 +834,8 @@ def admin_create_event(
deadline=deadline_dt,
status="open",
sponsor_name=sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None,
allow_guests=allow_guests
allow_guests=allow_guests,
guest_token=secrets.token_urlsafe(16)
)
db.add(new_event)
db.commit()
@@ -821,9 +877,11 @@ def admin_edit_event(
event.status = status_val
event.sponsor_name = sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None
event.allow_guests = allow_guests
if not event.guest_token:
event.guest_token = secrets.token_urlsafe(16)
db.commit()
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=event&success=Event aktualisiert", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/events/{event_id}/complete-and-cleanup")
@@ -842,7 +900,6 @@ def admin_complete_event_and_cleanup_guests(
event.status = "completed"
# Gast-Bestellungen sichern und Gast-Konten entfernen
orders = db.query(Order).filter(Order.event_id == event.id).all()
cleaned_count = 0
for ord_obj in orders:
@@ -872,10 +929,10 @@ def admin_delete_event(
db.delete(event)
db.commit()
return RedirectResponse(url="/admin?tab=event", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=event&success=Event gelöscht", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN PRODUCT CATALOG MANAGEMENT ---
# --- ADMIN PRODUCT CATALOG MANAGEMENT (MIT SICHERUNGSSPERRE) ---
@app.post("/admin/products/new")
def admin_create_product(
@@ -907,7 +964,7 @@ def admin_create_product(
db.add(product)
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=products&success=Produkt angelegt", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/products/{product_id}/edit")
@@ -933,6 +990,13 @@ def admin_edit_product(
except Exception:
raise HTTPException(status_code=400, detail="Ungültiger Preis")
# Prüfen, ob Preis gesperrt ist wegen laufender Bestellungen
if prod.price != clean_price and is_product_locked_in_active_event(product_id, tenant.id, db):
return RedirectResponse(
url="/admin?tab=products&error=Preisänderung gesperrt: Dieser Artikel wurde in der laufenden Runde bereits bestellt!",
status_code=status.HTTP_303_SEE_OTHER
)
prod.name = name.strip()
prod.description = description.strip()
prod.price = clean_price
@@ -940,7 +1004,7 @@ def admin_edit_product(
prod.sort_order = sort_order
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=products&success=Produkt aktualisiert", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/products/{product_id}/toggle")
@@ -954,13 +1018,20 @@ def admin_toggle_product(
prod = db.query(Product).filter(Product.id == product_id, Product.tenant_id == tenant.id).first()
if prod:
# Wenn aktiv -> versuche zu deaktivieren: prüfen, ob in aktiver Runde bestellt
if prod.is_active and is_product_locked_in_active_event(product_id, tenant.id, db):
return RedirectResponse(
url="/admin?tab=products&error=Deaktivierung gesperrt: Artikel wurde in der laufenden Runde bereits bestellt!",
status_code=status.HTTP_303_SEE_OTHER
)
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) ---
# --- ONBOARDING / SETUP WIZARD & TESTDATEN ---
@app.get("/setup", response_class=HTMLResponse)
def setup_page(request: Request, db: Session = Depends(get_db)):
@@ -1008,7 +1079,94 @@ def seed_standard_products(request: Request, db: Session = Depends(get_db)):
))
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=products&success=Standard-Sortiment geladen", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/setup/seed-test-data")
def seed_realistic_test_data(
request: Request,
event_id: Optional[int] = Form(None),
db: Session = Depends(get_db)
):
"""Spielt realistische Testdaten für das nächste Event ein."""
tenant = resolve_tenant(request, db)
require_admin(request, db)
# Event finden
event = None
if event_id:
event = db.query(Event).filter(Event.tenant_id == tenant.id, Event.id == event_id).first()
if not event:
event = db.query(Event).filter(Event.tenant_id == tenant.id, Event.status == "open").order_by(Event.event_date.asc()).first()
if not event:
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
if not event:
raise HTTPException(status_code=400, detail="Kein Event vorhanden")
# Produkte finden
products = {p.name: p for p in db.query(Product).filter(Product.tenant_id == tenant.id).all()}
if not products:
raise HTTPException(status_code=400, detail="Bitte zuerst Standard-Produkte anlegen")
# Sicherstellen, dass Basisprodukte aktiv sind
for p in products.values():
if not p.is_active:
p.is_active = True
test_users_data = [
{"email": "anna.huber@firma.de", "fname": "Anna", "lname": "Huber", "items": [("Weißwurst", 2), ("Breze", 1), ("Senf", 1)], "paid": True},
{"email": "stefan.meier@firma.de", "fname": "Stefan", "lname": "Meier", "items": [("Leberkas", 2), ("Bier", 1)], "paid": False},
{"email": "laura.wagner@firma.de", "fname": "Laura", "lname": "Wagner", "items": [("Breze", 1), ("Senf", 1), ("Weißwurst", 2)], "paid": True},
{"email": "michael.schmidt@firma.de", "fname": "Michael", "lname": "Schmidt", "items": [("Wiener", 2), ("Breze", 1)], "paid": False},
{"email": "christian.dienstleister@extern.de", "fname": "Christian", "lname": "Bauer (IT-Service)", "items": [("Weißwurst", 4), ("Breze", 2), ("Senf", 1)], "paid": True, "role": "guest"},
]
for tdata in test_users_data:
user = db.query(User).filter(User.tenant_id == tenant.id, User.email == tdata["email"]).first()
if not user:
user = User(
tenant_id=tenant.id,
email=tdata["email"],
username=tdata["email"].split("@")[0],
first_name=tdata["fname"],
last_name=tdata["lname"],
password_hash=hash_password("demo123"),
role=tdata.get("role", "user")
)
db.add(user)
db.flush()
# Bestellung prüfen
existing = db.query(Order).filter(Order.event_id == event.id, Order.user_id == user.id).first()
if not existing:
tot = Decimal("0.00")
new_ord = Order(
tenant_id=tenant.id,
event_id=event.id,
user_id=user.id,
guest_display_name=f"{user.first_name} {user.last_name}",
is_paid=tdata["paid"]
)
db.add(new_ord)
db.flush()
for key_term, qty in tdata["items"]:
prod = products.get(key_term)
if not prod:
term_lower = key_term.lower()
for p_name, p_obj in products.items():
if term_lower in p_name.lower():
prod = p_obj
break
if prod:
tot += prod.price * qty
db.add(OrderItem(order_id=new_ord.id, product_id=prod.id, quantity=qty, unit_price=prod.price))
new_ord.total_price = tot
db.commit()
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=orders&success=Realistische Testdaten erfolgreich eingespielt", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/toggle-paid/{order_id}")