feat: Paket 1 - Token-based invites, event & deadline management with live countdown, printable distribution sheet
This commit is contained in:
+268
-30
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Optional, Dict
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -11,7 +12,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
|
||||
from .models import Tenant, User, Product, Event, Order, OrderItem, Invitation
|
||||
from .auth import hash_password, verify_password
|
||||
|
||||
app = FastAPI(title="Schmankerl – Das moderne Brotzeitportal")
|
||||
@@ -56,6 +57,16 @@ def get_current_user(request: Request, db: Session) -> Optional[User]:
|
||||
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 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()
|
||||
@@ -94,15 +105,28 @@ def index(request: Request, db: Session = Depends(get_db)):
|
||||
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
# Require Login: Weiterleitung zur Anmeldeseite
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
# Aktuelle offene Runde suchen
|
||||
event = db.query(Event).filter(
|
||||
Event.tenant_id == tenant.id,
|
||||
Event.status == "open"
|
||||
).order_by(Event.deadline.asc()).first()
|
||||
|
||||
# Wenn keine offene, das neueste anzeigen
|
||||
if not event:
|
||||
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
|
||||
|
||||
# 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,
|
||||
@@ -131,11 +155,13 @@ def index(request: Request, db: Session = Depends(get_db)):
|
||||
"tenant": tenant,
|
||||
"current_user": current_user,
|
||||
"event": event,
|
||||
"is_closed": is_closed,
|
||||
"seconds_remaining": seconds_remaining,
|
||||
"products": products,
|
||||
"existing_order": existing_order,
|
||||
"user_items": user_items,
|
||||
"order_count": order_count,
|
||||
"now": datetime.now()
|
||||
"now": now
|
||||
})
|
||||
|
||||
|
||||
@@ -203,9 +229,46 @@ def quick_demo_login(
|
||||
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@app.post("/register")
|
||||
def register(
|
||||
@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 invitation.used_at is not None:
|
||||
error = "Dieser Einladungslink wurde bereits eingelöst. Bitte melde dich mit deinem Konto an."
|
||||
elif 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(...),
|
||||
@@ -213,16 +276,30 @@ def register(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
tenant = resolve_tenant(request, db)
|
||||
clean_email = email.strip().lower()
|
||||
invitation = db.query(Invitation).filter(
|
||||
Invitation.token == token,
|
||||
Invitation.tenant_id == tenant.id
|
||||
).first()
|
||||
|
||||
existing = db.query(User).filter(User.tenant_id == tenant.id, User.email == clean_email).first()
|
||||
if existing:
|
||||
return templates.TemplateResponse(request, "login.html", context={
|
||||
if not invitation or invitation.used_at is not None or invitation.expires_at < datetime.now():
|
||||
raise HTTPException(status_code=400, detail="Ungültiger oder abgelaufener Einladungslink")
|
||||
|
||||
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,
|
||||
"error": "Diese E-Mail-Adresse ist bereits registriert. Bitte melde dich an.",
|
||||
"invitation": invitation,
|
||||
"token": token,
|
||||
"error": "Diese E-Mail-Adresse ist bereits im Team registriert. Bitte melde dich an.",
|
||||
"current_user": None
|
||||
}, status_code=400)
|
||||
|
||||
# Neuen Benutzer anlegen
|
||||
username = clean_email.split("@")[0]
|
||||
new_user = User(
|
||||
tenant_id=tenant.id,
|
||||
@@ -231,20 +308,21 @@ def register(
|
||||
first_name=first_name.strip(),
|
||||
last_name=last_name.strip(),
|
||||
password_hash=hash_password(password),
|
||||
role="user"
|
||||
role=invitation.role
|
||||
)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
|
||||
# Einladung als verwendet markieren
|
||||
invitation.used_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
# Direkt einloggen
|
||||
request.session["user_id"] = new_user.id
|
||||
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)
|
||||
|
||||
# --- BESTELLUNGEN (/order) ---
|
||||
|
||||
@app.post("/order")
|
||||
async def place_order(
|
||||
@@ -259,12 +337,11 @@ async def place_order(
|
||||
|
||||
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 bereits geschlossen")
|
||||
raise HTTPException(status_code=400, detail="Diese Bestellrunde ist geschlossen.")
|
||||
|
||||
if event.deadline < datetime.now():
|
||||
raise HTTPException(status_code=400, detail="Die Bestellfrist ist leider abgelaufen!")
|
||||
raise HTTPException(status_code=400, detail="Die Bestellfrist für diese Runde ist leider abgelaufen!")
|
||||
|
||||
# Formulardaten auslesen
|
||||
form_data = await request.form()
|
||||
order_items = []
|
||||
total_price = Decimal("0.00")
|
||||
@@ -287,7 +364,7 @@ async def place_order(
|
||||
))
|
||||
|
||||
if not order_items:
|
||||
raise HTTPException(status_code=400, detail="Bitte wähle mindestens ein Produkt aus!")
|
||||
raise HTTPException(status_code=400, detail="Bitte wähle mindestens einen Artikel aus!")
|
||||
|
||||
# Bestehende Bestellung des Users in diesem Event aktualisieren oder neu anlegen
|
||||
existing_order = db.query(Order).filter(
|
||||
@@ -330,7 +407,7 @@ def order_success(order_id: int, request: Request, db: Session = Depends(get_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()
|
||||
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")
|
||||
|
||||
@@ -341,8 +418,15 @@ def order_success(order_id: int, request: Request, db: Session = Depends(get_db)
|
||||
})
|
||||
|
||||
|
||||
# --- ADMIN COCKPIT (/admin) ---
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
def admin_dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
def admin_dashboard(
|
||||
request: Request,
|
||||
event_id: Optional[int] = None,
|
||||
tab: str = "orders",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
tenant = resolve_tenant(request, db)
|
||||
if not tenant:
|
||||
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
|
||||
@@ -350,17 +434,26 @@ def admin_dashboard(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)
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Zugriff nur für Administratoren")
|
||||
|
||||
# Neuestes Event finden
|
||||
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
|
||||
# Alle Events des Mandanten
|
||||
all_events = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(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 and all_events:
|
||||
selected_event = all_events[0]
|
||||
|
||||
orders = []
|
||||
item_sums = {}
|
||||
total_revenue = Decimal("0.00")
|
||||
total_paid = Decimal("0.00")
|
||||
|
||||
if event:
|
||||
orders = db.query(Order).filter(Order.event_id == event.id).order_by(Order.created_at.asc()).all()
|
||||
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:
|
||||
@@ -373,17 +466,162 @@ def admin_dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
item_sums[pname] = {"quantity": 0, "unit": unit}
|
||||
item_sums[pname]["quantity"] += it.quantity
|
||||
|
||||
# Teammitglieder & Einladungen laden
|
||||
team_members = db.query(User).filter(User.tenant_id == tenant.id).order_by(User.role.asc(), User.first_name.asc()).all()
|
||||
pending_invitations = db.query(Invitation).filter(
|
||||
Invitation.tenant_id == tenant.id,
|
||||
Invitation.used_at == None,
|
||||
Invitation.expires_at > datetime.now()
|
||||
).order_by(Invitation.created_at.desc()).all()
|
||||
|
||||
return templates.TemplateResponse(request, "admin.html", context={
|
||||
"tenant": tenant,
|
||||
"current_user": current_user,
|
||||
"event": event,
|
||||
"all_events": all_events,
|
||||
"event": selected_event,
|
||||
"orders": orders,
|
||||
"item_sums": item_sums,
|
||||
"total_revenue": total_revenue,
|
||||
"total_paid": total_paid
|
||||
"total_paid": total_paid,
|
||||
"team_members": team_members,
|
||||
"pending_invitations": pending_invitations,
|
||||
"active_tab": tab,
|
||||
"now": datetime.now()
|
||||
})
|
||||
|
||||
|
||||
@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(...),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
tenant = resolve_tenant(request, db)
|
||||
require_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}")
|
||||
|
||||
new_event = Event(
|
||||
tenant_id=tenant.id,
|
||||
title=title.strip(),
|
||||
event_date=ev_date,
|
||||
deadline=deadline_dt,
|
||||
status="open"
|
||||
)
|
||||
db.add(new_event)
|
||||
db.commit()
|
||||
db.refresh(new_event)
|
||||
|
||||
return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=event", 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(...),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
tenant = resolve_tenant(request, db)
|
||||
require_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}")
|
||||
|
||||
event.title = title.strip()
|
||||
event.event_date = ev_date
|
||||
event.deadline = deadline_dt
|
||||
event.status = status_val
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@app.post("/admin/events/{event_id}/toggle-status")
|
||||
def admin_toggle_event_status(
|
||||
event_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
tenant = resolve_tenant(request, db)
|
||||
require_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 = "closed" if event.status == "open" else "open"
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse(url=f"/admin?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@app.post("/admin/invitations/create")
|
||||
def admin_create_invitation(
|
||||
request: Request,
|
||||
email: Optional[str] = Form(None),
|
||||
role: str = Form("user"),
|
||||
valid_days: int = Form(14),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
tenant = resolve_tenant(request, db)
|
||||
admin = require_admin(request, db)
|
||||
|
||||
token = secrets.token_urlsafe(20)
|
||||
expires_at = datetime.now() + timedelta(days=valid_days)
|
||||
clean_email = email.strip().lower() if email and email.strip() else None
|
||||
|
||||
inv = Invitation(
|
||||
tenant_id=tenant.id,
|
||||
token=token,
|
||||
email=clean_email,
|
||||
role=role,
|
||||
created_by=admin.id,
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse(url="/admin?tab=team", 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_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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
Reference in New Issue
Block a user