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

@@ -1,3 +0,0 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/bacchus
JWT_SECRET=<secret here>
RUST_LOG=bacchus=debug,axum=info,tower_http=info

2497
apps/backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +0,0 @@
[package]
name = "bacchus"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8.4", features = ["macros","json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls","postgres","macros","uuid","chrono","migrate"] }
uuid = { version = "1", features = ["serde","v4"] }
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.6.6", features = ["cors","trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"

141
apps/backend/alembic.ini Normal file
View File

@@ -0,0 +1,141 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql://postgres:bacchus@localhost:5432/bacchus
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

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)

View File

@@ -0,0 +1,96 @@
from datetime import timedelta
import secrets
from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy.orm import Session as DBSession
from app.core.database import get_db
from app.models.session import Session as SessionModel
from app.models.user import User
SESSION_COOKIE_NAME = "bacchus_session"
CSRF_COOKIE_NAME = "bacchus_csrf"
CSRF_HEADER_NAME = "X-CSRF-Token"
SESSION_TTL = timedelta(hours=8)
def _new_token() -> str:
return secrets.token_urlsafe(32)
# ---------- Role-Normalisierung ----------
def _normalize_role(role_raw) -> str:
# Enum? -> value
if hasattr(role_raw, "value"):
role_raw = role_raw.value
role = str(role_raw or "user").strip()
if "." in role: # z.B. "UserRole.admin"
role = role.split(".")[-1]
return role.lower()
def issue_csrf_cookie(resp: Response) -> str:
token = _new_token()
resp.set_cookie(
key=CSRF_COOKIE_NAME,
value=token,
max_age=7200,
secure=False, # PROD: True
samesite="lax",
path="/",
)
return token
def clear_csrf_cookie(resp: Response) -> None:
resp.delete_cookie(key=CSRF_COOKIE_NAME, path="/", samesite="lax")
def verify_csrf(request: Request) -> None:
# CSRF nur für mutierende Methoden
if request.method in ("GET", "HEAD", "OPTIONS"):
return
cookie = request.cookies.get(CSRF_COOKIE_NAME)
header = request.headers.get(CSRF_HEADER_NAME)
if cookie != header:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF check failed")
def create_session(db: DBSession, user_id: int) -> str:
token = _new_token()
db.add(SessionModel(user_id=user_id, token=token))
db.commit()
return token
def set_session_cookie(resp: Response, token: str) -> None:
resp.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=True,
secure=False, # PROD: True
samesite="lax",
max_age=int(SESSION_TTL.total_seconds()),
path="/",
)
def clear_session_cookie(resp: Response) -> None:
resp.delete_cookie(key=SESSION_COOKIE_NAME, path="/", samesite="lax")
def get_current_user(request: Request, db: DBSession = Depends(get_db)) -> User:
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session")
session = db.query(SessionModel).filter_by(token=token).first()
if not session:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session")
return session.user
def requires_role(*roles: str):
roles_norm = tuple(_normalize_role(r) for r in roles)
def dep(user: User = Depends(get_current_user)):
user_role = _normalize_role(getattr(user, "role", None))
if roles_norm and user_role not in roles_norm:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
return user
return dep
# ---- Hybrid-Gates ----
def requires_role_relaxed(*roles: str):
return requires_role(*roles)
def requires_role_mgmt(*roles: str):
# Später hier optional Session-Typ "management" erzwingen
return requires_role(*roles)

View File

@@ -0,0 +1,32 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from contextlib import contextmanager
# Datenbank-URL (später besser aus Umgebungsvariable laden)
DATABASE_URL = "postgresql://postgres:bacchus@localhost:5432/bacchus"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Dependency für FastAPI-Routen
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Optional: Kontextmanager für Skripte außerhalb von FastAPI
@contextmanager
def db_session():
db = SessionLocal()
try:
yield db
db.commit()
except:
db.rollback()
raise
finally:
db.close()

108
apps/backend/app/main.py Normal file
View File

@@ -0,0 +1,108 @@
from pathlib import Path
from dotenv import load_dotenv
import os
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
os.environ["DOTENV_PATH"] = str(ENV_PATH)
load_dotenv(dotenv_path=ENV_PATH, override=True)
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from app.api import (
auth, users, products, bookings, deliveries, stats, topups,
audit_logs, profile, transactions, categories, ledger, admin_settings, paypal_ipn
)
from app.core.auth import CSRF_HEADER_NAME, verify_csrf
ALLOWED_ORIGINS = ["http://localhost:5173"]
# CSRF-Ausnahmen (nur für DEV sinnvoll!)
CSRF_EXEMPT = {
"/auth/pin-login",
"/auth/management-login",
"/auth/logout",
"/auth/csrf",
"/docs",
"/openapi.json",
"/redoc",
"/docs/oauth2-redirect",
}
# Swagger-/Redoc-Pfade
DOCS_PATHS = ("/docs", "/redoc", "/openapi.json")
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", CSRF_HEADER_NAME],
)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
# CSRF nur für mutierende Methoden prüfen, ausgenommene Pfade überspringen
if (
request.method in {"POST", "PUT", "PATCH", "DELETE"}
and not any(path == p or path.startswith(p + "/") for p in CSRF_EXEMPT)
):
verify_csrf(request)
response: Response = await call_next(request)
if any(path == p or path.startswith(p + "/") for p in DOCS_PATHS):
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self' https:; "
"img-src 'self' data: https:; "
"style-src 'self' 'unsafe-inline' https:; "
"script-src 'self' 'unsafe-inline' https:"
)
return response
# Normale Security-Header + strikte CSP
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'"
)
return response
app.add_middleware(SecurityHeadersMiddleware)
# Medienordner sicherstellen (verhindert RuntimeError beim Mount)
os.makedirs("media/avatars", exist_ok=True)
# Static files für Avatare & Medien
app.mount("/media", StaticFiles(directory="media"), name="media")
# Router registrieren
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(products.router)
app.include_router(bookings.router)
app.include_router(deliveries.router)
app.include_router(stats.router)
app.include_router(topups.router)
app.include_router(audit_logs.router)
app.include_router(profile.router)
app.include_router(transactions.router)
app.include_router(categories.router)
app.include_router(ledger.router)
app.include_router(admin_settings.router)
app.include_router(paypal_ipn.router)
# app.include_router(sessions.router)
@app.get("/")
def root():
return {"message": "Bacchus API läuft"}

View File

@@ -0,0 +1,29 @@
from app.core.database import Base
from sqlalchemy import Column, Integer, String, DateTime, Enum, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
class AuditAction(enum.Enum):
booking_create = "booking_create"
booking_delete = "booking_delete"
topup_create = "topup_create"
# Weitere Aktionen können hier ergänzt werden, z.B.:
# topup_confirm = "topup_confirm"
# user_update = "user_update"
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
action = Column(String, nullable=False)
amount_cents = Column(Integer, nullable=True)
old_balance_cents = Column(Integer, nullable=True)
new_balance_cents = Column(Integer, nullable=True)
info = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User")

View File

@@ -0,0 +1,18 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime, String
from sqlalchemy.orm import relationship
from app.core.database import Base
from datetime import datetime
class Booking(Base):
__tablename__ = "bookings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
amount = Column(Integer, nullable=False) # Anzahl der Produkte in dieser Buchung
total_cents = Column(Integer, nullable=False) # Gesamtsumme in Cent (Preis * Anzahl)
comment = Column(String, nullable=True)
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False) # Zeitpunkt der Buchung
user = relationship("User")
product = relationship("Product")

View File

@@ -0,0 +1,11 @@
from sqlalchemy import Column, String, Text, DateTime, func
from app.core.database import Base
class Config(Base):
__tablename__ = "config"
key = Column(String(64), primary_key=True, index=True)
value = Column(Text, nullable=False, default="")
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -0,0 +1,22 @@
from app.core.database import Base
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime
class Delivery(Base):
__tablename__ = "deliveries"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True)
amount = Column(Integer, nullable=False, default=0) # gelieferte Menge (Stück / Einheiten)
price_cents = Column(Integer, nullable=False, default=0) # Einkaufspreis in Cent (gesamt oder pro Einheit je nach Modell)
delivered_at = Column(DateTime(timezone=True), nullable=True) # Lieferdatum
supplier = Column(String, nullable=True) # Lieferant (frei)
invoice_number = Column(String, nullable=True) # Rechnungsnummer
created_by = Column(Integer, nullable=True) # optional: User-ID, der die Lieferung erfasst hat
deposit_return_cents = Column(Integer, nullable=False, server_default="0")
note = Column(Text, nullable=True) # oder String(1000)
# Rückverknüpfung zum Produkt
product = relationship("Product", back_populates="deliveries")

View File

@@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.database import Base
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, unique=True, index=True)
category = Column(String, nullable=True, index=True)
volume_ml = Column(Integer, nullable=False)
price_cents = Column(Integer, nullable=False)
supplier_number = Column(String, nullable=True)
image_url = Column(String, nullable=True)
stock = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
pack_size = Column(Integer, nullable=False, default=1) # z.B. 6, 12, 24
purchase_price_cents = Column(Integer, nullable=False, default=0)
# >>> NEU: Gegenbeziehung zur Delivery-Relation
deliveries = relationship(
"Delivery",
back_populates="product",
cascade="all, delete-orphan",
lazy="selectin",
)

View File

@@ -0,0 +1,14 @@
from app.core.database import Base
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from datetime import datetime
class Session(Base):
__tablename__ = "sessions"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
token = Column(String(64), unique=True, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User")

View File

@@ -0,0 +1,25 @@
from app.core.database import Base
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
class TopupStatus(enum.Enum):
pending = "pending"
confirmed = "confirmed"
rejected = "rejected"
class Topup(Base):
__tablename__ = "topups"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
amount_cents = Column(Integer, nullable=False)
status = Column(Enum(TopupStatus), default=TopupStatus.pending, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
confirmed_at = Column(DateTime, nullable=True)
paypal_email = Column(String, nullable=True)
note = Column(String, nullable=True)
user = relationship("User")

View File

@@ -0,0 +1,42 @@
from app.core.database import Base
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum, JSON
import enum
from datetime import datetime
class UserRole(enum.Enum):
user = "user"
manager = "manager"
admin = "admin"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
email = Column(String, nullable=False, unique=True, index=True)
hashed_password = Column(String, nullable=False)
# Hinweis: hashed_pin als unique kann problematisch sein, falls None in deiner DB ist es gesetzt.
hashed_pin = Column(String, nullable=False, unique=True, index=True)
# Sichtbarkeit/Alias für die Stats-Seite
alias = Column(String, nullable=True, unique=True)
public_stats = Column(Boolean, nullable=False, default=False) # <— NEU: Opt-in
paypal_email = Column(String, nullable=True)
role = Column(Enum(UserRole), nullable=False, default=UserRole.user)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
balance_cents = Column(Integer, nullable=False, default=0)
favorites = Column(JSON, nullable=False, default=list)
avatar_url = Column(String, nullable=True)
# PIN-Sicherheit (Lockout etc.)
from sqlalchemy import String as SQLAString
pin_lookup = Column(SQLAString(64), index=True, nullable=True) # HMAC-SHA256(PEPPER, pin)
pin_fail_count = Column(Integer, nullable=False, default=0)
pin_locked_until = Column(DateTime, nullable=True)

View File

@@ -0,0 +1,29 @@
from datetime import datetime
from typing import Optional
# Pydantic v1/v2 Kompatibilität
try:
from pydantic import BaseModel, ConfigDict # v2
_V2 = True
except ImportError: # v1
from pydantic import BaseModel # type: ignore
_V2 = False
class AuditLogOut(BaseModel):
id: int
user_id: Optional[int] = None
timestamp: Optional[datetime] = None
# WICHTIG: frei als String, kein zu enges Enum -> verhindert ResponseValidationError
action: str
info: Optional[str] = None
old_balance_cents: Optional[int] = None
new_balance_cents: Optional[int] = None
if _V2:
# Pydantic v2
model_config = ConfigDict(from_attributes=True)
else:
# Pydantic v1
class Config:
orm_mode = True

View File

@@ -0,0 +1,19 @@
from pydantic import BaseModel
from datetime import datetime
class BookingBase(BaseModel):
user_id: int
product_id: int
amount: int
total_cents: int
comment: str | None = None
class BookingCreate(BookingBase):
pass
class BookingOut(BookingBase):
id: int
timestamp: datetime
class Config:
from_attributes = True # Pydantic V2

View File

@@ -0,0 +1,38 @@
from pydantic import BaseModel, ConfigDict
from datetime import date, datetime
from typing import Optional, List
# Bestehendes Einzeilen-Schema bleibt
class DeliveryBase(BaseModel):
product_id: int
amount: int
price_cents: int
delivered_at: Optional[date] = None
supplier: Optional[str] = None
invoice_number: Optional[str] = None
created_by: Optional[int] = None
note: str | None = None
deposit_return_cents: int = 0
model_config = ConfigDict(from_attributes=True) # statt orm_mode
class DeliveryCreate(DeliveryBase):
pass
class DeliveryOut(DeliveryBase):
id: int
class Config:
from_attributes = True
# 🆕 Für die neue Seite (Header + Items + Pfand)
class DeliveryItemIn(BaseModel):
product_id: int
quantity_units: int
unit_cost_cents: int
class DeliveryCreateBulk(BaseModel):
supplier: Optional[str] = None
date: Optional[date] = None
invoice_no: Optional[str] = None
note: Optional[str] = None
deposit_return_cents: int = 0 # Netto-Pfand (Cent, positiv)
items: List[DeliveryItemIn]

View File

@@ -0,0 +1,28 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class ProductBase(BaseModel):
name: str
price_cents: int # Verkaufspreis (falls genutzt)
purchase_price_cents: int = 0 # 🆕 EK je Einheit (Cent)
pack_size: int = 1 # 🆕 6, 12, 24 ...
supplier_number: Optional[str] = None
volume_ml: Optional[int] = None
category: Optional[str] = None
stock: int = 0 # Default sinnvoller
is_active: bool = True # Default sinnvoller
class ProductCreate(ProductBase):
pass
class ProductUpdate(ProductBase):
pass
class ProductOut(ProductBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from enum import Enum
class TopupStatus(str, Enum):
pending = "pending"
confirmed = "confirmed"
rejected = "rejected"
# Für Ausgaben und Admin-Create weiterhin kompatibel:
class TopupBase(BaseModel):
user_id: Optional[int] = None # <- optional gemacht
amount_cents: int
paypal_email: Optional[str] = None
note: Optional[str] = None
class TopupCreate(TopupBase):
# user_id kann für Admin-Aufrufe gesetzt werden, für User-Create weggelassen
pass
class TopupStatusUpdate(BaseModel):
status: TopupStatus
class TopupOut(TopupBase):
id: int
status: TopupStatus
created_at: datetime
confirmed_at: Optional[datetime] = None
class Config:
from_attributes = True # pydantic v2 (ersetzt orm_mode)

View File

@@ -0,0 +1,46 @@
from pydantic import BaseModel, EmailStr, constr
from typing import Optional, List
from enum import Enum
class UserRole(str, Enum):
user = "user"
manager = "manager"
admin = "admin"
class UserOut(BaseModel):
id: int
name: str
email: EmailStr
alias: Optional[str] = None
paypal_email: Optional[EmailStr] = None
role: UserRole
is_active: bool
balance_cents: int
favorites: List[int]
# NEU:
avatar_url: Optional[str] = None
public_stats: bool
class Config:
orm_mode = True
class UserCreate(BaseModel):
name: str
email: EmailStr
password: constr(min_length=8)
pin: constr(min_length=6, max_length=6)
alias: Optional[str] = None
paypal_email: Optional[EmailStr] = None
role: Optional[UserRole] = UserRole.user
class UserUpdate(BaseModel):
name: Optional[str] = None
email: Optional[EmailStr] = None
password: Optional[constr(min_length=8)] = None
pin: Optional[constr(min_length=6, max_length=6)] = None
alias: Optional[str] = None
paypal_email: Optional[EmailStr] = None
role: Optional[UserRole] = None
is_active: Optional[bool] = None
balance_cents: Optional[int] = None
favorites: Optional[List[int]] = None

View File

@@ -0,0 +1,19 @@
import secrets
from sqlalchemy.orm import Session as DBSession
from app.models.session import Session as SessionModel
def create_session(db: DBSession, user_id: int) -> str:
token = secrets.token_hex(32)
session = SessionModel(user_id=user_id, token=token)
db.add(session)
db.commit()
db.refresh(session)
return token
def get_user_by_token(db: DBSession, token: str):
session = db.query(SessionModel).filter_by(token=token).first()
return session.user if session else None
def delete_session(db: DBSession, token: str):
db.query(SessionModel).filter_by(token=token).delete()
db.commit()

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 KiB

View File

@@ -1,63 +0,0 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
price_cents INT NOT NULL CHECK (price_cents >= 0),
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE ledger (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
amount_cents INT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('topup','purchase','adjustment')),
ref_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
total_cents INT NOT NULL CHECK (total_cents >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id),
qty INT NOT NULL CHECK (qty > 0),
price_cents INT NOT NULL CHECK (price_cents >= 0),
PRIMARY KEY (order_id, product_id)
);
CREATE TABLE inventory_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id),
qty INT NOT NULL,
reason TEXT NOT NULL CHECK (reason IN ('purchase','consumption','correction')),
note TEXT,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE VIEW user_balances AS
SELECT user_id, COALESCE(SUM(amount_cents),0) AS balance_cents
FROM ledger GROUP BY user_id;
CREATE VIEW product_stock AS
SELECT p.id AS product_id, COALESCE(SUM(m.qty),0) AS stock
FROM products p
LEFT JOIN inventory_movements m ON m.product_id = p.id
GROUP BY p.id;

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, HTTPException, status, Body
from sqlalchemy.orm import Session
from app.schemas.user import UserOut, UserCreate, UserUpdate, UserRole
from app.models.user import User
from app.core.database import SessionLocal
from passlib.context import CryptContext
from typing import List
router = APIRouter(prefix="/users", tags=["users"])
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# — Endpoints —
@router.get("/", response_model=List[UserOut])
def list_users(db: Session = Depends(get_db)):
return db.query(User).all()
@router.get("/{user_id}", response_model=UserOut)
def get_user(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def create_user(user: UserCreate, db: Session = Depends(get_db)):
if db.query(User).filter(User.email == user.email).first():
raise HTTPException(status_code=409, detail="E-Mail already exists")
if user.alias and db.query(User).filter(User.alias == user.alias).first():
raise HTTPException(status_code=409, detail="Alias already exists")
hashed_password = pwd_context.hash(user.password)
hashed_pin = pwd_context.hash(user.pin)
db_user = User(
name=user.name,
email=user.email,
hashed_password=hashed_password,
hashed_pin=hashed_pin,
alias=user.alias,
paypal_email=user.paypal_email,
role=user.role,
is_active=True
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.patch("/{user_id}", response_model=UserOut)
def update_user(user_id: int, user: UserUpdate, db: Session = Depends(get_db)):
db_user = db.query(User).filter(User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
# ... (bestehende Update-Logik) ...
db.commit()
db.refresh(db_user)
return db_user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, db: Session = Depends(get_db)):
db_user = db.query(User).filter(User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
db.delete(db_user)
db.commit()
# — NEU: PIN-Login endpoint —
@router.post("/login/pin", response_model=UserOut)
def login_with_pin(pin: str = Body(...), db: Session = Depends(get_db)):
# alle aktiven User laden und PIN gegen hashed_pin prüfen
users = db.query(User).filter(User.is_active == True).all()
for u in users:
if pwd_context.verify(pin, u.hashed_pin):
return u
raise HTTPException(status_code=401, detail="Invalid PIN")

View File

@@ -1,14 +0,0 @@
use axum::{
routing::get,
Router,
};
#[tokio::main]
async fn main() {
// build our application with a single route
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
// run our app with hyper, listening globally on port 3000
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View File

@@ -1 +0,0 @@
VITE_API_URL=http://localhost:8080

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "bacchus-ui", "name": "bacchus-frontend",
"version": "0.1.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -8,17 +8,17 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"react": "^19.1.1", "react": "^18.2.0",
"react-dom": "^19.1.1", "react-dom": "^18.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^7.8.2", "react-router-dom": "^6.23.0",
"recharts": "^3.1.2" "recharts": "^2.15.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^5.0", "@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.12", "tailwindcss": "^3.4.3",
"vite": "^7.0.4" "vite": "^7.0.4"
} }
} }

View File

@@ -1,9 +1,8 @@
// api.js
export const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000"; export const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000";
/* ===================== Cookies & CSRF ===================== */ /* ===================== Cookies & CSRF ===================== */
const API = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
function getCookie(name) { function getCookie(name) {
const re = new RegExp( const re = new RegExp(
"(?:^|; )" + "(?:^|; )" +
@@ -14,7 +13,6 @@ function getCookie(name) {
return m ? decodeURIComponent(m[1]) : null; return m ? decodeURIComponent(m[1]) : null;
} }
// Holt CSRF
async function getCsrfMaybe() { async function getCsrfMaybe() {
const fromCookie = getCookie("bacchus_csrf"); const fromCookie = getCookie("bacchus_csrf");
if (fromCookie) return fromCookie; if (fromCookie) return fromCookie;
@@ -29,7 +27,6 @@ async function getCsrfMaybe() {
return token; return token;
} }
//
export async function getCsrfToken() { export async function getCsrfToken() {
return getCsrfMaybe(); return getCsrfMaybe();
} }
@@ -42,11 +39,9 @@ export async function request(path, options = {}) {
const headers = { const headers = {
Accept: "application/json", Accept: "application/json",
...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), ...(options.body !== undefined && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}), ...(options.headers || {}),
}; };
headers["X-CSRF-Token"] = await getCsrfMaybe(); headers["X-CSRF-Token"] = await getCsrfMaybe();
const res = await fetch(url, { const res = await fetch(url, {
@@ -61,11 +56,7 @@ export async function request(path, options = {}) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
let data = null; let data = null;
if (text) { if (text) {
try { try { data = JSON.parse(text); } catch { /* non-JSON */ }
data = JSON.parse(text);
} catch {
/* Hi */
}
} }
if (!res.ok) { if (!res.ok) {
@@ -79,17 +70,17 @@ export async function request(path, options = {}) {
} }
export async function getJson(path, params) { export async function getJson(path, params) {
const qs = let qs = "";
params && typeof params === "object" if (params && typeof params === "object") {
? "?" + const pairs = Object.entries(params)
Object.entries(params) .filter(([, v]) => v !== undefined && v !== null && v !== "")
.filter(([, v]) => v !== undefined && v !== null) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) if (pairs.length) qs = "?" + pairs.join("&"); // <— nur wenn wirklich Paare existieren
.join("&") }
: "";
return request(`${path}${qs}`, { method: "GET" }); return request(`${path}${qs}`, { method: "GET" });
} }
/* ===================== Auth / Session ===================== */ /* ===================== Auth / Session ===================== */
export function loginPin(pin) { export function loginPin(pin) {
@@ -125,11 +116,10 @@ export function getStatsSummary() {
return request("/stats/summary"); return request("/stats/summary");
} }
/* ===================== Users & Favorites ===================== */ /* ===================== Users ===================== */
// Parametrische Liste (Suche/Filter/Sort/Paging)
export function listUsers(params = {}) { export function listUsers(params = {}) {
return getJson("/users/", params); // trailing slash beibehalten return getJson("/users/", params);
} }
export function getUsers() { export function getUsers() {
@@ -158,7 +148,6 @@ export function deleteUser(userId) {
return request(`/users/${userId}`, { method: "DELETE" }); return request(`/users/${userId}`, { method: "DELETE" });
} }
// Sicherheitsaktionen (Admin/Manager)
export function setUserPin(userId, pin) { export function setUserPin(userId, pin) {
return request(`/users/${userId}/set-pin`, { return request(`/users/${userId}/set-pin`, {
method: "POST", method: "POST",
@@ -200,6 +189,26 @@ export function replaceFavorites(userId, favorites) {
}); });
} }
// für logs/admintransactionen
function pickName(u) {
const full = [u.first_name, u.last_name].filter(Boolean).join(" ").trim();
return u.display_name || u.name || (full || null) || u.username || u.email || `User ${u.id}`;
}
// NEU: kein Versuch mehr, /users/lite aufzurufen → keine 422 mehr in der Konsole
export async function getUsersLite({ limit = 200, offset = 0 } = {}) {
const data = await getJson("/users/", { limit, offset }); // nur dieser Endpoint
const arr = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
return arr.map(u => ({ id: u.id, name: pickName(u) }));
}
export async function getUsersMap(opts) {
const list = await getUsersLite(opts);
const map = {};
for (const u of list) map[u.id] = u.name;
return map;
}
/* ===================== Profile / Avatar ===================== */ /* ===================== Profile / Avatar ===================== */
export async function uploadAvatar(file) { export async function uploadAvatar(file) {
@@ -210,7 +219,7 @@ export async function uploadAvatar(file) {
method: "POST", method: "POST",
body: form, body: form,
credentials: "include", credentials: "include",
headers: { "X-CSRF-Token": await getCsrfMaybe() }, // multipart: kein Content-Type setzen headers: { "X-CSRF-Token": await getCsrfToken() },
}); });
if (!res.ok) throw new Error(`Upload fehlgeschlagen (${res.status})`); if (!res.ok) throw new Error(`Upload fehlgeschlagen (${res.status})`);
return res.json(); return res.json();
@@ -276,8 +285,7 @@ export function listBookings({ user_id, limit = 10, offset = 0 } = {}) {
return getJson("/bookings/", { user_id, limit, offset }); return getJson("/bookings/", { user_id, limit, offset });
} }
/* ===================== Deliveries ===================== */
/* ===================== Deliveries (Manager/Admin) ===================== */
export function getDeliveries() { export function getDeliveries() {
return request("/deliveries/"); return request("/deliveries/");
@@ -292,6 +300,56 @@ export function deleteDelivery(id) {
return request(`/deliveries/${id}`, { method: "DELETE" }); return request(`/deliveries/${id}`, { method: "DELETE" });
} }
// --- utils (numeric) ---
function toInt(x) {
return x == null || x === "" ? 0 : parseInt(x, 10) || 0;
}
export async function createDeliveryBulk(payload, products = []) {
// payload: { supplier, date, invoice_no, note, deposit_return_cents, items:[{product_id, quantity_units, unit_cost_cents}] }
const withUnits = {
...payload, // ← richtig, nicht ".payload"
items: (payload.items || []).map(it => {
const ps = (products.find(p => p.id === it.product_id)?.pack_size) ?? 1;
return {
...it, // ← richtig, nicht ".it"
quantity_units: toInt(it.quantity_units),
unit_cost_cents: toInt(it.unit_cost_cents),
units: toInt(it.quantity_units) * ps,
};
}),
};
// 1) Versuche echten Bulk-Endpoint
try {
return await request("/deliveries/bulk", {
method: "POST",
body: JSON.stringify(withUnits),
});
} catch (e) {
if ([404, 405, 501].includes(e?.status)) {
throw new Error("Server unterstützt /deliveries/bulk nicht bitte Backend aktualisieren.");
}
throw e;
}
}
// PDF-Import → Draft
export async function importDeliveryInvoice(file) {
const form = new FormData();
form.append("file", file);
const res = await fetch(`${API_BASE}/deliveries/invoice/import`, {
method: "POST",
body: form,
credentials: "include",
headers: { "X-CSRF-Token": await getCsrfToken() }, // multipart: KEIN Content-Type setzen
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
/* ===================== Topups (Manager/Admin) ===================== */ /* ===================== Topups (Manager/Admin) ===================== */
export function patchTopupStatus(topupId, status) { export function patchTopupStatus(topupId, status) {
@@ -305,6 +363,27 @@ export function getMyTopups({ limit = 100, offset = 0 } = {}) {
return getJson("/topups/me", { limit, offset }); return getJson("/topups/me", { limit, offset });
} }
export async function listTopupsAdmin({ limit = 200, offset = 0, status_filter, user_id } = {}) {
const params = { limit, offset };
if (status_filter) params.status_filter = status_filter;
if (user_id) params.user_id = user_id;
return getJson("/topups", params);
}
export async function createTopupAdmin({ user_id, amount_cents, note = "" }) {
return request("/topups", {
method: "POST",
body: JSON.stringify({ user_id, amount_cents, note }),
});
}
export async function updateTopupStatus(id, status) {
return request(`/topups/${id}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
}
/* ===================== Stats ===================== */ /* ===================== Stats ===================== */
export function getConsumptionPerUser() { export function getConsumptionPerUser() {
@@ -319,12 +398,22 @@ export function getMonthlyRanking({ year, month, limit = 10 } = {}) {
return getJson("/stats/monthly-ranking", { year, month, limit }); return getJson("/stats/monthly-ranking", { year, month, limit });
} }
export const getStatsMeta = () => request("/stats/meta");
export function getTopDrinkers({ period = "last_delivery", category = "all", limit = 5, tz = "Europe/Berlin" } = {}) {
return getJson("/stats/top-drinkers", { period, category, limit, tz });
}
export function getProductShare({ period = "last_delivery", tz = "Europe/Berlin" } = {}) {
return getJson("/stats/product-share", { period, tz });
}
/* ===================== Audit / Transactions ===================== */ /* ===================== Audit / Transactions ===================== */
// Audit-Logs (Admin) // Audit-Logs (Admin)
export function getAuditLogs({ limit = 100, offset = 0, user_id, action, q, date_from, date_to } = {}) { export function getAuditLogs({ limit = 100, offset = 0, user_id, action, q, date_from, date_to } = {}) {
return getJson("/audit-logs/", { limit, offset, user_id, action, q, date_from, date_to }); return getJson("/audit-logs/", { limit, offset, user_id, action, q, date_from, date_to });
} }
// Eigene Transaktionen // Eigene Transaktionen
export function getMyTransactions({ limit = 100, offset = 0 } = {}) { export function getMyTransactions({ limit = 100, offset = 0 } = {}) {
@@ -332,26 +421,22 @@ export function getMyTransactions({ limit = 100, offset = 0 } = {}) {
} }
// Admin: alle Transaktionen // Admin: alle Transaktionen
export function getTransactionsAdmin({ limit = 100, offset = 0 } = {}) { export function getTransactionsAdmin({ limit = 100, offset = 0, user_id, type, date_from, date_to } = {}) {
return getJson("/transactions", { limit, offset }); return getJson("/transactions", { limit, offset, user_id, type, date_from, date_to });
} }
/* ===================== Categories (Manager/Admin) ===================== */ /* ===================== Categories (Manager/Admin) ===================== */
// Liste aller Kategorien (Array<string>)
export function getCategories() { export function getCategories() {
return request("/categories/"); // trailing slash wie bei /products/ return request("/categories/");
} }
// Kategorie umbenennen: alle Produkte mit old_name -> new_name
export function renameCategory(oldName, newName) { export function renameCategory(oldName, newName) {
if (!oldName || !newName) throw new Error("oldName und newName sind erforderlich"); if (!oldName || !newName) throw new Error("oldName und newName sind erforderlich");
const qs = `?old_name=${encodeURIComponent(oldName)}&new_name=${encodeURIComponent(newName)}`; const qs = `?old_name=${encodeURIComponent(oldName)}&new_name=${encodeURIComponent(newName)}`;
return request(`/categories/rename${qs}`, { method: "PUT" }); return request(`/categories/rename${qs}`, { method: "PUT" });
} }
// Kategorie löschen, optional umhängen (reassign_to)
// Wenn reassignTo null/undefined ist, wird category auf NULL gesetzt
export function deleteCategory(name, reassignTo = null) { export function deleteCategory(name, reassignTo = null) {
if (!name) throw new Error("name ist erforderlich"); if (!name) throw new Error("name ist erforderlich");
const params = new URLSearchParams({ name }); const params = new URLSearchParams({ name });
@@ -359,7 +444,7 @@ export function deleteCategory(name, reassignTo = null) {
return request(`/categories/?${params.toString()}`, { method: "DELETE" }); return request(`/categories/?${params.toString()}`, { method: "DELETE" });
} }
/* ================== Transaktionen Tracker ==============*/ /* ===================== Ledger / Topups (Self) ===================== */
export function getLedgerMe({ limit = 100, offset = 0, types = "topup,booking" } = {}) { export function getLedgerMe({ limit = 100, offset = 0, types = "topup,booking" } = {}) {
return getJson("/ledger/me", { limit, offset, types }); return getJson("/ledger/me", { limit, offset, types });
@@ -374,84 +459,21 @@ export function createTopup(amount_cents, note = null) {
}); });
} }
// ===================== NEU: Stats (öffentlich, aber auth-pflichtig) ===================== export function addLedgerEntry(amount_cents, note = "") {
export const getStatsMeta = () => request("/stats/meta"); return request("/ledger", {
export function getTopDrinkers({ period = "last_delivery", category = "all", limit = 5, tz = "Europe/Berlin" } = {}) {
return getJson("/stats/top-drinkers", { period, category, limit, tz });
}
export function getProductShare({ period = "last_delivery", tz = "Europe/Berlin" } = {}) {
return getJson("/stats/product-share", { period, tz });
}
let CSRF = { token: null, header: "X-CSRF-Token", fetchedAt: 0 };
async function ensureCsrf() {
if (CSRF.token && Date.now() - CSRF.fetchedAt < 5 * 60 * 1000) return CSRF;
const res = await fetch(API + "/auth/csrf", { credentials: "include" });
let data = {};
try { data = await res.json(); } catch {}
CSRF.token =
data?.token || data?.csrf || data?.csrf_token || data?.value || null;
CSRF.header =
data?.header_name || data?.header || CSRF.header; // Server kann den Headernamen mitliefern
CSRF.fetchedAt = Date.now();
if (!CSRF.token) throw new Error("CSRF token missing from /auth/csrf");
return CSRF;
}
async function req(path, opts = {}) {
const method = (opts.method || "GET").toUpperCase();
const isMutating = method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
const headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
if (isMutating) {
const { token, header } = await ensureCsrf(); // <- WICHTIG
headers[header] = token;
}
const res = await fetch(API + path, {
credentials: "include",
headers,
...opts,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
return res.status === 204 ? null : res.json();
}
export async function listTopupsAdmin({ limit = 200, offset = 0, status_filter, user_id } = {}) {
const qs = new URLSearchParams({ limit, offset });
if (status_filter) qs.set("status_filter", status_filter);
if (user_id) qs.set("user_id", user_id);
return req(`/topups?${qs.toString()}`);
}
export async function createTopupAdmin({ user_id, amount_cents, note = "" }) {
return req(`/topups`, {
method: "POST", method: "POST",
body: JSON.stringify({ user_id, amount_cents, note }), body: JSON.stringify({ amount_cents, note }),
}); });
} }
export async function updateTopupStatus(id, status) { /* ===================== Admin Settings (PayPal) ===================== */
return req(`/topups/${id}/status`, { export function getPaypalSettings() {
method: "PATCH", return request("/admin/settings/paypal");
body: JSON.stringify({ status }), }
export function updatePaypalSettings({ paypal_me = "", paypal_receiver = "" } = {}) {
return request("/admin/settings/paypal", {
method: "PUT",
body: JSON.stringify({ paypal_me, paypal_receiver }),
}); });
} }
export async function getUsersLite({ limit = 200, offset = 0 } = {}) {
const lim = Math.min(Number(limit) || 200, 200);
return req(`/users?limit=${lim}&offset=${offset}`);
}

View File

@@ -19,6 +19,18 @@ function applyOrderColorsFromStorage() {
document.documentElement.style.setProperty("--order-bg2", c2); document.documentElement.style.setProperty("--order-bg2", c2);
} }
// --- NEU: lokale Bestände anhand des Warenkorbs reduzieren
function decrementStockLocal(prevProducts, cart) {
const delta = {};
for (const { product, quantity } of Object.values(cart)) {
delta[product.id] = (delta[product.id] || 0) + quantity;
}
return prevProducts.map((p) =>
delta[p.id] ? { ...p, stock: (Number(p.stock) || 0) - delta[p.id] } : p
);
}
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -53,6 +65,11 @@ function chipStyleFor(cat, active) {
return active ? strong(h) : soft(h); return active ? strong(h) : soft(h);
} }
function ringColorForCategory(cat) {
if (!cat) return "#22c55e";
// nutzt deine bestehende Farblogik der Chips:
return chipStyleFor(cat, true).br; // starke Border-Farbe der Kategorie
}
// robustes Aktiv-Flag (API kann bool/zahl/string liefern) // robustes Aktiv-Flag (API kann bool/zahl/string liefern)
function isActiveProduct(p) { function isActiveProduct(p) {
const v = p?.is_active; const v = p?.is_active;
@@ -236,11 +253,15 @@ const avatarSrc = useMemo(() => {
product.price_cents * quantity product.price_cents * quantity
); );
} }
setProducts((prev) => decrementStockLocal(prev, cart));
try { try {
const refreshed = await getCurrentUser(); const refreshed = await getCurrentUser();
if (refreshed) setUser(refreshed); if (refreshed) setUser(refreshed);
} catch {} } catch {}
setCart({}); setCart({});
try { await logout(); } finally { navigate("/", { replace: true }); } try { await logout(); } finally { navigate("/", { replace: true }); }
} catch (e) { } catch (e) {
setError(e?.message || "Bezahlen fehlgeschlagen."); setError(e?.message || "Bezahlen fehlgeschlagen.");
@@ -318,6 +339,7 @@ const avatarSrc = useMemo(() => {
isFavorite={favorites.has(p.id)} isFavorite={favorites.has(p.id)}
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
count={cart[p.id]?.quantity || 0} count={cart[p.id]?.quantity || 0}
ringColor={ringColorForCategory(p.category)}
/> />
</div> </div>
))} ))}

View File

@@ -6,7 +6,8 @@ export default function ProductCard({
onSelect, onSelect,
isFavorite, isFavorite,
onToggleFavorite, onToggleFavorite,
count = 0, // Menge im Warenkorb count = 0,
ringColor = "#22c55e", // Fallback
}) { }) {
const baseName = (product?.name || '').replace(/\s+/g, '').toLowerCase(); const baseName = (product?.name || '').replace(/\s+/g, '').toLowerCase();
@@ -65,9 +66,12 @@ export default function ProductCard({
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
className="relative rounded-xl overflow-hidden bg-gray-800 cursor-pointer transform transition duration-150 hover:scale-105 ring-2 ring-transparent hover:ring-green-500" style={{ "--tw-ring-color": ringColor }}
className="relative rounded-xl overflow-hidden bg-gray-800 cursor-pointer transform transition duration-150 hover:scale-95
ring-0 hover:ring-4 ring-offset-0 ring-offset-gray-900
focus-visible:outline-none focus-visible:ring-2"
onClick={() => onSelect(product)} onClick={() => onSelect(product)}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onSelect(product)} onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onSelect(product)}
> >
<img <img
src={imgSrc} src={imgSrc}

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import './styles.css'; import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>

View File

@@ -1,11 +1,20 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { listTopupsAdmin, patchTopupStatus, getUsersLite } from "../../api"; import { listTopupsAdmin, patchTopupStatus, getUsersLite, getPaypalSettings, updatePaypalSettings, createTopupAdmin } from "../../api";
const euro = (c) => (c ?? 0) / 100; const euro = (c) => (c ?? 0) / 100;
const fmt = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const fmt = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const fmtDT = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "medium", timeZone: "Europe/Berlin" }); const fmtDT = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "medium", timeZone: "Europe/Berlin" });
const SHOW_CREATE = false; const SHOW_CREATE = false;
const toText = (v) => {
if (v == null) return "—";
if (typeof v === "object") {
try { return JSON.stringify(v); } catch { return "[obj]"; }
}
return String(v);
};
const STATUS_LABELS_DE = { const STATUS_LABELS_DE = {
pending: "ausstehend", pending: "ausstehend",
confirmed: "bestätigt", confirmed: "bestätigt",
@@ -27,7 +36,6 @@ function Pill({ status }) {
} }
/** Kleiner Avatar (Initialen) */ /** Kleiner Avatar (Initialen) */
function Initials({ label }) { function Initials({ label }) {
const txt = String(label || "") const txt = String(label || "")
@@ -211,6 +219,26 @@ function StatusSelect({ value, onChange }) {
export default function AdminTransactionsPage() { export default function AdminTransactionsPage() {
// PayPal-Settings (jetzt legal innerhalb der Component)
const [ppOpen, setPpOpen] = useState(false);
const [ppMe, setPpMe] = useState("");
const [ppReceiver, setPpReceiver] = useState("");
const [ppSaving, setPpSaving] = useState(false);
useEffect(() => {
(async () => {
try {
const cfg = await getPaypalSettings();
setPpMe(cfg?.paypal_me || "");
setPpReceiver(cfg?.paypal_receiver || "");
} catch {}
})();
}, []);
async function savePaypal() {
setPpSaving(true);
try { await updatePaypalSettings({ paypal_me: ppMe, paypal_receiver: ppReceiver }); }
finally { setPpSaving(false); }
}
// Daten // Daten
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -351,6 +379,40 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
</div> </div>
</form> </form>
)} )}
{/* PayPal-Einstellungen */}
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
<button type="button"
onClick={() => setPpOpen(o=>!o)}
className="w-full flex items-center justify-between text-left">
<h2 className="text-white/90 font-semibold">PayPal-Einstellungen</h2>
<span className="text-white/60">{ppOpen ? "▴" : "▾"}</span>
</button>
{ppOpen && (
<div className="mt-4 grid gap-3 md:grid-cols-3">
<label className="block">
<div className="text-xs text-white/60 mb-1">PayPal.me Handle (optional)</div>
<input value={ppMe} onChange={e=>setPpMe(e.target.value)}
className="w-full bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2"
placeholder="z. B. Getraenkewart" />
</label>
<label className="block md:col-span-2">
<div className="text-xs text-white/60 mb-1">Geschäfts-E-Mail für Webscr/IPN</div>
<input value={ppReceiver} onChange={e=>setPpReceiver(e.target.value)}
className="w-full bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2"
placeholder="kasse@example.org" />
</label>
<div className="md:col-span-3">
<button onClick={savePaypal} disabled={ppSaving}
className="px-4 py-2 rounded-xl font-semibold bg-emerald-600/80 hover:bg-emerald-600 text-white shadow disabled:opacity-60">
Speichern
</button>
</div>
</div>
)}
</div>
{/* Tabelle */} {/* Tabelle */}
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5"> <div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@@ -402,7 +464,7 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
{rows.map(r => { {rows.map(r => {
const u = usersById.get(r.user_id); const u = usersById.get(r.user_id);
const canAct = String(r.status) === "pending"; const canAct = String(r.status) === "pending";
const ts = r.created_at ? new Date(r.created_at) : null; const ts = r.created_at ? new Date(String(r.created_at).replace(" ", "T")) : null;
return ( return (
<tr key={r.id} className="border-t border-white/10"> <tr key={r.id} className="border-t border-white/10">
<td className="py-2 pr-4 font-mono">{r.id}</td> <td className="py-2 pr-4 font-mono">{r.id}</td>
@@ -410,7 +472,7 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
<td className="py-2 pr-4">{u?.name || u?.alias || u?.email || r.user_id}</td> <td className="py-2 pr-4">{u?.name || u?.alias || u?.email || r.user_id}</td>
<td className="py-2 pr-4 font-mono">{fmt.format(euro(r.amount_cents))}</td> <td className="py-2 pr-4 font-mono">{fmt.format(euro(r.amount_cents))}</td>
<td className="py-2 pr-4"><Pill status={String(r.status)} /></td> <td className="py-2 pr-4"><Pill status={String(r.status)} /></td>
<td className="py-2 pr-4">{r.note || "—"}</td> <td className="py-2 pr-4 font-mono break-all">{toText(r.note)}</td>
<td className="py-2 pr-4"> <td className="py-2 pr-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { getCapabilities, getStatsSummary } from "../../api"; import { getCapabilities, getStatsSummary } from "../../api";
import { FiCreditCard, FiTrendingUp, FiDatabase, FiZap } from "react-icons/fi"; import { FiCreditCard, FiTrendingUp, FiDatabase, FiZap, FiAlertTriangle } from "react-icons/fi";
function Panel({ title, icon: Icon, children, accent = "cyan" }) { function Panel({ title, icon: Icon, children, accent = "cyan" }) {
const accentClasses = const accentClasses =
@@ -193,6 +193,23 @@ export default function DashboardPage() {
</div> </div>
)} )}
</Panel> </Panel>
{/* Leer */}
<Panel title="Leer :(" icon={FiAlertTriangle}>
<div className="text-white/70 text-sm mb-3">
Hier leere Produkte auflisten
</div>
<div className="flex flex-wrap gap-2">
<a
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl
bg-emerald-500/20 text-emerald-200 border border-emerald-400/30
hover:bg-emerald-500/30 transition"
>
</a>
</div>
</Panel>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,63 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { getAuditLogs, getUsersLite } from "../../api"; import { getAuditLogs, getUsersLite } from "../../api";
/* ----------------------------- kleine Utils ----------------------------- */ /* ----------------------------- kleine Utils ----------------------------- */
function classNames(...xs) { return xs.filter(Boolean).join(" "); }
function euroDelta(newC, oldC) { function euroDelta(newC, oldC) {
if (newC == null || oldC == null) return null; // null/undefined → kein Wert
const d = (newC - oldC) / 100; if (newC == null || oldC == null) return "—";
// Strings erlauben
const n = Number(newC);
const o = Number(oldC);
if (!Number.isFinite(n) || !Number.isFinite(o)) return "—";
const d = (n - o) / 100;
return (d >= 0 ? "+" : "") + d.toFixed(2) + " €"; return (d >= 0 ? "+" : "") + d.toFixed(2) + " €";
} }
function fmtDT(v) { function fmtDT(v) {
if (!v) return "—"; if (v == null) return "—_-";
// ganze oder dezimale Zahl-Strings → Zahl
if (typeof v === "string" && /^\d+(\.\d+)?$/.test(v)) v = Number(v);
// Sekunden → ms
if (typeof v === "number" && v < 1e12) v = v * 1000;
const d = new Date(v); const d = new Date(v);
if (Number.isNaN(d.getTime())) return "—"; return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
return d.toLocaleString();
} }
function classNames(...xs) { return xs.filter(Boolean).join(" "); }
// vereinheitlicht Zeitfelder aus der API
function normalizeLog(e) {
let t =
e.timestamp ?? e.ts ?? e.time ??
e.created_at ?? e.createdAt ?? e.created ??
e.occurred_at ?? e.date ??
(e.meta && e.meta.timestamp) ?? null;
if (t == null) return { ...e, timestamp: null };
// Zahl-String (Sekunden oder ms, ggf. mit Dezimalen)
if (typeof t === "string" && /^\d+(\.\d+)?$/.test(t)) {
const n = Number(t);
return { ...e, timestamp: n < 1e12 ? n * 1000 : n };
}
if (typeof t === "number") {
return { ...e, timestamp: t < 1e12 ? t * 1000 : t };
}
if (typeof t === "string") {
let s = t.trim();
s = s.replace(" ", "T"); // "YYYY-MM-DD HH:MM:SS" → "YYYY-MM-DDTHH:MM:SS"
s = s.replace(/([+-]\d{2})(\d{2})$/, "$1:$2"); // +0000 → +00:00
s = s.replace(/([+-]\d{2})$/, "$1:00"); // +00 → +00:00
s = s.replace(/(\.\d{3})\d+/, "$1"); // Mikrosekunden auf ms kürzen
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(s)) s += "Z"; // naive → UTC
return { ...e, timestamp: s };
}
return { ...e, timestamp: null };
}
/* -------------------------- NiceDropdown (inline) ------------------------ */ /* -------------------------- NiceDropdown (inline) ------------------------ */
@@ -48,7 +92,7 @@ function NiceDropdown({
ref={btnRef} ref={btnRef}
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
className={classNames( className={classNames(
"w-full bg-gray-800 text-gray-100 rounded-xl border border-gray-600 px-3 py-2", "w-full bg-white/10 text-gray-100 rounded-xl border border-gray-600 px-3 py-2",
"text-left flex items-center justify-between hover:bg-gray-700", "text-left flex items-center justify-between hover:bg-gray-700",
buttonClassName buttonClassName
)} )}
@@ -140,7 +184,7 @@ function UserPicker({ value, onChange, placeholder = "Alle Nutzer" }) {
type="button" type="button"
ref={btnRef} ref={btnRef}
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
className="w-full bg-gray-800 text-gray-100 rounded-xl border border-gray-600 px-3 py-2 flex items-center justify-between hover:bg-gray-700" className="w-full bg-white/10- text-gray-100 rounded-xl border border-gray-600 px-3 py-2 flex items-center justify-between hover:bg-gray-700"
> >
<span className={value ? "" : "text-white/60"}> <span className={value ? "" : "text-white/60"}>
{selected ? (selected.name || selected.alias || selected.email) : placeholder} {selected ? (selected.name || selected.alias || selected.email) : placeholder}
@@ -155,7 +199,7 @@ function UserPicker({ value, onChange, placeholder = "Alle Nutzer" }) {
> >
<input <input
autoFocus autoFocus
className="w-full px-3 py-2 mb-2 rounded-lg bg-gray-800 text-gray-100 border border-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40" className="w-full px-3 py-2 mb-2 rounded-lg bg-white/10 text-gray-100 border border-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
placeholder="Suchen…" placeholder="Suchen…"
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
@@ -231,7 +275,7 @@ export default function LogsPage() {
date_from, date_from,
date_to, date_to,
}); });
const arr = Array.isArray(data) ? data : (data.items || []); const arr = (Array.isArray(data) ? data : (data.items || [])).map(normalizeLog);
setRows((prev) => (reset ? arr : [...prev, ...arr])); setRows((prev) => (reset ? arr : [...prev, ...arr]));
setHasMore(arr.length === LIMIT); setHasMore(arr.length === LIMIT);
setOffset(pageOffset + arr.length); setOffset(pageOffset + arr.length);
@@ -296,14 +340,14 @@ export default function LogsPage() {
/> />
<input <input
className="ml-auto w-full sm:w-64 px-3 py-2 rounded-xl bg-gray-800 text-gray-100 border border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40" className="ml-auto w-full sm:w-64 px-3 py-2 rounded-xl bg-white/10 text-gray-100 border border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
placeholder="Suche (User, Aktion, Info)…" placeholder="Suche (User, Aktion, Info)…"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
/> />
<button <button
className="px-3 py-2 rounded-xl bg-gray-800 border border-gray-600 hover:bg-gray-700 text-gray-100" className="px-3 py-2 rounded-xl bg-white/10 border border-gray-600 hover:bg-gray-700 text-gray-100"
onClick={() => { setUserId(""); setAction(""); setRange("7d"); setQuery(""); load(true); }} onClick={() => { setUserId(""); setAction(""); setRange("7d"); setQuery(""); load(true); }}
> >
Zurücksetzen Zurücksetzen
@@ -325,11 +369,13 @@ export default function LogsPage() {
<tbody> <tbody>
{visible.map((r) => ( {visible.map((r) => (
<tr key={r.id ?? `${r.timestamp}-${r.user_id}-${r.action}`} className="border-t border-white/10"> <tr key={r.id ?? `${r.timestamp}-${r.user_id}-${r.action}`} className="border-t border-white/10">
<td className="py-2 pr-4 font-mono">{fmtDT(r.timestamp)}</td> <td className="py-2 pr-4 font-mono">
{fmtDT(r.timestamp ?? r.created_at ?? r.ts ?? r.time ?? r.date)}
</td>
<td className="py-2 pr-4">{r.user?.name ?? r.user?.alias ?? r.user_id}</td> <td className="py-2 pr-4">{r.user?.name ?? r.user?.alias ?? r.user_id}</td>
<td className="py-2 pr-4 font-mono">{String(r.action ?? "")}</td> <td className="py-2 pr-4 font-mono">{String(r.action ?? "-_-")}</td>
<td className="py-2 pr-4">{r.info ?? ""}</td> <td className="py-2 pr-4">{r.info ?? "-_-"}</td>
<td className="py-2 pr-4 font-mono">{euroDelta(r.new_balance_cents, r.old_balance_cents) ?? ""}</td> <td className="py-2 pr-4 font-mono">{euroDelta(r.new_balance_cents, r.old_balance_cents) ?? "-_-"}</td>
</tr> </tr>
))} ))}
{loading && ( {loading && (

View File

@@ -7,6 +7,8 @@ import {
updateProduct, updateProduct,
} from "../../api"; } from "../../api";
import { FiEye, FiEdit2, FiX, FiPlus, FiTrash2, FiCheck, FiSearch } from "react-icons/fi"; import { FiEye, FiEdit2, FiX, FiPlus, FiTrash2, FiCheck, FiSearch } from "react-icons/fi";
import { createPortal } from "react-dom";
/* ---------- Helpers ---------- */ /* ---------- Helpers ---------- */
const euro = (cents) => ((Number(cents ?? 0)) / 100).toFixed(2); const euro = (cents) => ((Number(cents ?? 0)) / 100).toFixed(2);
@@ -160,7 +162,6 @@ const filtered = useMemo(() => {
setReassignInfo(null); setReassignInfo(null);
} }
// Persistentes Speichern aus dem Edit-Dialog
async function saveEditedProduct(updated) { async function saveEditedProduct(updated) {
const payload = { const payload = {
name: updated.name, name: updated.name,
@@ -170,12 +171,16 @@ const filtered = useMemo(() => {
supplier_number: updated.supplier_number ?? null, supplier_number: updated.supplier_number ?? null,
stock: updated.stock, stock: updated.stock,
is_active: updated.is_active, is_active: updated.is_active,
// NEU:
pack_size: Math.max(1, Number(updated.pack_size) || 1),
purchase_price_cents: Number(updated.purchase_price_cents) || 0,
}; };
await updateProduct(updated.id, payload); await updateProduct(updated.id, payload);
await refreshProductsAndCategories(); await refreshProductsAndCategories();
setEditItem(null); setEditItem(null);
} }
if (loading) return <div className="text-white">Lade Produkte</div>; if (loading) return <div className="text-white">Lade Produkte</div>;
if (err) return <div className="text-red-300">Fehler: {String(err.message || err)}</div>; if (err) return <div className="text-red-300">Fehler: {String(err.message || err)}</div>;
@@ -340,10 +345,29 @@ const filtered = useMemo(() => {
/* ---------- Sub-Komponenten ---------- */ /* ---------- Sub-Komponenten ---------- */
function Modal({ title, children, onClose }) { function Modal({ title, children, onClose }) {
return ( // ESC schließt + Body-Scroll locken
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> useEffect(() => {
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("keydown", onKey);
};
}, [onClose]);
const node = (
<div className="fixed inset-0 z-[9999]">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60" onClick={onClose} /> <div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative w-full max-w-2xl rounded-2xl bg-slate-900 border border-cyan-400/20 shadow-xl"> {/* Centering-Layer */}
<div className="absolute inset-0 p-4 flex items-center justify-center">
<div
role="dialog"
aria-modal="true"
className="relative w-full max-w-2xl rounded-2xl bg-slate-900 border border-cyan-400/20 shadow-xl"
>
<div className="flex items-center justify-between px-5 py-3 border-b border-white/10"> <div className="flex items-center justify-between px-5 py-3 border-b border-white/10">
<h3 className="text-white/90 font-semibold">{title}</h3> <h3 className="text-white/90 font-semibold">{title}</h3>
<button <button
@@ -358,15 +382,26 @@ function Modal({ title, children, onClose }) {
<div className="p-5 text-white/80">{children}</div> <div className="p-5 text-white/80">{children}</div>
</div> </div>
</div> </div>
</div>
); );
return createPortal(node, document.body);
} }
function DetailView({ item }) { function DetailView({ item }) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoRow label="ID" value={<code>{item.id}</code>} /> <InfoRow label="ID" value={<code>{item.id}</code>} />
<InfoRow label="Name" value={item.name ?? ""} /> <InfoRow label="Name" value={item.name ?? ""} />
<InfoRow label="Preis" value={`${euro(item.price_cents)}`} /> <InfoRow label="Preis" value={`${euro(item.price_cents)}`} />
{/* NEU: Einkaufspreis & Pack-Größe */}
{"purchase_price_cents" in item && (
<InfoRow label="Einkaufspreis" value={`${euro(item.purchase_price_cents)}`} />
)}
{"pack_size" in item && (
<InfoRow label="Pack-Größe" value={displayVal(item.pack_size)} />
)}
<InfoRow label="Kategorie" value={item.category ?? ""} /> <InfoRow label="Kategorie" value={item.category ?? ""} />
{"volume_ml" in item && <InfoRow label="Volumen (ml)" value={displayVal(item.volume_ml)} />} {"volume_ml" in item && <InfoRow label="Volumen (ml)" value={displayVal(item.volume_ml)} />}
<InfoRow label="Bestand" value={displayVal(item.stock)} /> <InfoRow label="Bestand" value={displayVal(item.stock)} />
@@ -376,6 +411,7 @@ function DetailView({ item }) {
); );
} }
function InfoRow({ label, value }) { function InfoRow({ label, value }) {
return ( return (
<div className="flex items-center justify-between border-b border-white/10 py-2"> <div className="flex items-center justify-between border-b border-white/10 py-2">
@@ -392,6 +428,9 @@ function EditForm({ item, onSave, onCancel, categories }) {
id: item.id, id: item.id,
name: item.name ?? "", name: item.name ?? "",
price_cents: Number(item.price_cents ?? 0), price_cents: Number(item.price_cents ?? 0),
// NEU:
purchase_price_cents: Number(item.purchase_price_cents ?? 0),
pack_size: Number(item.pack_size ?? 1),
category: item.category ?? "", category: item.category ?? "",
stock: item.stock ?? 0, stock: item.stock ?? 0,
volume_ml: item.volume_ml ?? "", volume_ml: item.volume_ml ?? "",
@@ -409,6 +448,9 @@ function EditForm({ item, onSave, onCancel, categories }) {
...item, ...item,
...form, ...form,
price_cents: Number(form.price_cents) || 0, price_cents: Number(form.price_cents) || 0,
// NEU:
purchase_price_cents: Number(form.purchase_price_cents) || 0,
pack_size: Math.max(1, Number(form.pack_size) || 1),
stock: form.stock === "" ? null : Number(form.stock), stock: form.stock === "" ? null : Number(form.stock),
volume_ml: form.volume_ml === "" ? null : Number(form.volume_ml), volume_ml: form.volume_ml === "" ? null : Number(form.volume_ml),
}); });
@@ -434,7 +476,7 @@ function EditForm({ item, onSave, onCancel, categories }) {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Field label="Preis (Cent)"> <Field label="Verkaufspreis (Cent)">
<input <input
type="number" type="number"
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white" className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
@@ -443,6 +485,27 @@ function EditForm({ item, onSave, onCancel, categories }) {
min={0} min={0}
/> />
</Field> </Field>
<Field label="Einkaufspreis (Cent)">
<input
type="number"
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
value={form.purchase_price_cents}
onChange={(e) => set("purchase_price_cents", e.target.value)}
min={0}
/>
</Field>
<Field label="Pack-Größe (Stk/Pack)">
<input
type="number"
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
value={form.pack_size}
onChange={(e) => set("pack_size", e.target.value)}
min={1}
/>
</Field>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Field label="Volumen (ml)"> <Field label="Volumen (ml)">
<input <input
type="number" type="number"
@@ -461,9 +524,6 @@ function EditForm({ item, onSave, onCancel, categories }) {
min={0} min={0}
/> />
</Field> </Field>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="Lieferantennr."> <Field label="Lieferantennr.">
<input <input
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white" className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
@@ -471,7 +531,9 @@ function EditForm({ item, onSave, onCancel, categories }) {
onChange={(e) => set("supplier_number", e.target.value)} onChange={(e) => set("supplier_number", e.target.value)}
/> />
</Field> </Field>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="Aktiv"> <Field label="Aktiv">
<Switch <Switch
checked={!!form.is_active} checked={!!form.is_active}
@@ -480,15 +542,7 @@ function EditForm({ item, onSave, onCancel, categories }) {
labelOff="inaktiv" labelOff="inaktiv"
/> />
</Field> </Field>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="Preis (Anzeige)">
<div className="px-3 py-2 rounded bg-white/5 border border-white/10 text-white/80">
{euro(form.price_cents)}
</div>
</Field>
<div />
</div> </div>
<div className="flex justify-end gap-3 pt-2"> <div className="flex justify-end gap-3 pt-2">
@@ -510,6 +564,7 @@ function EditForm({ item, onSave, onCancel, categories }) {
); );
} }
function Field({ label, children }) { function Field({ label, children }) {
return ( return (
<label className="block"> <label className="block">

View File

@@ -15,7 +15,6 @@ import {
} from "react-icons/fi"; } from "react-icons/fi";
/* ---------- Geld & PayPal ---------- */ /* ---------- Geld & PayPal ---------- */
const euroFmt = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
function parseEuroToCents(input) { function parseEuroToCents(input) {
if (typeof input !== "string") return null; if (typeof input !== "string") return null;
@@ -29,7 +28,7 @@ function parseEuroToCents(input) {
function genCode5() { function genCode5() {
return String(Math.floor(Math.random() * 100000)).padStart(5, "0"); return String(Math.floor(Math.random() * 100000)).padStart(5, "0");
} }
function buildPaypalUrl(amountCents, code) { function buildPaypalUrl(amountCents, code, topupId) {
if (!amountCents || amountCents <= 0) return null; if (!amountCents || amountCents <= 0) return null;
const amountEuro = (amountCents / 100).toFixed(2); const amountEuro = (amountCents / 100).toFixed(2);
const me = import.meta.env.VITE_PAYPAL_ME && String(import.meta.env.VITE_PAYPAL_ME).trim(); const me = import.meta.env.VITE_PAYPAL_ME && String(import.meta.env.VITE_PAYPAL_ME).trim();
@@ -45,6 +44,10 @@ function buildPaypalUrl(amountCents, code) {
amount: amountEuro, amount: amountEuro,
item_name: `Bacchus Top-Up ${code || ""}`, item_name: `Bacchus Top-Up ${code || ""}`,
no_note: "1", no_note: "1",
custom: `topup:${topupId || ""}|code:${code || ""}`,
notify_url: `${window.location.origin.replace(/\/$/, "")}/api/paypal/ipn`,
return: `${window.location.origin}/management/transactions?ok=1`,
cancel_return: `${window.location.origin}/management/transactions?canceled=1`,
}); });
return `https://www.paypal.com/cgi-bin/webscr?${qs.toString()}`; return `https://www.paypal.com/cgi-bin/webscr?${qs.toString()}`;
} }
@@ -134,6 +137,9 @@ export default function TransactionPage() {
const [err, setErr] = useState(null); const [err, setErr] = useState(null);
const [balanceCents, setBalanceCents] = useState(null); const [balanceCents, setBalanceCents] = useState(null);
const euroFmt = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
const [topupId, setTopupId] = useState(null);
const [amountInput, setAmountInput] = useState(""); const [amountInput, setAmountInput] = useState("");
const amountCents = useMemo(() => parseEuroToCents(amountInput), [amountInput]); const amountCents = useMemo(() => parseEuroToCents(amountInput), [amountInput]);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
@@ -195,8 +201,8 @@ export default function TransactionPage() {
const readyToSave = amountCents != null && amountCents > 0; const readyToSave = amountCents != null && amountCents > 0;
const paypalUrl = useMemo( const paypalUrl = useMemo(
() => (saved ? buildPaypalUrl(amountCents, code) : null), () => (saved ? buildPaypalUrl(amountCents, code, topupId) : null),
[saved, amountCents, code] [saved, amountCents, code, topupId]
); );
async function onSave() { async function onSave() {
@@ -206,7 +212,8 @@ export default function TransactionPage() {
setCode(newCode); setCode(newCode);
setCopied(false); setCopied(false);
try { try {
await createTopup(amountCents, newCode); // speichert Top-up + Code const created = await createTopup(amountCents, newCode);
setTopupId(created?.id ?? null);
await reloadTx(); await reloadTx();
} catch (e) { } catch (e) {
setTxErr(e?.message || "Top-up konnte nicht angelegt werden."); setTxErr(e?.message || "Top-up konnte nicht angelegt werden.");
@@ -230,7 +237,6 @@ export default function TransactionPage() {
<div className="text-3xl font-mono"> <div className="text-3xl font-mono">
{typeof balanceCents === "number" ? euroFmt.format(balanceCents / 100) : "—"} {typeof balanceCents === "number" ? euroFmt.format(balanceCents / 100) : "—"}
</div> </div>
<div className="text-white/50 text-xs mt-1">Stand aus deinem Profil</div>
</Card> </Card>
{/* Aufladen */} {/* Aufladen */}

View File

@@ -541,12 +541,7 @@ async function openViewer(id) {
</div> </div>
</div> </div>
<TextField
label="Name"
value={formBase.name}
onChange={(v) => { setFormBase(f => ({ ...f, name: v })); setDirty(true); }}
inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
/>
{/* Drawer (Bearbeiten) fixed */} {/* Drawer (Bearbeiten) fixed */}
{open && ( {open && (
@@ -886,22 +881,36 @@ function Th({ children, onClick, active, order }) {
); );
} }
function TextField({ label, value, onChange, type = "text", maxLength, placeholder }) { function TextField({
label,
value,
onChange,
type = "text",
maxLength,
placeholder,
inputClassName = "",
}) {
return ( return (
<label className="block"> <label className="block">
<div className="text-white/60 text-xs mb-1">{label}</div> <div className="text-white/60 text-xs mb-1">{label}</div>
<input <input
className="w-full px-3 py-2 rounded-xl bg-gre/5 border border-white/10 text-white/90 placeholder-white/30 focus:outline-none focus:border-cyan-400/40"
value={value}
onChange={(e) => onChange(e.target.value)}
type={type} type={type}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
maxLength={maxLength} maxLength={maxLength}
placeholder={placeholder} placeholder={placeholder}
className={cx(
"w-full px-3 py-2 rounded-xl border bg-gray-800 text-gray-100",
"border-gray-600 placeholder:text-gray-400",
"focus:outline-none focus:ring-2 focus:ring-gray-400/40",
inputClassName
)}
/> />
</label> </label>
); );
} }
function SelectField({ label, value, onChange, options }) { function SelectField({ label, value, onChange, options }) {
return ( return (
<label className="block"> <label className="block">

View File

@@ -13,3 +13,4 @@ export default {
}, },
plugins: [], plugins: [],
} }

View File

@@ -1,10 +1,6 @@
import { defineConfig } from "vite"; import { defineConfig } from 'vite';
import react from "@vitejs/plugin-react"; import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
port: 3000,
open: true
}
}); });