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 re
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List, Set
|
||||||
from decimal import Decimal
|
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.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -68,6 +68,19 @@ def require_admin(request: Request, db: Session) -> User:
|
|||||||
return current_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):
|
def seed_demo_users_if_needed(tenant: Tenant, db: Session):
|
||||||
"""Stellt sicher, dass die Standard-Accounts für die Demo existieren."""
|
"""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()
|
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)
|
@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)
|
tenant = resolve_tenant(request, db)
|
||||||
if not tenant:
|
if not tenant:
|
||||||
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
|
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:
|
if not event and open_events:
|
||||||
event = open_events[0]
|
event = open_events[0]
|
||||||
if not event:
|
if not event:
|
||||||
# Neuestes Event überhaupt anzeigen
|
|
||||||
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
|
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
|
||||||
|
|
||||||
# Gast-Modus
|
# Sicherer Gast-Check: Nur wenn token übereinstimmt, ist Gast-Modus aktiv
|
||||||
is_guest_mode = (request.query_params.get("guest") == "1")
|
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
|
# Wenn nicht eingeloggt und kein gültiger Gast-Token -> Redirect zu Login
|
||||||
if not current_user:
|
if not current_user and not is_valid_guest:
|
||||||
if not event or not event.allow_guests:
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
# Fristprüfung & Countdown
|
# Fristprüfung & Countdown
|
||||||
is_closed = True
|
is_closed = True
|
||||||
@@ -168,7 +181,8 @@ def index(request: Request, event_id: Optional[int] = None, db: Session = Depend
|
|||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"event": event,
|
"event": event,
|
||||||
"open_events": open_events,
|
"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,
|
"is_closed": is_closed,
|
||||||
"seconds_remaining": seconds_remaining,
|
"seconds_remaining": seconds_remaining,
|
||||||
"products": products,
|
"products": products,
|
||||||
@@ -189,19 +203,10 @@ def login_page(request: Request, db: Session = Depends(get_db), error: Optional[
|
|||||||
if current_user:
|
if current_user:
|
||||||
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
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={
|
return templates.TemplateResponse(request, "login.html", context={
|
||||||
"tenant": tenant,
|
"tenant": tenant,
|
||||||
"error": error,
|
"error": error,
|
||||||
"success": success,
|
"success": success,
|
||||||
"allow_guests": allow_guests,
|
|
||||||
"active_event": active_event,
|
|
||||||
"current_user": None
|
"current_user": None
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -221,8 +226,7 @@ def login(
|
|||||||
return templates.TemplateResponse(request, "login.html", context={
|
return templates.TemplateResponse(request, "login.html", context={
|
||||||
"tenant": tenant,
|
"tenant": tenant,
|
||||||
"error": "Ungültige E-Mail-Adresse oder falsches Passwort.",
|
"error": "Ungültige E-Mail-Adresse oder falsches Passwort.",
|
||||||
"current_user": None,
|
"current_user": None
|
||||||
"allow_guests": False
|
|
||||||
}, status_code=400)
|
}, status_code=400)
|
||||||
|
|
||||||
request.session["user_id"] = user.id
|
request.session["user_id"] = user.id
|
||||||
@@ -356,6 +360,7 @@ def process_join(
|
|||||||
async def place_order(
|
async def place_order(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: int = Form(...),
|
event_id: int = Form(...),
|
||||||
|
guest_token: Optional[str] = Form(None),
|
||||||
guest_first_name: Optional[str] = Form(None),
|
guest_first_name: Optional[str] = Form(None),
|
||||||
guest_last_name: Optional[str] = Form(None),
|
guest_last_name: Optional[str] = Form(None),
|
||||||
guest_email: Optional[str] = Form(None),
|
guest_email: Optional[str] = Form(None),
|
||||||
@@ -373,8 +378,9 @@ async def place_order(
|
|||||||
|
|
||||||
guest_name_str = None
|
guest_name_str = None
|
||||||
if not current_user:
|
if not current_user:
|
||||||
if not event.allow_guests:
|
# Gast-Token prüfen
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
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():
|
if not guest_first_name or not guest_first_name.strip():
|
||||||
raise HTTPException(status_code=400, detail="Bitte gib als Gast deinen Namen an!")
|
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) ---
|
# --- ADMIN COCKPIT (/admin) ---
|
||||||
|
|
||||||
@app.get("/admin", response_class=HTMLResponse)
|
@app.get("/admin", response_class=HTMLResponse)
|
||||||
@@ -487,6 +536,7 @@ def admin_dashboard(
|
|||||||
tab: str = "orders",
|
tab: str = "orders",
|
||||||
warn_active_id: Optional[int] = None,
|
warn_active_id: Optional[int] = None,
|
||||||
success: Optional[str] = None,
|
success: Optional[str] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
bulk_count: Optional[int] = None,
|
bulk_count: Optional[int] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -503,13 +553,12 @@ def admin_dashboard(
|
|||||||
# Alle Events des Mandanten
|
# 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()
|
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
|
selected_event = None
|
||||||
if event_id:
|
if event_id:
|
||||||
selected_event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
selected_event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||||||
|
|
||||||
if not selected_event:
|
if not selected_event:
|
||||||
# Bevorzuge aktives offenes Event
|
|
||||||
for ev in all_events:
|
for ev in all_events:
|
||||||
if ev.status == "open":
|
if ev.status == "open":
|
||||||
selected_event = ev
|
selected_event = ev
|
||||||
@@ -536,7 +585,7 @@ def admin_dashboard(
|
|||||||
item_sums[pname] = {"quantity": 0, "unit": unit}
|
item_sums[pname] = {"quantity": 0, "unit": unit}
|
||||||
item_sums[pname]["quantity"] += it.quantity
|
item_sums[pname]["quantity"] += it.quantity
|
||||||
|
|
||||||
# Teammitglieder laden (Gäste filtern oder am Ende aufführen)
|
# Teammitglieder laden
|
||||||
team_members = db.query(User).filter(
|
team_members = db.query(User).filter(
|
||||||
User.tenant_id == tenant.id,
|
User.tenant_id == tenant.id,
|
||||||
User.role != "guest"
|
User.role != "guest"
|
||||||
@@ -558,6 +607,14 @@ def admin_dashboard(
|
|||||||
# Produkte laden
|
# Produkte laden
|
||||||
products = db.query(Product).filter(Product.tenant_id == tenant.id).order_by(Product.sort_order.asc(), Product.id.asc()).all()
|
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
|
# Warnung bei existierendem aktivem Event
|
||||||
active_warning_event = None
|
active_warning_event = None
|
||||||
if warn_active_id:
|
if warn_active_id:
|
||||||
@@ -576,9 +633,11 @@ def admin_dashboard(
|
|||||||
"active_guests": active_guests,
|
"active_guests": active_guests,
|
||||||
"pending_invitations": pending_invitations,
|
"pending_invitations": pending_invitations,
|
||||||
"products": products,
|
"products": products,
|
||||||
|
"locked_product_ids": locked_product_ids,
|
||||||
"active_tab": tab,
|
"active_tab": tab,
|
||||||
"warn_active_event": active_warning_event,
|
"warn_active_event": active_warning_event,
|
||||||
"success": success,
|
"success": success,
|
||||||
|
"error": error,
|
||||||
"bulk_count": bulk_count,
|
"bulk_count": bulk_count,
|
||||||
"now": now
|
"now": now
|
||||||
})
|
})
|
||||||
@@ -606,7 +665,7 @@ def admin_reset_password(
|
|||||||
user.password_hash = hash_password(new_password.strip())
|
user.password_hash = hash_password(new_password.strip())
|
||||||
db.commit()
|
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")
|
@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()
|
user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
|
||||||
if user:
|
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()
|
orders = db.query(Order).filter(Order.user_id == user.id).all()
|
||||||
for ord_obj in orders:
|
for ord_obj in orders:
|
||||||
ord_obj.guest_display_name = f"{user.first_name} {user.last_name}"
|
ord_obj.guest_display_name = f"{user.first_name} {user.last_name}"
|
||||||
@@ -631,10 +689,10 @@ def admin_delete_user(
|
|||||||
db.delete(user)
|
db.delete(user)
|
||||||
db.commit()
|
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")
|
@app.post("/admin/invitations/create")
|
||||||
def admin_create_invitation(
|
def admin_create_invitation(
|
||||||
@@ -684,7 +742,6 @@ def admin_bulk_invitations(
|
|||||||
tenant = resolve_tenant(request, db)
|
tenant = resolve_tenant(request, db)
|
||||||
admin = require_admin(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-.]+'
|
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)]))
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {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:
|
if not force:
|
||||||
active_existing = db.query(Event).filter(
|
active_existing = db.query(Event).filter(
|
||||||
Event.tenant_id == tenant.id,
|
Event.tenant_id == tenant.id,
|
||||||
@@ -766,7 +822,6 @@ def admin_create_event(
|
|||||||
Event.event_date >= date.today()
|
Event.event_date >= date.today()
|
||||||
).first()
|
).first()
|
||||||
if active_existing:
|
if active_existing:
|
||||||
# Weiterleitung mit Warn-Hinweis
|
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"/admin?tab=event&warn_active_id={active_existing.id}",
|
url=f"/admin?tab=event&warn_active_id={active_existing.id}",
|
||||||
status_code=status.HTTP_303_SEE_OTHER
|
status_code=status.HTTP_303_SEE_OTHER
|
||||||
@@ -779,7 +834,8 @@ def admin_create_event(
|
|||||||
deadline=deadline_dt,
|
deadline=deadline_dt,
|
||||||
status="open",
|
status="open",
|
||||||
sponsor_name=sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None,
|
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.add(new_event)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -821,9 +877,11 @@ def admin_edit_event(
|
|||||||
event.status = status_val
|
event.status = status_val
|
||||||
event.sponsor_name = sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None
|
event.sponsor_name = sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None
|
||||||
event.allow_guests = allow_guests
|
event.allow_guests = allow_guests
|
||||||
|
if not event.guest_token:
|
||||||
|
event.guest_token = secrets.token_urlsafe(16)
|
||||||
db.commit()
|
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")
|
@app.post("/admin/events/{event_id}/complete-and-cleanup")
|
||||||
@@ -842,7 +900,6 @@ def admin_complete_event_and_cleanup_guests(
|
|||||||
|
|
||||||
event.status = "completed"
|
event.status = "completed"
|
||||||
|
|
||||||
# Gast-Bestellungen sichern und Gast-Konten entfernen
|
|
||||||
orders = db.query(Order).filter(Order.event_id == event.id).all()
|
orders = db.query(Order).filter(Order.event_id == event.id).all()
|
||||||
cleaned_count = 0
|
cleaned_count = 0
|
||||||
for ord_obj in orders:
|
for ord_obj in orders:
|
||||||
@@ -872,10 +929,10 @@ def admin_delete_event(
|
|||||||
db.delete(event)
|
db.delete(event)
|
||||||
db.commit()
|
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")
|
@app.post("/admin/products/new")
|
||||||
def admin_create_product(
|
def admin_create_product(
|
||||||
@@ -907,7 +964,7 @@ def admin_create_product(
|
|||||||
db.add(product)
|
db.add(product)
|
||||||
db.commit()
|
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")
|
@app.post("/admin/products/{product_id}/edit")
|
||||||
@@ -933,6 +990,13 @@ def admin_edit_product(
|
|||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=400, detail="Ungültiger Preis")
|
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.name = name.strip()
|
||||||
prod.description = description.strip()
|
prod.description = description.strip()
|
||||||
prod.price = clean_price
|
prod.price = clean_price
|
||||||
@@ -940,7 +1004,7 @@ def admin_edit_product(
|
|||||||
prod.sort_order = sort_order
|
prod.sort_order = sort_order
|
||||||
db.commit()
|
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")
|
@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()
|
prod = db.query(Product).filter(Product.id == product_id, Product.tenant_id == tenant.id).first()
|
||||||
if prod:
|
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
|
prod.is_active = not prod.is_active
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
|
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)
|
@app.get("/setup", response_class=HTMLResponse)
|
||||||
def setup_page(request: Request, db: Session = Depends(get_db)):
|
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()
|
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}")
|
@app.post("/admin/toggle-paid/{order_id}")
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class Event(Base):
|
|||||||
status = Column(String(20), default="open") # 'open', 'closed', 'completed'
|
status = Column(String(20), default="open") # 'open', 'closed', 'completed'
|
||||||
allow_guests = Column(Boolean, default=True, nullable=False)
|
allow_guests = Column(Boolean, default=True, nullable=False)
|
||||||
sponsor_name = Column(String(150), nullable=True)
|
sponsor_name = Column(String(150), nullable=True)
|
||||||
|
guest_token = Column(String(64), nullable=True)
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
tenant = relationship("Tenant", back_populates="events")
|
tenant = relationship("Tenant", back_populates="events")
|
||||||
|
|||||||
+119
-7
@@ -93,7 +93,9 @@
|
|||||||
<div class="admin-actions-top">
|
<div class="admin-actions-top">
|
||||||
{% if event %}
|
{% if event %}
|
||||||
<button onclick="copyShoppingList()" class="btn-primary">📋 Einkaufsliste kopieren</button>
|
<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>
|
<button onclick="window.print()" class="btn-secondary">🖨️ Liste drucken (A4)</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -105,6 +107,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if bulk_count %}
|
||||||
<div class="alert-success" style="margin-bottom: 20px;">
|
<div class="alert-success" style="margin-bottom: 20px;">
|
||||||
✓ Erfolgreich <strong>{{ bulk_count }}</strong> Einladungen generiert! Die Links sind unten aufgeführt.
|
✓ Erfolgreich <strong>{{ bulk_count }}</strong> Einladungen generiert! Die Links sind unten aufgeführt.
|
||||||
@@ -201,9 +209,19 @@
|
|||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Teilnehmer</div>
|
<div class="stat-label">Teilnehmer</div>
|
||||||
<div class="stat-value">{{ orders|length }} Personen</div>
|
<div class="stat-value">{{ orders|length }} Personen</div>
|
||||||
</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">
|
<div class="admin-columns">
|
||||||
<!-- Aggregated Shopping List -->
|
<!-- Aggregated Shopping List -->
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
@@ -442,7 +460,12 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for prod in products %}
|
{% for prod in products %}
|
||||||
<tr class="{% if not prod.is_active %}row-inactive{% endif %}">
|
<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><small class="text-muted">{{ prod.description }}</small></td>
|
||||||
<td><strong>{{ "%.2f"|format(prod.price) }} €</strong></td>
|
<td><strong>{{ "%.2f"|format(prod.price) }} €</strong></td>
|
||||||
<td>{{ prod.unit }}</td>
|
<td>{{ prod.unit }}</td>
|
||||||
@@ -455,12 +478,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display: flex; gap: 6px;">
|
<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;">
|
<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 %};">
|
<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 %}
|
{% if prod.is_active %}Deaktivieren{% else %}Aktivieren{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -509,6 +536,9 @@
|
|||||||
<div id="editProductModal" class="modal-backdrop" style="display: none;">
|
<div id="editProductModal" class="modal-backdrop" style="display: none;">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<h3>✏️ Produkt bearbeiten</h3>
|
<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;">
|
<form id="editProductForm" method="POST" class="admin-form" style="margin-top: 14px;">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="m_prod_name">Artikelname</label>
|
<label for="m_prod_name">Artikelname</label>
|
||||||
@@ -729,6 +759,52 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -753,14 +829,35 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyGuestPassLink(eventId) {
|
function copyGuestPassLink(eventId, guestToken) {
|
||||||
const origin = window.location.origin;
|
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(() => {
|
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.");
|
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() {
|
function copyAllInviteLinks() {
|
||||||
const origin = window.location.origin;
|
const origin = window.location.origin;
|
||||||
const items = document.querySelectorAll('#invitationsListContainer .invite-item');
|
const items = document.querySelectorAll('#invitationsListContainer .invite-item');
|
||||||
@@ -798,7 +895,7 @@
|
|||||||
document.getElementById('new_pw_input').value = pw;
|
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 modal = document.getElementById('editProductModal');
|
||||||
const form = document.getElementById('editProductForm');
|
const form = document.getElementById('editProductForm');
|
||||||
form.action = '/admin/products/' + id + '/edit';
|
form.action = '/admin/products/' + id + '/edit';
|
||||||
@@ -807,6 +904,21 @@
|
|||||||
document.getElementById('m_prod_price').value = price;
|
document.getElementById('m_prod_price').value = price;
|
||||||
document.getElementById('m_prod_unit').value = unit;
|
document.getElementById('m_prod_unit').value = unit;
|
||||||
document.getElementById('m_prod_sort').value = sort;
|
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';
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<span>📅 Weitere offene Termine:</span>
|
<span>📅 Weitere offene Termine:</span>
|
||||||
<div class="multi-event-chips">
|
<div class="multi-event-chips">
|
||||||
{% for ev in open_events %}
|
{% 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 }}
|
{{ ev.event_date.strftime('%d.%m.') }} – {{ ev.title }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
<!-- Order Form -->
|
<!-- Order Form -->
|
||||||
<form action="/order" method="POST" class="order-form" id="orderForm">
|
<form action="/order" method="POST" class="order-form" id="orderForm">
|
||||||
<input type="hidden" name="event_id" value="{{ event.id }}">
|
<input type="hidden" name="event_id" value="{{ event.id }}">
|
||||||
|
<input type="hidden" name="guest_token" value="{{ guest_token }}">
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<!-- Left: Products -->
|
<!-- Left: Products -->
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
<h2>2. Angaben & Übersicht</h2>
|
<h2>2. Angaben & Übersicht</h2>
|
||||||
|
|
||||||
{% if current_user and not is_guest_mode %}
|
{% if current_user %}
|
||||||
<div class="logged-in-badge">
|
<div class="logged-in-badge">
|
||||||
<div class="user-avatar">👤</div>
|
<div class="user-avatar">👤</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="success-card">
|
<div class="success-card">
|
||||||
<div class="success-icon">🎉</div>
|
<div class="success-icon">🎉</div>
|
||||||
<h1>Bestellung erfolgreich erfasst!</h1>
|
<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>
|
<p>Deine Brotzeit für <strong>{{ order.event.title }}</strong> ist fest reserviert.</p>
|
||||||
|
|
||||||
<div class="order-receipt">
|
<div class="order-receipt">
|
||||||
@@ -24,9 +24,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="/" 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user