feat: Compact 1-page print view, event lifecycle & conflict warnings, bulk email invites, guest pass link and post-event guest cleanup

This commit is contained in:
2026-09-21 13:29:15 +02:00
parent e5ca9c3cbf
commit 913aa646ea
5 changed files with 741 additions and 263 deletions
+168 -32
View File
@@ -1,4 +1,5 @@
import os
import re
import secrets
from datetime import datetime, timedelta, date
from typing import Optional, Dict, List
@@ -98,7 +99,7 @@ def seed_demo_users_if_needed(tenant: Tenant, db: Session):
@app.get("/", response_class=HTMLResponse)
def index(request: Request, db: Session = Depends(get_db)):
def index(request: Request, event_id: Optional[int] = None, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db)
if not tenant:
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
@@ -106,16 +107,25 @@ def index(request: Request, db: Session = Depends(get_db)):
current_user = get_current_user(request, db)
now = datetime.now()
# Aktuelle offene Runde suchen
event = db.query(Event).filter(
# Alle offenen Events laden
open_events = db.query(Event).filter(
Event.tenant_id == tenant.id,
Event.status == "open"
).order_by(Event.deadline.asc()).first()
).order_by(Event.event_date.asc(), Event.deadline.asc()).all()
# Wenn keine offene, das neueste anzeigen
# Gewünschtes Event wählen
event = None
if event_id:
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
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")
# Wenn nicht eingeloggt und Event erlaubt keine Gäste -> Redirect zu Login
if not current_user:
if not event or not event.allow_guests:
@@ -157,6 +167,8 @@ def index(request: Request, db: Session = Depends(get_db)):
"tenant": tenant,
"current_user": current_user,
"event": event,
"open_events": open_events,
"is_guest_mode": is_guest_mode,
"is_closed": is_closed,
"seconds_remaining": seconds_remaining,
"products": products,
@@ -177,7 +189,6 @@ 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",
@@ -190,6 +201,7 @@ def login_page(request: Request, db: Session = Depends(get_db), error: Optional[
"error": error,
"success": success,
"allow_guests": allow_guests,
"active_event": active_event,
"current_user": None
})
@@ -328,7 +340,6 @@ def process_join(
)
db.add(new_user)
# Nur Einmal-Links als verwendet markieren; wiederverwendbare Links bleiben offen!
if not invitation.is_reusable:
invitation.used_at = datetime.now()
@@ -360,16 +371,17 @@ 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
guest_name_str = None
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!")
raise HTTPException(status_code=400, detail="Bitte gib als Gast deinen Namen an!")
fname = guest_first_name.strip()
lname = guest_last_name.strip() if guest_last_name else "(Gast)"
guest_name_str = f"{fname} {lname}"
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(
@@ -386,6 +398,8 @@ async def place_order(
db.refresh(guest_user)
current_user = guest_user
request.session["user_id"] = guest_user.id
else:
guest_name_str = f"{current_user.first_name} {current_user.last_name}"
form_data = await request.form()
order_items = []
@@ -411,7 +425,6 @@ async def place_order(
if not order_items:
raise HTTPException(status_code=400, detail="Bitte wähle mindestens einen Artikel aus!")
# Bestehende Bestellung des Users in diesem Event aktualisieren oder neu anlegen
existing_order = db.query(Order).filter(
Order.tenant_id == tenant.id,
Order.event_id == event.id,
@@ -421,6 +434,7 @@ async def place_order(
if existing_order:
db.query(OrderItem).filter(OrderItem.order_id == existing_order.id).delete()
existing_order.total_price = total_price
existing_order.guest_display_name = guest_name_str
for item in order_items:
item.order_id = existing_order.id
db.add(item)
@@ -431,6 +445,7 @@ async def place_order(
tenant_id=tenant.id,
event_id=event.id,
user_id=current_user.id,
guest_display_name=guest_name_str,
total_price=total_price,
is_paid=False
)
@@ -470,6 +485,9 @@ def admin_dashboard(
request: Request,
event_id: Optional[int] = None,
tab: str = "orders",
warn_active_id: Optional[int] = None,
success: Optional[str] = None,
bulk_count: Optional[int] = None,
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
@@ -483,14 +501,21 @@ def admin_dashboard(
raise HTTPException(status_code=403, detail="Zugriff nur für Administratoren")
# Alle Events des Mandanten
all_events = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).all()
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
# Ausgewähltes Event bestimmen: Wenn event_id übergeben, nehmen wir das, sonst das erste offene, sonst das neueste
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 and all_events:
selected_event = all_events[0]
if not selected_event:
# Bevorzuge aktives offenes Event
for ev in all_events:
if ev.status == "open":
selected_event = ev
break
if not selected_event and all_events:
selected_event = all_events[0]
orders = []
item_sums = {}
@@ -511,19 +536,33 @@ def admin_dashboard(
item_sums[pname] = {"quantity": 0, "unit": unit}
item_sums[pname]["quantity"] += it.quantity
# 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)
# Teammitglieder laden (Gäste filtern oder am Ende aufführen)
team_members = db.query(User).filter(
User.tenant_id == tenant.id,
User.role != "guest"
).order_by(User.role.asc(), User.first_name.asc()).all()
# Aktive Gäste laden
active_guests = db.query(User).filter(
User.tenant_id == tenant.id,
User.role == "guest"
).all()
# Offene Einladungen
now = datetime.now()
pending_invitations = db.query(Invitation).filter(
Invitation.tenant_id == tenant.id,
(Invitation.expires_at == None) | (Invitation.expires_at > now)
).order_by(Invitation.created_at.desc()).all()
# Produkte des Mandanten laden
# Produkte laden
products = db.query(Product).filter(Product.tenant_id == tenant.id).order_by(Product.sort_order.asc(), Product.id.asc()).all()
# Warnung bei existierendem aktivem Event
active_warning_event = None
if warn_active_id:
active_warning_event = db.query(Event).filter(Event.id == warn_active_id, Event.tenant_id == tenant.id).first()
return templates.TemplateResponse(request, "admin.html", context={
"tenant": tenant,
"current_user": current_user,
@@ -534,9 +573,13 @@ def admin_dashboard(
"total_revenue": total_revenue,
"total_paid": total_paid,
"team_members": team_members,
"active_guests": active_guests,
"pending_invitations": pending_invitations,
"products": products,
"active_tab": tab,
"warn_active_event": active_warning_event,
"success": success,
"bulk_count": bulk_count,
"now": now
})
@@ -580,20 +623,25 @@ 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}"
ord_obj.user_id = None
db.delete(user)
db.commit()
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN INVITATIONS ---
# --- ADMIN INVITATIONS (EINZELN & BULK) ---
@app.post("/admin/invitations/create")
def admin_create_invitation(
request: Request,
email: Optional[str] = Form(None),
role: str = Form("user"),
valid_days: str = Form("14"),
valid_days: str = Form("unlimited"),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
@@ -602,7 +650,6 @@ def admin_create_invitation(
token = secrets.token_urlsafe(20)
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
@@ -626,6 +673,50 @@ def admin_create_invitation(
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/invitations/bulk")
def admin_bulk_invitations(
request: Request,
emails_text: str = Form(...),
role: str = Form("user"),
valid_days: str = Form("14"),
db: Session = Depends(get_db)
):
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)]))
if not found_emails:
raise HTTPException(status_code=400, detail="Keine gültigen E-Mail-Adressen gefunden.")
expires_at = None
is_reusable = False
if valid_days in ["0", "unlimited", "forever"]:
is_reusable = True
else:
expires_at = datetime.now() + timedelta(days=int(valid_days))
count = 0
for em in found_emails:
token = secrets.token_urlsafe(20)
inv = Invitation(
tenant_id=tenant.id,
token=token,
email=em,
role=role,
created_by=admin.id,
expires_at=expires_at,
is_reusable=is_reusable
)
db.add(inv)
count += 1
db.commit()
return RedirectResponse(url=f"/admin?tab=team&bulk_count={count}", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/invitations/{inv_id}/revoke")
def admin_revoke_invitation(
inv_id: int,
@@ -654,6 +745,7 @@ def admin_create_event(
deadline_time: str = Form(...),
sponsor_name: Optional[str] = Form(None),
allow_guests: bool = Form(True),
force: bool = Form(False),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
@@ -666,6 +758,20 @@ 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,
Event.status == "open",
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
)
new_event = Event(
tenant_id=tenant.id,
title=title.strip(),
@@ -679,7 +785,7 @@ def admin_create_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)
return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=orders", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/events/{event_id}/edit")
@@ -720,8 +826,40 @@ def admin_edit_event(
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(
@app.post("/admin/events/{event_id}/complete-and-cleanup")
def admin_complete_event_and_cleanup_guests(
event_id: int,
request: Request,
db: Session = Depends(get_db)
):
"""Schließt das Event ab und bereinigt temporäre Gast-Konten."""
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 = "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:
if ord_obj.user and ord_obj.user.role == "guest":
ord_obj.guest_display_name = f"{ord_obj.user.first_name} {ord_obj.user.last_name}"
guest_user = ord_obj.user
ord_obj.user_id = None
db.delete(guest_user)
cleaned_count += 1
db.commit()
msg = f"Event erfolgreich abgeschlossen. {cleaned_count} Gastkonten bereinigt."
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=orders&success={msg}", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/events/{event_id}/delete")
def admin_delete_event(
event_id: int,
request: Request,
db: Session = Depends(get_db)
@@ -730,13 +868,11 @@ def admin_toggle_event_status(
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 event:
db.delete(event)
db.commit()
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)
return RedirectResponse(url="/admin?tab=event", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN PRODUCT CATALOG MANAGEMENT ---
@@ -885,7 +1021,7 @@ def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
if order:
order.is_paid = not order.is_paid
db.commit()
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url=f"/admin?event_id={order.event_id}", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/health")