259 lines
11 KiB
Python
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)
|