new init
This commit is contained in:
258
apps/backend/app/api/users.py
Normal file
258
apps/backend/app/api/users.py
Normal file
@@ -0,0 +1,258 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, or_, asc, desc
|
||||
from typing import List, Optional
|
||||
from passlib.context import CryptContext
|
||||
from pydantic import BaseModel, constr, conint
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserOut, UserCreate, UserUpdate
|
||||
from app.core.auth import get_current_user, requires_role_relaxed, requires_role_mgmt
|
||||
|
||||
# NEU: Topup-Model importieren, damit Admin-Gutschriften als Topup geloggt werden
|
||||
from app.models.topup import Topup, TopupStatus
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def get_db_sess():
|
||||
db = get_db()
|
||||
# get_db ist ein generator → FastAPI injiziert automatisch.
|
||||
# falls du eine SessionLocal-Funktion hast, nutze die hier analog.
|
||||
return db
|
||||
|
||||
@router.get("/", response_model=List[UserOut], dependencies=[Depends(requires_role_relaxed("manager","admin"))])
|
||||
def list_users(
|
||||
q: Optional[str] = Query(default=None, description="Suche in Name/E-Mail/Alias"),
|
||||
role: Optional[str] = Query(default=None, regex="^(user|manager|admin)$"),
|
||||
active: Optional[bool] = Query(default=None),
|
||||
balance_lt: Optional[int] = Query(default=None, description="Kontostand < Wert (Cent)"),
|
||||
limit: int = Query(default=25, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
sort: str = Query(default="name", description="name|email|role|balance_cents|is_active"),
|
||||
order: str = Query(default="asc", regex="^(asc|desc)$"),
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
):
|
||||
qy = db.query(User)
|
||||
if q:
|
||||
like = f"%{q.lower()}%"
|
||||
qy = qy.filter(or_(
|
||||
func.lower(User.name).like(like),
|
||||
func.lower(User.email).like(like),
|
||||
func.lower(func.coalesce(User.alias, "")).like(like),
|
||||
))
|
||||
if role:
|
||||
qy = qy.filter(User.role == role)
|
||||
if active is not None:
|
||||
qy = qy.filter(User.is_active == active)
|
||||
if balance_lt is not None:
|
||||
qy = qy.filter((User.balance_cents != None) & (User.balance_cents < balance_lt))
|
||||
|
||||
sort_map = {
|
||||
"name": User.name,
|
||||
"email": User.email,
|
||||
"role": User.role,
|
||||
"balance_cents": User.balance_cents,
|
||||
"is_active": User.is_active,
|
||||
}
|
||||
col = sort_map.get(sort, User.name)
|
||||
qy = qy.order_by(asc(col) if order == "asc" else desc(col))
|
||||
|
||||
return qy.limit(limit).offset(offset).all()
|
||||
|
||||
@router.get("/{user_id}", response_model=UserOut, dependencies=[Depends(requires_role_relaxed("manager","admin"))])
|
||||
def get_user(user_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user)):
|
||||
u = db.query(User).filter(User.id == user_id).first()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return u
|
||||
|
||||
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED, dependencies=[Depends(requires_role_mgmt("manager","admin"))])
|
||||
def create_user(payload: UserCreate, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
|
||||
if db.query(User).filter(User.email == payload.email).first():
|
||||
raise HTTPException(status_code=409, detail="E-Mail already exists")
|
||||
if payload.alias and db.query(User).filter(User.alias == payload.alias).first():
|
||||
raise HTTPException(status_code=409, detail="Alias already exists")
|
||||
u = User(
|
||||
name=payload.name,
|
||||
email=payload.email,
|
||||
hashed_password=pwd.hash(payload.password),
|
||||
hashed_pin=pwd.hash(payload.pin),
|
||||
alias=payload.alias,
|
||||
paypal_email=payload.paypal_email,
|
||||
role=payload.role,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(u); db.commit(); db.refresh(u)
|
||||
return u
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserOut, dependencies=[Depends(requires_role_mgmt("manager","admin"))])
|
||||
def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
|
||||
u = db.query(User).filter(User.id == user_id).first()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if payload.email is not None:
|
||||
if db.query(User).filter(User.email == payload.email, User.id != user_id).first():
|
||||
raise HTTPException(status_code=409, detail="E-Mail already exists")
|
||||
u.email = payload.email
|
||||
if payload.alias is not None:
|
||||
if db.query(User).filter(User.alias == payload.alias, User.id != user_id).first():
|
||||
raise HTTPException(status_code=409, detail="Alias already exists")
|
||||
u.alias = payload.alias
|
||||
if payload.name is not None:
|
||||
u.name = payload.name
|
||||
if payload.password is not None:
|
||||
u.hashed_password = pwd.hash(payload.password)
|
||||
if payload.pin is not None:
|
||||
u.hashed_pin = pwd.hash(payload.pin)
|
||||
if payload.paypal_email is not None:
|
||||
u.paypal_email = payload.paypal_email
|
||||
if payload.role is not None:
|
||||
u.role = payload.role
|
||||
if payload.is_active is not None:
|
||||
u.is_active = payload.is_active
|
||||
|
||||
db.commit(); db.refresh(u)
|
||||
return u
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(requires_role_mgmt("manager","admin"))])
|
||||
def delete_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
|
||||
u = db.query(User).filter(User.id == user_id).first()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
db.delete(u); db.commit()
|
||||
return None
|
||||
|
||||
# -----------------------------
|
||||
# Sicherheitsaktionen
|
||||
# -----------------------------
|
||||
|
||||
class SetPinIn(BaseModel):
|
||||
pin: constr(min_length=6, max_length=6)
|
||||
|
||||
class SetPasswordIn(BaseModel):
|
||||
password: constr(min_length=8)
|
||||
|
||||
@router.post("/{user_id}/set-pin", dependencies=[Depends(requires_role_mgmt("manager","admin"))])
|
||||
def set_pin(user_id: int, payload: SetPinIn, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
|
||||
u = db.query(User).filter(User.id == user_id).first()
|
||||
if not u: raise HTTPException(status_code=404, detail="User not found")
|
||||
if admin.role == "manager" and u.role == "admin":
|
||||
raise HTTPException(status_code=403, detail="Managers cannot modify admin credentials")
|
||||
u.hashed_pin = pwd.hash(payload.pin); db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.post("/{user_id}/set-password", dependencies=[Depends(requires_role_mgmt("manager","admin"))])
|
||||
def set_password(user_id: int, payload: SetPasswordIn, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
|
||||
u = db.query(User).filter(User.id == user_id).first()
|
||||
if not u: raise HTTPException(status_code=404, detail="User not found")
|
||||
if admin.role == "manager" and u.role == "admin":
|
||||
raise HTTPException(status_code=403, detail="Managers cannot modify admin credentials")
|
||||
u.hashed_password = pwd.hash(payload.password); db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
class BalanceAdjustIn(BaseModel):
|
||||
amount_cents: conint(strict=True)
|
||||
reason: constr(min_length=1, max_length=500)
|
||||
|
||||
@router.post("/{user_id}/adjust-balance", dependencies=[Depends(requires_role_mgmt("manager","admin"))])
|
||||
def adjust_balance(user_id: int, payload: BalanceAdjustIn, db: Session = Depends(get_db), admin: User = Depends(get_current_user)):
|
||||
"""
|
||||
Admin/Manager passt das Guthaben an.
|
||||
- Betrag != 0 erforderlich.
|
||||
- Bei positiver Anpassung wird zusätzlich ein Topup mit status=confirmed und note=reason erstellt,
|
||||
damit es in /topups/me (und der TransactionPage) sichtbar ist.
|
||||
"""
|
||||
amount = int(payload.amount_cents or 0)
|
||||
if amount == 0:
|
||||
raise HTTPException(status_code=400, detail="amount_cents must be non-zero")
|
||||
|
||||
u = db.query(User).filter(User.id == user_id).first()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if admin.role == "manager" and u.role == "admin":
|
||||
raise HTTPException(status_code=403, detail="Managers cannot adjust admin accounts")
|
||||
|
||||
before = int(u.balance_cents or 0)
|
||||
u.balance_cents = before + amount
|
||||
|
||||
created_topup_id: Optional[int] = None
|
||||
|
||||
# Nur bei Gutschrift (+) zusätzlich Topup schreiben (mit Notiz = reason).
|
||||
if amount > 0:
|
||||
t = Topup(
|
||||
user_id=u.id,
|
||||
amount_cents=amount,
|
||||
status=TopupStatus.confirmed, # direkt bestätigt
|
||||
created_at=datetime.utcnow(),
|
||||
confirmed_at=datetime.utcnow(),
|
||||
note=payload.reason.strip() if payload.reason else None,
|
||||
)
|
||||
db.add(t)
|
||||
db.flush() # ID holen, ohne Zwischen-Commit
|
||||
created_topup_id = t.id
|
||||
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"user_id": u.id,
|
||||
"old_balance_cents": before,
|
||||
"new_balance_cents": u.balance_cents,
|
||||
"created_topup_id": created_topup_id, # nur bei positiver Anpassung gesetzt
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# FAVORITES: GET / PUT / PATCH
|
||||
# -----------------------------
|
||||
|
||||
def _ensure_can_edit(target_user_id: int, actor: User):
|
||||
"""Eigenen Datensatz immer; sonst nur manager/admin."""
|
||||
if actor.id != target_user_id and str(actor.role) not in {"manager", "admin"}:
|
||||
raise HTTPException(status_code=403, detail="Not allowed")
|
||||
|
||||
@router.get("/{user_id}/favorites", response_model=List[int])
|
||||
def get_user_favorites(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
_ensure_can_edit(user_id, current)
|
||||
u = db.query(User).filter(User.id == user_id).first()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return list(u.favorites or [])
|
||||
|
||||
@router.put("/{user_id}/favorites", response_model=List[int])
|
||||
def replace_user_favorites(
|
||||
user_id: int,
|
||||
favorites: List[int] = Body(..., embed=False, description="Array von Produkt-IDs"),
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
_ensure_can_edit(user_id, current)
|
||||
u = db.query(User).filter(User.id == user_id).first()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Sanitizing: nur eindeutige, positive ints
|
||||
clean = sorted({int(x) for x in favorites if isinstance(x, int) and x > 0})
|
||||
u.favorites = clean
|
||||
db.add(u)
|
||||
db.commit()
|
||||
return clean
|
||||
|
||||
@router.patch("/{user_id}/favorites", response_model=List[int])
|
||||
def patch_user_favorites(
|
||||
user_id: int,
|
||||
favorites: List[int] = Body(..., embed=False, description="Array von Produkt-IDs"),
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
# Dein Frontend sendet bei PATCH das komplette Array → identisch zu PUT behandeln.
|
||||
return replace_user_favorites(user_id, favorites, db, current)
|
Reference in New Issue
Block a user