diff --git a/app/main.py b/app/main.py index c35b4f0..30b334f 100644 --- a/app/main.py +++ b/app/main.py @@ -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}") diff --git a/app/models.py b/app/models.py index cf86ac9..471e364 100644 --- a/app/models.py +++ b/app/models.py @@ -81,6 +81,7 @@ class Event(Base): status = Column(String(20), default="open") # 'open', 'closed', 'completed' allow_guests = Column(Boolean, default=True, nullable=False) sponsor_name = Column(String(150), nullable=True) + guest_token = Column(String(64), nullable=True) created_at = Column(DateTime, default=func.now()) tenant = relationship("Tenant", back_populates="events") diff --git a/app/templates/admin.html b/app/templates/admin.html index b2ab48d..65cfe75 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -93,7 +93,9 @@