fix(users): repair create delete and duplicate email handling

This commit is contained in:
Schubert Ferenc 2026-07-11 17:14:41 +02:00
parent 155fdbb16a
commit 0bbcaba211
22 changed files with 773 additions and 107 deletions

View file

@ -6,13 +6,14 @@ import json
import logging
import shutil
import uuid
from datetime import UTC, datetime
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
from fastapi.encoders import jsonable_encoder
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, ProgrammingError
from sqlalchemy.orm import Session
from app.api.dependencies import current_admin, current_user
@ -560,7 +561,7 @@ def _user_payload(user: User) -> dict:
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"role": user.role,
"role": UserRole(user.role),
"is_active": user.is_active,
"must_change_password": user.must_change_password,
"last_login_at": user.last_login_at,
@ -581,6 +582,7 @@ def list_users(
_: User = Depends(current_admin),
):
query = select(User)
query = query.where(User.deleted_at.is_(None))
if search:
term = f"%{search.strip()}%"
query = query.where(
@ -605,8 +607,9 @@ def list_users(
sort_column = sort_columns.get(sort_by, User.created_at)
query = query.order_by(sort_column.asc() if sort_order.lower() == "asc" else sort_column.desc())
total = session.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
pages = max((total + page_size - 1) // page_size, 1)
items = list(session.scalars(query.offset((page - 1) * page_size).limit(page_size)))
return {"items": items, "total": total, "page": page, "page_size": page_size}
return {"items": items, "total": total, "page": page, "page_size": page_size, "pages": pages}
@router.post("/users", status_code=201)
@ -615,26 +618,47 @@ def create_user(payload: UserCreate, session: Session = Depends(get_session), _:
import secrets
from app.models.user import User
normalized_email = payload.email.strip().lower()
logger.debug("create_user request email=%s normalized_email=%s", payload.email, normalized_email)
existing = session.scalar(select(User).where(func.lower(User.email) == normalized_email))
logger.debug("create_user select result=%s count=%s", bool(existing), 1 if existing else 0)
if existing is not None:
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.")
temporary_password = payload.temporary_password or secrets.token_urlsafe(12)
user = User(
email=payload.email.lower(),
first_name=payload.first_name,
last_name=payload.last_name,
role=payload.role.value,
password_hash=hash_password(temporary_password),
is_active=payload.is_active,
must_change_password=payload.must_change_password,
)
session.add(user)
session.commit()
session.refresh(user)
return JSONResponse(content={"temporary_password": temporary_password, "user": jsonable_encoder(user)}, status_code=201)
try:
user = User(
email=normalized_email,
first_name=payload.first_name,
last_name=payload.last_name,
role=payload.role.value,
password_hash=hash_password(temporary_password),
is_active=payload.is_active,
must_change_password=payload.must_change_password,
)
logger.debug("create_user insert email=%s", normalized_email)
session.add(user)
session.commit()
logger.debug("create_user commit ok email=%s id=%s", normalized_email, user.id)
session.refresh(user)
logger.debug("create_user response email=%s status=201", normalized_email)
return JSONResponse(
content={"temporary_password": temporary_password, "user": jsonable_encoder(user)},
status_code=201,
)
except IntegrityError as exc:
session.rollback()
logger.exception("create_user integrity error email=%s", normalized_email)
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.") from exc
except ProgrammingError as exc:
session.rollback()
raise HTTPException(status_code=422, detail="Die Benutzerrolle ist ungültig.") from exc
@router.get("/users/{item_id}", response_model=UserRead)
def get_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
if user is None or user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Resource not found")
return user
@ -642,7 +666,7 @@ def get_user(item_id: str, session: Session = Depends(get_session), _: User = De
@router.put("/users/{item_id}", response_model=UserRead)
def update_user(item_id: str, payload: UserUpdate, session: Session = Depends(get_session), me: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
if user is None or user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Resource not found")
if user.id == me.id and not payload.is_active:
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
@ -651,44 +675,58 @@ def update_user(item_id: str, payload: UserUpdate, session: Session = Depends(ge
) or 0
if user.role == UserRole.ADMIN.value and active_admins <= 1 and (not payload.is_active or payload.role != UserRole.ADMIN.value):
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert oder herabgestuft werden.")
user.first_name = payload.first_name
user.last_name = payload.last_name
user.email = payload.email.lower()
user.role = payload.role.value
user.is_active = payload.is_active
user.must_change_password = payload.must_change_password
session.commit()
session.refresh(user)
return user
try:
normalized_email = payload.email.strip().lower()
logger.debug("update_user request id=%s email=%s normalized_email=%s", item_id, payload.email, normalized_email)
existing = session.scalar(
select(User).where(func.lower(User.email) == normalized_email, User.id != user.id)
)
logger.debug("update_user select result=%s count=%s", bool(existing), 1 if existing else 0)
if existing is not None:
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.")
user.first_name = payload.first_name
user.last_name = payload.last_name
user.email = normalized_email
user.role = payload.role.value
user.is_active = payload.is_active
user.must_change_password = payload.must_change_password
logger.debug("update_user commit start id=%s email=%s", user.id, normalized_email)
session.commit()
logger.debug("update_user commit ok id=%s email=%s", user.id, normalized_email)
session.refresh(user)
return user
except IntegrityError as exc:
session.rollback()
logger.exception("update_user integrity error id=%s email=%s", item_id, payload.email)
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.") from exc
except ProgrammingError as exc:
session.rollback()
raise HTTPException(status_code=422, detail="Die Benutzerrolle ist ungültig.") from exc
@router.post("/users/{item_id}/deactivate", response_model=UserRead)
def deactivate_user(item_id: str, session: Session = Depends(get_session), me: User = Depends(current_admin)):
@router.delete("/users/{item_id}", status_code=204)
def delete_user(item_id: str, session: Session = Depends(get_session), me: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
if user is None or user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Resource not found")
if user.id == me.id:
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
raise HTTPException(status_code=409, detail="Das eigene Benutzerkonto kann nicht geloescht werden.")
active_admins = session.scalar(
select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True))
) or 0
if user.role == UserRole.ADMIN.value and active_admins <= 1:
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert werden.")
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht geloescht werden.")
referenced_validations = session.scalar(
select(func.count()).select_from(Validation).where(Validation.examiner_id == user.id)
) or 0
if referenced_validations == 0:
session.delete(user)
session.commit()
return Response(status_code=204)
user.is_active = False
user.deleted_at = datetime.now(UTC)
session.commit()
session.refresh(user)
return user
@router.post("/users/{item_id}/activate", response_model=UserRead)
def activate_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
raise HTTPException(status_code=404, detail="Resource not found")
user.is_active = True
session.commit()
session.refresh(user)
return user
return Response(status_code=204)
@router.post("/users/{item_id}/reset-password")
@ -697,7 +735,7 @@ def reset_user_password(item_id: str, session: Session = Depends(get_session), _
from app.core.security import hash_password
user = session.get(User, item_id)
if user is None:
if user is None or user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Resource not found")
temporary_password = secrets.token_urlsafe(12)
user.password_hash = hash_password(temporary_password)