This commit is contained in:
2025-09-28 19:13:01 +02:00
parent 49edf780b5
commit 541ecb48f2
67 changed files with 5176 additions and 5008 deletions

View File

@@ -0,0 +1,31 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.auth import requires_role, get_current_user
from app.models.config import Config # Key/Value Tabelle: key (pk), value (text)
router = APIRouter(prefix="/admin/settings", tags=["admin-settings"], dependencies=[Depends(requires_role("admin","manager"))])
class PaypalCfg(BaseModel):
paypal_me: str | None = None
paypal_receiver: EmailStr | None = None
@router.get("/paypal", response_model=PaypalCfg)
def get_paypal(db: Session = Depends(get_db)):
def get(key):
obj = db.query(Config).get(key)
return obj.value if obj else None
return PaypalCfg(
paypal_me=get("paypal_me") or None,
paypal_receiver=get("paypal_receiver") or None
)
@router.put("/paypal", response_model=PaypalCfg)
def put_paypal(cfg: PaypalCfg, db: Session = Depends(get_db), user=Depends(get_current_user)):
for k,v in [("paypal_me", cfg.paypal_me), ("paypal_receiver", cfg.paypal_receiver)]:
row = db.query(Config).get(k)
if row: row.value = v or ""
else: db.add(Config(key=k, value=v or ""))
db.commit()
return cfg

View File

@@ -0,0 +1,108 @@
from datetime import datetime, timezone
from enum import Enum
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func
from sqlalchemy.orm import Session
from sqlalchemy.exc import NoResultFound
from app.core.database import get_db
from app.core.auth import get_current_user
from app.models.user import User
from app.models.transaction import Transaction # siehe Modell unten falls noch nicht vorhanden
from app.schemas.transaction import TransactionOut, TransactionCreate # siehe Schemas unten
router = APIRouter(prefix="/admin/transactions", tags=["admin-transactions"])
def require_admin(current=Depends(get_current_user)):
if getattr(current, "role", None) != "admin":
raise HTTPException(status_code=403, detail="Admin only")
return current
@router.get("", response_model=List[TransactionOut])
def list_transactions(
db: Session = Depends(get_db),
_admin=Depends(require_admin),
limit: int = Query(200, ge=1, le=500),
offset: int = Query(0, ge=0),
order: str = Query("desc", pattern="^(asc|desc)$"),
status: Optional[str] = Query(None, pattern="^(waiting|approved|rejected)$"),
):
q = select(Transaction).offset(offset).limit(limit)
if status:
q = q.where(Transaction.status == status)
if order == "desc":
q = q.order_by(Transaction.created_at.desc())
else:
q = q.order_by(Transaction.created_at.asc())
return db.execute(q).scalars().all()
@router.post("", response_model=TransactionOut, status_code=201)
def create_transaction(
body: TransactionCreate,
db: Session = Depends(get_db),
admin=Depends(require_admin),
):
tx = Transaction(
user_id=body.user_id,
amount_cents=body.amount_cents,
note=body.note or "",
status="waiting",
created_at=datetime.now(timezone.utc),
created_by_id=admin.id,
kind="manual",
)
db.add(tx)
db.commit()
db.refresh(tx)
return tx
@router.post("/{tx_id}/approve", response_model=TransactionOut)
def approve_transaction(
tx_id: int,
db: Session = Depends(get_db),
admin=Depends(require_admin),
):
tx = db.get(Transaction, tx_id)
if not tx:
raise HTTPException(404, "Transaktion nicht gefunden")
if tx.status != "waiting":
raise HTTPException(status_code=409, detail=f"Transaktion bereits {tx.status}")
# Nutzer sperren, um Doppelbuchungen zu vermeiden
user = db.execute(
select(User).where(User.id == tx.user_id).with_for_update()
).scalar_one()
new_balance = (user.balance_cents or 0) + tx.amount_cents
if new_balance < 0:
raise HTTPException(400, "Saldo würde negativ Buchung abgelehnt")
user.balance_cents = new_balance
tx.status = "approved"
tx.processed_at = datetime.now(timezone.utc)
tx.processed_by_id = admin.id
db.commit()
db.refresh(tx)
return tx
@router.post("/{tx_id}/reject", response_model=TransactionOut)
def reject_transaction(
tx_id: int,
db: Session = Depends(get_db),
admin=Depends(require_admin),
):
tx = db.get(Transaction, tx_id)
if not tx:
raise HTTPException(404, "Transaktion nicht gefunden")
if tx.status != "waiting":
raise HTTPException(status_code=409, detail=f"Transaktion bereits {tx.status}")
tx.status = "rejected"
tx.processed_at = datetime.now(timezone.utc)
tx.processed_by_id = admin.id
db.commit()
db.refresh(tx)
return tx

View File

@@ -0,0 +1,73 @@
from typing import Optional, List
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.core.auth import get_current_user, requires_role
from app.models.user import User
from app.models.audit_log import AuditLog
from app.schemas.audit_log import AuditLogOut
router = APIRouter(prefix="/audit-logs", tags=["audit-logs"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def _ts_col():
# vorhandene Zeitspalte wählen, sonst Fallback auf id
return (
getattr(AuditLog, "created_at", None)
or getattr(AuditLog, "timestamp", None)
or getattr(AuditLog, "created", None)
or AuditLog.id
)
@router.get("/", response_model=List[AuditLogOut], dependencies=[Depends(requires_role("admin"))])
def list_audit_logs(
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
user_id: Optional[int] = Query(None, ge=1),
action: Optional[str] = Query(None),
q: Optional[str] = Query(None),
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
):
qs = db.query(AuditLog)
if user_id is not None:
qs = qs.filter(AuditLog.user_id == user_id)
if action:
qs = qs.filter(AuditLog.action == action)
if q and hasattr(AuditLog, "info"):
like = f"%{q}%"
qs = qs.filter(AuditLog.info.ilike(like))
ts = _ts_col()
# ISO-Zeiten robust parsen
def _parse_iso(s: Optional[str]) -> Optional[datetime]:
if not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except Exception:
return None
dtf = _parse_iso(date_from)
dtt = _parse_iso(date_to)
if dtf is not None and ts is not AuditLog.id:
qs = qs.filter(ts >= dtf)
if dtt is not None and ts is not AuditLog.id:
qs = qs.filter(ts <= dtt)
qs = qs.order_by(ts.desc(), AuditLog.id.desc()).limit(limit).offset(offset)
return qs.all()

View File

@@ -0,0 +1,292 @@
from fastapi import APIRouter, Depends, HTTPException, Response, Body, Request, status
from sqlalchemy.orm import Session as DBSession
from sqlalchemy import func
from passlib.context import CryptContext
from datetime import datetime, timedelta
from collections import defaultdict
import os, hmac, hashlib
from app.core.database import get_db
from app.core.auth import (
set_session_cookie,
issue_csrf_cookie,
clear_session_cookie,
clear_csrf_cookie,
get_current_user,
SESSION_COOKIE_NAME,
)
from app.services.sessions import create_session, delete_session
from app.models.user import User
router = APIRouter(prefix="/auth", tags=["auth"])
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_pin(plain_pin: str, hashed_pin: str) -> bool:
if not hashed_pin:
return False
return pwd_context.verify(plain_pin, hashed_pin)
def verify_password(plain_password: str, hashed_password: str) -> bool:
if not hashed_password:
return False
return pwd_context.verify(plain_password, hashed_password)
# -------- Rolle robust normalisieren --------
def _normalize_role(role_raw) -> str:
# Enum? -> value
if hasattr(role_raw, "value"):
role_raw = role_raw.value
# Stringifizieren
role = str(role_raw or "user").strip()
# ggf. Präfixe/Namespaces entfernen (z. B. "userrole.admin")
if "." in role:
role = role.split(".")[-1]
return role.lower()
# -----------------------------
# Throttling für Management-Login
# -----------------------------
_FAILED_LIMIT = 5
_LOCK_MINUTES = 2
_login_state = defaultdict(lambda: {"fails": 0, "lock_until": None})
def _client_ip(request: Request) -> str:
xff = request.headers.get("X-Forwarded-For")
if xff:
first = xff.split(",")[0].strip()
if first:
return first
return request.client.host if request.client else "0.0.0.0"
def _throttle_check(email_lower: str, ip: str):
st = _login_state[(email_lower, ip)]
now = datetime.utcnow()
if st["lock_until"] and now < st["lock_until"]:
seconds = int((st["lock_until"] - now).total_seconds())
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Login gesperrt. Bitte {seconds} Sekunden warten.",
)
def _throttle_fail(email_lower: str, ip: str):
st = _login_state[(email_lower, ip)]
st["fails"] += 1
if st["fails"] >= _FAILED_LIMIT:
st["lock_until"] = datetime.utcnow() + timedelta(minutes=_LOCK_MINUTES)
def _throttle_reset(email_lower: str, ip: str):
_login_state[(email_lower, ip)] = {"fails": 0, "lock_until": None}
# -----------------------------
# PIN-Login: O(1) via HMAC-Lookup + IP-Rate-Limit
# -----------------------------
# Servergeheimer Pepper für HMAC über die **volle** PIN
PIN_PEPPER = os.getenv("PIN_PEPPER")
if not PIN_PEPPER or len(PIN_PEPPER) < 32:
where = os.environ.get("DOTENV_PATH") or ".env (nicht gefunden)"
raise RuntimeError(f"PIN_PEPPER env var fehlt/zu kurz .env: {where}")
def _pin_hmac(pin: str) -> str:
"""hex(HMAC_SHA256(PEPPER, pin))"""
return hmac.new(PIN_PEPPER.encode("utf-8"), pin.encode("utf-8"), hashlib.sha256).hexdigest()
# sehr einfaches IP-Rate-Limit (pro Prozess)
_pin_attempts = defaultdict(list) # ip -> [timestamps]
def _pin_rate_limit_ok(ip: str, limit: int = 10, window_sec: int = 60) -> bool:
now = datetime.utcnow().timestamp()
arr = _pin_attempts[ip]
# altes Fenster abschneiden
arr[:] = [t for t in arr if now - t < window_sec]
if len(arr) >= limit:
return False
arr.append(now)
return True
# -----------------------------
# Management-Login (E-Mail/Passwort)
# -----------------------------
@router.post("/management-login")
def management_login(
request: Request,
response: Response,
email: str = Body(..., embed=True),
password: str = Body(..., embed=True),
db: DBSession = Depends(get_db),
):
email_lower = (email or "").strip().lower()
ip = _client_ip(request)
_throttle_check(email_lower, ip)
user = db.query(User).filter(func.lower(User.email) == email_lower).one_or_none()
if not user or not getattr(user, "is_active", True):
_throttle_fail(email_lower, ip)
raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten")
hashed_pw = getattr(user, "hashed_password", None) or getattr(user, "password_hash", None)
if not hashed_pw:
_throttle_fail(email_lower, ip)
raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten")
if not verify_password(password, hashed_pw):
_throttle_fail(email_lower, ip)
raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten")
role = _normalize_role(getattr(user, "role", "user"))
_throttle_reset(email_lower, ip)
token = create_session(db, user.id)
set_session_cookie(response, token)
issue_csrf_cookie(response)
return {
"message": "Login erfolgreich",
"user": {"id": user.id, "name": user.name, "role": role},
}
# -----------------------------
# PIN-Login (O(1) Kandidaten, dann 1× Hash-Verify)
# -----------------------------
@router.post("/pin-login")
def pin_login(
request: Request,
response: Response,
pin: str = Body(..., embed=True),
db: DBSession = Depends(get_db),
):
# IP-Rate-Limit (z. B. 10 Versuche/60s)
ip = _client_ip(request)
if not _pin_rate_limit_ok(ip):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Zu viele Versuche. Bitte kurz warten.")
if not pin or not pin.isdigit() or len(pin) != 6:
# gleiche Antwortzeit für Fehler
raise HTTPException(status_code=401, detail="Ungültiger PIN")
token = _pin_hmac(pin)
# Schnellpfad: direkt per HMAC-Token Kandidaten suchen (O(1) via Index)
candidates = db.query(User).filter(
User.is_active == True,
getattr(User, "pin_lookup") == token # erwartet Spalte users.pin_lookup (VARCHAR(64))
).all()
# Normalfall: genau 1 Kandidat; bei Kollisionen (gleiche PIN) sind es wenige
matched = None
for u in candidates:
# sichere Verifikation mit dem langsamen Hash
hashed_pin = getattr(u, "hashed_pin", None) or getattr(u, "pin_hash", None)
if hashed_pin and verify_pin(pin, hashed_pin):
matched = u
break
if not matched:
# Altbestand (pin_lookup leer) langsamer Fallback: aktive Nutzer iterieren
# Hinweis: Bei vielen Nutzern besser deaktivieren und stattdessen Nutzer zur PIN-Neusetzung zwingen.
slow = db.query(User).filter(User.is_active == True).with_entities(User.id, User.hashed_pin).all()
for uid, h in slow:
if h and verify_pin(pin, h):
matched = db.query(User).get(uid) # type: ignore
if matched:
# Nachmigration: pin_lookup setzen, damit künftige Logins schnell sind
setattr(matched, "pin_lookup", token)
db.add(matched)
db.commit()
db.refresh(matched)
break
if not matched:
raise HTTPException(status_code=401, detail="Ungültiger PIN")
# Erfolg → Session-Cookie + CSRF
token_val = create_session(db, matched.id)
set_session_cookie(response, token_val)
issue_csrf_cookie(response)
return {
"message": "Login erfolgreich",
"user": {
"id": matched.id,
"name": matched.name,
"role": _normalize_role(getattr(matched, "role", "user")),
},
}
# -----------------------------
# Logout
# -----------------------------
@router.post("/logout")
def logout(
request: Request,
response: Response,
db: DBSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
delete_session(db, token)
clear_session_cookie(response)
clear_csrf_cookie(response)
return Response(status_code=204)
# -----------------------------
# CSRF-Token beziehen
# -----------------------------
@router.get("/csrf")
def get_csrf(response: Response):
issue_csrf_cookie(response)
return {"csrf": "ok"}
# -----------------------------
# Aktueller Nutzer
# -----------------------------
@router.get("/me")
def me(current_user: User = Depends(get_current_user)):
return current_user
# -----------------------------
# Capabilities (UI-Gating)
# -----------------------------
@router.get("/capabilities")
def get_capabilities(current_user: User = Depends(get_current_user)):
role = _normalize_role(getattr(current_user, "role", "user"))
caps_by_role = {
"user": [
"dashboard",
"profile",
"my-bookings",
"my-transactions",
"system-config",
"stats-advanced",
],
"manager": [
"dashboard",
"profile",
"my-bookings",
"my-transactions",
"products",
"deliveries",
"system-config",
"stats-advanced",
],
"admin": [
"dashboard",
"profile",
"my-bookings",
"my-transactions",
"users",
"products",
"deliveries",
"stats-advanced",
"audit-logs",
"system-config",
"transactions",
],
}
return {"role": role, "capabilities": caps_by_role.get(role, caps_by_role["user"])}

View File

@@ -0,0 +1,171 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from app.core.database import get_db
from app.core.auth import get_current_user, requires_role
from app.models.booking import Booking
from app.models.user import User
from app.models.product import Product
from app.schemas.booking import BookingOut, BookingCreate
router = APIRouter(prefix="/bookings", tags=["bookings"])
# Konstante aus deinen Policies: Nutzer dürfen nicht < 0 € fallen (Stand 2025-07-02).
ALLOWED_OVERDRAFT_CENTS = 0 # falls -500 erlaubt sein soll: setze auf -500
@router.post("/", response_model=BookingOut, status_code=status.HTTP_201_CREATED)
def create_booking(
booking: BookingCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.role not in ("manager", "admin") and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
if booking.amount is None or booking.amount <= 0:
raise HTTPException(status_code=400, detail="Invalid amount")
# Produkt mit Row-Lock
product = (
db.query(Product)
.filter(Product.id == booking.product_id)
.with_for_update()
.first()
)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
if hasattr(product, "is_active") and not bool(product.is_active):
raise HTTPException(status_code=400, detail="Product is not active")
qty = int(booking.amount)
# Bestand prüfen/abziehen, falls Feld vorhanden
if hasattr(product, "stock"):
current_stock = int(product.stock or 0)
if current_stock < qty:
raise HTTPException(status_code=409, detail="Not enough stock")
product.stock = current_stock - qty
server_total = int(product.price_cents) * qty
try:
# User mit Row-Lock
user = (
db.query(User)
.filter(User.id == booking.user_id)
.with_for_update()
.first()
)
if not user:
raise HTTPException(status_code=404, detail="User not found")
new_balance = int(user.balance_cents) - server_total
if new_balance < -ALLOWED_OVERDRAFT_CENTS:
raise HTTPException(status_code=400, detail="Insufficient balance")
user.balance_cents = new_balance
db_booking = Booking(
user_id=booking.user_id,
product_id=booking.product_id,
amount=qty,
total_cents=server_total, # Client-Wert wird ignoriert
)
db.add(db_booking)
db.flush()
db.refresh(db_booking)
db.commit()
return db_booking
except HTTPException:
db.rollback()
raise
except Exception:
db.rollback()
raise
def _role_name(r) -> str:
# robust gegen Enum/String/None
if r is None:
return ""
v = getattr(r, "value", r)
return str(v).strip().lower()
@router.get("/", response_model=List[BookingOut])
def list_bookings(
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
user_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = db.query(Booking)
role = _role_name(getattr(current_user, "role", None))
is_manager = role in ("manager", "admin")
if is_manager:
# Admin/Manager: optionaler Filter auf user_id
if user_id is not None:
q = q.filter(Booking.user_id == user_id)
else:
# Normale Nutzer: strikt nur eigene Buchungen
q = q.filter(Booking.user_id == current_user.id)
# stabile, absteigende Reihenfolge Zeitfeld optional
order_cols = []
if hasattr(Booking, "created_at"):
order_cols.append(Booking.created_at.desc())
elif hasattr(Booking, "timestamp"):
order_cols.append(Booking.timestamp.desc())
elif hasattr(Booking, "created"):
order_cols.append(Booking.created.desc())
order_cols.append(Booking.id.desc())
return q.order_by(*order_cols).limit(limit).offset(offset).all()
@router.get("/me", response_model=List[BookingOut])
def list_my_bookings(
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = (
db.query(Booking)
.filter(Booking.user_id == current_user.id)
.order_by(Booking.id.desc())
.limit(limit)
.offset(offset)
)
return q.all()
@router.get("/{booking_id}", response_model=BookingOut)
def get_booking(
booking_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
b = db.query(Booking).filter(Booking.id == booking_id).first()
if not b:
raise HTTPException(status_code=404, detail="Booking not found")
if b.user_id != current_user.id and current_user.role not in ("manager", "admin"):
raise HTTPException(status_code=403, detail="Forbidden")
return b
@router.delete("/{booking_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_booking(
booking_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
requires_role("manager", "admin")(current_user)
b = db.query(Booking).filter(Booking.id == booking_id).first()
if not b:
raise HTTPException(status_code=404, detail="Booking not found")
db.delete(b)
db.commit()
return None

View File

@@ -0,0 +1,85 @@
# app/routes/categories.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.core.auth import get_current_user, requires_role
from app.models.user import User
from app.models.product import Product
router = APIRouter(prefix="/categories", tags=["categories"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/", response_model=list[str])
def list_categories(db: Session = Depends(get_db)):
"""
Alle Kategorien (distinct aus Product.category).
"""
cats = db.query(Product.category).distinct().all()
return sorted([c[0] for c in cats if c[0]])
@router.put(
"/rename",
dependencies=[Depends(requires_role("manager", "admin"))],
)
def rename_category(
old_name: str,
new_name: str,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Kategorie umbenennen: alle Produkte mit old_name -> new_name.
"""
if not new_name or new_name.lower() == "alle":
raise HTTPException(status_code=400, detail="Invalid category name")
updated = (
db.query(Product)
.filter(Product.category == old_name)
.update({"category": new_name})
)
if updated == 0:
raise HTTPException(status_code=404, detail="Category not found")
db.commit()
return {"updated": updated, "new_name": new_name}
@router.delete(
"/",
dependencies=[Depends(requires_role("manager", "admin"))],
)
def delete_category(
name: str,
reassign_to: str | None = None,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Kategorie löschen.
- Mit reassign_to: alle Produkte auf neue Kategorie umhängen.
- Ohne: Kategorie auf NULL setzen.
"""
q = db.query(Product).filter(Product.category == name)
if not q.first():
raise HTTPException(status_code=404, detail="Category not found")
if reassign_to:
updated = q.update({"category": reassign_to})
else:
updated = q.update({"category": None})
db.commit()
return {"updated": updated, "reassigned_to": reassign_to}

View File

@@ -0,0 +1,284 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel
from io import BytesIO
from datetime import date
import re
import difflib
import datetime as dt
try:
import pdfplumber # type: ignore
except Exception: # pragma: no cover
pdfplumber = None
from app.schemas.delivery import DeliveryCreate, DeliveryOut
from app.models.delivery import Delivery
from app.models.product import Product
from app.core.database import SessionLocal
from app.core.auth import get_current_user, requires_role
from app.models.user import User
router = APIRouter(prefix="/deliveries", tags=["deliveries"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.post("/", response_model=DeliveryOut, dependencies=[Depends(requires_role("manager", "admin"))])
def create_delivery(delivery: DeliveryCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
# Session.get statt .query().get (SQLAlchemy 2.x)
product = db.get(Product, delivery.product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
if delivery.amount < 1:
raise HTTPException(status_code=400, detail="Amount must be at least 1")
# Pydantic v2: model_dump()
db_delivery = Delivery(**delivery.model_dump())
db.add(db_delivery)
db.commit()
db.refresh(db_delivery)
return db_delivery
@router.get("/", response_model=list[DeliveryOut], dependencies=[Depends(requires_role("manager", "admin"))])
def list_deliveries(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(Delivery).all()
@router.get("/{delivery_id}", response_model=DeliveryOut, dependencies=[Depends(requires_role("manager", "admin"))])
def get_delivery(delivery_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
delivery = db.get(Delivery, delivery_id)
if not delivery:
raise HTTPException(status_code=404, detail="Delivery not found")
return delivery
@router.delete("/{delivery_id}", status_code=204, dependencies=[Depends(requires_role("manager", "admin"))])
def delete_delivery(delivery_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
delivery = db.get(Delivery, delivery_id)
if not delivery:
raise HTTPException(status_code=404, detail="Delivery not found")
db.delete(delivery)
db.commit()
# ---- Draft-Schemata für den PDF-Import (Antwort an das Frontend) -----------------------
class DeliveryItemDraft(BaseModel):
product_id: Optional[int] = None
product_hint: Optional[str] = None # originaler Text aus der PDF
quantity_units: int # Anzahl (Einheiten/KA)
unit_cost_cents: int # E-Preis je Einheit in Cent
class DeliveryDraft(BaseModel):
supplier: Optional[str] = None
date: Optional[str] = None # ISO yyyy-mm-dd
invoice_no: Optional[str] = None
note: Optional[str] = None
deposit_return_cents: int = 0 # Netto-Pfand (Cent, positiv)
items: List[DeliveryItemDraft] = []
# ---- Hilfsfunktionen -------------------------------------------------------------------
def _parse_decimal_de(s: str) -> float:
"""'1.234,56' -> 1234.56"""
t = s.strip().replace(".", "").replace("", "").replace(",", ".")
try:
return float(t)
except ValueError:
return 0.0
def _to_cents(v: float) -> int:
return int(round(v * 100))
def _date_de_to_iso(s: str) -> Optional[str]:
try:
d = dt.datetime.strptime(s.strip(), "%d.%m.%Y").date()
return d.isoformat()
except Exception:
return None
_pack_pat = re.compile(r"(\d+)\s*/\s*(\d+[.,]?\d*)") # z.B. 24/0,33 oder 12/1,0
def _extract_pack_size(text: str) -> Optional[int]:
m = _pack_pat.search(text)
if not m:
return None
try:
return int(m.group(1))
except Exception:
return None
def _norm(s: str) -> str:
return re.sub(r"[^0-9a-z]+", " ", s.lower()).strip()
def _best_product_match(desc: str, products: List[Product]) -> Optional[int]:
"""Einfache Fuzzy-Suche: Textähnlichkeit + Bonus bei passender Packgröße."""
if not products:
return None
cand_name = _norm(desc)
want_pack = _extract_pack_size(desc)
best_id, best_score = None, 0.0
for p in products:
name = getattr(p, "name", "") or ""
score = difflib.SequenceMatcher(a=cand_name, b=_norm(name)).ratio()
pack = getattr(p, "pack_size", None)
if want_pack and pack and int(pack) == int(want_pack):
score += 0.08 # leichter Bonus
if score > best_score:
best_score, best_id = score, int(getattr(p, "id"))
return best_id if best_score >= 0.75 else None
# ---- Kern: PDF parsen (ein Supplier, textbasiert) --------------------------------------
def _parse_invoice_pdf(data: bytes, all_products: List[Product]) -> DeliveryDraft:
if pdfplumber is None:
raise HTTPException(
status_code=501,
detail="pdfplumber ist nicht installiert. Bitte `pip install pdfplumber` ausführen."
)
text_pages: List[str] = []
with pdfplumber.open(BytesIO(data)) as pdf:
for page in pdf.pages:
# Text mit Zeilenumbrüchen; für dieses Layout reicht Text-Parsing
text_pages.append(page.extract_text() or "")
full = "\n".join(text_pages)
# Header-Felder
inv = None
m = re.search(r"Rechnungs[-\s]?Nr\.?\s*:\s*([A-Za-z0-9\-\/]+)", full, re.I)
if m:
inv = m.group(1).strip()
date_iso = None
m = re.search(r"Datum\s*:\s*(\d{2}\.\d{2}\.\d{4})", full, re.I)
if m:
date_iso = _date_de_to_iso(m.group(1))
deposit_cents = 0
# 1) Bevorzugt die Steuerzeile netto Summe Pfand
m = re.search(r"(?mi)^\s*summe\s+pfand\b.*?([0-9\.,]+)\s*$", full)
if not m:
# 2) Fallback
m = re.search(r"(?mi)^\s*pfand\b.*?eur\s*([0-9\.,]+)\s*$", full)
if m:
deposit_cents = _to_cents(_parse_decimal_de(m.group(1)))
# Positionsblock: Zeilen zwischen Kopf "Art-Nr" und "Zwischensumme Warenwert"
items: List[DeliveryItemDraft] = []
block = []
in_block = False
for line in full.splitlines():
if not in_block and re.search(r"\bArt[-\s]?Nr\b", line):
in_block = True
continue
if in_block and re.search(r"Zwischensumme\s+Warenwert", line):
break
if in_block:
if line.strip():
block.append(line)
# Zeilen parsen: "<artnr> <bezeichnung…> <anzahl> <ME> <E-Preis> <G-Preis>"
row_re = re.compile(
r"^\s*\d+\s+(?P<desc>.+?)\s+(?P<qty>\d+)\s+(?P<me>[A-Za-z]+)\s+(?P<eprice>\d+,\d{2})\s+(?P<gprice>\d+,\d{2})\s*$"
)
for ln in block:
m = row_re.match(ln)
if not m:
continue
desc = m.group("desc").strip()
qty = int(m.group("qty"))
me = m.group("me").upper()
eprice = _to_cents(_parse_decimal_de(m.group("eprice")))
# Nur ME = KA (Kästen/Einheit) übernehmen andere ignorieren
if me not in {"KA", "KASTEN", "EINHEIT"}:
# zur Not trotzdem übernehmen
pass
prod_id = _best_product_match(desc, all_products)
items.append(
DeliveryItemDraft(
product_id=prod_id,
product_hint=(None if prod_id else desc),
quantity_units=qty,
unit_cost_cents=eprice,
)
)
return DeliveryDraft(
supplier=None, # optional kann bei Bedarf aus Kopf extrahiert werden
date=date_iso,
invoice_no=inv,
note=None,
deposit_return_cents=abs(deposit_cents),
items=items or [],
)
# ---- Endpoint: PDF hochladen & Draft liefern -------------------------------------------
@router.post("/invoice/import", response_model=DeliveryDraft, dependencies=[Depends(requires_role("manager", "admin"))])
def import_invoice_pdf(
file: UploadFile = File(...), # ← fix
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if not file.filename.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="Bitte eine PDF-Datei hochladen.")
data = file.file.read()
if not data:
raise HTTPException(status_code=400, detail="Leere Datei.")
# Produkte für Matching laden
products = db.query(Product).all()
try:
draft = _parse_invoice_pdf(data, products)
except HTTPException:
raise
except Exception as e: # robustes Fehlerhandling
raise HTTPException(status_code=422, detail=f"PDF konnte nicht geparst werden: {e}")
# Optional: minimale Plausibilitätsprüfung
if not draft.items:
# Kein harter Fehler Frontend kann manuell ergänzen
draft.note = (draft.note or "")
return draft
# ---- Bulk-Schema ----
class DeliveryItemIn(BaseModel):
product_id: int
quantity_units: int
unit_cost_cents: int
units: int | None = None # optional: Stückzahl vom Client
class DeliveryBulkIn(BaseModel):
supplier: str
date: date
invoice_no: str | None = None
note: str | None = None
deposit_return_cents: int = 0
items: list[DeliveryItemIn]
@router.post("/bulk")
def create_delivery_bulk(body: DeliveryBulkIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
requires_role("manager", "admin")(user)
created_ids: list[int] = []
deposit = int(body.deposit_return_cents or 0)
for idx, it in enumerate(body.items):
prod = db.get(Product, it.product_id) or HTTPException(status_code=400, detail=f"Unknown product_id {it.product_id}")
pack = int(getattr(prod, "pack_size") or 1)
amount_pieces = int(it.units) if it.units is not None else int(it.quantity_units) * pack
row = Delivery(
product_id=it.product_id,
amount=amount_pieces, # Stück gesamt
price_cents=int(it.unit_cost_cents), # Preis pro Stück
delivered_at=body.date,
supplier=body.supplier,
invoice_number=body.invoice_no,
note=body.note,
deposit_return_cents=(deposit if idx == 0 else 0), # <- hier
)
db.add(row); db.flush(); created_ids.append(row.id)
db.commit()
return {"created": created_ids}

View File

@@ -0,0 +1,126 @@
from typing import List, Optional, Literal, Set
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from fastapi import Body
from app.core.database import get_db
from app.core.auth import get_current_user
from app.models.user import User
from app.models.topup import Topup
from app.models.booking import Booking
router = APIRouter(prefix="/ledger", tags=["ledger"])
class LedgerItem(BaseModel):
id: str
type: Literal["topup", "booking"]
amount_cents: int # topup > 0, booking < 0
created_at: str # ISO-String
status: Optional[str] = None # nur für topup: pending | confirmed | rejected
note: Optional[str] = None # nur für topup/admin: Notiz / 5-stelliger Code
product_id: Optional[int] = None
product_name: Optional[str] = None
@router.get("/me", response_model=List[LedgerItem])
def list_ledger_me(
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
types: str = Query("topup,booking", description="CSV: topup,booking"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Vereinheitlichte Übersicht über Aufladungen (Top-ups) und Getränkebuchungen.
Global nach Datum sortiert, danach paginiert.
"""
want: Set[str] = {t.strip().lower() for t in types.split(",") if t.strip()}
rows: list[LedgerItem] = []
if "topup" in want:
topups = (
db.query(Topup)
.filter(Topup.user_id == current_user.id)
.order_by(Topup.id.desc())
.all()
)
for t in topups:
rows.append(
LedgerItem(
id=f"topup-{t.id}",
type="topup",
amount_cents=int(t.amount_cents or 0),
created_at=(t.created_at.isoformat() if getattr(t, "created_at", None) else ""),
status=(str(t.status.value) if hasattr(t, "status") and t.status is not None else None),
note=(t.note or None),
)
)
if "booking" in want:
bookings = (
db.query(Booking)
.filter(Booking.user_id == current_user.id)
.order_by(Booking.id.desc())
.all()
)
for b in bookings:
total = getattr(b, "total_cents", None)
if total is None:
price = getattr(b, "price_cents", None)
if price is None and hasattr(b, "product") and getattr(b, "product") is not None:
price = getattr(b.product, "price_cents", 0)
total = (price or 0) * (getattr(b, "amount", 1) or 1)
rows.append(
LedgerItem(
id=f"booking-{b.id}",
type="booking",
amount_cents=-int(total or 0), # Buchungen als negative Werte
created_at=(b.created_at.isoformat() if getattr(b, "created_at", None) else ""),
product_id=getattr(b, "product_id", None),
product_name=(getattr(b.product, "name", None) if hasattr(b, "product") and getattr(b, "product") is not None else None),
)
)
# global sortieren und erst dann paginieren
rows.sort(key=lambda r: r.created_at, reverse=True)
return rows[offset : offset + limit]
# ------- ab hier NEU: auf Modulebene, nicht eingerückt! -------
class LedgerAdd(BaseModel):
amount_cents: int # darf +/ sein (Korrektur/Einlage)
note: Optional[str] = None
@router.post("/", response_model=LedgerItem)
def add_ledger_entry(
payload: LedgerAdd = Body(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Topup erzeugen (als Kassenbewegung)
try:
from app.models.topup import TopupStatus # falls Enum existiert
status_value = TopupStatus.confirmed
except Exception:
status_value = "confirmed"
t = Topup(
user_id=current_user.id,
amount_cents=int(payload.amount_cents or 0),
note=(payload.note or "Manuelle Kassenkorrektur"),
status=status_value,
)
db.add(t)
db.commit()
db.refresh(t)
return LedgerItem(
id=f"topup-{t.id}",
type="topup",
amount_cents=int(t.amount_cents or 0),
created_at=(t.created_at.isoformat() if getattr(t, "created_at", None) else ""),
status=(str(t.status.value) if hasattr(t, "status") and t.status is not None else "confirmed"),
note=(t.note or None),
)

View File

@@ -0,0 +1,79 @@
# app/api/paypal_ipn.py
import httpx, urllib.parse, os, re
from fastapi import APIRouter, Request, Response, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.topup import Topup, TopupStatus
from app.models.user import User
router = APIRouter(prefix="/paypal", tags=["paypal"])
PP_IPN_VERIFY_URL = "https://ipnpb.paypal.com/cgi-bin/webscr" # live
# Sandbox: "https://ipnpb.sandbox.paypal.com/cgi-bin/webscr"
def parse_custom(s: str):
# erwartet "topup:<id>|code:<CODE>"
data = {}
for part in (s or "").split("|"):
if ":" in part:
k,v = part.split(":",1)
data[k.strip()] = v.strip()
return data
@router.post("/ipn")
async def handle_ipn(request: Request, db: Session = Depends(get_db)):
raw = await request.body()
params = urllib.parse.parse_qs(raw.decode("utf-8"), keep_blank_values=True)
# 1) an PayPal zurückposten:
verify_payload = "cmd=_notify-validate&" + raw.decode("utf-8")
async with httpx.AsyncClient(timeout=15) as c:
vr = await c.post(PP_IPN_VERIFY_URL, data=verify_payload,
headers={"Content-Type":"application/x-www-form-urlencoded"})
if vr.text.strip() != "VERIFIED":
return Response("ignored", status_code=400)
payment_status = (params.get("payment_status",[None])[0] or "").lower()
receiver_email = (params.get("receiver_email",[None])[0] or "").lower()
mc_currency = params.get("mc_currency",[None])[0] or ""
mc_gross = params.get("mc_gross",[None])[0] or ""
custom = params.get("custom",[None])[0] or ""
item_name = params.get("item_name",[None])[0] or ""
txn_id = params.get("txn_id",[None])[0] or ""
# Grundvalidierungen
if payment_status != "completed":
return {"ok": True} # nur completed interessiert
# Optional: Empfänger-Email prüfen (gegen Config)
# if receiver_email != CONFIG.paypal_receiver.lower(): return Response("wrong receiver", 400)
if mc_currency != "EUR":
return Response("wrong currency", 400)
meta = parse_custom(custom)
topup_id = int(meta.get("topup") or 0)
code = meta.get("code") or ""
t = db.query(Topup).filter(Topup.id == topup_id).first()
if not t or t.status == TopupStatus.confirmed:
return {"ok": True}
# Betrag prüfen
try:
cents_from_paypal = int(round(float(mc_gross.replace(",", ".")) * 100))
except Exception:
return Response("bad amount", 400)
if int(t.amount_cents or 0) != cents_from_paypal:
return Response("amount mismatch", 400)
# Sicherheits-Match: Code im item_name?
if code and code not in (item_name or ""):
# nicht hart abbrechen, aber flaggen wäre möglich
pass
# Topup bestätigen + Saldo buchen (idempotent)
t.status = TopupStatus.confirmed
u = db.query(User).filter(User.id == t.user_id).first()
u.balance_cents = int(u.balance_cents or 0) + int(t.amount_cents or 0)
# Optional: t.paypal_txn_id = txn_id
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.models.product import Product
from app.schemas.product import ProductCreate, ProductUpdate, ProductOut
from app.core.auth import get_current_user, requires_role
from app.models.user import User
router = APIRouter(prefix="/products", tags=["products"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.post("/", response_model=ProductOut, dependencies=[Depends(requires_role("manager", "admin"))])
def create_product(product: ProductCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
db_product = Product(**product.dict())
db.add(db_product)
db.commit()
db.refresh(db_product)
return db_product
@router.get("/", response_model=list[ProductOut])
def list_products(db: Session = Depends(get_db)):
return db.query(Product).all()
@router.get("/{product_id}", response_model=ProductOut)
def get_product(product_id: int, db: Session = Depends(get_db)):
product = db.query(Product).get(product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return product
@router.put("/{product_id}", response_model=ProductOut, dependencies=[Depends(requires_role("manager", "admin"))])
def update_product(product_id: int, product: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
db_product = db.query(Product).get(product_id)
if not db_product:
raise HTTPException(status_code=404, detail="Product not found")
for key, value in product.dict(exclude_unset=True).items():
setattr(db_product, key, value)
db.commit()
db.refresh(db_product)
return db_product
@router.delete("/{product_id}", status_code=204, dependencies=[Depends(requires_role("manager", "admin"))])
def delete_product(product_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
db_product = db.query(Product).get(product_id)
if not db_product:
raise HTTPException(status_code=404, detail="Product not found")
db.delete(db_product)
db.commit()

View File

@@ -0,0 +1,135 @@
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from fastapi import status
from sqlalchemy.orm import Session as DBSession
from pathlib import Path
import shutil
from typing import Optional
from pydantic import BaseModel, EmailStr, field_validator
from app.core.database import SessionLocal
from app.core.auth import get_current_user
from app.models.user import User
router = APIRouter(prefix="/profile", tags=["profile"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# -----------------------------
# 1) Neuer PIN anfordern (Mock)
# -----------------------------
@router.post("/request-new-pin", status_code=status.HTTP_202_ACCEPTED)
def request_new_pin(current_user: User = Depends(get_current_user)):
return {"status": "queued", "message": "PIN request queued (mock)"}
# -----------------------------
# 2) Profil-Avatar hochladen (persistiert avatar_url)
# -----------------------------
UPLOAD_DIR = Path("media/avatars")
@router.post("/avatar", status_code=status.HTTP_200_OK)
def upload_avatar(
file: UploadFile = File(...),
db: DBSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
filename = file.filename or "avatar"
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in {"png", "jpg", "jpeg", "webp", "gif", ""}:
raise HTTPException(status_code=400, detail="Unsupported file type")
final_ext = ext if ext else "png"
dest = UPLOAD_DIR / f"{current_user.id}.{final_ext}"
with dest.open("wb") as out:
shutil.copyfileobj(file.file, out)
# User in diese Session laden
u = db.query(User).filter(User.id == current_user.id).first()
if not u:
raise HTTPException(status_code=404, detail="User not found")
u.avatar_url = f"/media/avatars/{u.id}.{final_ext}"
db.commit()
db.refresh(u)
import time
return {
"status": "ok",
"avatar_url": f"{u.avatar_url}?t={int(time.time())}",
"path": str(dest),
}
# -----------------------------
# 3) Eigene Basisdaten ändern (inkl. PayPal + Passwort)
# -----------------------------
class ProfileUpdate(BaseModel):
alias: Optional[str] = None
paypal_email: Optional[EmailStr] = None
visible_in_stats: Optional[bool] = None
public_stats: Optional[bool] = None
# Passwortwechsel
current_password: Optional[str] = None
new_password: Optional[str] = None
@field_validator("new_password")
@classmethod
def _min_len_pw(cls, v):
if v is not None and len(v) < 8:
raise ValueError("Password too short (min. 8)")
return v
@router.put("/me", response_model=dict)
def update_profile(
payload: ProfileUpdate,
db: DBSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
u = db.query(User).filter(User.id == current_user.id).first()
if not u:
raise HTTPException(status_code=404, detail="User not found")
changed = False
# Alias
if payload.alias is not None:
u.alias = payload.alias
changed = True
# PayPal-Mail
if payload.paypal_email is not None:
u.paypal_email = str(payload.paypal_email).lower()
changed = True
# Sichtbarkeit (Kompatibilität: beide Keys akzeptieren)
flag = payload.public_stats if payload.public_stats is not None else payload.visible_in_stats
if flag is not None:
u.public_stats = bool(flag)
changed = True
# Passwortwechsel nur mit current_password
if payload.new_password is not None:
from passlib.context import CryptContext
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Wenn ein Passwort gesetzt ist, muss current_password mitgeschickt werden
if getattr(u, "hashed_password", None):
if not payload.current_password:
raise HTTPException(status_code=400, detail="Current password required")
if not pwd.verify(payload.current_password, u.hashed_password):
raise HTTPException(status_code=400, detail="Current password invalid")
u.hashed_password = pwd.hash(payload.new_password)
changed = True
if changed:
db.commit()
db.refresh(u)
return {"status": "ok"}

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.orm import Session as DBSession
from app.core.database import get_db
from app.models.session import Session
from app.core.auth import SESSION_COOKIE_NAME, clear_session_cookie
router = APIRouter()
@router.delete("/sessions/me")
def delete_own_session(request: Request, response: Response, db: DBSession = Depends(get_db)):
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
clear_session_cookie(response)
return {"detail": "No active session"}
session = db.query(Session).filter_by(token=token).first()
if session:
db.delete(session)
db.commit()
clear_session_cookie(response)
return {"detail": "Session deleted"}

View File

@@ -0,0 +1,361 @@
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Dict, Any, Tuple
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, and_
from sqlalchemy.orm import Session
# TZ: ZoneInfo ist vorhanden, aber es fehlen auf Windows oft die IANA-Daten.
try:
from zoneinfo import ZoneInfo
except Exception: # sehr defensive Fallbacks
ZoneInfo = None # type: ignore
from app.core.database import get_db
from app.core.auth import get_current_user, requires_role
from app.models.booking import Booking
from app.models.user import User
from app.models.product import Product
from app.models.delivery import Delivery # hat KEIN created_at bei dir
router = APIRouter(prefix="/stats", tags=["stats"])
# ----------------- Helpers -----------------
def _get_tz(tz_name: str) -> timezone:
"""
Liefert eine tzinfo. Auf Windows ohne tzdata fällt das auf UTC zurück,
statt eine ZoneInfoNotFoundError zu werfen.
"""
if ZoneInfo is not None:
try:
return ZoneInfo(tz_name) # type: ignore
except Exception:
pass
# Fallback: UTC korrekt, aber ohne CET/CEST-Offset.
return timezone.utc
def _to_naive_utc(dt: Optional[datetime]) -> Optional[datetime]:
"""
Konvertiert timezone-aware nach UTC und entfernt tzinfo.
Bei None -> None. Bei nativ-naiv wird unverändert zurückgegeben.
"""
if dt is None:
return None
if dt.tzinfo is None:
return dt
return dt.astimezone(timezone.utc).replace(tzinfo=None)
def _delivery_time_column():
"""
Ermittelt die Zeitspalte des Delivery-Modells dynamisch.
Reihenfolge: created_at | timestamp | created | delivered_at
Gibt (Column, name) oder (None, None) zurück.
"""
for name in ("created_at", "timestamp", "created", "delivered_at"):
col = getattr(Delivery, name, None)
if col is not None:
return col, name
return None, None
def _last_delivery_at(db: Session) -> Optional[datetime]:
"""
Bestimmt den Zeitpunkt der letzten Lieferung anhand der vorhandenen Spalte.
Achtung: Gibt das zurück, was in der DB liegt (aware oder naiv).
"""
col, _ = _delivery_time_column()
if col is None:
return None
return db.query(func.max(col)).scalar()
def _period_bounds(db: Session, period: str, tz_name: str = "Europe/Berlin") -> Tuple[Optional[datetime], Optional[datetime], Optional[datetime]]:
"""
Liefert (from_utc_naive, to_utc_naive, last_delivery_time_original).
Booking.timestamp ist in deiner App naiv/UTC, daher filtern wir mit naiven UTC-Grenzen.
"""
tz = _get_tz(tz_name)
now_local = datetime.now(tz)
now_utc_naive = now_local.astimezone(timezone.utc).replace(tzinfo=None)
last_delivery = _last_delivery_at(db)
if period == "last_delivery":
if last_delivery is None:
# Fallback: 30 Tage rückwärts
from_utc_naive = (now_local - timedelta(days=30)).astimezone(timezone.utc).replace(tzinfo=None)
else:
from_utc_naive = _to_naive_utc(last_delivery)
return (from_utc_naive, now_utc_naive, last_delivery)
if period == "ytd":
start_local = datetime(now_local.year, 1, 1, tzinfo=tz)
start_utc_naive = start_local.astimezone(timezone.utc).replace(tzinfo=None)
return (start_utc_naive, now_utc_naive, last_delivery)
# "all" -> keine untere Grenze
return (None, now_utc_naive, last_delivery)
# ----------------- Öffentliche Stat-Endpunkte (auth-pflichtig, aber nicht nur Admin) -----------------
@router.get("/meta")
def stats_meta(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
tz: str = Query("Europe/Berlin")
) -> Dict[str, Any]:
"""
Metadaten für die Stats-Seite:
- categories: distinct Product.category (ohne NULL)
- last_delivery_at: ISO-Zeitpunkt der letzten Lieferung (falls vorhanden)
- visible_count: Nutzer mit public_stats==true & alias gesetzt & is_active==true
- hidden_count: aktive Nutzer minus visible_count
- now: Serverzeit in tz
"""
# Kategorien
cats = [
c for (c,) in db.query(Product.category)
.filter(Product.category.isnot(None))
.distinct()
.order_by(Product.category.asc())
.all()
]
# Letzte Lieferung robust gegen fehlende Spalte
last_delivery_time = _last_delivery_at(db)
# Sichtbarkeit
visible_count = (
db.query(func.count(User.id))
.filter(
and_(
User.is_active.is_(True),
User.alias.isnot(None),
func.length(func.trim(User.alias)) > 0,
getattr(User, "public_stats", False) == True, # robust falls Migration noch nicht durch
)
)
.scalar()
or 0
)
active_count = db.query(func.count(User.id)).filter(User.is_active.is_(True)).scalar() or 0
now_local = datetime.now(_get_tz(tz))
return {
"categories": cats,
"last_delivery_at": last_delivery_time.isoformat() if last_delivery_time else None,
"visible_count": int(visible_count),
"hidden_count": int(max(0, active_count - visible_count)),
"now": now_local.isoformat(),
}
@router.get("/top-drinkers")
def top_drinkers(
period: str = Query("last_delivery", pattern="^(last_delivery|ytd|all)$"),
category: str = Query("all"),
limit: int = Query(5, ge=1, le=50),
tz: str = Query("Europe/Berlin"),
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> List[Dict[str, Any]]:
"""
Top-Trinker je Zeitraum und optional Kategorie.
Aggregation: Summe Booking.amount (Stückzahl), zusätzlich count(*) und SUM(total_cents).
Nur Nutzer mit Opt-in (public_stats) + Alias + aktiv.
"""
from_dt, to_dt, _ = _period_bounds(db, period, tz)
q = (
db.query(
User.id.label("user_id"),
User.alias.label("alias"),
User.avatar_url.label("avatar_url"),
func.coalesce(func.sum(Booking.amount), 0).label("amount_sum"),
func.count(Booking.id).label("count"),
func.coalesce(func.sum(Booking.total_cents), 0).label("revenue_cents"),
)
.join(User, User.id == Booking.user_id)
)
if category != "all":
q = q.join(Product, Product.id == Booking.product_id).filter(Product.category == category)
if from_dt is not None:
q = q.filter(Booking.timestamp >= from_dt)
if to_dt is not None:
q = q.filter(Booking.timestamp <= to_dt)
# Sichtbarkeitsregeln robust falls Spalte noch nicht migriert:
public_stats_col = getattr(User, "public_stats", None)
conds = [
User.is_active.is_(True),
User.alias.isnot(None),
func.length(func.trim(User.alias)) > 0,
]
if public_stats_col is not None:
conds.append(public_stats_col.is_(True))
q = q.filter(and_(*conds))
q = q.group_by(User.id, User.alias, User.avatar_url).order_by(func.coalesce(func.sum(Booking.amount), 0).desc()).limit(limit)
rows = q.all()
return [
{
"user_id": r.user_id,
"alias": r.alias,
"avatar_url": r.avatar_url,
"amount_sum": int(r.amount_sum or 0),
"count": int(r.count or 0),
"revenue_cents": int(r.revenue_cents or 0),
}
for r in rows
]
@router.get("/product-share")
def product_share(
period: str = Query("last_delivery", pattern="^(last_delivery|ytd|all)$"),
tz: str = Query("Europe/Berlin"),
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Dict[str, Any]:
"""
Produktverteilung (Kreisdiagramm): COUNT(Booking.id) je Produkt im Zeitraum.
Unabhängig vom Opt-in, da keine personenbezogenen Daten.
"""
from_dt, to_dt, _ = _period_bounds(db, period, tz)
q = (
db.query(
Product.id.label("product_id"),
Product.name.label("product_name"),
func.count(Booking.id).label("count_rows"),
)
.join(Product, Product.id == Booking.product_id)
)
if from_dt is not None:
q = q.filter(Booking.timestamp >= from_dt)
if to_dt is not None:
q = q.filter(Booking.timestamp <= to_dt)
q = q.group_by(Product.id, Product.name).order_by(func.count(Booking.id).desc())
items = q.all()
total = sum(int(r.count_rows or 0) for r in items)
return {
"total": int(total),
"items": [
{
"product_id": r.product_id,
"product_name": r.product_name,
"count": int(r.count_rows or 0),
}
for r in items
],
}
# ----------------- Bestehende Admin-Stats (weiterhin verfügbar) -----------------
@router.get("/summary", dependencies=[Depends(requires_role("manager", "admin"))])
def summary(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
users = db.query(func.count(User.id)).scalar() or 0
products = db.query(func.count(Product.id)).scalar() or 0
bookings = db.query(func.count(Booking.id)).scalar() or 0
total_revenue = db.query(func.coalesce(func.sum(Booking.total_cents), 0)).scalar() or 0
return {
"num_users": users,
"num_products": products,
"num_bookings": bookings,
"total_revenue_cents": int(total_revenue),
"my_balance_cents": int(user.balance_cents or 0),
"stock_overview": None,
}
@router.get("/revenue-summary", dependencies=[Depends(requires_role("manager", "admin"))])
def revenue_summary(db: Session = Depends(get_db), _: User = Depends(get_current_user)):
total_cents = db.query(func.coalesce(func.sum(Booking.total_cents), 0)).scalar() or 0
# dynamische Delivery-Zeitspalte verwenden
last_delivery_time = _last_delivery_at(db)
if last_delivery_time:
since_last_cents = (
db.query(func.coalesce(func.sum(Booking.total_cents), 0))
.filter(Booking.timestamp > _to_naive_utc(last_delivery_time))
.scalar()
or 0
)
else:
since_last_cents = total_cents
return {
"revenue_total_cents": int(total_cents),
"revenue_since_last_delivery_cents": int(since_last_cents),
"last_delivery_at": last_delivery_time.isoformat() if last_delivery_time else None,
}
# (optionale Alt-Endpunkte, falls noch genutzt)
@router.get("/consumption-per-user", dependencies=[Depends(requires_role("manager", "admin"))])
def consumption_per_user(db: Session = Depends(get_db), _: User = Depends(get_current_user)):
rows = (
db.query(
User.id.label("user_id"),
User.name,
func.coalesce(func.sum(Booking.total_cents), 0).label("total_cents"),
)
.outerjoin(Booking, Booking.user_id == User.id)
.group_by(User.id, User.name)
.order_by(func.coalesce(func.sum(Booking.total_cents), 0).desc())
.all()
)
return [{"user_id": r.user_id, "name": r.name, "total_cents": int(r.total_cents or 0)} for r in rows]
@router.get("/consumption-per-product", dependencies=[Depends(requires_role("manager", "admin"))])
def consumption_per_product(db: Session = Depends(get_db), _: User = Depends(get_current_user)):
rows = (
db.query(
Product.id.label("product_id"),
Product.name.label("name"),
func.count(Booking.id).label("count"),
)
.outerjoin(Booking, Booking.product_id == Product.id)
.group_by(Product.id, Product.name)
.order_by(func.count(Booking.id).desc())
.all()
)
return [{"product_id": r.product_id, "name": r.name, "count": int(r.count or 0)} for r in rows]
@router.get("/monthly-ranking", dependencies=[Depends(requires_role("manager", "admin"))])
def monthly_ranking(
year: Optional[int] = Query(None),
month: Optional[int] = Query(None),
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
):
q = (
db.query(
User.id.label("user_id"),
User.name,
func.coalesce(func.sum(Booking.total_cents), 0).label("total_cents"),
)
.join(User, User.id == Booking.user_id)
.group_by(User.id, User.name)
.order_by(func.coalesce(func.sum(Booking.total_cents), 0).desc())
.limit(10)
)
return [{"user_id": r.user_id, "name": r.name, "total_cents": int(r.total_cents or 0)} for r in q.all()]

View File

@@ -0,0 +1,203 @@
# app/api/topups.py
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from typing import List, Optional, Literal
from pydantic import BaseModel
from app.core.database import get_db
from app.core.auth import get_current_user, requires_role
from app.models.user import User
from app.models.topup import Topup, TopupStatus as DBTopupStatus
from app.schemas.topup import TopupOut, TopupCreate # Pydantic v2 Schemas
# Optionales Audit-Log nur nutzen, wenn vorhanden
try:
from app.models.audit_log import AuditLog, AuditAction
HAS_AUDIT = True
except Exception:
AuditLog = None
AuditAction = None
HAS_AUDIT = False
router = APIRouter(prefix="/topups", tags=["topups"])
# ---------------------------
# CREATE (User oder Admin)
# ---------------------------
@router.post("/", response_model=TopupOut, status_code=status.HTTP_201_CREATED)
def create_topup(
topup: TopupCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Legt ein Topup an. amount_cents ist Pflicht. user_id ist optional; wenn nicht gesetzt,
wird der aktuelle User verwendet.
"""
payload = topup.model_dump() # Pydantic v2
# user_id für User-Flow ergänzen
user_id = payload.get("user_id") or current_user.id
amount_cents = int(payload.get("amount_cents") or 0)
if amount_cents <= 0:
raise HTTPException(status_code=400, detail="amount_cents must be > 0")
db_topup = Topup(
user_id=user_id,
amount_cents=amount_cents,
paypal_email=payload.get("paypal_email"),
note=payload.get("note"),
# status default: pending (siehe Model)
)
db.add(db_topup)
db.commit()
db.refresh(db_topup)
if HAS_AUDIT:
db.add(
AuditLog(
user_id=db_topup.user_id,
action=getattr(AuditAction, "topup_create", "topup_create"),
amount_cents=db_topup.amount_cents,
info=f"created by {current_user.id}",
)
)
db.commit()
return db_topup
# ---------------------------
# LIST (Admin/Manager, gefiltert)
# ---------------------------
@router.get("/", response_model=List[TopupOut], dependencies=[Depends(requires_role("manager", "admin"))])
def list_topups(
user_id: Optional[int] = Query(default=None),
status_filter: Optional[str] = Query(default=None, description="pending|confirmed|rejected"),
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = db.query(Topup)
if user_id is not None:
q = q.filter(Topup.user_id == user_id)
if status_filter:
try:
enum_val = DBTopupStatus(status_filter)
except ValueError:
raise HTTPException(status_code=400, detail="invalid status_filter")
q = q.filter(Topup.status == enum_val)
q = q.order_by(Topup.id.desc()).limit(limit).offset(offset)
return q.all()
# ---------------------------
# LIST OWN (alle eingeloggten)
# ---------------------------
@router.get("/me", response_model=List[TopupOut])
def list_my_topups(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = (
db.query(Topup)
.filter(Topup.user_id == current_user.id)
.order_by(Topup.id.desc())
.limit(limit)
.offset(offset)
)
return q.all()
# ---------------------------
# STATUS ändern (Admin/Manager)
# ---------------------------
class TopupStatusIn(BaseModel):
status: Literal["confirmed", "rejected"]
@router.patch("/{topup_id}/status", dependencies=[Depends(requires_role("manager", "admin"))])
def patch_topup_status(
topup_id: int,
payload: TopupStatusIn,
db: Session = Depends(get_db),
admin: User = Depends(get_current_user),
):
t = db.query(Topup).filter(Topup.id == topup_id).first()
if not t:
raise HTTPException(status_code=404, detail="Topup not found")
# robustes Enum-Handling
old_enum = t.status if isinstance(t.status, DBTopupStatus) else DBTopupStatus(str(t.status))
try:
new_enum = DBTopupStatus(payload.status)
except ValueError:
raise HTTPException(status_code=400, detail="invalid status")
if old_enum == new_enum:
return {"status": "ok", "topup_id": t.id, "new_status": old_enum.value}
# Status setzen
t.status = new_enum
# Balance nur bei Übergang zu 'confirmed' buchen (idempotent)
if new_enum == DBTopupStatus.confirmed and old_enum != DBTopupStatus.confirmed:
u = db.query(User).filter(User.id == t.user_id).first()
if not u:
raise HTTPException(status_code=404, detail="User for topup not found")
before = int(u.balance_cents or 0)
u.balance_cents = before + int(t.amount_cents or 0)
if HAS_AUDIT:
db.add(
AuditLog(
user_id=u.id,
action=getattr(AuditAction, "topup_confirmed", "topup_confirmed"),
amount_cents=int(t.amount_cents or 0),
old_balance_cents=before,
new_balance_cents=u.balance_cents,
info=f"topup:{t.id} confirmed by {admin.id}",
)
)
# (Kein automatisches Reversal bei Wechsel zu rejected)
db.commit()
db.refresh(t)
return {"status": "ok", "topup_id": t.id, "new_status": t.status.value}
# ---------------------------
# DELETE (Admin/Manager)
# ---------------------------
@router.delete("/{topup_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(requires_role("manager", "admin"))])
def delete_topup(
topup_id: int,
db: Session = Depends(get_db),
admin: User = Depends(get_current_user),
):
t = db.query(Topup).filter(Topup.id == topup_id).first()
if not t:
raise HTTPException(status_code=404, detail="Topup not found")
db.delete(t)
db.commit()
if HAS_AUDIT:
db.add(
AuditLog(
user_id=t.user_id,
action=getattr(AuditAction, "topup_delete", "topup_delete"),
amount_cents=int(t.amount_cents or 0),
info=f"topup:{t.id} deleted by {admin.id}",
)
)
db.commit()
return None

View File

@@ -0,0 +1,127 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Literal, TypedDict
from app.core.database import get_db
from app.core.auth import get_current_user, requires_role
from app.models.user import User
from app.models.topup import Topup
from app.models.booking import Booking
router = APIRouter(prefix="/transactions", tags=["transactions"])
class TransactionOut(TypedDict):
id: str
type: Literal["topup", "booking"]
amount_cents: int
created_at: str # ISO
@router.get("/me")
def list_my_transactions(
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Kombiniert Topups (+) und Buchungen () des aktuellen Users.
"""
topups = (
db.query(Topup)
.filter(Topup.user_id == current_user.id)
.order_by(Topup.id.desc())
.limit(limit)
.offset(offset)
.all()
)
bookings = (
db.query(Booking)
.filter(Booking.user_id == current_user.id)
.order_by(Booking.id.desc())
.limit(limit)
.offset(offset)
.all()
)
tx: List[TransactionOut] = []
for t in topups:
tx.append(
{
"id": f"topup-{t.id}",
"type": "topup",
"amount_cents": int(t.amount_cents or 0),
"created_at": t.created_at.isoformat() if hasattr(t, "created_at") and t.created_at else "",
}
)
for b in bookings:
# Wenn du Booking.total_cents hast, nutze das. Sonst product.price_cents * amount.
total = getattr(b, "total_cents", None)
if total is None:
price = getattr(b, "price_cents", None)
if price is None and hasattr(b, "product") and b.product:
price = getattr(b.product, "price_cents", 0)
total = (price or 0) * (getattr(b, "amount", 1) or 1)
tx.append(
{
"id": f"booking-{b.id}",
"type": "booking",
"amount_cents": -int(total or 0), # Buchungen als negative Beträge
"created_at": b.created_at.isoformat() if hasattr(b, "created_at") and b.created_at else "",
}
)
# Absteigend nach Datum
tx.sort(key=lambda x: x["created_at"], reverse=True)
# einfache Paginierung nach Sortierung (optional): tx = tx[offset:offset+limit]
return tx
@router.get("/", dependencies=[Depends(requires_role("manager", "admin"))])
def list_all_transactions_admin(
limit: int = Query(200, ge=1, le=2000),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Admin/Manager: kombinierte Übersicht (einfach, nicht performant für große Datenmengen).
"""
topups = (
db.query(Topup).order_by(Topup.id.desc()).limit(limit).offset(offset).all()
)
bookings = (
db.query(Booking).order_by(Booking.id.desc()).limit(limit).offset(offset).all()
)
tx: List[TransactionOut] = []
for t in topups:
tx.append(
{
"id": f"topup-{t.id}",
"type": "topup",
"amount_cents": int(t.amount_cents or 0),
"created_at": t.created_at.isoformat() if hasattr(t, "created_at") and t.created_at else "",
}
)
for b in bookings:
total = getattr(b, "total_cents", None)
if total is None:
price = getattr(b, "price_cents", None)
if price is None and hasattr(b, "product") and b.product:
price = getattr(b.product, "price_cents", 0)
total = (price or 0) * (getattr(b, "amount", 1) or 1)
tx.append(
{
"id": f"booking-{b.id}",
"type": "booking",
"amount_cents": -int(total or 0),
"created_at": b.created_at.isoformat() if hasattr(b, "created_at") and b.created_at else "",
}
)
tx.sort(key=lambda x: x["created_at"], reverse=True)
return tx

View File

@@ -0,0 +1,258 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
from sqlalchemy.orm import Session
from sqlalchemy import func, or_, asc, desc
from typing import List, Optional
from passlib.context import CryptContext
from pydantic import BaseModel, constr, conint
from datetime import datetime
from app.core.database import get_db
from app.models.user import User
from app.schemas.user import UserOut, UserCreate, UserUpdate
from app.core.auth import get_current_user, requires_role_relaxed, requires_role_mgmt
# NEU: Topup-Model importieren, damit Admin-Gutschriften als Topup geloggt werden
from app.models.topup import Topup, TopupStatus
router = APIRouter(prefix="/users", tags=["users"])
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_db_sess():
db = get_db()
# get_db ist ein generator → FastAPI injiziert automatisch.
# falls du eine SessionLocal-Funktion hast, nutze die hier analog.
return db
@router.get("/", response_model=List[UserOut], dependencies=[Depends(requires_role_relaxed("manager","admin"))])
def list_users(
q: Optional[str] = Query(default=None, description="Suche in Name/E-Mail/Alias"),
role: Optional[str] = Query(default=None, regex="^(user|manager|admin)$"),
active: Optional[bool] = Query(default=None),
balance_lt: Optional[int] = Query(default=None, description="Kontostand < Wert (Cent)"),
limit: int = Query(default=25, ge=1, le=200),
offset: int = Query(default=0, ge=0),
sort: str = Query(default="name", description="name|email|role|balance_cents|is_active"),
order: str = Query(default="asc", regex="^(asc|desc)$"),
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
):
qy = db.query(User)
if q:
like = f"%{q.lower()}%"
qy = qy.filter(or_(
func.lower(User.name).like(like),
func.lower(User.email).like(like),
func.lower(func.coalesce(User.alias, "")).like(like),
))
if role:
qy = qy.filter(User.role == role)
if active is not None:
qy = qy.filter(User.is_active == active)
if balance_lt is not None:
qy = qy.filter((User.balance_cents != None) & (User.balance_cents < balance_lt))
sort_map = {
"name": User.name,
"email": User.email,
"role": User.role,
"balance_cents": User.balance_cents,
"is_active": User.is_active,
}
col = sort_map.get(sort, User.name)
qy = qy.order_by(asc(col) if order == "asc" else desc(col))
return qy.limit(limit).offset(offset).all()
@router.get("/{user_id}", response_model=UserOut, dependencies=[Depends(requires_role_relaxed("manager","admin"))])
def get_user(user_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user)):
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(status_code=404, detail="User not found")
return u
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED, dependencies=[Depends(requires_role_mgmt("manager","admin"))])
def create_user(payload: UserCreate, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
if db.query(User).filter(User.email == payload.email).first():
raise HTTPException(status_code=409, detail="E-Mail already exists")
if payload.alias and db.query(User).filter(User.alias == payload.alias).first():
raise HTTPException(status_code=409, detail="Alias already exists")
u = User(
name=payload.name,
email=payload.email,
hashed_password=pwd.hash(payload.password),
hashed_pin=pwd.hash(payload.pin),
alias=payload.alias,
paypal_email=payload.paypal_email,
role=payload.role,
is_active=True,
)
db.add(u); db.commit(); db.refresh(u)
return u
@router.patch("/{user_id}", response_model=UserOut, dependencies=[Depends(requires_role_mgmt("manager","admin"))])
def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(status_code=404, detail="User not found")
if payload.email is not None:
if db.query(User).filter(User.email == payload.email, User.id != user_id).first():
raise HTTPException(status_code=409, detail="E-Mail already exists")
u.email = payload.email
if payload.alias is not None:
if db.query(User).filter(User.alias == payload.alias, User.id != user_id).first():
raise HTTPException(status_code=409, detail="Alias already exists")
u.alias = payload.alias
if payload.name is not None:
u.name = payload.name
if payload.password is not None:
u.hashed_password = pwd.hash(payload.password)
if payload.pin is not None:
u.hashed_pin = pwd.hash(payload.pin)
if payload.paypal_email is not None:
u.paypal_email = payload.paypal_email
if payload.role is not None:
u.role = payload.role
if payload.is_active is not None:
u.is_active = payload.is_active
db.commit(); db.refresh(u)
return u
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(requires_role_mgmt("manager","admin"))])
def delete_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(status_code=404, detail="User not found")
db.delete(u); db.commit()
return None
# -----------------------------
# Sicherheitsaktionen
# -----------------------------
class SetPinIn(BaseModel):
pin: constr(min_length=6, max_length=6)
class SetPasswordIn(BaseModel):
password: constr(min_length=8)
@router.post("/{user_id}/set-pin", dependencies=[Depends(requires_role_mgmt("manager","admin"))])
def set_pin(user_id: int, payload: SetPinIn, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
u = db.query(User).filter(User.id == user_id).first()
if not u: raise HTTPException(status_code=404, detail="User not found")
if admin.role == "manager" and u.role == "admin":
raise HTTPException(status_code=403, detail="Managers cannot modify admin credentials")
u.hashed_pin = pwd.hash(payload.pin); db.commit()
return {"status": "ok"}
@router.post("/{user_id}/set-password", dependencies=[Depends(requires_role_mgmt("manager","admin"))])
def set_password(user_id: int, payload: SetPasswordIn, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
u = db.query(User).filter(User.id == user_id).first()
if not u: raise HTTPException(status_code=404, detail="User not found")
if admin.role == "manager" and u.role == "admin":
raise HTTPException(status_code=403, detail="Managers cannot modify admin credentials")
u.hashed_password = pwd.hash(payload.password); db.commit()
return {"status": "ok"}
class BalanceAdjustIn(BaseModel):
amount_cents: conint(strict=True)
reason: constr(min_length=1, max_length=500)
@router.post("/{user_id}/adjust-balance", dependencies=[Depends(requires_role_mgmt("manager","admin"))])
def adjust_balance(user_id: int, payload: BalanceAdjustIn, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
"""
Admin/Manager passt das Guthaben an.
- Betrag != 0 erforderlich.
- Bei positiver Anpassung wird zusätzlich ein Topup mit status=confirmed und note=reason erstellt,
damit es in /topups/me (und der TransactionPage) sichtbar ist.
"""
amount = int(payload.amount_cents or 0)
if amount == 0:
raise HTTPException(status_code=400, detail="amount_cents must be non-zero")
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(status_code=404, detail="User not found")
if admin.role == "manager" and u.role == "admin":
raise HTTPException(status_code=403, detail="Managers cannot adjust admin accounts")
before = int(u.balance_cents or 0)
u.balance_cents = before + amount
created_topup_id: Optional[int] = None
# Nur bei Gutschrift (+) zusätzlich Topup schreiben (mit Notiz = reason).
if amount > 0:
t = Topup(
user_id=u.id,
amount_cents=amount,
status=TopupStatus.confirmed, # direkt bestätigt
created_at=datetime.utcnow(),
confirmed_at=datetime.utcnow(),
note=payload.reason.strip() if payload.reason else None,
)
db.add(t)
db.flush() # ID holen, ohne Zwischen-Commit
created_topup_id = t.id
db.commit()
db.refresh(u)
return {
"status": "ok",
"user_id": u.id,
"old_balance_cents": before,
"new_balance_cents": u.balance_cents,
"created_topup_id": created_topup_id, # nur bei positiver Anpassung gesetzt
}
# -----------------------------
# FAVORITES: GET / PUT / PATCH
# -----------------------------
def _ensure_can_edit(target_user_id: int, actor: User):
"""Eigenen Datensatz immer; sonst nur manager/admin."""
if actor.id != target_user_id and str(actor.role) not in {"manager", "admin"}:
raise HTTPException(status_code=403, detail="Not allowed")
@router.get("/{user_id}/favorites", response_model=List[int])
def get_user_favorites(
user_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
_ensure_can_edit(user_id, current)
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(status_code=404, detail="User not found")
return list(u.favorites or [])
@router.put("/{user_id}/favorites", response_model=List[int])
def replace_user_favorites(
user_id: int,
favorites: List[int] = Body(..., embed=False, description="Array von Produkt-IDs"),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
_ensure_can_edit(user_id, current)
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(status_code=404, detail="User not found")
# Sanitizing: nur eindeutige, positive ints
clean = sorted({int(x) for x in favorites if isinstance(x, int) and x > 0})
u.favorites = clean
db.add(u)
db.commit()
return clean
@router.patch("/{user_id}/favorites", response_model=List[int])
def patch_user_favorites(
user_id: int,
favorites: List[int] = Body(..., embed=False, description="Array von Produkt-IDs"),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
# Dein Frontend sendet bei PATCH das komplette Array → identisch zu PUT behandeln.
return replace_user_favorites(user_id, favorites, db, current)