Files
bacchus/apps/backend/app/api/users.py
2025-09-28 19:13:01 +02:00

259 lines
11 KiB
Python

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)