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)