feat: 4-tier role concept, personal balance & cash register system, left off-canvas drawer navigation

This commit is contained in:
2026-09-22 09:05:14 +02:00
parent 0bdcbe7976
commit 896f1d7d08
8 changed files with 1350 additions and 257 deletions
+270 -29
View File
@@ -13,7 +13,7 @@ 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
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")
@@ -31,22 +31,17 @@ app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), na
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
def resolve_tenant(request: Request, db: Session) -> Optional[Tenant]:
"""Ermittelt den Mandanten anhand der Subdomain (Host-Header)."""
def resolve_tenant(request: Request, db: Session) -> Tenant:
host = request.headers.get("host", "").split(":")[0].lower()
parts = host.split(".")
slug = "demo"
if len(parts) >= 4 and parts[-3:] == ["waiswuaschd", "de", ""]:
slug = parts[0]
elif len(parts) >= 3 and parts[1] == "dev" and parts[2] == "waiswuaschd":
slug = parts[0]
elif parts and parts[0] not in ["localhost", "127", "dev"]:
slug = parts[0]
subdomain = host.split(".")[0]
tenant = db.query(Tenant).filter(Tenant.slug == slug, Tenant.is_active == True).first()
tenant = db.query(Tenant).filter(Tenant.subdomain == host, Tenant.is_active == True).first()
if not tenant:
tenant = db.query(Tenant).filter(Tenant.slug == "demo").first()
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
@@ -68,6 +63,16 @@ def require_admin(request: Request, db: Session) -> User:
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()
@@ -96,6 +101,19 @@ def seed_demo_users_if_needed(tenant: Tenant, db: Session):
)
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(
@@ -544,6 +562,8 @@ def buyer_dashboard(
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()
@@ -623,8 +643,14 @@ def admin_dashboard(
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 != "admin":
raise HTTPException(status_code=403, detail="Zugriff nur für Administratoren")
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()
@@ -768,6 +794,32 @@ def admin_delete_user(
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")
@@ -779,7 +831,11 @@ def admin_create_invitation(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
admin = require_admin(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
@@ -797,7 +853,7 @@ def admin_create_invitation(
token=token,
email=clean_email,
role=role,
created_by=admin.id,
created_by=actor.id,
expires_at=expires_at,
is_reusable=is_reusable
)
@@ -816,7 +872,11 @@ def admin_bulk_invitations(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
admin = require_admin(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)]))
@@ -839,7 +899,7 @@ def admin_bulk_invitations(
token=token,
email=em,
role=role,
created_by=admin.id,
created_by=actor.id,
expires_at=expires_at,
is_reusable=is_reusable
)
@@ -857,7 +917,7 @@ def admin_revoke_invitation(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(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:
@@ -883,7 +943,7 @@ def admin_create_event(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
require_organizer_or_admin(request, db)
try:
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
@@ -944,7 +1004,7 @@ def admin_edit_event(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(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:
@@ -986,7 +1046,7 @@ def admin_complete_event_and_cleanup_guests(
):
"""Schließt das Event ab und bereinigt temporäre Gast-Konten."""
tenant = resolve_tenant(request, db)
require_admin(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:
@@ -1016,7 +1076,7 @@ def admin_delete_event(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(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:
@@ -1039,7 +1099,7 @@ def admin_create_product(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
require_organizer_or_admin(request, db)
try:
clean_price = Decimal(price.strip().replace(",", "."))
@@ -1073,7 +1133,7 @@ def admin_edit_product(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(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:
@@ -1108,7 +1168,7 @@ def admin_toggle_product(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(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:
@@ -1263,6 +1323,168 @@ def seed_realistic_test_data(
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)
@@ -1271,7 +1493,26 @@ def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
order = db.query(Order).filter(Order.id == order_id).first()
if order:
order.is_paid = not order.is_paid
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 ""