Files
timemaster/backend/app/routers/time_entries.py
T
sysops 1fedd683e0 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>
2026-05-23 20:03:27 +02:00

279 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import date
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.time_entry import EntryStatus
from app.models.user import User, UserRole
from app.schemas.time_entry import (
BalanceResponse,
ManualEntryCreate,
RejectRequest,
StampInRequest,
StampOutRequest,
TimeEntryListResponse,
TimeEntryOut,
TimeEntryUpdate,
TimeEntryWithWarnings,
WorkScheduleCreate,
WorkScheduleOut,
)
from app.services.time_service import time_service
router = APIRouter(prefix="/time", tags=["Zeiterfassung"])
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Stempeluhr ────────────────────────────────────────────────────────────────
@router.post("/stamp-in", response_model=TimeEntryWithWarnings, status_code=201)
async def stamp_in(
data: StampInRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Einstempeln startet einen neuen Zeiterfassungseintrag."""
entry, warnings = await time_service.stamp_in(current_user, data, db)
await db.commit()
await db.refresh(entry)
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.post("/stamp-out", response_model=TimeEntryWithWarnings)
async def stamp_out(
data: StampOutRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Ausstempeln schließt den offenen Zeiterfassungseintrag."""
entry, warnings = await time_service.stamp_out(current_user, data.note, db)
await db.commit()
await db.refresh(entry)
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.post("/break-start", response_model=TimeEntryOut)
async def break_start(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Pause beginnen."""
entry = await time_service.break_start(current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.post("/break-end", response_model=TimeEntryOut)
async def break_end(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Pause beenden."""
entry = await time_service.break_end(current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
# ── Heute ─────────────────────────────────────────────────────────────────────
@router.get("/today", response_model=list[TimeEntryOut])
async def get_today(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Alle Einträge des heutigen Tages für den aktuellen Benutzer."""
entries = await time_service.get_today(current_user, db)
return [TimeEntryOut.model_validate(e) for e in entries]
# ── Einträge ──────────────────────────────────────────────────────────────────
@router.get("/entries", response_model=TimeEntryListResponse)
async def list_entries(
current_user: CurrentUser,
user_id: UUID | None = Query(None),
date_from: date | None = Query(None),
date_to: date | None = Query(None),
status: EntryStatus | None = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
total, entries = await time_service.list_entries(
current_user.company_id, current_user, db,
user_id=user_id, date_from=date_from, date_to=date_to,
status=status, skip=skip, limit=limit,
)
return TimeEntryListResponse(total=total, items=[TimeEntryOut.model_validate(e) for e in entries])
@router.post("/entries", response_model=TimeEntryWithWarnings, status_code=201)
async def create_manual_entry(
data: ManualEntryCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Manuellen Zeiterfassungseintrag anlegen."""
entry, warnings = await time_service.create_manual(data, current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.patch("/entries/{entry_id}", response_model=TimeEntryOut)
async def update_entry(
entry_id: UUID,
data: TimeEntryUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag korrigieren."""
entry = await time_service.update_entry(entry_id, data, current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.post("/entries/{entry_id}/approve", response_model=TimeEntryOut)
async def approve_entry(
entry_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag genehmigen."""
entry = await time_service.approve_entry(entry_id, current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.post("/entries/{entry_id}/reject", response_model=TimeEntryOut)
async def reject_entry(
entry_id: UUID,
data: RejectRequest,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag ablehnen."""
entry = await time_service.reject_entry(entry_id, current_user, data.rejection_note, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.delete("/entries/{entry_id}", status_code=204)
async def delete_entry(
entry_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Zeiteintrag löschen. Mitarbeiter: nur eigene offene/ausstehende Einträge. Manager: alle außer genehmigten (außer HR/Admin)."""
await time_service.delete_entry(entry_id, current_user, db)
await db.commit()
# ── Überstundenkonto ──────────────────────────────────────────────────────────
@router.get("/balance/me", response_model=BalanceResponse)
async def get_own_balance(
current_user: CurrentUser,
period_start: date | None = Query(None),
period_end: date | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Eigenes Überstundenkonto."""
return await time_service.get_balance(current_user.id, current_user, db, period_start, period_end)
@router.get("/balance/{user_id}", response_model=BalanceResponse)
async def get_balance(
user_id: UUID,
current_user: CurrentUser,
period_start: date | None = Query(None),
period_end: date | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Überstundenkonto für einen Benutzer."""
if user_id != current_user.id:
if current_user.role == UserRole.EMPLOYEE:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
target_user = await db.get(User, user_id)
if target_user is None or target_user.company_id != current_user.company_id:
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
return await time_service.get_balance(user_id, current_user, db, period_start, period_end)
# ── Arbeitspläne ──────────────────────────────────────────────────────────────
@router.get("/schedules", response_model=list[WorkScheduleOut])
async def list_schedules(
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
schedules = await time_service.list_work_schedules(current_user.company_id, db)
return [WorkScheduleOut.model_validate(s) for s in schedules]
@router.post("/schedules", response_model=WorkScheduleOut, status_code=201)
async def create_schedule(
data: WorkScheduleCreate,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
schedule = await time_service.create_work_schedule(current_user.company_id, data, db)
await db.commit()
await db.refresh(schedule)
return WorkScheduleOut.model_validate(schedule)
@router.patch("/schedules/{schedule_id}", response_model=WorkScheduleOut)
async def update_schedule(
schedule_id: UUID,
data: WorkScheduleCreate,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select as sa_select
from app.models.work_schedule import WorkSchedule
schedule = await db.scalar(
sa_select(WorkSchedule).where(
WorkSchedule.id == schedule_id,
WorkSchedule.company_id == current_user.company_id,
)
)
if not schedule:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden")
for field, value in data.model_dump().items():
setattr(schedule, field, value)
await db.commit()
await db.refresh(schedule)
return WorkScheduleOut.model_validate(schedule)
@router.delete("/schedules/{schedule_id}", status_code=204)
async def delete_schedule(
schedule_id: UUID,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select as sa_select
from app.models.work_schedule import WorkSchedule
schedule = await db.scalar(
sa_select(WorkSchedule).where(
WorkSchedule.id == schedule_id,
WorkSchedule.company_id == current_user.company_id,
)
)
if not schedule:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden")
await db.delete(schedule)
await db.commit()