new init
This commit is contained in:
31
apps/backend/app/api/admin_settings.py
Normal file
31
apps/backend/app/api/admin_settings.py
Normal 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
|
108
apps/backend/app/api/admin_transactions.py
Normal file
108
apps/backend/app/api/admin_transactions.py
Normal 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
|
73
apps/backend/app/api/audit_logs.py
Normal file
73
apps/backend/app/api/audit_logs.py
Normal 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()
|
292
apps/backend/app/api/auth.py
Normal file
292
apps/backend/app/api/auth.py
Normal 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"])}
|
171
apps/backend/app/api/bookings.py
Normal file
171
apps/backend/app/api/bookings.py
Normal 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
|
85
apps/backend/app/api/categories.py
Normal file
85
apps/backend/app/api/categories.py
Normal 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}
|
284
apps/backend/app/api/deliveries.py
Normal file
284
apps/backend/app/api/deliveries.py
Normal 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}
|
||||
|
126
apps/backend/app/api/ledger.py
Normal file
126
apps/backend/app/api/ledger.py
Normal 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),
|
||||
)
|
79
apps/backend/app/api/paypal_ipn.py
Normal file
79
apps/backend/app/api/paypal_ipn.py
Normal 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}
|
54
apps/backend/app/api/products.py
Normal file
54
apps/backend/app/api/products.py
Normal 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()
|
135
apps/backend/app/api/profile.py
Normal file
135
apps/backend/app/api/profile.py
Normal 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"}
|
20
apps/backend/app/api/sessions.py
Normal file
20
apps/backend/app/api/sessions.py
Normal 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"}
|
361
apps/backend/app/api/stats.py
Normal file
361
apps/backend/app/api/stats.py
Normal 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()]
|
203
apps/backend/app/api/topups.py
Normal file
203
apps/backend/app/api/topups.py
Normal 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
|
127
apps/backend/app/api/transactions.py
Normal file
127
apps/backend/app/api/transactions.py
Normal 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
|
258
apps/backend/app/api/users.py
Normal file
258
apps/backend/app/api/users.py
Normal 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)
|
Reference in New Issue
Block a user