1619 lines
58 KiB
Python
1619 lines
58 KiB
Python
import os
|
||
import re
|
||
import secrets
|
||
import urllib.parse
|
||
from datetime import datetime, timedelta, date
|
||
from typing import Optional, Dict, List, Set
|
||
from decimal import Decimal
|
||
|
||
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
|
||
from starlette.middleware.sessions import SessionMiddleware
|
||
from sqlalchemy.orm import Session
|
||
|
||
from .database import get_db
|
||
from .models import Tenant, User, Product, Event, Order, OrderItem, Invitation, BalanceTransaction
|
||
from .auth import hash_password, verify_password
|
||
|
||
app = FastAPI(title="Schmankerl – Das moderne Brotzeitportal")
|
||
|
||
# Session Middleware für sichere Login-Cookies
|
||
app.add_middleware(
|
||
SessionMiddleware,
|
||
secret_key=os.getenv("SECRET_KEY", "schmankerl-secret-key-waiswuaschd-2026!"),
|
||
session_cookie="schmankerl_session",
|
||
max_age=86400 * 30 # 30 Tage
|
||
)
|
||
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
|
||
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
|
||
|
||
|
||
def resolve_tenant(request: Request, db: Session) -> Tenant:
|
||
host = request.headers.get("host", "").split(":")[0].lower()
|
||
subdomain = host.split(".")[0]
|
||
|
||
tenant = db.query(Tenant).filter(Tenant.subdomain == host, Tenant.is_active == True).first()
|
||
if not tenant:
|
||
tenant = db.query(Tenant).filter(Tenant.slug == subdomain, Tenant.is_active == True).first()
|
||
if not tenant:
|
||
tenant = db.query(Tenant).filter(Tenant.slug == "demo", Tenant.is_active == True).first()
|
||
if not tenant:
|
||
tenant = db.query(Tenant).first()
|
||
return tenant
|
||
|
||
|
||
def get_current_user(request: Request, db: Session) -> Optional[User]:
|
||
"""Liest den eingeloggten Benutzer aus der Session."""
|
||
user_id = request.session.get("user_id")
|
||
if not user_id:
|
||
return None
|
||
return db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||
|
||
|
||
def require_admin(request: Request, db: Session) -> User:
|
||
"""Stellt sicher, dass ein Administrator eingeloggt ist."""
|
||
current_user = get_current_user(request, db)
|
||
if not current_user:
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Anmeldung erforderlich")
|
||
if current_user.role != "admin":
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Nur für Administratoren zugänglich")
|
||
return current_user
|
||
|
||
|
||
def require_organizer_or_admin(request: Request, db: Session) -> User:
|
||
"""Stellt sicher, dass ein Organisator oder Administrator eingeloggt ist."""
|
||
current_user = get_current_user(request, db)
|
||
if not current_user:
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Anmeldung erforderlich")
|
||
if current_user.role not in ["admin", "organizer"]:
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Nur für Organisatoren und Administratoren zugänglich")
|
||
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()
|
||
if not demo_user:
|
||
demo_user = User(
|
||
tenant_id=tenant.id,
|
||
email="maxi@waiswuaschd.de",
|
||
username="maxi",
|
||
first_name="Maxi",
|
||
last_name="Mustermann",
|
||
password_hash=hash_password("demo123"),
|
||
role="user"
|
||
)
|
||
db.add(demo_user)
|
||
|
||
orga_user = db.query(User).filter(User.tenant_id == tenant.id, User.email == "orga@waiswuaschd.de").first()
|
||
if not orga_user:
|
||
orga_user = User(
|
||
tenant_id=tenant.id,
|
||
email="orga@waiswuaschd.de",
|
||
username="orga",
|
||
first_name="Monika",
|
||
last_name="Gruber",
|
||
password_hash=hash_password("demo123"),
|
||
role="organizer"
|
||
)
|
||
db.add(orga_user)
|
||
|
||
admin_user = db.query(User).filter(User.tenant_id == tenant.id, User.email == "admin@waiswuaschd.de").first()
|
||
if not admin_user:
|
||
admin_user = User(
|
||
tenant_id=tenant.id,
|
||
email="admin@waiswuaschd.de",
|
||
username="admin",
|
||
first_name="Franz",
|
||
last_name="Xaver",
|
||
password_hash=hash_password("admin123"),
|
||
role="admin"
|
||
)
|
||
db.add(admin_user)
|
||
db.commit()
|
||
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
def index(
|
||
request: Request,
|
||
event_id: Optional[int] = None,
|
||
token: Optional[str] = None,
|
||
success: Optional[str] = None,
|
||
error: 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={})
|
||
|
||
current_user = get_current_user(request, db)
|
||
now = datetime.now()
|
||
|
||
# Alle offenen Events laden
|
||
open_events = db.query(Event).filter(
|
||
Event.tenant_id == tenant.id,
|
||
Event.status == "open"
|
||
).order_by(Event.event_date.asc(), Event.deadline.asc()).all()
|
||
|
||
# Gewünschtes Event wählen
|
||
event = None
|
||
if event_id:
|
||
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||
if not event and open_events:
|
||
event = open_events[0]
|
||
if not event:
|
||
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
|
||
|
||
# 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 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
|
||
seconds_remaining = 0
|
||
if event:
|
||
is_closed = (event.status != "open") or (event.deadline < now)
|
||
if not is_closed:
|
||
seconds_remaining = max(0, int((event.deadline - now).total_seconds()))
|
||
|
||
# Alle aktiven Produkte des Mandanten
|
||
products = db.query(Product).filter(
|
||
Product.tenant_id == tenant.id,
|
||
Product.is_active == True
|
||
).order_by(Product.sort_order.asc(), Product.id.asc()).all()
|
||
|
||
# Bestehende Bestellung des Users in diesem Event
|
||
existing_order = None
|
||
user_items: Dict[int, int] = {}
|
||
if event and current_user:
|
||
existing_order = db.query(Order).filter(
|
||
Order.tenant_id == tenant.id,
|
||
Order.event_id == event.id,
|
||
Order.user_id == current_user.id
|
||
).first()
|
||
if existing_order:
|
||
for it in existing_order.items:
|
||
user_items[it.product_id] = it.quantity
|
||
|
||
# Bestellungen insgesamt zählen
|
||
order_count = 0
|
||
if event:
|
||
order_count = db.query(Order).filter(Order.event_id == event.id).count()
|
||
|
||
return templates.TemplateResponse(request, "index.html", context={
|
||
"tenant": tenant,
|
||
"current_user": current_user,
|
||
"event": event,
|
||
"open_events": open_events,
|
||
"is_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,
|
||
"existing_order": existing_order,
|
||
"user_items": user_items,
|
||
"order_count": order_count,
|
||
"success": success,
|
||
"error": error,
|
||
"now": now
|
||
})
|
||
|
||
|
||
@app.get("/login", response_class=HTMLResponse)
|
||
def login_page(request: Request, db: Session = Depends(get_db), error: Optional[str] = None, success: Optional[str] = None):
|
||
tenant = resolve_tenant(request, db)
|
||
if tenant and tenant.slug == "demo":
|
||
seed_demo_users_if_needed(tenant, db)
|
||
|
||
current_user = get_current_user(request, db)
|
||
if current_user:
|
||
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
return templates.TemplateResponse(request, "login.html", context={
|
||
"tenant": tenant,
|
||
"error": error,
|
||
"success": success,
|
||
"current_user": None
|
||
})
|
||
|
||
|
||
@app.post("/login")
|
||
def login(
|
||
request: Request,
|
||
email: str = Form(...),
|
||
password: str = Form(...),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
clean_email = email.strip().lower()
|
||
|
||
user = db.query(User).filter(User.tenant_id == tenant.id, User.email == clean_email).first()
|
||
if not user or not verify_password(password, user.password_hash):
|
||
return templates.TemplateResponse(request, "login.html", context={
|
||
"tenant": tenant,
|
||
"error": "Ungültige E-Mail-Adresse oder falsches Passwort.",
|
||
"current_user": None
|
||
}, status_code=400)
|
||
|
||
request.session["user_id"] = user.id
|
||
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/login/quick")
|
||
def quick_demo_login(
|
||
request: Request,
|
||
role: str = Form(...),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
if not tenant:
|
||
raise HTTPException(status_code=404, detail="Mandant nicht gefunden")
|
||
|
||
seed_demo_users_if_needed(tenant, db)
|
||
|
||
target_email = "admin@waiswuaschd.de" if role == "admin" else "maxi@waiswuaschd.de"
|
||
user = db.query(User).filter(User.tenant_id == tenant.id, User.email == target_email).first()
|
||
if not user:
|
||
raise HTTPException(status_code=500, detail="Demo-Benutzer konnte nicht geladen werden")
|
||
|
||
request.session["user_id"] = user.id
|
||
if role == "admin":
|
||
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.get("/logout")
|
||
def logout(request: Request):
|
||
request.session.clear()
|
||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
# --- EINLADUNGS- & BEITRITTS-FLOW (/join) ---
|
||
|
||
@app.get("/join", response_class=HTMLResponse)
|
||
def join_page(request: Request, token: str, db: Session = Depends(get_db)):
|
||
tenant = resolve_tenant(request, db)
|
||
if not tenant:
|
||
raise HTTPException(status_code=404, detail="Mandant nicht gefunden")
|
||
|
||
invitation = db.query(Invitation).filter(
|
||
Invitation.token == token,
|
||
Invitation.tenant_id == tenant.id
|
||
).first()
|
||
|
||
error = None
|
||
if not invitation:
|
||
error = "Dieser Einladungslink ist ungültig oder gehört zu einem anderen Team."
|
||
elif not invitation.is_reusable and invitation.used_at is not None:
|
||
error = "Dieser Einladungslink wurde bereits eingelöst. Bitte melde dich mit deinem Konto an."
|
||
elif invitation.expires_at and invitation.expires_at < datetime.now():
|
||
error = "Dieser Einladungslink ist leider abgelaufen. Bitte frage deinen Admin nach einem neuen Link."
|
||
|
||
return templates.TemplateResponse(request, "join.html", context={
|
||
"tenant": tenant,
|
||
"invitation": invitation,
|
||
"token": token,
|
||
"error": error,
|
||
"current_user": None
|
||
})
|
||
|
||
|
||
@app.post("/join")
|
||
def process_join(
|
||
request: Request,
|
||
token: str = Form(...),
|
||
first_name: str = Form(...),
|
||
last_name: str = Form(...),
|
||
email: str = Form(...),
|
||
password: str = Form(...),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
invitation = db.query(Invitation).filter(
|
||
Invitation.token == token,
|
||
Invitation.tenant_id == tenant.id
|
||
).first()
|
||
|
||
if not invitation:
|
||
raise HTTPException(status_code=400, detail="Ungültiger Einladungslink")
|
||
|
||
if not invitation.is_reusable and invitation.used_at is not None:
|
||
raise HTTPException(status_code=400, detail="Einladungslink wurde bereits verwendet")
|
||
|
||
if invitation.expires_at and invitation.expires_at < datetime.now():
|
||
raise HTTPException(status_code=400, detail="Einladungslink ist abgelaufen")
|
||
|
||
clean_email = email.strip().lower()
|
||
existing_user = db.query(User).filter(
|
||
User.tenant_id == tenant.id,
|
||
User.email == clean_email
|
||
).first()
|
||
|
||
if existing_user:
|
||
return templates.TemplateResponse(request, "join.html", context={
|
||
"tenant": tenant,
|
||
"invitation": invitation,
|
||
"token": token,
|
||
"error": "Diese E-Mail-Adresse ist bereits im Team registriert. Bitte melde dich an.",
|
||
"current_user": None
|
||
}, status_code=400)
|
||
|
||
username = clean_email.split("@")[0]
|
||
new_user = User(
|
||
tenant_id=tenant.id,
|
||
email=clean_email,
|
||
username=username,
|
||
first_name=first_name.strip(),
|
||
last_name=last_name.strip(),
|
||
password_hash=hash_password(password),
|
||
role=invitation.role
|
||
)
|
||
db.add(new_user)
|
||
|
||
if not invitation.is_reusable:
|
||
invitation.used_at = datetime.now()
|
||
|
||
db.commit()
|
||
db.refresh(new_user)
|
||
|
||
request.session["user_id"] = new_user.id
|
||
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
# --- BESTELLUNGEN (/order) ---
|
||
|
||
@app.post("/order")
|
||
async def place_order(
|
||
request: Request,
|
||
event_id: int = Form(...),
|
||
guest_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),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
current_user = get_current_user(request, db)
|
||
|
||
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||
if not event or event.status != "open":
|
||
raise HTTPException(status_code=400, detail="Diese Bestellrunde ist geschlossen.")
|
||
|
||
if event.deadline < datetime.now():
|
||
raise HTTPException(status_code=400, detail="Die Bestellfrist für diese Runde ist leider abgelaufen!")
|
||
|
||
guest_name_str = None
|
||
if not current_user:
|
||
# 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!")
|
||
|
||
fname = guest_first_name.strip()
|
||
lname = guest_last_name.strip() if guest_last_name else "(Gast)"
|
||
guest_name_str = f"{fname} {lname}"
|
||
clean_email = (guest_email.strip().lower() if guest_email and guest_email.strip() else f"gast_{secrets.token_hex(4)}@{tenant.slug}.local")
|
||
|
||
guest_user = User(
|
||
tenant_id=tenant.id,
|
||
email=clean_email,
|
||
username=f"gast_{secrets.token_hex(4)}",
|
||
first_name=fname,
|
||
last_name=lname,
|
||
role="guest",
|
||
password_hash=""
|
||
)
|
||
db.add(guest_user)
|
||
db.commit()
|
||
db.refresh(guest_user)
|
||
current_user = guest_user
|
||
request.session["user_id"] = guest_user.id
|
||
else:
|
||
guest_name_str = f"{current_user.first_name} {current_user.last_name}"
|
||
|
||
form_data = await request.form()
|
||
order_items = []
|
||
total_price = Decimal("0.00")
|
||
|
||
products = db.query(Product).filter(Product.tenant_id == tenant.id, Product.is_active == True).all()
|
||
for prod in products:
|
||
qty_str = form_data.get(f"qty_{prod.id}", "0")
|
||
try:
|
||
qty = int(qty_str)
|
||
except ValueError:
|
||
qty = 0
|
||
|
||
if qty > 0:
|
||
item_total = prod.price * qty
|
||
total_price += item_total
|
||
order_items.append(OrderItem(
|
||
product_id=prod.id,
|
||
quantity=qty,
|
||
unit_price=prod.price
|
||
))
|
||
|
||
if not order_items:
|
||
raise HTTPException(status_code=400, detail="Bitte wähle mindestens einen Artikel aus!")
|
||
|
||
existing_order = db.query(Order).filter(
|
||
Order.tenant_id == tenant.id,
|
||
Order.event_id == event.id,
|
||
Order.user_id == current_user.id
|
||
).first()
|
||
|
||
if existing_order:
|
||
if existing_order.is_paid:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Diese Bestellung wurde bereits als bezahlt verbucht und kann nicht mehr geändert werden. Bitte wende dich an deinen Organisator."
|
||
)
|
||
db.query(OrderItem).filter(OrderItem.order_id == existing_order.id).delete()
|
||
existing_order.total_price = total_price
|
||
existing_order.guest_display_name = guest_name_str
|
||
for item in order_items:
|
||
item.order_id = existing_order.id
|
||
db.add(item)
|
||
db.commit()
|
||
order_id = existing_order.id
|
||
else:
|
||
new_order = Order(
|
||
tenant_id=tenant.id,
|
||
event_id=event.id,
|
||
user_id=current_user.id,
|
||
guest_display_name=guest_name_str,
|
||
total_price=total_price,
|
||
is_paid=False
|
||
)
|
||
db.add(new_order)
|
||
db.flush()
|
||
for item in order_items:
|
||
item.order_id = new_order.id
|
||
db.add(item)
|
||
db.commit()
|
||
order_id = new_order.id
|
||
|
||
return RedirectResponse(url=f"/order/{order_id}/success", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/order/cancel")
|
||
def cancel_order(
|
||
request: Request,
|
||
event_id: int = Form(...),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
current_user = get_current_user(request, db)
|
||
if not current_user:
|
||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event nicht gefunden")
|
||
|
||
if event.status != "open" or event.deadline < datetime.now():
|
||
raise HTTPException(status_code=400, detail="Die Frist ist abgelaufen. Bestellungen können nicht mehr storniert werden.")
|
||
|
||
order = db.query(Order).filter(
|
||
Order.tenant_id == tenant.id,
|
||
Order.event_id == event.id,
|
||
Order.user_id == current_user.id
|
||
).first()
|
||
|
||
if not order:
|
||
return RedirectResponse(url=f"/?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
if order.is_paid:
|
||
raise HTTPException(status_code=400, detail="Bereits bezahlte Bestellungen können nur vom Organisator storniert werden.")
|
||
|
||
db.delete(order)
|
||
db.commit()
|
||
|
||
success_msg = urllib.parse.quote("Deine Bestellung wurde erfolgreich storniert")
|
||
return RedirectResponse(url=f"/?event_id={event.id}&success={success_msg}", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.get("/order/{order_id}/success", response_class=HTMLResponse)
|
||
def order_success(order_id: int, request: Request, db: Session = Depends(get_db)):
|
||
tenant = resolve_tenant(request, db)
|
||
current_user = get_current_user(request, db)
|
||
if not current_user:
|
||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
order = db.query(Order).filter(Order.id == order_id, Order.tenant_id == tenant.id).first()
|
||
if not order:
|
||
raise HTTPException(status_code=404, detail="Bestellung nicht gefunden")
|
||
|
||
return templates.TemplateResponse(request, "order_success.html", context={
|
||
"tenant": tenant,
|
||
"current_user": current_user,
|
||
"order": order
|
||
})
|
||
|
||
|
||
# --- 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"'}
|
||
)
|
||
|
||
|
||
# --- EINKAUFS- & KASSEN-ZENTRALE (/einkauf) ---
|
||
|
||
@app.get("/einkauf", response_class=HTMLResponse)
|
||
def buyer_dashboard(
|
||
request: Request,
|
||
event_id: Optional[int] = None,
|
||
success: Optional[str] = None,
|
||
error: 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={})
|
||
|
||
current_user = get_current_user(request, db)
|
||
if not current_user:
|
||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||
if current_user.role == "guest":
|
||
raise HTTPException(status_code=403, detail="Gäste haben keinen Zugriff auf das Einkäufer-Cockpit")
|
||
|
||
# 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:
|
||
# 1. Prio: explizit übergebenes event_id
|
||
# 2. Prio: Event, bei dem current_user als buyer zugewiesen ist und status == 'open'
|
||
# 3. Prio: erstes offenes Event
|
||
# 4. Prio: neuestes Event
|
||
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:
|
||
for ev in all_events:
|
||
if ev.buyer_id == current_user.id and ev.status == "open":
|
||
selected_event = ev
|
||
break
|
||
if not selected_event:
|
||
for ev in all_events:
|
||
if ev.status == "open":
|
||
selected_event = ev
|
||
break
|
||
if not selected_event and all_events:
|
||
selected_event = all_events[0]
|
||
|
||
orders = []
|
||
item_sums = {}
|
||
total_revenue = Decimal("0.00")
|
||
total_paid = Decimal("0.00")
|
||
|
||
if selected_event:
|
||
orders = db.query(Order).filter(Order.event_id == selected_event.id).order_by(Order.created_at.asc()).all()
|
||
for ord_obj in orders:
|
||
total_revenue += ord_obj.total_price
|
||
if ord_obj.is_paid:
|
||
total_paid += ord_obj.total_price
|
||
|
||
for it in ord_obj.items:
|
||
pname = it.product.name if it.product else "Artikel"
|
||
unit = it.product.unit if it.product else "Stk"
|
||
if pname not in item_sums:
|
||
item_sums[pname] = {"quantity": 0, "unit": unit}
|
||
item_sums[pname]["quantity"] += it.quantity
|
||
|
||
return templates.TemplateResponse(request, "buyer.html", context={
|
||
"tenant": tenant,
|
||
"current_user": current_user,
|
||
"event": selected_event,
|
||
"all_events": all_events,
|
||
"orders": orders,
|
||
"item_sums": item_sums,
|
||
"total_revenue": total_revenue,
|
||
"total_paid": total_paid,
|
||
"success": success,
|
||
"error": error,
|
||
})
|
||
|
||
|
||
# --- ADMIN COCKPIT (/admin) ---
|
||
|
||
@app.get("/admin", response_class=HTMLResponse)
|
||
def admin_dashboard(
|
||
request: Request,
|
||
event_id: Optional[int] = None,
|
||
tab: str = "orders",
|
||
warn_active_id: Optional[int] = None,
|
||
success: Optional[str] = None,
|
||
error: Optional[str] = None,
|
||
bulk_count: Optional[int] = None,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
if not tenant:
|
||
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
|
||
|
||
current_user = get_current_user(request, db)
|
||
if not current_user:
|
||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||
if current_user.role not in ["admin", "organizer"]:
|
||
raise HTTPException(status_code=403, detail="Zugriff nur für Administratoren und Organisatoren")
|
||
|
||
# Tab-Aliase unterstützen
|
||
if tab == "events":
|
||
tab = "event"
|
||
elif tab == "invitations":
|
||
tab = "team"
|
||
|
||
# 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
|
||
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:
|
||
for ev in all_events:
|
||
if ev.status == "open":
|
||
selected_event = ev
|
||
break
|
||
if not selected_event and all_events:
|
||
selected_event = all_events[0]
|
||
|
||
orders = []
|
||
item_sums = {}
|
||
total_revenue = Decimal("0.00")
|
||
total_paid = Decimal("0.00")
|
||
|
||
if selected_event:
|
||
orders = db.query(Order).filter(Order.event_id == selected_event.id).order_by(Order.created_at.asc()).all()
|
||
for ord_obj in orders:
|
||
total_revenue += ord_obj.total_price
|
||
if ord_obj.is_paid:
|
||
total_paid += ord_obj.total_price
|
||
|
||
for it in ord_obj.items:
|
||
pname = it.product.name if it.product else "Artikel"
|
||
unit = it.product.unit if it.product else "Stk"
|
||
if pname not in item_sums:
|
||
item_sums[pname] = {"quantity": 0, "unit": unit}
|
||
item_sums[pname]["quantity"] += it.quantity
|
||
|
||
# Teammitglieder laden
|
||
team_members = db.query(User).filter(
|
||
User.tenant_id == tenant.id,
|
||
User.role != "guest"
|
||
).order_by(User.role.asc(), User.first_name.asc()).all()
|
||
|
||
# Aktive Gäste laden
|
||
active_guests = db.query(User).filter(
|
||
User.tenant_id == tenant.id,
|
||
User.role == "guest"
|
||
).all()
|
||
|
||
# Offene Einladungen
|
||
now = datetime.now()
|
||
pending_invitations = db.query(Invitation).filter(
|
||
Invitation.tenant_id == tenant.id,
|
||
(Invitation.expires_at == None) | (Invitation.expires_at > now)
|
||
).order_by(Invitation.created_at.desc()).all()
|
||
|
||
# Produkte laden
|
||
products = db.query(Product).filter(Product.tenant_id == tenant.id).order_by(Product.sort_order.asc(), Product.id.asc()).all()
|
||
|
||
# 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:
|
||
active_warning_event = db.query(Event).filter(Event.id == warn_active_id, Event.tenant_id == tenant.id).first()
|
||
|
||
return templates.TemplateResponse(request, "admin.html", context={
|
||
"tenant": tenant,
|
||
"current_user": current_user,
|
||
"all_events": all_events,
|
||
"event": selected_event,
|
||
"orders": orders,
|
||
"item_sums": item_sums,
|
||
"total_revenue": total_revenue,
|
||
"total_paid": total_paid,
|
||
"team_members": team_members,
|
||
"active_guests": active_guests,
|
||
"pending_invitations": pending_invitations,
|
||
"products": products,
|
||
"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
|
||
})
|
||
|
||
|
||
# --- ADMIN USER MANAGEMENT ---
|
||
|
||
@app.post("/admin/users/{user_id}/reset-password")
|
||
def admin_reset_password(
|
||
user_id: int,
|
||
request: Request,
|
||
new_password: str = Form(...),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
require_admin(request, db)
|
||
|
||
user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
|
||
|
||
if not new_password or len(new_password.strip()) < 4:
|
||
raise HTTPException(status_code=400, detail="Passwort muss mindestens 4 Zeichen lang sein")
|
||
|
||
user.password_hash = hash_password(new_password.strip())
|
||
db.commit()
|
||
|
||
return RedirectResponse(url="/admin?tab=team&success=Passwort erfolgreich aktualisiert", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/admin/users/{user_id}/delete")
|
||
def admin_delete_user(
|
||
user_id: int,
|
||
request: Request,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
admin = require_admin(request, db)
|
||
|
||
if admin.id == user_id:
|
||
raise HTTPException(status_code=400, detail="Du kannst dein eigenes Admin-Konto nicht löschen")
|
||
|
||
user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
|
||
if user:
|
||
orders = db.query(Order).filter(Order.user_id == user.id).all()
|
||
for ord_obj in orders:
|
||
ord_obj.guest_display_name = f"{user.first_name} {user.last_name}"
|
||
ord_obj.user_id = None
|
||
db.delete(user)
|
||
db.commit()
|
||
|
||
return RedirectResponse(url="/admin?tab=team&success=Benutzer gelöscht", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/admin/users/{user_id}/role")
|
||
def admin_change_user_role(
|
||
user_id: int,
|
||
request: Request,
|
||
role: str = Form(...),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
admin = require_admin(request, db)
|
||
|
||
if admin.id == user_id:
|
||
raise HTTPException(status_code=400, detail="Du kannst deine eigene Rolle nicht ändern")
|
||
|
||
if role not in ["user", "organizer", "admin"]:
|
||
raise HTTPException(status_code=400, detail="Ungültige Rolle")
|
||
|
||
user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
|
||
|
||
user.role = role
|
||
db.commit()
|
||
|
||
return RedirectResponse(url="/admin?tab=team&success=Rolle erfolgreich geändert", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
# --- ADMIN INVITATIONS ---
|
||
|
||
@app.post("/admin/invitations/create")
|
||
def admin_create_invitation(
|
||
request: Request,
|
||
email: Optional[str] = Form(None),
|
||
role: str = Form("user"),
|
||
valid_days: str = Form("unlimited"),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
actor = require_organizer_or_admin(request, db)
|
||
|
||
# Sicherheit: Organisatoren können keine Admin-Rolle vergeben
|
||
if actor.role != "admin" and role == "admin":
|
||
role = "user"
|
||
|
||
token = secrets.token_urlsafe(20)
|
||
clean_email = email.strip().lower() if email and email.strip() else None
|
||
|
||
if valid_days in ["0", "unlimited", "forever"]:
|
||
expires_at = None
|
||
is_reusable = True
|
||
else:
|
||
days = int(valid_days)
|
||
expires_at = datetime.now() + timedelta(days=days)
|
||
is_reusable = False
|
||
|
||
inv = Invitation(
|
||
tenant_id=tenant.id,
|
||
token=token,
|
||
email=clean_email,
|
||
role=role,
|
||
created_by=actor.id,
|
||
expires_at=expires_at,
|
||
is_reusable=is_reusable
|
||
)
|
||
db.add(inv)
|
||
db.commit()
|
||
|
||
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/admin/invitations/bulk")
|
||
def admin_bulk_invitations(
|
||
request: Request,
|
||
emails_text: str = Form(...),
|
||
role: str = Form("user"),
|
||
valid_days: str = Form("14"),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
actor = require_organizer_or_admin(request, db)
|
||
|
||
# Sicherheit: Organisatoren können keine Admin-Rolle vergeben
|
||
if actor.role != "admin" and role == "admin":
|
||
role = "user"
|
||
|
||
email_pattern = r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+'
|
||
found_emails = list(set([e.lower().strip() for e in re.findall(email_pattern, emails_text)]))
|
||
|
||
if not found_emails:
|
||
raise HTTPException(status_code=400, detail="Keine gültigen E-Mail-Adressen gefunden.")
|
||
|
||
expires_at = None
|
||
is_reusable = False
|
||
if valid_days in ["0", "unlimited", "forever"]:
|
||
is_reusable = True
|
||
else:
|
||
expires_at = datetime.now() + timedelta(days=int(valid_days))
|
||
|
||
count = 0
|
||
for em in found_emails:
|
||
token = secrets.token_urlsafe(20)
|
||
inv = Invitation(
|
||
tenant_id=tenant.id,
|
||
token=token,
|
||
email=em,
|
||
role=role,
|
||
created_by=actor.id,
|
||
expires_at=expires_at,
|
||
is_reusable=is_reusable
|
||
)
|
||
db.add(inv)
|
||
count += 1
|
||
|
||
db.commit()
|
||
return RedirectResponse(url=f"/admin?tab=team&bulk_count={count}", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/admin/invitations/{inv_id}/revoke")
|
||
def admin_revoke_invitation(
|
||
inv_id: int,
|
||
request: Request,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
require_organizer_or_admin(request, db)
|
||
|
||
inv = db.query(Invitation).filter(Invitation.id == inv_id, Invitation.tenant_id == tenant.id).first()
|
||
if inv:
|
||
db.delete(inv)
|
||
db.commit()
|
||
|
||
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
# --- ADMIN EVENT MANAGEMENT ---
|
||
|
||
@app.post("/admin/events/new")
|
||
def admin_create_event(
|
||
request: Request,
|
||
title: str = Form(...),
|
||
event_date: str = Form(...),
|
||
deadline_date: str = Form(...),
|
||
deadline_time: str = Form(...),
|
||
sponsor_name: Optional[str] = Form(None),
|
||
buyer_id: Optional[str] = Form(None),
|
||
allow_guests: bool = Form(True),
|
||
force: bool = Form(False),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
require_organizer_or_admin(request, db)
|
||
|
||
try:
|
||
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
|
||
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}"
|
||
deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M")
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
|
||
|
||
if not force:
|
||
active_existing = db.query(Event).filter(
|
||
Event.tenant_id == tenant.id,
|
||
Event.status == "open",
|
||
Event.event_date >= date.today()
|
||
).first()
|
||
if active_existing:
|
||
return RedirectResponse(
|
||
url=f"/admin?tab=event&warn_active_id={active_existing.id}",
|
||
status_code=status.HTTP_303_SEE_OTHER
|
||
)
|
||
|
||
buyer_id_int: Optional[int] = None
|
||
if buyer_id and buyer_id.strip():
|
||
try:
|
||
buyer_id_int = int(buyer_id.strip())
|
||
except ValueError:
|
||
pass
|
||
|
||
new_event = Event(
|
||
tenant_id=tenant.id,
|
||
title=title.strip(),
|
||
event_date=ev_date,
|
||
deadline=deadline_dt,
|
||
status="open",
|
||
sponsor_name=sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None,
|
||
buyer_id=buyer_id_int,
|
||
allow_guests=allow_guests,
|
||
guest_token=secrets.token_urlsafe(16)
|
||
)
|
||
db.add(new_event)
|
||
db.commit()
|
||
db.refresh(new_event)
|
||
|
||
return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=orders", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/admin/events/{event_id}/edit")
|
||
def admin_edit_event(
|
||
event_id: int,
|
||
request: Request,
|
||
title: str = Form(...),
|
||
event_date: str = Form(...),
|
||
deadline_date: str = Form(...),
|
||
deadline_time: str = Form(...),
|
||
status_val: str = Form(...),
|
||
sponsor_name: Optional[str] = Form(None),
|
||
buyer_id: Optional[str] = Form(None),
|
||
allow_guests: bool = Form(False),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
require_organizer_or_admin(request, db)
|
||
|
||
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event nicht gefunden")
|
||
|
||
try:
|
||
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
|
||
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}"
|
||
deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M")
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
|
||
|
||
buyer_id_int: Optional[int] = None
|
||
if buyer_id and buyer_id.strip():
|
||
try:
|
||
buyer_id_int = int(buyer_id.strip())
|
||
except ValueError:
|
||
pass
|
||
|
||
event.title = title.strip()
|
||
event.event_date = ev_date
|
||
event.deadline = deadline_dt
|
||
event.status = status_val
|
||
event.sponsor_name = sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None
|
||
event.buyer_id = buyer_id_int
|
||
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&success=Event aktualisiert", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/admin/events/{event_id}/complete-and-cleanup")
|
||
def admin_complete_event_and_cleanup_guests(
|
||
event_id: int,
|
||
request: Request,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""Schließt das Event ab und bereinigt temporäre Gast-Konten."""
|
||
tenant = resolve_tenant(request, db)
|
||
require_organizer_or_admin(request, db)
|
||
|
||
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event nicht gefunden")
|
||
|
||
event.status = "completed"
|
||
|
||
orders = db.query(Order).filter(Order.event_id == event.id).all()
|
||
cleaned_count = 0
|
||
for ord_obj in orders:
|
||
if ord_obj.user and ord_obj.user.role == "guest":
|
||
ord_obj.guest_display_name = f"{ord_obj.user.first_name} {ord_obj.user.last_name}"
|
||
guest_user = ord_obj.user
|
||
ord_obj.user_id = None
|
||
db.delete(guest_user)
|
||
cleaned_count += 1
|
||
|
||
db.commit()
|
||
msg = f"Event erfolgreich abgeschlossen. {cleaned_count} Gastkonten bereinigt."
|
||
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=orders&success={msg}", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/admin/events/{event_id}/delete")
|
||
def admin_delete_event(
|
||
event_id: int,
|
||
request: Request,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
require_organizer_or_admin(request, db)
|
||
|
||
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||
if event:
|
||
db.delete(event)
|
||
db.commit()
|
||
|
||
return RedirectResponse(url="/admin?tab=event&success=Event gelöscht", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
# --- ADMIN PRODUCT CATALOG MANAGEMENT (MIT SICHERUNGSSPERRE) ---
|
||
|
||
@app.post("/admin/products/new")
|
||
def admin_create_product(
|
||
request: Request,
|
||
name: str = Form(...),
|
||
description: str = Form(""),
|
||
price: str = Form(...),
|
||
unit: str = Form("Stück"),
|
||
sort_order: int = Form(0),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
require_organizer_or_admin(request, db)
|
||
|
||
try:
|
||
clean_price = Decimal(price.strip().replace(",", "."))
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="Ungültiger Preis")
|
||
|
||
product = Product(
|
||
tenant_id=tenant.id,
|
||
name=name.strip(),
|
||
description=description.strip(),
|
||
price=clean_price,
|
||
unit=unit.strip(),
|
||
sort_order=sort_order,
|
||
is_active=True
|
||
)
|
||
db.add(product)
|
||
db.commit()
|
||
|
||
return RedirectResponse(url="/admin?tab=products&success=Produkt angelegt", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/admin/products/{product_id}/edit")
|
||
def admin_edit_product(
|
||
product_id: int,
|
||
request: Request,
|
||
name: str = Form(...),
|
||
description: str = Form(""),
|
||
price: str = Form(...),
|
||
unit: str = Form("Stück"),
|
||
sort_order: int = Form(0),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
require_organizer_or_admin(request, db)
|
||
|
||
prod = db.query(Product).filter(Product.id == product_id, Product.tenant_id == tenant.id).first()
|
||
if not prod:
|
||
raise HTTPException(status_code=404, detail="Produkt nicht gefunden")
|
||
|
||
try:
|
||
clean_price = Decimal(price.strip().replace(",", "."))
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="Ungültiger Preis")
|
||
|
||
# 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
|
||
prod.unit = unit.strip()
|
||
prod.sort_order = sort_order
|
||
db.commit()
|
||
|
||
return RedirectResponse(url="/admin?tab=products&success=Produkt aktualisiert", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/admin/products/{product_id}/toggle")
|
||
def admin_toggle_product(
|
||
product_id: int,
|
||
request: Request,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
require_organizer_or_admin(request, db)
|
||
|
||
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 & TESTDATEN ---
|
||
|
||
@app.get("/setup", response_class=HTMLResponse)
|
||
def setup_page(request: Request, db: Session = Depends(get_db)):
|
||
tenant = resolve_tenant(request, db)
|
||
if not tenant:
|
||
raise HTTPException(status_code=404, detail="Mandant nicht gefunden")
|
||
|
||
current_user = get_current_user(request, db)
|
||
prod_count = db.query(Product).filter(Product.tenant_id == tenant.id).count()
|
||
user_count = db.query(User).filter(User.tenant_id == tenant.id).count()
|
||
|
||
return templates.TemplateResponse(request, "setup.html", context={
|
||
"tenant": tenant,
|
||
"current_user": current_user,
|
||
"prod_count": prod_count,
|
||
"user_count": user_count
|
||
})
|
||
|
||
|
||
@app.post("/setup/seed-products")
|
||
def seed_standard_products(request: Request, db: Session = Depends(get_db)):
|
||
tenant = resolve_tenant(request, db)
|
||
require_admin(request, db)
|
||
|
||
standard_items = [
|
||
{"name": "Original Münchner Weißwürste", "desc": "Frisch vom Metzger, traditionell hergestellt", "price": Decimal("1.40"), "unit": "Stück", "order": 1},
|
||
{"name": "Frische Laugenbreze", "desc": "Knusprig gebacken, ofenfrisch vom Bäcker", "price": Decimal("1.10"), "unit": "Stück", "order": 2},
|
||
{"name": "Händlmaier Hausmachersenf", "desc": "Original bayerischer süßer Hausmachersenf", "price": Decimal("0.40"), "unit": "Portion", "order": 3},
|
||
{"name": "Leberkassemmel (warm)", "desc": "Feiner bayerischer Leberkäse in der Kaisersemmel", "price": Decimal("2.80"), "unit": "Semmel", "order": 4},
|
||
{"name": "Wiener Würstchen", "desc": "Knackige Wiener Würstchen (Paar)", "price": Decimal("2.60"), "unit": "Paar", "order": 5},
|
||
{"name": "Alkoholfreies Weißbier (0,5l)", "desc": "Erfrischendes bayerisches Hefe-Weißbier alkoholfrei", "price": Decimal("1.80"), "unit": "Flasche", "order": 6},
|
||
]
|
||
|
||
for item in standard_items:
|
||
exists = db.query(Product).filter(Product.tenant_id == tenant.id, Product.name == item["name"]).first()
|
||
if not exists:
|
||
db.add(Product(
|
||
tenant_id=tenant.id,
|
||
name=item["name"],
|
||
description=item["desc"],
|
||
price=item["price"],
|
||
unit=item["unit"],
|
||
sort_order=item["order"],
|
||
is_active=True
|
||
))
|
||
db.commit()
|
||
|
||
return RedirectResponse(url="/admin?tab=products&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)
|
||
|
||
|
||
# --- KASSENSYSTEM & GUTHABEN ---
|
||
|
||
@app.get("/kasse", response_class=HTMLResponse)
|
||
def kasse_dashboard(
|
||
request: Request,
|
||
success: Optional[str] = None,
|
||
error: 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={})
|
||
|
||
current_user = get_current_user(request, db)
|
||
if not current_user:
|
||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
# Persönliche Transaktionen des aktuellen Nutzers
|
||
user_transactions = db.query(BalanceTransaction).filter(
|
||
BalanceTransaction.tenant_id == tenant.id,
|
||
BalanceTransaction.user_id == current_user.id
|
||
).order_by(BalanceTransaction.created_at.desc()).limit(100).all()
|
||
|
||
# Für Kassenwarte / Admins / Organisatoren: Teamübersicht & Gesamthistorie
|
||
team_members = []
|
||
all_transactions = []
|
||
total_team_balance = Decimal("0.00")
|
||
positive_users_count = 0
|
||
|
||
if current_user.role in ["admin", "organizer"]:
|
||
team_members = db.query(User).filter(
|
||
User.tenant_id == tenant.id,
|
||
User.role.in_(["user", "organizer", "admin"])
|
||
).order_by(User.first_name.asc(), User.last_name.asc()).all()
|
||
|
||
for tm in team_members:
|
||
if tm.balance > 0:
|
||
positive_users_count += 1
|
||
total_team_balance += tm.balance
|
||
|
||
all_transactions = db.query(BalanceTransaction).filter(
|
||
BalanceTransaction.tenant_id == tenant.id
|
||
).order_by(BalanceTransaction.created_at.desc()).limit(200).all()
|
||
|
||
return templates.TemplateResponse(request, "kasse.html", context={
|
||
"tenant": tenant,
|
||
"current_user": current_user,
|
||
"team_members": team_members,
|
||
"user_transactions": user_transactions,
|
||
"all_transactions": all_transactions,
|
||
"total_team_balance": total_team_balance,
|
||
"positive_users_count": positive_users_count,
|
||
"success": success,
|
||
"error": error
|
||
})
|
||
|
||
|
||
@app.post("/kasse/deposit")
|
||
def kasse_deposit(
|
||
request: Request,
|
||
user_id: int = Form(...),
|
||
amount: str = Form(...),
|
||
description: Optional[str] = Form("Barzahlung"),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
actor = require_organizer_or_admin(request, db)
|
||
|
||
try:
|
||
clean_amount = Decimal(amount.strip().replace(",", "."))
|
||
if clean_amount <= 0:
|
||
raise ValueError()
|
||
except Exception:
|
||
return RedirectResponse(url="/kasse?error=Ungültiger Betrag", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
target_user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
|
||
if not target_user:
|
||
return RedirectResponse(url="/kasse?error=Mitarbeiter nicht gefunden", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
# Guthaben aufbuchen
|
||
target_user.balance += clean_amount
|
||
|
||
# Transaktion revisionssicher protokollieren
|
||
tx = BalanceTransaction(
|
||
tenant_id=tenant.id,
|
||
user_id=target_user.id,
|
||
amount=clean_amount,
|
||
transaction_type="deposit",
|
||
description=description.strip() if description and description.strip() else "Einzahlung",
|
||
created_by_id=actor.id
|
||
)
|
||
db.add(tx)
|
||
db.commit()
|
||
|
||
return RedirectResponse(
|
||
url=f"/kasse?success={clean_amount:.2f} € erfolgreich für {target_user.first_name} {target_user.last_name} gutgeschrieben",
|
||
status_code=status.HTTP_303_SEE_OTHER
|
||
)
|
||
|
||
|
||
@app.post("/kasse/charge-order/{order_id}")
|
||
def kasse_charge_order(
|
||
order_id: int,
|
||
request: Request,
|
||
method: str = "balance",
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
actor = get_current_user(request, db)
|
||
if not actor:
|
||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
order = db.query(Order).filter(Order.id == order_id, Order.tenant_id == tenant.id).first()
|
||
if not order:
|
||
raise HTTPException(status_code=404, detail="Bestellung nicht gefunden")
|
||
|
||
is_buyer = order.event and order.event.buyer_id == actor.id
|
||
if actor.role not in ["admin", "organizer"] and not is_buyer:
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung zur Kassenverwaltung")
|
||
|
||
ev_id = order.event_id
|
||
|
||
if method == "balance":
|
||
if not order.user:
|
||
return RedirectResponse(
|
||
url=f"/einkauf?event_id={ev_id}&error=Gäste ohne Konto können nicht per Guthaben abbuchen",
|
||
status_code=status.HTTP_303_SEE_OTHER
|
||
)
|
||
if order.user.balance < order.total_price:
|
||
return RedirectResponse(
|
||
url=f"/einkauf?event_id={ev_id}&error=Nicht genügend Guthaben vorhanden ({order.user.balance:.2f} €)",
|
||
status_code=status.HTTP_303_SEE_OTHER
|
||
)
|
||
|
||
# Guthaben abbuchen
|
||
order.user.balance -= order.total_price
|
||
order.is_paid = True
|
||
order.payment_method = "balance"
|
||
|
||
event = order.event
|
||
ev_title = event.title if event else "Brotzeit"
|
||
|
||
tx = BalanceTransaction(
|
||
tenant_id=tenant.id,
|
||
user_id=order.user.id,
|
||
order_id=order.id,
|
||
amount=-order.total_price,
|
||
transaction_type="order_charge",
|
||
description=f"Brotzeit: {ev_title}",
|
||
created_by_id=actor.id
|
||
)
|
||
db.add(tx)
|
||
db.commit()
|
||
else:
|
||
# Barzahlung
|
||
order.is_paid = True
|
||
order.payment_method = "cash"
|
||
db.commit()
|
||
|
||
return RedirectResponse(url=f"/einkauf?event_id={ev_id}", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/admin/toggle-paid/{order_id}")
|
||
def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
|
||
current_user = get_current_user(request, db)
|
||
if not current_user:
|
||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
order = db.query(Order).filter(Order.id == order_id).first()
|
||
if order:
|
||
if order.is_paid:
|
||
# Bei Rücknahme: Falls per Guthaben bezahlt, Guthaben wieder gutschreiben (Refund)
|
||
if order.payment_method == "balance" and order.user:
|
||
order.user.balance += order.total_price
|
||
ev_title = order.event.title if order.event else "Brotzeit"
|
||
tx = BalanceTransaction(
|
||
tenant_id=order.tenant_id,
|
||
user_id=order.user.id,
|
||
order_id=order.id,
|
||
amount=order.total_price,
|
||
transaction_type="refund",
|
||
description=f"Rückerstattung: {ev_title}",
|
||
created_by_id=current_user.id
|
||
)
|
||
db.add(tx)
|
||
order.is_paid = False
|
||
order.payment_method = "cash"
|
||
else:
|
||
order.is_paid = True
|
||
order.payment_method = "cash"
|
||
db.commit()
|
||
|
||
ev_id = order.event_id if order else ""
|
||
referer = request.headers.get("referer", "")
|
||
if "/einkauf" in referer:
|
||
return RedirectResponse(url=f"/einkauf?event_id={ev_id}", status_code=status.HTTP_303_SEE_OTHER)
|
||
return RedirectResponse(url=f"/admin?event_id={ev_id}&tab=orders", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/admin/orders/{order_id}/delete")
|
||
def admin_delete_order(
|
||
order_id: int,
|
||
request: Request,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
tenant = resolve_tenant(request, db)
|
||
actor = require_organizer_or_admin(request, db)
|
||
|
||
order = db.query(Order).filter(Order.id == order_id, Order.tenant_id == tenant.id).first()
|
||
if not order:
|
||
raise HTTPException(status_code=404, detail="Bestellung nicht gefunden")
|
||
|
||
ev_id = order.event_id
|
||
|
||
# Falls per Guthaben bezahlt, Betrag automatisch zurückerstatten!
|
||
if order.is_paid and order.payment_method == "balance" and order.user:
|
||
order.user.balance += order.total_price
|
||
ev_title = order.event.title if order.event else "Brotzeit"
|
||
tx = BalanceTransaction(
|
||
tenant_id=tenant.id,
|
||
user_id=order.user.id,
|
||
order_id=order.id,
|
||
amount=order.total_price,
|
||
transaction_type="refund",
|
||
description=f"Rückerstattung wegen Stornierung: {ev_title}",
|
||
created_by_id=actor.id
|
||
)
|
||
db.add(tx)
|
||
|
||
db.delete(order)
|
||
db.commit()
|
||
|
||
referer = request.headers.get("referer", "")
|
||
if "/einkauf" in referer:
|
||
return RedirectResponse(url=f"/einkauf?event_id={ev_id}&success=Bestellung erfolgreich storniert und ggf. erstattet", status_code=status.HTTP_303_SEE_OTHER)
|
||
return RedirectResponse(url=f"/admin?event_id={ev_id}&tab=orders&success=Bestellung erfolgreich storniert und ggf. erstattet", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok", "app": "Schmankerl"}
|