Initial commit – TimeMaster Zeiterfassung & HR-Tool
Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer), Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst. Migrations 0001–0023 deployed auf 192.168.1.137 + .164. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import CurrentUser, require_role
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.auth import MessageResponse
|
||||
from app.schemas.user import (
|
||||
InviteRequest,
|
||||
NextPersonnelNumberResponse,
|
||||
SetKioskPinRequest,
|
||||
UserImportResult,
|
||||
UserImportRowResult,
|
||||
UserListResponse,
|
||||
UserOut,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.services import user_import_service
|
||||
from app.services.user_service import user_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Users"])
|
||||
|
||||
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||
_hr_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN, UserRole.HR, UserRole.MANAGER)
|
||||
|
||||
|
||||
@router.get("/", response_model=UserListResponse)
|
||||
async def list_users(
|
||||
current_user: User = require_role(*_hr_roles),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
active_only: bool = Query(True),
|
||||
search: str | None = Query(None, max_length=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
total, users = await user_service.list_users(
|
||||
current_user.company_id, db, skip, limit, active_only, search,
|
||||
)
|
||||
return UserListResponse(total=total, items=[UserOut.model_validate(u) for u in users])
|
||||
|
||||
|
||||
@router.post("/invite", response_model=UserOut, status_code=201)
|
||||
async def invite_user(
|
||||
data: InviteRequest,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await user_service.invite(data, current_user.company_id, current_user, db)
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def get_me(current_user: CurrentUser):
|
||||
return UserOut.model_validate(current_user)
|
||||
|
||||
|
||||
@router.get("/next-personnel-number", response_model=NextPersonnelNumberResponse)
|
||||
async def next_personnel_number(
|
||||
current_user: User = require_role(*_hr_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Schlägt die nächste freie Personalnummer vor (ohne den Counter zu erhöhen)."""
|
||||
suggestion = await user_service.next_personnel_suggestion(current_user.company_id, db)
|
||||
return NextPersonnelNumberResponse(next=suggestion)
|
||||
|
||||
|
||||
@router.get("/by-personnel/{number}", response_model=UserOut)
|
||||
async def get_user_by_personnel(
|
||||
number: str,
|
||||
current_user: User = require_role(*_hr_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await user_service.get_by_personnel_number(number, current_user.company_id, db)
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.get("/import-template.csv", response_class=PlainTextResponse)
|
||||
async def import_template(
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
):
|
||||
csv_text = user_import_service.build_template_csv()
|
||||
return PlainTextResponse(
|
||||
content=csv_text,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": 'attachment; filename="user-import-template.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import/preview", response_model=UserImportResult)
|
||||
async def user_import_preview(
|
||||
file: Annotated[UploadFile, File()],
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
content = await file.read()
|
||||
result = await user_import_service.preview_csv(content, current_user.company_id, current_user, db)
|
||||
return _to_import_result_schema(result)
|
||||
|
||||
|
||||
@router.post("/import/apply", response_model=UserImportResult)
|
||||
async def user_import_apply(
|
||||
file: Annotated[UploadFile, File()],
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
content = await file.read()
|
||||
result = await user_import_service.apply_csv(content, current_user.company_id, current_user, db)
|
||||
return _to_import_result_schema(result)
|
||||
|
||||
|
||||
def _to_import_result_schema(result) -> UserImportResult:
|
||||
return UserImportResult(
|
||||
total_rows=result.total_rows,
|
||||
created=result.created,
|
||||
reactivated=result.reactivated,
|
||||
errors=result.errors,
|
||||
items=[
|
||||
UserImportRowResult(
|
||||
row=i.row, email=i.email, personnel_number=i.personnel_number,
|
||||
action=i.action, message=i.message,
|
||||
)
|
||||
for i in result.items
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserOut)
|
||||
async def get_user(
|
||||
user_id: UUID,
|
||||
current_user: User = require_role(*_hr_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await user_service.get_by_id(user_id, current_user.company_id, db)
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserOut)
|
||||
async def update_user(
|
||||
user_id: UUID,
|
||||
data: UserUpdate,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await user_service.update(user_id, data, current_user, db)
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/deactivate", response_model=UserOut)
|
||||
async def deactivate_user(
|
||||
user_id: UUID,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await user_service.deactivate(user_id, current_user, db)
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/reactivate", response_model=UserOut)
|
||||
async def reactivate_user(
|
||||
user_id: UUID,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await user_service.reactivate(user_id, current_user, db)
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/kiosk-pin", response_model=MessageResponse)
|
||||
async def set_kiosk_pin(
|
||||
user_id: UUID,
|
||||
data: SetKioskPinRequest,
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Users can set their own PIN; admins can set for any user in company
|
||||
if user_id != current_user.id and not current_user.is_admin_or_above():
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Not allowed")
|
||||
user = await user_service.get_by_id(user_id, current_user.company_id, db)
|
||||
await user_service.set_kiosk_pin(user, data.pin, db)
|
||||
return MessageResponse(message="Kiosk PIN updated")
|
||||
Reference in New Issue
Block a user