import os import re import secrets from datetime import datetime, timedelta, date from typing import Optional, Dict, List from decimal import Decimal from fastapi import FastAPI, Request, Depends, HTTPException, Form, status from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware from sqlalchemy.orm import Session from .database import get_db from .models import Tenant, User, Product, Event, Order, OrderItem, Invitation from .auth import hash_password, verify_password app = FastAPI(title="Schmankerl – Das moderne Brotzeitportal") # Session Middleware für sichere Login-Cookies app.add_middleware( SessionMiddleware, secret_key=os.getenv("SECRET_KEY", "schmankerl-secret-key-waiswuaschd-2026!"), session_cookie="schmankerl_session", max_age=86400 * 30 # 30 Tage ) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static") templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) def resolve_tenant(request: Request, db: Session) -> Optional[Tenant]: """Ermittelt den Mandanten anhand der Subdomain (Host-Header).""" host = request.headers.get("host", "").split(":")[0].lower() parts = host.split(".") slug = "demo" if len(parts) >= 4 and parts[-3:] == ["waiswuaschd", "de", ""]: slug = parts[0] elif len(parts) >= 3 and parts[1] == "dev" and parts[2] == "waiswuaschd": slug = parts[0] elif parts and parts[0] not in ["localhost", "127", "dev"]: slug = parts[0] tenant = db.query(Tenant).filter(Tenant.slug == slug, Tenant.is_active == True).first() if not tenant: tenant = db.query(Tenant).filter(Tenant.slug == "demo").first() return tenant def get_current_user(request: Request, db: Session) -> Optional[User]: """Liest den eingeloggten Benutzer aus der Session.""" user_id = request.session.get("user_id") if not user_id: return None return db.query(User).filter(User.id == user_id, User.is_active == True).first() def require_admin(request: Request, db: Session) -> User: """Stellt sicher, dass ein Administrator eingeloggt ist.""" current_user = get_current_user(request, db) if not current_user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Anmeldung erforderlich") if current_user.role != "admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Nur für Administratoren zugänglich") return current_user 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() if not demo_user: demo_user = User( tenant_id=tenant.id, email="maxi@waiswuaschd.de", username="maxi", first_name="Maxi", last_name="Mustermann", password_hash=hash_password("demo123"), role="user" ) db.add(demo_user) admin_user = db.query(User).filter(User.tenant_id == tenant.id, User.email == "admin@waiswuaschd.de").first() if not admin_user: admin_user = User( tenant_id=tenant.id, email="admin@waiswuaschd.de", username="admin", first_name="Franz", last_name="Xaver", password_hash=hash_password("admin123"), role="admin" ) db.add(admin_user) db.commit() @app.get("/", response_class=HTMLResponse) 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={}) current_user = get_current_user(request, db) now = datetime.now() # Alle offenen Events laden open_events = db.query(Event).filter( Event.tenant_id == tenant.id, Event.status == "open" ).order_by(Event.event_date.asc(), Event.deadline.asc()).all() # 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: return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) # Fristprüfung & Countdown is_closed = True seconds_remaining = 0 if event: is_closed = (event.status != "open") or (event.deadline < now) if not is_closed: seconds_remaining = max(0, int((event.deadline - now).total_seconds())) # Alle aktiven Produkte des Mandanten products = db.query(Product).filter( Product.tenant_id == tenant.id, Product.is_active == True ).order_by(Product.sort_order.asc(), Product.id.asc()).all() # Bestehende Bestellung des Users in diesem Event existing_order = None user_items: Dict[int, int] = {} if event and current_user: existing_order = db.query(Order).filter( Order.tenant_id == tenant.id, Order.event_id == event.id, Order.user_id == current_user.id ).first() if existing_order: for it in existing_order.items: user_items[it.product_id] = it.quantity # Bestellungen insgesamt zählen order_count = 0 if event: order_count = db.query(Order).filter(Order.event_id == event.id).count() return templates.TemplateResponse(request, "index.html", context={ "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, "existing_order": existing_order, "user_items": user_items, "order_count": order_count, "now": now }) @app.get("/login", response_class=HTMLResponse) def login_page(request: Request, db: Session = Depends(get_db), error: Optional[str] = None, success: Optional[str] = None): tenant = resolve_tenant(request, db) if tenant and tenant.slug == "demo": seed_demo_users_if_needed(tenant, db) current_user = get_current_user(request, db) 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 }) @app.post("/login") def login( request: Request, email: str = Form(...), password: str = Form(...), db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) clean_email = email.strip().lower() user = db.query(User).filter(User.tenant_id == tenant.id, User.email == clean_email).first() if not user or not verify_password(password, user.password_hash): return templates.TemplateResponse(request, "login.html", context={ "tenant": tenant, "error": "Ungültige E-Mail-Adresse oder falsches Passwort.", "current_user": None, "allow_guests": False }, status_code=400) request.session["user_id"] = user.id return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) @app.post("/login/quick") def quick_demo_login( request: Request, role: str = Form(...), db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) if not tenant: raise HTTPException(status_code=404, detail="Mandant nicht gefunden") seed_demo_users_if_needed(tenant, db) target_email = "admin@waiswuaschd.de" if role == "admin" else "maxi@waiswuaschd.de" user = db.query(User).filter(User.tenant_id == tenant.id, User.email == target_email).first() if not user: raise HTTPException(status_code=500, detail="Demo-Benutzer konnte nicht geladen werden") request.session["user_id"] = user.id if role == "admin": return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) @app.get("/logout") def logout(request: Request): request.session.clear() return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) # --- EINLADUNGS- & BEITRITTS-FLOW (/join) --- @app.get("/join", response_class=HTMLResponse) def join_page(request: Request, token: str, db: Session = Depends(get_db)): tenant = resolve_tenant(request, db) if not tenant: raise HTTPException(status_code=404, detail="Mandant nicht gefunden") invitation = db.query(Invitation).filter( Invitation.token == token, Invitation.tenant_id == tenant.id ).first() error = None if not invitation: error = "Dieser Einladungslink ist ungültig oder gehört zu einem anderen Team." 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 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={ "tenant": tenant, "invitation": invitation, "token": token, "error": error, "current_user": None }) @app.post("/join") def process_join( request: Request, token: str = Form(...), first_name: str = Form(...), last_name: str = Form(...), email: str = Form(...), password: str = Form(...), db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) invitation = db.query(Invitation).filter( Invitation.token == token, Invitation.tenant_id == tenant.id ).first() 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( User.tenant_id == tenant.id, User.email == clean_email ).first() if existing_user: return templates.TemplateResponse(request, "join.html", context={ "tenant": tenant, "invitation": invitation, "token": token, "error": "Diese E-Mail-Adresse ist bereits im Team registriert. Bitte melde dich an.", "current_user": None }, status_code=400) username = clean_email.split("@")[0] new_user = User( tenant_id=tenant.id, email=clean_email, username=username, first_name=first_name.strip(), last_name=last_name.strip(), password_hash=hash_password(password), role=invitation.role ) db.add(new_user) if not invitation.is_reusable: invitation.used_at = datetime.now() db.commit() db.refresh(new_user) request.session["user_id"] = new_user.id return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) # --- BESTELLUNGEN (/order) --- @app.post("/order") 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) event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first() if not event or event.status != "open": raise HTTPException(status_code=400, detail="Diese Bestellrunde ist geschlossen.") if event.deadline < datetime.now(): raise HTTPException(status_code=400, detail="Die Bestellfrist für diese Runde ist leider abgelaufen!") 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 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( 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 else: guest_name_str = f"{current_user.first_name} {current_user.last_name}" form_data = await request.form() order_items = [] total_price = Decimal("0.00") products = db.query(Product).filter(Product.tenant_id == tenant.id, Product.is_active == True).all() for prod in products: qty_str = form_data.get(f"qty_{prod.id}", "0") try: qty = int(qty_str) except ValueError: qty = 0 if qty > 0: item_total = prod.price * qty total_price += item_total order_items.append(OrderItem( product_id=prod.id, quantity=qty, unit_price=prod.price )) if not order_items: raise HTTPException(status_code=400, detail="Bitte wähle mindestens einen Artikel aus!") existing_order = db.query(Order).filter( Order.tenant_id == tenant.id, Order.event_id == event.id, Order.user_id == current_user.id ).first() 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) db.commit() order_id = existing_order.id else: new_order = 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 ) db.add(new_order) db.flush() for item in order_items: item.order_id = new_order.id db.add(item) db.commit() order_id = new_order.id return RedirectResponse(url=f"/order/{order_id}/success", status_code=status.HTTP_303_SEE_OTHER) @app.get("/order/{order_id}/success", response_class=HTMLResponse) def order_success(order_id: int, request: Request, 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) order = db.query(Order).filter(Order.id == order_id, Order.tenant_id == tenant.id).first() if not order: raise HTTPException(status_code=404, detail="Bestellung nicht gefunden") return templates.TemplateResponse(request, "order_success.html", context={ "tenant": tenant, "current_user": current_user, "order": order }) # --- ADMIN COCKPIT (/admin) --- @app.get("/admin", response_class=HTMLResponse) 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) if not tenant: 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) if current_user.role != "admin": 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.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 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 break if not selected_event and all_events: selected_event = all_events[0] orders = [] item_sums = {} total_revenue = Decimal("0.00") total_paid = Decimal("0.00") if selected_event: orders = db.query(Order).filter(Order.event_id == selected_event.id).order_by(Order.created_at.asc()).all() for ord_obj in orders: total_revenue += ord_obj.total_price if ord_obj.is_paid: total_paid += ord_obj.total_price for it in ord_obj.items: pname = it.product.name if it.product else "Artikel" unit = it.product.unit if it.product else "Stk" if pname not in item_sums: item_sums[pname] = {"quantity": 0, "unit": unit} item_sums[pname]["quantity"] += it.quantity # 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 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, "all_events": all_events, "event": selected_event, "orders": orders, "item_sums": item_sums, "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 }) # --- ADMIN USER MANAGEMENT --- @app.post("/admin/users/{user_id}/reset-password") def admin_reset_password( user_id: int, request: Request, new_password: str = Form(...), db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) require_admin(request, db) 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") if not new_password or len(new_password.strip()) < 4: raise HTTPException(status_code=400, detail="Passwort muss mindestens 4 Zeichen lang sein") user.password_hash = hash_password(new_password.strip()) db.commit() return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER) @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) admin = require_admin(request, db) if admin.id == user_id: raise HTTPException(status_code=400, detail="Du kannst dein eigenes Admin-Konto nicht löschen") 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 (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("unlimited"), db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) admin = require_admin(request, db) token = secrets.token_urlsafe(20) clean_email = email.strip().lower() if email and email.strip() else None 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, is_reusable=is_reusable ) db.add(inv) db.commit() 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, request: Request, db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) require_admin(request, db) inv = db.query(Invitation).filter(Invitation.id == inv_id, Invitation.tenant_id == tenant.id).first() if inv: db.delete(inv) db.commit() 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), force: bool = Form(False), 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}") # 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(), 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=orders", 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}/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) ): 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 event: db.delete(event) db.commit() return RedirectResponse(url="/admin?tab=event", 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) if not current_user: return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) order = db.query(Order).filter(Order.id == order_id).first() if order: order.is_paid = not order.is_paid db.commit() return RedirectResponse(url=f"/admin?event_id={order.event_id}", status_code=status.HTTP_303_SEE_OTHER) @app.get("/health") def health(): return {"status": "ok", "app": "Schmankerl"}