Files

1619 lines
58 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"}