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:
+168
-32
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user