feat: 4-tier role concept, personal balance & cash register system, left off-canvas drawer navigation
This commit is contained in:
+270
-29
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user