feat(users): add user administration and password management
This commit is contained in:
parent
47f54d3461
commit
b584e60273
16 changed files with 720 additions and 22 deletions
|
|
@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
bearer = HTTPBearer()
|
||||
|
||||
|
|
@ -30,3 +30,17 @@ def current_user(
|
|||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
|
||||
return user
|
||||
|
||||
|
||||
def require_role(*roles: UserRole):
|
||||
def dependency(user: User = Depends(current_user)) -> User:
|
||||
if user.role not in roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
return user
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def current_admin(user: User = Depends(current_user)) -> User:
|
||||
if user.role != UserRole.ADMIN.value:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
return user
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.dependencies import current_user
|
||||
from app.db.session import get_session
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginRequest, TokenResponse, UserRead
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.schemas.auth import ChangePasswordRequest, LoginRequest, TokenResponse, UserRead
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
|
@ -22,3 +25,24 @@ def login(payload: LoginRequest, session: Session = Depends(get_session)) -> Tok
|
|||
def me(user: User = Depends(current_user)) -> User:
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
def change_password(
|
||||
payload: ChangePasswordRequest,
|
||||
user: User = Depends(current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
if not verify_password(payload.current_password, user.password_hash):
|
||||
raise HTTPException(status_code=422, detail="Das aktuelle Passwort ist falsch.")
|
||||
if payload.new_password != payload.new_password_confirmation:
|
||||
raise HTTPException(status_code=422, detail="Die neuen Passwoerter stimmen nicht ueberein.")
|
||||
if len(payload.new_password) < 12:
|
||||
raise HTTPException(status_code=422, detail="Das neue Passwort muss mindestens 12 Zeichen haben.")
|
||||
if verify_password(payload.new_password, user.password_hash):
|
||||
raise HTTPException(status_code=422, detail="Das neue Passwort darf nicht identisch zum alten sein.")
|
||||
|
||||
user.password_hash = hash_password(payload.new_password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
return {"status": "ok"}
|
||||
|
|
|
|||
|
|
@ -9,18 +9,20 @@ import uuid
|
|||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, 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.orm import Session
|
||||
|
||||
from app.api.dependencies import current_user
|
||||
from app.api.dependencies import current_admin, current_user
|
||||
from app.db.session import get_session
|
||||
from app.models.contact import Contact
|
||||
from app.models.customer import Customer
|
||||
from app.models.device import Device
|
||||
from app.models.equipment import Equipment
|
||||
from app.models.location import Location
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.validation import Validation
|
||||
from app.modules.helios.service import HeliosImportService
|
||||
from app.modules.orion.service import OrionReportService
|
||||
|
|
@ -50,6 +52,10 @@ from app.schemas.domain import (
|
|||
ValidationRead,
|
||||
ValidationReview,
|
||||
ValidationUpdate,
|
||||
UserCreate,
|
||||
UserRead,
|
||||
UserUpdate,
|
||||
UserPasswordResetRequest,
|
||||
)
|
||||
from app.services.domain_service import CrudService, DomainServices
|
||||
from app.services.validation_workflow import ValidationWorkflowService
|
||||
|
|
@ -546,3 +552,155 @@ def validation_report_pdf(item_id: str, session: Session = Depends(get_session))
|
|||
media_type="application/pdf",
|
||||
filename=report_path.name,
|
||||
)
|
||||
def _user_payload(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"created_at": user.created_at,
|
||||
"updated_at": user.updated_at,
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"must_change_password": user.must_change_password,
|
||||
"last_login_at": user.last_login_at,
|
||||
"password_changed_at": user.password_changed_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users", response_model=PaginatedResponse[UserRead])
|
||||
def list_users(
|
||||
session: Session = Depends(get_session),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(default=None),
|
||||
role: str | None = Query(default=None),
|
||||
active: bool | None = Query(default=None),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_order: str = Query(default="desc"),
|
||||
_: User = Depends(current_admin),
|
||||
):
|
||||
query = select(User)
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
query = query.where(
|
||||
(User.first_name.ilike(term))
|
||||
| (User.last_name.ilike(term))
|
||||
| (User.email.ilike(term))
|
||||
)
|
||||
if role:
|
||||
query = query.where(User.role == role)
|
||||
if active is not None:
|
||||
query = query.where(User.is_active.is_(active))
|
||||
sort_columns = {
|
||||
"name": User.last_name,
|
||||
"email": User.email,
|
||||
"role": User.role,
|
||||
"is_active": User.is_active,
|
||||
"last_login_at": User.last_login_at,
|
||||
"password_changed_at": User.password_changed_at,
|
||||
"created_at": User.created_at,
|
||||
"updated_at": User.updated_at,
|
||||
}
|
||||
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
|
||||
items = list(session.scalars(query.offset((page - 1) * page_size).limit(page_size)))
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
@router.post("/users", status_code=201)
|
||||
def create_user(payload: UserCreate, session: Session = Depends(get_session), _: User = Depends(current_admin)):
|
||||
from app.core.security import hash_password
|
||||
import secrets
|
||||
from app.models.user import User
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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:
|
||||
raise HTTPException(status_code=404, detail="Resource not found")
|
||||
return user
|
||||
|
||||
|
||||
@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:
|
||||
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")
|
||||
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 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
|
||||
|
||||
|
||||
@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)):
|
||||
user = session.get(User, item_id)
|
||||
if user is 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")
|
||||
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.")
|
||||
user.is_active = False
|
||||
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
|
||||
|
||||
|
||||
@router.post("/users/{item_id}/reset-password")
|
||||
def reset_user_password(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
|
||||
import secrets
|
||||
from app.core.security import hash_password
|
||||
|
||||
user = session.get(User, item_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="Resource not found")
|
||||
temporary_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = hash_password(temporary_password)
|
||||
user.must_change_password = True
|
||||
session.commit()
|
||||
return JSONResponse(content={"temporary_password": temporary_password})
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ def seed_admin() -> None:
|
|||
session.add(
|
||||
User(
|
||||
email=settings.admin_email.lower(),
|
||||
full_name="Validation Suite Administrator",
|
||||
role=UserRole.admin,
|
||||
first_name="Validation Suite",
|
||||
last_name="Administrator",
|
||||
role=UserRole.ADMIN.value,
|
||||
password_hash=hash_password(settings.admin_password),
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
|
@ -27,4 +29,3 @@ def seed_admin() -> None:
|
|||
|
||||
if __name__ == "__main__":
|
||||
seed_admin()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Enum, String
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
admin = "admin"
|
||||
employee = "employee"
|
||||
auditor = "auditor"
|
||||
ADMIN = "admin"
|
||||
PRUEFER = "pruefer"
|
||||
MITARBEITER = "mitarbeiter"
|
||||
LESER = "leser"
|
||||
|
||||
|
||||
class User(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
full_name: Mapped[str] = mapped_column(String(160))
|
||||
role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.employee)
|
||||
first_name: Mapped[str] = mapped_column(String(80))
|
||||
last_name: Mapped[str] = mapped_column(String(80))
|
||||
role: Mapped[str] = mapped_column(String(40), default=UserRole.MITARBEITER.value)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
return f"{self.first_name} {self.last_name}".strip()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
|
@ -18,7 +20,16 @@ class TokenResponse(BaseModel):
|
|||
|
||||
class UserRead(EntityRead):
|
||||
email: EmailStr
|
||||
full_name: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
must_change_password: bool
|
||||
last_login_at: datetime | None = None
|
||||
password_changed_at: datetime | None = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
new_password_confirmation: str
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import EmailStr, Field, field_validator
|
||||
|
||||
from app.models.customer import CustomerType
|
||||
from app.models.equipment import EquipmentKind, EquipmentStatus
|
||||
from app.models.user import UserRole
|
||||
from app.models.validation import ValidationStatus
|
||||
from app.schemas.common import EntityRead, ORMModel
|
||||
|
||||
|
|
@ -241,3 +243,29 @@ class MeasurementImportValueConfirm(ORMModel):
|
|||
|
||||
class MeasurementImportConfirmRequest(ORMModel):
|
||||
values: list[MeasurementImportValueConfirm]
|
||||
|
||||
|
||||
class UserBase(ORMModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: EmailStr
|
||||
role: UserRole
|
||||
is_active: bool = True
|
||||
must_change_password: bool = True
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
temporary_password: str
|
||||
|
||||
|
||||
class UserUpdate(UserBase):
|
||||
pass
|
||||
|
||||
|
||||
class UserRead(UserBase, EntityRead):
|
||||
last_login_at: datetime | None = None
|
||||
password_changed_at: datetime | None = None
|
||||
|
||||
|
||||
class UserPasswordResetRequest(ORMModel):
|
||||
temporary_password: str | None = None
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -19,5 +21,6 @@ class AuthService:
|
|||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return create_access_token(user.id, user.role.value)
|
||||
|
||||
user.last_login_at = datetime.now(UTC)
|
||||
self.users.session.commit()
|
||||
return create_access_token(user.id, user.role)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from app.models.customer import Customer
|
|||
from app.models.device import Device
|
||||
from app.models.equipment import Equipment
|
||||
from app.models.location import Location
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.validation import Validation
|
||||
from app.repositories.base import Repository
|
||||
from app.repositories.domain import (
|
||||
|
|
@ -20,6 +21,7 @@ from app.repositories.domain import (
|
|||
DeviceRepository,
|
||||
EquipmentRepository,
|
||||
LocationRepository,
|
||||
UserRepository,
|
||||
ValidationRepository,
|
||||
)
|
||||
|
||||
|
|
@ -78,6 +80,7 @@ class CrudService:
|
|||
|
||||
class DomainServices:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.users: CrudService[User] = CrudService(UserRepository(session))
|
||||
self.customers: CrudService[Customer] = CrudService(CustomerRepository(session))
|
||||
self.locations: CrudService[Location] = CrudService(LocationRepository(session))
|
||||
self.contacts: CrudService[Contact] = CrudService(ContactRepository(session))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue