feat: token-secured guest invites, product freeze lock during active events, Outlook ics export and template, realistic test data
This commit is contained in:
+201
-43
@@ -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}")
|
||||
|
||||
@@ -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")
|
||||
|
||||
+119
-7
@@ -93,7 +93,9 @@
|
||||
<div class="admin-actions-top">
|
||||
{% if event %}
|
||||
<button onclick="copyShoppingList()" class="btn-primary">📋 Einkaufsliste kopieren</button>
|
||||
<button onclick="copyGuestPassLink('{{ event.id }}')" class="btn-secondary" title="Gast-Link für Dienstleister">🔗 Dienstleister-Gastlink</button>
|
||||
<button onclick="copyGuestPassLink('{{ event.id }}', '{{ event.guest_token or '' }}')" class="btn-secondary" title="Gast-Link für Dienstleister">🔗 Dienstleister-Gastlink</button>
|
||||
<a href="/events/{{ event.id }}/calendar.ics" class="btn-secondary" title="Termin als Outlook/Kalenderdatei (.ics) herunterladen" style="text-decoration: none; display: inline-flex; align-items: center;">📅 Outlook (.ics)</a>
|
||||
<button onclick="openOutlookModal()" class="btn-secondary" title="Outlook Einladungsvorlage anzeigen">✉️ Outlook-Vorlage</button>
|
||||
<button onclick="window.print()" class="btn-secondary">🖨️ Liste drucken (A4)</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -105,6 +107,12 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert-warning-card" style="margin-bottom: 20px; border-color: #ef4444; background: rgba(239, 68, 68, 0.15); color: #fca5a5;">
|
||||
⚠️ {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if bulk_count %}
|
||||
<div class="alert-success" style="margin-bottom: 20px;">
|
||||
✓ Erfolgreich <strong>{{ bulk_count }}</strong> Einladungen generiert! Die Links sind unten aufgeführt.
|
||||
@@ -201,8 +209,18 @@
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Teilnehmer</div>
|
||||
<div class="stat-value">{{ orders|length }} Personen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if event and event.status == 'open' %}
|
||||
<div style="display: flex; justify-content: flex-end; margin-bottom: 16px;">
|
||||
<form action="/setup/seed-test-data" method="POST" style="margin: 0;" onsubmit="return confirm('Möchtest du 5 realistische Demo-Bestellungen für Kollegen und Gäste für diese Runde einfügen?');">
|
||||
<input type="hidden" name="event_id" value="{{ event.id }}">
|
||||
<button type="submit" class="btn-secondary" style="font-size: 0.85rem;" title="Erstellt Beispielbestellungen für Anna Huber, Stefan Meier, Michael Schmidt etc.">
|
||||
🧪 Realistische Demo-Bestellungen einfügen (5 Kollegen)
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="admin-columns">
|
||||
<!-- Aggregated Shopping List -->
|
||||
@@ -442,7 +460,12 @@
|
||||
<tbody>
|
||||
{% for prod in products %}
|
||||
<tr class="{% if not prod.is_active %}row-inactive{% endif %}">
|
||||
<td><strong>{{ prod.name }}</strong></td>
|
||||
<td>
|
||||
<strong>{{ prod.name }}</strong>
|
||||
{% if prod.id in locked_product_ids %}
|
||||
<span class="badge-role" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b; margin-left: 6px;" title="Für die offene Runde bestellt. Preis und Deaktivierung gesichert.">🔒 Gesichert</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><small class="text-muted">{{ prod.description }}</small></td>
|
||||
<td><strong>{{ "%.2f"|format(prod.price) }} €</strong></td>
|
||||
<td>{{ prod.unit }}</td>
|
||||
@@ -455,12 +478,16 @@
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button type="button" class="btn-copy-small" onclick="openEditProduct('{{ prod.id }}', '{{ prod.name|escape }}', '{{ prod.description|escape }}', '{{ '%.2f'|format(prod.price) }}', '{{ prod.unit }}', '{{ prod.sort_order }}')">✏️ Bearbeiten</button>
|
||||
<button type="button" class="btn-copy-small" onclick="openEditProduct('{{ prod.id }}', '{{ prod.name|escape }}', '{{ prod.description|escape }}', '{{ '%.2f'|format(prod.price) }}', '{{ prod.unit }}', '{{ prod.sort_order }}', {{ 'true' if prod.id in locked_product_ids else 'false' }})">✏️ Bearbeiten</button>
|
||||
{% if prod.id in locked_product_ids and prod.is_active %}
|
||||
<button type="button" class="btn-revoke-small" style="opacity: 0.5; cursor: not-allowed; color: #fca5a5;" title="In der laufenden Runde bereits bestellt – Deaktivierung gesperrt" onclick="alert('Dieser Artikel wurde in der laufenden Runde bereits bestellt und kann daher erst nach Abschluss des Events deaktiviert werden.')">Deaktivieren</button>
|
||||
{% else %}
|
||||
<form action="/admin/products/{{ prod.id }}/toggle" method="POST" style="margin: 0; display: inline;">
|
||||
<button type="submit" class="btn-revoke-small" style="color: {% if prod.is_active %}#fca5a5{% else %}#86efac{% endif %};">
|
||||
{% if prod.is_active %}Deaktivieren{% else %}Aktivieren{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -509,6 +536,9 @@
|
||||
<div id="editProductModal" class="modal-backdrop" style="display: none;">
|
||||
<div class="modal-card">
|
||||
<h3>✏️ Produkt bearbeiten</h3>
|
||||
<div id="productLockedNotice" class="alert-warning-card" style="display: none; padding: 10px 14px; margin-top: 10px; margin-bottom: 10px; font-size: 0.85rem; border-color: rgba(245, 158, 11, 0.4);">
|
||||
🔒 <strong>Artikel gesichert:</strong> Dieses Produkt wurde für eine offene Runde bereits bestellt. Der Preis ist eingefroren und kann erst nach Abschluss des Events geändert werden.
|
||||
</div>
|
||||
<form id="editProductForm" method="POST" class="admin-form" style="margin-top: 14px;">
|
||||
<div class="input-group">
|
||||
<label for="m_prod_name">Artikelname</label>
|
||||
@@ -729,6 +759,52 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Outlook Template Modal (Global für alle Tabs verfügbar) -->
|
||||
<div id="outlookModal" class="modal-backdrop" style="display: none;">
|
||||
<div class="modal-card" style="max-width: 640px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h3 style="margin: 0;">✉️ Outlook-Einladungsvorlage</h3>
|
||||
<button type="button" class="btn-revoke-small" onclick="closeOutlookModal()" style="font-size: 1rem; padding: 2px 8px;">✕</button>
|
||||
</div>
|
||||
<p class="card-hint">Kopiere diese Vorlage direkt in Outlook oder öffne dein Mailprogramm per Klick:</p>
|
||||
|
||||
<div class="input-group" style="margin-bottom: 12px;">
|
||||
<label for="outlookSubject">Betreff</label>
|
||||
<input type="text" id="outlookSubject" readonly value="🥨 Einladung: {{ event.title if event else 'Brotzeitrunde' }} ({{ event.event_date.strftime('%d.%m.%Y') if event else '' }})" style="width: 100%;">
|
||||
</div>
|
||||
|
||||
<div class="input-group" style="margin-bottom: 16px;">
|
||||
<label for="outlookBody">E-Mail-Text (inkl. Bestelllinks)</label>
|
||||
<textarea id="outlookBody" rows="11" readonly style="width: 100%; padding: 12px; font-family: inherit; font-size: 0.85rem; line-height: 1.5; background: rgba(11, 15, 25, 0.8); border: 1px solid var(--border-color); border-radius: var(--radius-md); color: #fff; resize: vertical;">{% if event %}Hallo zusammen,
|
||||
|
||||
wir machen wieder eine gemeinsame Brotzeitrunde am {{ event.event_date.strftime('%d.%m.%Y') }}!
|
||||
|
||||
🥨 Bitte tragt eure Bestellung bis spätestens {{ event.deadline.strftime('%d.%m.%Y um %H:%M Uhr') }} im Brotzeit-Portal ein:
|
||||
👉 https://demo.dev.waiswuaschd.de/?event_id={{ event.id }}
|
||||
|
||||
{% if event.allow_guests and event.guest_token %}
|
||||
Für externe Dienstleister & Gäste ohne Firmenkonto:
|
||||
🔗 Gast-Bestelllink: https://demo.dev.waiswuaschd.de/?event_id={{ event.id }}&token={{ event.guest_token }}
|
||||
{% endif %}
|
||||
{% if event.sponsor_name %}
|
||||
🎉 Gute Nachrichten: Die Runde wird heute von {{ event.sponsor_name }} spendiert!
|
||||
{% endif %}
|
||||
|
||||
Viele Grüße,
|
||||
{{ current_user.first_name }} {{ current_user.last_name }}{% endif %}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<button type="button" class="btn-primary" onclick="copyOutlookTemplate()">📋 Text kopieren</button>
|
||||
<button type="button" class="btn-secondary" onclick="openMailtoClient()">✉️ In Outlook öffnen</button>
|
||||
{% if event %}
|
||||
<a href="/events/{{ event.id }}/calendar.ics" class="btn-secondary" style="text-decoration: none;">📅 .ics Kalenderdatei</a>
|
||||
{% endif %}
|
||||
<button type="button" class="btn-secondary" onclick="closeOutlookModal()" style="margin-left: auto;">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -753,14 +829,35 @@
|
||||
});
|
||||
}
|
||||
|
||||
function copyGuestPassLink(eventId) {
|
||||
function copyGuestPassLink(eventId, guestToken) {
|
||||
const origin = window.location.origin;
|
||||
const url = origin + '/?event_id=' + eventId + '&guest=1';
|
||||
const url = origin + '/?event_id=' + eventId + (guestToken ? '&token=' + guestToken : '');
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
alert("Dienstleister-Gastlink kopiert!\n\n" + url + "\n\nExterne können damit für diesen Termin mitbestellen, ohne ein Konto anzulegen.");
|
||||
});
|
||||
}
|
||||
|
||||
function openOutlookModal() {
|
||||
document.getElementById('outlookModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeOutlookModal() {
|
||||
document.getElementById('outlookModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function copyOutlookTemplate() {
|
||||
const body = document.getElementById('outlookBody').value;
|
||||
navigator.clipboard.writeText(body).then(() => {
|
||||
alert("Outlook-Einladungstext in die Zwischenablage kopiert! Kann direkt in Outlook eingefügt werden.");
|
||||
});
|
||||
}
|
||||
|
||||
function openMailtoClient() {
|
||||
const subject = encodeURIComponent(document.getElementById('outlookSubject').value);
|
||||
const body = encodeURIComponent(document.getElementById('outlookBody').value);
|
||||
window.location.href = `mailto:?subject=${subject}&body=${body}`;
|
||||
}
|
||||
|
||||
function copyAllInviteLinks() {
|
||||
const origin = window.location.origin;
|
||||
const items = document.querySelectorAll('#invitationsListContainer .invite-item');
|
||||
@@ -798,7 +895,7 @@
|
||||
document.getElementById('new_pw_input').value = pw;
|
||||
}
|
||||
|
||||
function openEditProduct(id, name, desc, price, unit, sort) {
|
||||
function openEditProduct(id, name, desc, price, unit, sort, isLocked) {
|
||||
const modal = document.getElementById('editProductModal');
|
||||
const form = document.getElementById('editProductForm');
|
||||
form.action = '/admin/products/' + id + '/edit';
|
||||
@@ -807,6 +904,21 @@
|
||||
document.getElementById('m_prod_price').value = price;
|
||||
document.getElementById('m_prod_unit').value = unit;
|
||||
document.getElementById('m_prod_sort').value = sort;
|
||||
|
||||
const notice = document.getElementById('productLockedNotice');
|
||||
const priceInput = document.getElementById('m_prod_price');
|
||||
if (isLocked) {
|
||||
if (notice) notice.style.display = 'block';
|
||||
priceInput.readOnly = true;
|
||||
priceInput.title = 'Preis ist gesichert (bereits bestellt)';
|
||||
priceInput.style.opacity = '0.7';
|
||||
} else {
|
||||
if (notice) notice.style.display = 'none';
|
||||
priceInput.readOnly = false;
|
||||
priceInput.title = '';
|
||||
priceInput.style.opacity = '1';
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<span>📅 Weitere offene Termine:</span>
|
||||
<div class="multi-event-chips">
|
||||
{% for ev in open_events %}
|
||||
<a href="/?event_id={{ ev.id }}{% if is_guest_mode %}&guest=1{% endif %}" class="mini-chip {% if event and event.id == ev.id %}active{% endif %}">
|
||||
<a href="/?event_id={{ ev.id }}{% if is_valid_guest %}&token={{ guest_token }}{% endif %}" class="mini-chip {% if event and event.id == ev.id %}active{% endif %}">
|
||||
{{ ev.event_date.strftime('%d.%m.') }} – {{ ev.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
@@ -56,6 +56,7 @@
|
||||
<!-- Order Form -->
|
||||
<form action="/order" method="POST" class="order-form" id="orderForm">
|
||||
<input type="hidden" name="event_id" value="{{ event.id }}">
|
||||
<input type="hidden" name="guest_token" value="{{ guest_token }}">
|
||||
|
||||
<div class="form-grid">
|
||||
<!-- Left: Products -->
|
||||
@@ -92,7 +93,7 @@
|
||||
<div class="summary-card">
|
||||
<h2>2. Angaben & Übersicht</h2>
|
||||
|
||||
{% if current_user and not is_guest_mode %}
|
||||
{% if current_user %}
|
||||
<div class="logged-in-badge">
|
||||
<div class="user-avatar">👤</div>
|
||||
<div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="success-card">
|
||||
<div class="success-icon">🎉</div>
|
||||
<h1>Bestellung erfolgreich erfasst!</h1>
|
||||
<p class="success-sub">Vielen Dank, <strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong>!</p>
|
||||
<p class="success-sub">Vielen Dank, <strong>{{ order.display_name }}</strong>!</p>
|
||||
<p>Deine Brotzeit für <strong>{{ order.event.title }}</strong> ist fest reserviert.</p>
|
||||
|
||||
<div class="order-receipt">
|
||||
@@ -24,9 +24,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="success-actions">
|
||||
<div class="success-actions" style="display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-top: 24px;">
|
||||
<a href="/" class="btn-primary">Zurück zur Übersicht</a>
|
||||
<a href="/admin" class="btn-outline">Zum Einkäufer-Cockpit</a>
|
||||
<a href="/events/{{ order.event_id }}/calendar.ics" class="btn-secondary" style="text-decoration: none; display: inline-flex; align-items: center;">📅 Zu Outlook (.ics) hinzufügen</a>
|
||||
<a href="/admin" class="btn-secondary" style="text-decoration: none;">Zum Einkäufer-Cockpit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user