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:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
View File
+368
View File
@@ -0,0 +1,368 @@
from uuid import UUID
from fastapi import APIRouter, Depends, 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.absence import AbsenceStatus
from app.models.user import User, UserRole
from app.models.overtime_balance import OvertimeBalance
from app.schemas.absence import (
AbsenceCreate,
AbsenceListResponse,
AbsenceOut,
AbsenceReject,
AbsenceUpdate,
AbsenceTypeCreate,
AbsenceTypeOut,
AbsenceTypeUpdate,
CalendarEntry,
CertificateMarkIn,
OvertimeBalanceOut,
PublicHolidayCreate,
PublicHolidayOut,
QuickSickIn,
SickStatsOut,
VacationBalanceOut,
VacationBalanceUpdate,
)
from app.services.absence_service import absence_service
from app.models.company import Company
from sqlalchemy import select
from datetime import date
router = APIRouter(tags=["Abwesenheiten"])
def _carryover_expiry(company: Company, year: int) -> tuple[date | None, bool]:
"""Verfallsdatum für Resturlaub berechnen.
Gibt (expires_at, is_expired) zurück. None wenn kein Verfall konfiguriert."""
s = company.settings or {}
month = s.get("carryover_expires_month")
day = s.get("carryover_expires_day")
if not month or not day:
return None, False
try:
expires_at = date(year, int(month), int(day))
return expires_at, date.today() > expires_at
except ValueError:
return None, False
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Absence Types ─────────────────────────────────────────────────────────────
@router.get("/absence-types/", response_model=list[AbsenceTypeOut])
async def list_absence_types(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
types = await absence_service.list_types(current_user.company_id, db)
return [AbsenceTypeOut.model_validate(t) for t in types]
@router.post("/absence-types/", response_model=AbsenceTypeOut, status_code=201)
async def create_absence_type(
data: AbsenceTypeCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
at = await absence_service.create_type(current_user.company_id, data, db)
await db.commit()
await db.refresh(at)
return AbsenceTypeOut.model_validate(at)
@router.patch("/absence-types/{type_id}", response_model=AbsenceTypeOut)
async def update_absence_type(
type_id: UUID,
data: AbsenceTypeUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
at = await absence_service.update_type(type_id, current_user.company_id, data, db)
await db.commit()
await db.refresh(at)
return AbsenceTypeOut.model_validate(at)
# ── Public Holidays ───────────────────────────────────────────────────────────
@router.get("/public-holidays/", response_model=list[PublicHolidayOut])
async def list_public_holidays(
current_user: CurrentUser,
year: int = Query(...),
country: str = Query("DE"),
state: str | None = Query(None),
db: AsyncSession = Depends(get_db),
):
holidays = await absence_service.list_holidays(year, country, state, db)
return [PublicHolidayOut.model_validate(h) for h in holidays]
@router.post("/public-holidays/", response_model=PublicHolidayOut, status_code=201)
async def create_public_holiday(
data: PublicHolidayCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
holiday = await absence_service.create_holiday(data, db)
await db.commit()
await db.refresh(holiday)
return PublicHolidayOut.model_validate(holiday)
# ── Absences ──────────────────────────────────────────────────────────────────
@router.get("/absences/calendar", response_model=list[CalendarEntry])
async def get_calendar(
current_user: CurrentUser,
year: int = Query(...),
month: int | None = Query(None, ge=1, le=12),
db: AsyncSession = Depends(get_db),
):
"""Team-Kalender: alle Abwesenheiten im Zeitraum."""
entries = await absence_service.get_calendar(current_user.company_id, year, month, db)
return [CalendarEntry(**e) for e in entries]
@router.get("/absences/balance", response_model=VacationBalanceOut)
async def get_own_balance(
current_user: CurrentUser,
year: int = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Eigenes Urlaubskonto."""
balance = await absence_service.get_balance(current_user.id, year, db)
pending = await absence_service.get_pending_days(current_user.id, year, db)
company = await db.get(Company, current_user.company_id)
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
@router.get("/absences/balance/{user_id}", response_model=VacationBalanceOut)
async def get_balance_for_user(
user_id: UUID,
year: int = Query(...),
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Urlaubskonto eines Mitarbeiters (MANAGER/HR/ADMIN)."""
target_user = await db.get(User, user_id)
if target_user is None or target_user.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
balance = await absence_service.get_balance(user_id, year, db)
pending = await absence_service.get_pending_days(user_id, year, db)
company = await db.get(Company, current_user.company_id)
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
@router.post("/absences/quick-sick", response_model=AbsenceOut, status_code=201)
async def quick_sick(
data: QuickSickIn,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Sofort-Krankmeldung (auto-approved). Nutzt den ersten aktiven SICK-Typ der Firma."""
absence, _ = await absence_service.quick_sick(
data.start_date, data.end_date, current_user, db
)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.get("/absences/sick-stats", response_model=list[SickStatsOut])
async def get_sick_stats(
current_user: User = require_role(*_manager_roles),
user_id: UUID | None = Query(None),
ref_date: date | None = Query(None, description="Stichtag, default heute"),
db: AsyncSession = Depends(get_db),
):
"""Krankheitsstatistik (rolling 12 Monate ab ref_date) inkl. Bradford-Faktor."""
stats = await absence_service.get_sick_stats(
company_id=current_user.company_id,
current_user=current_user,
ref_date=ref_date or date.today(),
db=db,
user_id=user_id,
)
return [SickStatsOut(**s) for s in stats]
@router.get("/absences/overtime-balance", response_model=OvertimeBalanceOut)
async def get_overtime_balance(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Eigenes Überstunden-Konto."""
bal = await db.scalar(
select(OvertimeBalance).where(OvertimeBalance.user_id == current_user.id)
)
if bal is None:
return OvertimeBalanceOut(total_hours=0, taken_hours=0, available_hours=0)
return OvertimeBalanceOut(
total_hours=float(bal.total_hours),
taken_hours=float(bal.taken_hours),
available_hours=float(bal.available_hours),
)
@router.get("/absences/", response_model=AbsenceListResponse)
async def list_absences(
current_user: CurrentUser,
user_id: UUID | None = Query(None),
type_id: UUID | None = Query(None),
status: AbsenceStatus | None = Query(None),
year: int | None = Query(None),
db: AsyncSession = Depends(get_db),
):
total, absences = await absence_service.list_absences(
current_user.company_id, current_user, db,
user_id=user_id, type_id=type_id, status=status, year=year,
)
return AbsenceListResponse(total=total, items=[AbsenceOut.model_validate(a) for a in absences])
@router.post("/absences/", response_model=AbsenceOut, status_code=201)
async def create_absence(
data: AbsenceCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Abwesenheitsantrag stellen. HR/Admin kann for_user_id setzen um für andere anzulegen."""
acting_user = current_user
if data.for_user_id and data.for_user_id != current_user.id:
if current_user.role not in _manager_roles:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Keine Berechtigung, Abwesenheiten für andere anzulegen.")
target = await db.get(User, data.for_user_id)
if not target or target.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
acting_user = target
absence, warnings = await absence_service.create_absence(data, acting_user, db)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.patch("/absences/{absence_id}", response_model=AbsenceOut)
async def update_absence(
absence_id: UUID,
data: AbsenceUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Ausstehenden Antrag bearbeiten (Mitarbeiter: eigene; Manager: alle der Company)."""
absence = await absence_service.update_absence(absence_id, data, current_user, db)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.get("/absences/{absence_id}", response_model=AbsenceOut)
async def get_absence(
absence_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
absence = await absence_service.get_by_id(absence_id, current_user, db)
return AbsenceOut.model_validate(absence)
@router.delete("/absences/{absence_id}", status_code=204)
async def cancel_absence(
absence_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Eigenen ausstehenden Antrag stornieren."""
await absence_service.cancel_absence(absence_id, current_user, db)
await db.commit()
@router.post("/absences/{absence_id}/approve", response_model=AbsenceOut)
async def approve_absence(
absence_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
absence = await absence_service.approve_absence(absence_id, current_user, db)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.post("/absences/{absence_id}/reject", response_model=AbsenceOut)
async def reject_absence(
absence_id: UUID,
data: AbsenceReject,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
absence = await absence_service.reject_absence(absence_id, data, current_user, db)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.patch("/absences/{absence_id}/certificate", response_model=AbsenceOut)
async def mark_certificate_received(
absence_id: UUID,
data: CertificateMarkIn,
current_user: User = require_role(UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
"""HR/Admin markiert die AU-Bescheinigung als eingegangen."""
absence = await absence_service.mark_certificate_received(
absence_id, data.received_at, current_user, db
)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
# ── Urlaubskonto bearbeiten (HR/Admin) ────────────────────────────────────────
@router.patch("/absences/balance/{user_id}", response_model=VacationBalanceOut)
async def update_balance(
user_id: UUID,
data: VacationBalanceUpdate,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
year: int = Query(...),
):
"""Grundurlaub, Sondertage und Resturlaub eines Mitarbeiters anpassen."""
from app.models.vacation_balance import VacationBalance
target = await db.get(User, user_id)
if target is None or target.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(404, "Mitarbeiter nicht gefunden")
balance = await absence_service.get_balance(user_id, year, db)
for field, value in data.model_dump(exclude_unset=True).items():
setattr(balance, field, value)
await db.commit()
await db.refresh(balance)
pending = await absence_service.get_pending_days(user_id, year, db)
company = await db.get(Company, current_user.company_id)
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
+119
View File
@@ -0,0 +1,119 @@
"""AuditLog-Endpoint nur für COMPANY_ADMIN und SUPER_ADMIN, company-isoliert."""
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.audit_log import AuditLog
from app.models.user import User, UserRole
from app.schemas.audit_log import AuditLogEntry, AuditLogListResponse
router = APIRouter(tags=["Audit Log"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
@router.get("/audit-logs", response_model=AuditLogListResponse)
async def list_audit_logs(
user_id: UUID | None = Query(None),
action: str | None = Query(None),
entity_type: str | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
base_filter = [AuditLog.company_id == current_user.company_id]
if current_user.role == UserRole.SUPER_ADMIN:
base_filter = [] # SUPER_ADMIN sieht alle Firmen
if user_id:
base_filter.append(AuditLog.user_id == user_id)
if action:
base_filter.append(AuditLog.action.ilike(f"%{action}%"))
if entity_type:
base_filter.append(AuditLog.entity_type == entity_type)
if date_from:
base_filter.append(AuditLog.created_at >= date_from)
if date_to:
base_filter.append(AuditLog.created_at <= date_to)
count_q = select(func.count()).select_from(AuditLog).where(*base_filter)
total = await db.scalar(count_q) or 0
rows_q = (
select(AuditLog, User.first_name, User.last_name)
.outerjoin(User, AuditLog.user_id == User.id)
.where(*base_filter)
.order_by(AuditLog.created_at.desc())
.limit(limit)
.offset(offset)
)
rows = (await db.execute(rows_q)).all()
items = [
AuditLogEntry(
id=log.id,
user_id=log.user_id,
user_name=f"{first} {last}".strip() if first or last else None,
action=log.action,
entity_type=log.entity_type,
entity_id=log.entity_id,
old_value=log.old_value,
new_value=log.new_value,
ip_address=log.ip,
created_at=log.created_at,
)
for log, first, last in rows
]
return AuditLogListResponse(total=total, items=items)
@router.get("/audit-logs/actions", response_model=list[str])
async def list_audit_actions(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle vorhandenen Action-Werte für Filter-Dropdown."""
filter_cond = (
[] if current_user.role == UserRole.SUPER_ADMIN
else [AuditLog.company_id == current_user.company_id]
)
q = (
select(AuditLog.action)
.where(*filter_cond)
.distinct()
.order_by(AuditLog.action)
)
result = await db.execute(q)
return [r for (r,) in result.all()]
@router.get("/audit-logs/entity-types", response_model=list[str])
async def list_entity_types(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle vorhandenen Entity-Typen für Filter-Dropdown."""
filter_cond = (
[AuditLog.entity_type.isnot(None)]
if current_user.role == UserRole.SUPER_ADMIN
else [AuditLog.company_id == current_user.company_id, AuditLog.entity_type.isnot(None)]
)
q = (
select(AuditLog.entity_type)
.where(*filter_cond)
.distinct()
.order_by(AuditLog.entity_type)
)
result = await db.execute(q)
return [r for (r,) in result.all()]
+211
View File
@@ -0,0 +1,211 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser
from app.core.limiter import limiter
from app.core.security import hash_password, verify_password
from app.schemas.auth import (
LoginRequest,
MessageResponse,
PasswordResetConfirm,
PasswordResetRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
TotpConfirmRequest,
TotpDisableRequest,
TotpLoginRequest,
TotpSetupResponse,
)
from app.schemas.user import InviteAccept, UserOut
from app.services.auth_service import auth_service
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/register", response_model=TokenResponse, status_code=201)
@limiter.limit("3/hour")
async def register(request: Request, data: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Create a new company + admin account."""
return await auth_service.register(data, db)
@router.post("/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def login(request: Request, data: LoginRequest, db: AsyncSession = Depends(get_db)):
return await auth_service.login(data, db, request)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
return await auth_service.refresh(data.refresh_token, db)
@router.post("/logout", response_model=MessageResponse)
async def logout(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
await auth_service.logout(data.refresh_token, db)
return MessageResponse(message="Logged out successfully")
@router.post("/password-reset", response_model=MessageResponse)
@limiter.limit("3/hour")
async def request_password_reset(request: Request, data: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
result = await auth_service.request_password_reset(data.email, db)
if result == "ldap":
from fastapi import HTTPException
raise HTTPException(
status_code=400,
detail="Dein Konto wird über LDAP verwaltet. Bitte setze dein Passwort direkt beim LDAP-Administrator zurück.",
)
return MessageResponse(message="Falls diese E-Mail-Adresse registriert ist, wurde ein Reset-Link verschickt.")
@router.post("/password-reset/confirm", response_model=MessageResponse)
@limiter.limit("5/hour")
async def confirm_password_reset(request: Request, data: PasswordResetConfirm, db: AsyncSession = Depends(get_db)):
await auth_service.confirm_password_reset(data.token, data.new_password, db)
return MessageResponse(message="Password updated successfully")
@router.post("/invite/accept", response_model=UserOut)
@limiter.limit("10/hour")
async def accept_invite(request: Request, data: InviteAccept, db: AsyncSession = Depends(get_db)):
from app.services.user_service import user_service
user = await user_service.accept_invite(data, db)
return UserOut.model_validate(user)
@router.get("/me", response_model=UserOut)
async def me(current_user: CurrentUser):
return UserOut.model_validate(current_user)
@router.post("/change-password", response_model=MessageResponse)
async def change_password(
data: ChangePasswordRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Passwort ändern (eingeloggter User, benötigt aktuelles Passwort)."""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Aktuelles Passwort ist falsch")
import re
if not re.search(r'[A-Z]', data.new_password) or not re.search(r'[0-9]', data.new_password):
raise HTTPException(
status_code=400,
detail="Neues Passwort muss mindestens 1 Großbuchstaben und 1 Zahl enthalten"
)
current_user.password_hash = hash_password(data.new_password)
await db.commit()
return MessageResponse(message="Passwort erfolgreich geändert")
# ── TOTP / 2FA ────────────────────────────────────────────────────────────────
@router.post("/totp/setup", response_model=TotpSetupResponse)
async def totp_setup(current_user: CurrentUser):
"""Generiert ein neues TOTP-Secret und gibt die otpauth-URI zurück. Noch nicht aktiviert."""
import pyotp
secret = pyotp.random_base32()
issuer = "TimeMaster"
label = current_user.email
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=issuer)
# Secret temporär im User speichern (noch nicht totp_enabled)
current_user.totp_secret = secret
# Hinweis: DB-Commit passiert NICHT hier erst nach verify in /totp/confirm
# Damit das Secret nicht verloren geht, sofort speichern
return TotpSetupResponse(secret=secret, otpauth_uri=uri)
@router.post("/totp/setup/save", response_model=MessageResponse)
async def totp_setup_save(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Speichert das generierte Secret temporär (ohne Aktivierung)."""
import pyotp
if not current_user.totp_secret:
secret = pyotp.random_base32()
current_user.totp_secret = secret
await db.commit()
return MessageResponse(message="Secret gespeichert")
@router.post("/totp/confirm", response_model=MessageResponse)
async def totp_confirm(
data: TotpConfirmRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Bestätigt den ersten TOTP-Code und aktiviert 2FA."""
import pyotp
if not current_user.totp_secret:
raise HTTPException(400, "Kein TOTP-Secret vorhanden. Zuerst /totp/setup aufrufen.")
totp = pyotp.TOTP(current_user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
current_user.totp_enabled = True
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung aktiviert")
@router.post("/totp/disable", response_model=MessageResponse)
async def totp_disable(
data: TotpDisableRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Deaktiviert TOTP. Benötigt aktuelles Passwort + gültigen TOTP-Code."""
import pyotp
if not verify_password(data.password, current_user.password_hash or ""):
raise HTTPException(400, "Passwort falsch")
if not current_user.totp_enabled or not current_user.totp_secret:
raise HTTPException(400, "2FA ist nicht aktiv")
totp = pyotp.TOTP(current_user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger TOTP-Code")
current_user.totp_enabled = False
current_user.totp_secret = None
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung deaktiviert")
@router.post("/totp/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def totp_login(
request: Request,
data: TotpLoginRequest,
db: AsyncSession = Depends(get_db),
):
"""Zweiter Login-Schritt: partial_token + TOTP-Code → volle Tokens."""
import pyotp
from uuid import UUID
from app.core.security import decode_partial_token
from app.models.user import User
from jose import JWTError
try:
user_id = decode_partial_token(data.partial_token)
except JWTError:
raise HTTPException(401, "Ungültiger oder abgelaufener Token")
user = await db.get(User, UUID(user_id))
if not user or not user.is_active:
raise HTTPException(401, "Benutzer nicht gefunden")
if not user.totp_enabled or not user.totp_secret:
raise HTTPException(400, "2FA nicht aktiv")
totp = pyotp.TOTP(user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
from datetime import datetime, timezone
user.last_login = datetime.now(timezone.utc)
return await auth_service._create_session(user, db, request=request)
+168
View File
@@ -0,0 +1,168 @@
"""Busylight-Integration (Pull-Endpoint + Token-Verwaltung).
- Pull: GET /busylight/users Auth via per-Firma Bearer-Token (SHA-256 in DB)
- Verwaltung: POST/DELETE /companies/me/busylight-token COMPANY_ADMIN/SUPER_ADMIN
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.core.limiter import limiter
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceType
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.user import User, UserRole
from app.schemas.busylight import (
BusylightAbsenceItem,
BusylightTokenRotated,
BusylightTokenStatus,
BusylightUserItem,
BusylightUsersResponse,
)
router = APIRouter(tags=["Busylight"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_pull_bearer = HTTPBearer(auto_error=False)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
# ── Token-Verwaltung (eingeloggter Admin) ────────────────────────────────────
@router.get("/companies/me/busylight-token", response_model=BusylightTokenStatus)
async def get_busylight_token_status(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
return BusylightTokenStatus(
configured=company.busylight_pull_token_hash is not None,
created_at=company.busylight_token_created_at,
)
@router.post("/companies/me/busylight-token/rotate", response_model=BusylightTokenRotated)
async def rotate_busylight_token(
request: Request,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
token = secrets.token_urlsafe(32)
company.busylight_pull_token_hash = _hash_token(token)
company.busylight_token_created_at = datetime.now(timezone.utc)
db.add(AuditLog(
company_id=company.id,
user_id=current_user.id,
action="busylight_token_rotated",
entity_type="company",
entity_id=company.id,
ip=request.client.host if request.client else None,
))
await db.commit()
return BusylightTokenRotated(token=token, created_at=company.busylight_token_created_at)
@router.delete("/companies/me/busylight-token", status_code=204)
async def delete_busylight_token(
request: Request,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
if company.busylight_pull_token_hash is None:
return
company.busylight_pull_token_hash = None
company.busylight_token_created_at = None
db.add(AuditLog(
company_id=company.id,
user_id=current_user.id,
action="busylight_token_revoked",
entity_type="company",
entity_id=company.id,
ip=request.client.host if request.client else None,
))
await db.commit()
# ── Pull-Endpoint (busylight liest hier) ─────────────────────────────────────
async def _company_from_token(
credentials: HTTPAuthorizationCredentials | None,
db: AsyncSession,
) -> Company:
if credentials is None or not credentials.credentials:
raise HTTPException(status_code=401, detail="Missing token")
token_hash = _hash_token(credentials.credentials)
company = await db.scalar(
select(Company).where(Company.busylight_pull_token_hash == token_hash)
)
if company is None:
raise HTTPException(status_code=401, detail="Invalid token")
return company
@router.get("/busylight/users", response_model=BusylightUsersResponse)
@limiter.limit("60/minute")
async def list_users_for_busylight(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_pull_bearer),
db: AsyncSession = Depends(get_db),
):
company = await _company_from_token(credentials, db)
today = date.today()
users = (await db.scalars(
select(User)
.where(
User.company_id == company.id,
User.is_active == True,
User.personnel_number.is_not(None),
)
.order_by(User.last_name, User.first_name)
)).all()
user_ids = [u.id for u in users]
abs_rows = []
if user_ids:
abs_rows = (await db.execute(
select(Absence, AbsenceType)
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
Absence.user_id.in_(user_ids),
Absence.status == AbsenceStatus.APPROVED,
Absence.start_date <= today,
Absence.end_date >= today,
)
)).all()
by_user: dict = {uid: [] for uid in user_ids}
for absence, atype in abs_rows:
by_user[absence.user_id].append(
BusylightAbsenceItem(type=atype.name, category=atype.category.value)
)
items = [
BusylightUserItem(
personnel_number=u.personnel_number,
full_name=u.full_name,
absences_today=by_user[u.id],
)
for u in users
]
return BusylightUsersResponse(date=today, users=items)
+143
View File
@@ -0,0 +1,143 @@
"""
CalDAV-Konfiguration und manueller Sync-Trigger.
Firmenkalender: nur COMPANY_ADMIN / SUPER_ADMIN
Persönlicher Kalender: jeder eingeloggte Nutzer für sich selbst
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
from app.models.user import User, UserRole
from app.schemas.caldav import (
CaldavCompanyConfigOut,
CaldavCompanyConfigSave,
CaldavUserConfigOut,
CaldavUserConfigSave,
ResyncResult,
)
from app.services.caldav_service import caldav_service, encrypt_password
router = APIRouter(prefix="/caldav", tags=["CalDAV"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Firmenkalender ─────────────────────────────────────────────────────────────
@router.get("/company/config", response_model=CaldavCompanyConfigOut | None)
async def get_company_config(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_company_config(current_user.company_id, db)
return CaldavCompanyConfigOut.model_validate(cfg) if cfg else None
@router.post("/company/config", response_model=CaldavCompanyConfigOut)
async def save_company_config(
data: CaldavCompanyConfigSave,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_company_config(current_user.company_id, db)
if cfg is None:
cfg = CaldavCompanyConfig(company_id=current_user.company_id, id=uuid.uuid4())
db.add(cfg)
cfg.enabled = data.enabled
cfg.principal_url = data.principal_url
cfg.calendar_url = data.calendar_url
cfg.username = data.username
cfg.calendar_display_name = data.calendar_display_name
cfg.verify_ssl = data.verify_ssl
if data.password:
cfg.password_encrypted = encrypt_password(data.password)
elif not cfg.password_encrypted:
raise HTTPException(status_code=400, detail="Passwort wird beim ersten Speichern benötigt.")
await db.commit()
await db.refresh(cfg)
return CaldavCompanyConfigOut.model_validate(cfg)
@router.post("/company/test")
async def test_company_config(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_company_config(current_user.company_id, db)
if not cfg:
raise HTTPException(status_code=404, detail="Keine Firmen-CalDAV-Konfiguration vorhanden.")
result = await caldav_service.test_config(cfg)
if not result["ok"]:
raise HTTPException(status_code=502, detail=result["error"])
return {"message": "Verbindung erfolgreich.", "http_status": result.get("status")}
@router.post("/company/resync", response_model=ResyncResult)
async def resync_all(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle genehmigten Abwesenheiten neu in den Firmenkalender synchronisieren."""
result = await caldav_service.resync_all_approved(current_user.company_id, db)
await db.commit()
return ResyncResult(**result)
# ── Persönlicher Kalender ──────────────────────────────────────────────────────
@router.get("/user/config", response_model=CaldavUserConfigOut | None)
async def get_user_config(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_user_config(current_user.id, db)
return CaldavUserConfigOut.model_validate(cfg) if cfg else None
@router.post("/user/config", response_model=CaldavUserConfigOut)
async def save_user_config(
data: CaldavUserConfigSave,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_user_config(current_user.id, db)
if cfg is None:
cfg = CaldavUserConfig(user_id=current_user.id, id=uuid.uuid4())
db.add(cfg)
cfg.enabled = data.enabled
cfg.principal_url = data.principal_url
cfg.calendar_url = data.calendar_url
cfg.username = data.username
cfg.calendar_display_name = data.calendar_display_name
cfg.verify_ssl = data.verify_ssl
if data.password:
cfg.password_encrypted = encrypt_password(data.password)
elif not cfg.password_encrypted:
raise HTTPException(status_code=400, detail="Passwort wird beim ersten Speichern benötigt.")
await db.commit()
await db.refresh(cfg)
return CaldavUserConfigOut.model_validate(cfg)
@router.post("/user/test")
async def test_user_config(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_user_config(current_user.id, db)
if not cfg:
raise HTTPException(status_code=404, detail="Keine persönliche CalDAV-Konfiguration vorhanden.")
result = await caldav_service.test_config(cfg)
if not result["ok"]:
raise HTTPException(status_code=502, detail=result["error"])
return {"message": "Verbindung erfolgreich.", "http_status": result.get("status")}
+91
View File
@@ -0,0 +1,91 @@
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models import Company
from app.models.department import Department
from app.models.user import User, UserRole
from app.schemas.company import (
CompanyOut,
CompanyUpdate,
DepartmentCreate,
DepartmentOut,
DepartmentUpdate,
)
router = APIRouter(prefix="/companies", tags=["Companies"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
@router.get("/me", response_model=CompanyOut)
async def get_my_company(current_user: CurrentUser, db: AsyncSession = Depends(get_db)):
company = await db.get(Company, current_user.company_id)
return CompanyOut.model_validate(company)
@router.patch("/me", response_model=CompanyOut)
async def update_my_company(
data: CompanyUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
for field, value in data.model_dump(exclude_none=True).items():
setattr(company, field, value)
return CompanyOut.model_validate(company)
# ── Departments ──────────────────────────────────────────────────────────────
@router.get("/me/departments", response_model=list[DepartmentOut])
async def list_departments(current_user: CurrentUser, db: AsyncSession = Depends(get_db)):
depts = await db.scalars(
select(Department).where(Department.company_id == current_user.company_id)
)
return [DepartmentOut.model_validate(d) for d in depts.all()]
@router.post("/me/departments", response_model=DepartmentOut, status_code=201)
async def create_department(
data: DepartmentCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
dept = Department(company_id=current_user.company_id, **data.model_dump())
db.add(dept)
await db.flush()
return DepartmentOut.model_validate(dept)
@router.patch("/me/departments/{dept_id}", response_model=DepartmentOut)
async def update_department(
dept_id: UUID,
data: DepartmentUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
dept = await db.get(Department, dept_id)
if not dept or dept.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Department not found")
for field, value in data.model_dump(exclude_none=True).items():
setattr(dept, field, value)
return DepartmentOut.model_validate(dept)
@router.delete("/me/departments/{dept_id}", status_code=204)
async def delete_department(
dept_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
dept = await db.get(Department, dept_id)
if not dept or dept.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Department not found")
await db.delete(dept)
+87
View File
@@ -0,0 +1,87 @@
"""Router: Kimai CSV Import (nur HR / Admin)."""
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db, require_role
from app.models.user import User, UserRole
from app.services.kimai_import_service import (
ImportPreviewEntry,
ImportResult,
preview_kimai_import,
run_kimai_import,
)
router = APIRouter(prefix="/import", tags=["import"])
_allowed_roles = [UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN]
class ImportPreviewResponse(BaseModel):
preview: list[ImportPreviewEntry]
time_count: int
absence_count: int
skip_count: int
errors: list[str]
class ImportRunResponse(BaseModel):
time_imported: int
absence_imported: int
skipped: int
errors: list[str]
@router.post("/kimai/preview", response_model=ImportPreviewResponse)
async def kimai_preview(
user_id: Annotated[str, Form()],
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_allowed_roles),
db: AsyncSession = Depends(get_db),
):
"""Vorschau des Kimai-Imports (keine DB-Änderungen)."""
try:
target_id = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige user_id")
content = await file.read()
result: ImportResult = await preview_kimai_import(content, target_id, db)
time_count = sum(1 for p in result.preview if p.kind == "time" and not p.skipped)
abs_count = sum(1 for p in result.preview if p.kind == "absence" and not p.skipped)
return ImportPreviewResponse(
preview=result.preview,
time_count=time_count,
absence_count=abs_count,
skip_count=result.skipped,
errors=result.errors,
)
@router.post("/kimai/run", response_model=ImportRunResponse)
async def kimai_run(
user_id: Annotated[str, Form()],
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_allowed_roles),
db: AsyncSession = Depends(get_db),
):
"""Führt den Kimai-Import durch (schreibt in DB)."""
try:
target_id = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige user_id")
content = await file.read()
result: ImportResult = await run_kimai_import(content, target_id, current_user.id, db)
return ImportRunResponse(
time_imported=result.time_imported,
absence_imported=result.absence_imported,
skipped=result.skipped,
errors=result.errors,
)
+102
View File
@@ -0,0 +1,102 @@
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.user import User, UserRole
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceCreated, KioskDeviceOut, KioskDeviceUpdate
from app.services.kiosk_service import kiosk_service
router = APIRouter(prefix="/kiosk", tags=["Kiosk"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Geräteverwaltung (COMPANY_ADMIN) ──────────────────────────────────────────
@router.get("/devices", response_model=list[KioskDeviceOut])
async def list_devices(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle registrierten Kiosk-Geräte der Firma auflisten."""
return await kiosk_service.list_devices(current_user.company_id, db)
@router.post("/devices", response_model=KioskDeviceCreated, status_code=201)
async def create_device(
data: KioskDeviceCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Neues Kiosk-Gerät registrieren. Token wird nur einmalig zurückgegeben."""
device, raw_token = await kiosk_service.create_device(current_user.company_id, data, db)
await db.commit()
await db.refresh(device)
return KioskDeviceCreated(
**KioskDeviceOut.model_validate(device).model_dump(),
token=raw_token,
)
@router.get("/devices/{device_id}", response_model=KioskDeviceOut)
async def get_device(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
return await kiosk_service.get_device(device_id, current_user.company_id, db)
@router.patch("/devices/{device_id}", response_model=KioskDeviceOut)
async def update_device(
device_id: UUID,
data: KioskDeviceUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
device = await kiosk_service.update_device(device_id, current_user.company_id, data, db)
await db.commit()
await db.refresh(device)
return KioskDeviceOut.model_validate(device)
@router.post("/devices/{device_id}/rotate-token", response_model=KioskDeviceCreated)
async def rotate_token(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Token rotieren das alte Token wird sofort ungültig."""
device, raw_token = await kiosk_service.rotate_token(device_id, current_user.company_id, db)
await db.commit()
await db.refresh(device)
return KioskDeviceCreated(
**KioskDeviceOut.model_validate(device).model_dump(),
token=raw_token,
)
@router.delete("/devices/{device_id}", status_code=204)
async def delete_device(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
await kiosk_service.delete_device(device_id, current_user.company_id, db)
await db.commit()
# ── Kiosk-Auth (Gerät authentifiziert sich per Token) ─────────────────────────
@router.get("/me", response_model=KioskDeviceOut)
async def kiosk_me(
x_kiosk_token: str = Header(..., alias="X-Kiosk-Token", min_length=32, max_length=128),
db: AsyncSession = Depends(get_db),
):
"""Kiosk-Gerät prüft seine eigene Identität / aktualisiert last_seen_at."""
device = await kiosk_service.authenticate_device(x_kiosk_token, db)
await db.commit()
return KioskDeviceOut.model_validate(device)
+139
View File
@@ -0,0 +1,139 @@
"""LDAP configuration and sync endpoints.
All endpoints require COMPANY_ADMIN or SUPER_ADMIN role.
"""
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.ldap_config import LdapConfig
from app.models.user import User, UserRole
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
from app.schemas.ldap import (
LdapConfigCreate,
LdapConfigOut,
LdapConfigUpdate,
LdapSyncRequest,
LdapSyncResult,
LdapTestResult,
LdapUserPreview,
)
from app.services.ldap_service import decrypt_password, encrypt_password, ldap_service
router = APIRouter(prefix="/ldap", tags=["LDAP"])
@router.get("/config", response_model=LdapConfigOut | None)
async def get_ldap_config(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
return await ldap_service.get_config(current_user.company_id, db)
@router.post("/config", response_model=LdapConfigOut)
async def create_ldap_config(
data: LdapConfigCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
existing = await ldap_service.get_config(current_user.company_id, db)
if existing:
# Update instead of duplicate
return await _apply_update(existing, data.model_dump(), db)
cfg = LdapConfig(
company_id=current_user.company_id,
enabled=data.enabled,
host=data.host,
port=data.port,
use_ssl=data.use_ssl,
use_tls=data.use_tls,
bind_dn=data.bind_dn,
bind_password_encrypted=encrypt_password(data.bind_password),
base_dn=data.base_dn,
user_search_filter=data.user_search_filter,
attr_email=data.attr_email,
attr_firstname=data.attr_firstname,
attr_lastname=data.attr_lastname,
attr_username=data.attr_username,
attr_department=data.attr_department,
)
db.add(cfg)
await db.commit()
await db.refresh(cfg)
return cfg
@router.patch("/config", response_model=LdapConfigOut)
async def update_ldap_config(
data: LdapConfigUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
return await _apply_update(cfg, data.model_dump(exclude_none=True), db)
@router.post("/test", response_model=LdapTestResult)
async def test_ldap_connection(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
result = ldap_service.test_connection(cfg)
return LdapTestResult(success=result.success, message=result.message)
@router.get("/preview", response_model=list[LdapUserPreview])
async def preview_ldap_users(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Returns first 50 users found in LDAP (for preview before sync)."""
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
raw_users = ldap_service.search_users(cfg)
previews = []
for u in raw_users[:50]:
previews.append(LdapUserPreview(
dn=u.get("dn", ""),
email=str(u.get(cfg.attr_email, "") or "").lower(),
first_name=str(u.get(cfg.attr_firstname, "") or ""),
last_name=str(u.get(cfg.attr_lastname, "") or ""),
department=str(u.get(cfg.attr_department, "") or "") if cfg.attr_department else None,
))
return previews
@router.post("/sync", response_model=LdapSyncResult)
async def sync_ldap_users(
data: LdapSyncRequest,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
result = await ldap_service.sync_users(cfg, db, default_role=data.default_role)
return LdapSyncResult(
created=result.created,
updated=result.updated,
deactivated=result.deactivated,
errors=result.errors,
)
# ── Helpers ───────────────────────────────────────────────────────────────────
async def _apply_update(cfg: LdapConfig, updates: dict, db: AsyncSession) -> LdapConfig:
for field, value in updates.items():
if field == "bind_password" and value:
cfg.bind_password_encrypted = encrypt_password(value)
elif hasattr(cfg, field):
setattr(cfg, field, value)
await db.commit()
await db.refresh(cfg)
return cfg
+206
View File
@@ -0,0 +1,206 @@
from datetime import date
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.project import Project
from app.models.time_entry import TimeEntry, EntryStatus
from app.models.user import User, UserRole
from app.schemas.project import (
ProjectCreate,
ProjectListResponse,
ProjectOut,
ProjectTimeReport,
ProjectUpdate,
)
router = APIRouter(prefix="/projects", tags=["Projekte"])
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
def _assert_company(project: Project, company_id: UUID) -> None:
if project.company_id != company_id:
raise HTTPException(404, "Projekt nicht gefunden")
@router.get("", response_model=ProjectListResponse)
async def list_projects(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
include_inactive: bool = Query(False),
):
stmt = select(Project).where(Project.company_id == current_user.company_id)
if not include_inactive:
stmt = stmt.where(Project.is_active == True)
stmt = stmt.order_by(Project.name)
result = await db.scalars(stmt)
items = list(result.all())
return ProjectListResponse(total=len(items), items=items)
@router.post("", response_model=ProjectOut, status_code=201)
async def create_project(
data: ProjectCreate,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
project = Project(
company_id=current_user.company_id,
name=data.name,
description=data.description,
color=data.color,
budget_hours=data.budget_hours,
)
db.add(project)
await db.commit()
await db.refresh(project)
return project
@router.get("/report/summary", response_model=list[ProjectTimeReport])
async def projects_summary(
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
date_from: date | None = Query(None),
date_to: date | None = Query(None),
):
projects = list((await db.scalars(
select(Project).where(Project.company_id == current_user.company_id, Project.is_active == True)
)).all())
result = []
for project in projects:
stmt = (
select(TimeEntry)
.join(TimeEntry.user)
.where(
TimeEntry.project_id == project.id,
User.company_id == current_user.company_id,
TimeEntry.status == EntryStatus.APPROVED,
TimeEntry.end_time.is_not(None),
)
)
if date_from:
stmt = stmt.where(TimeEntry.date >= date_from)
if date_to:
stmt = stmt.where(TimeEntry.date <= date_to)
entries = list((await db.scalars(stmt)).all())
total_minutes = sum(e.worked_minutes or 0 for e in entries)
total_hours = round(total_minutes / 60, 2)
budget_used_pct = None
if project.budget_hours and float(project.budget_hours) > 0:
budget_used_pct = round(total_hours / float(project.budget_hours) * 100, 1)
result.append(ProjectTimeReport(
project_id=project.id,
project_name=project.name,
project_color=project.color,
total_hours=total_hours,
entry_count=len(entries),
budget_hours=float(project.budget_hours) if project.budget_hours else None,
budget_used_pct=budget_used_pct,
))
result.sort(key=lambda x: x.total_hours, reverse=True)
return result
@router.get("/{project_id}", response_model=ProjectOut)
async def get_project(
project_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Projekt nicht gefunden")
_assert_company(project, current_user.company_id)
return project
@router.patch("/{project_id}", response_model=ProjectOut)
async def update_project(
project_id: UUID,
data: ProjectUpdate,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Projekt nicht gefunden")
_assert_company(project, current_user.company_id)
for field, value in data.model_dump(exclude_unset=True).items():
setattr(project, field, value)
await db.commit()
await db.refresh(project)
return project
@router.delete("/{project_id}", status_code=204)
async def delete_project(
project_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Projekt nicht gefunden")
_assert_company(project, current_user.company_id)
project.is_active = False
await db.commit()
@router.get("/{project_id}/report", response_model=ProjectTimeReport)
async def project_time_report(
project_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
date_from: date | None = Query(None),
date_to: date | None = Query(None),
):
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Projekt nicht gefunden")
_assert_company(project, current_user.company_id)
stmt = (
select(TimeEntry)
.join(TimeEntry.user)
.where(
TimeEntry.project_id == project_id,
User.company_id == current_user.company_id,
TimeEntry.status == EntryStatus.APPROVED,
TimeEntry.end_time.is_not(None),
)
)
if date_from:
stmt = stmt.where(TimeEntry.date >= date_from)
if date_to:
stmt = stmt.where(TimeEntry.date <= date_to)
entries = list((await db.scalars(stmt)).all())
total_minutes = sum(e.worked_minutes or 0 for e in entries)
total_hours = round(total_minutes / 60, 2)
budget_used_pct = None
if project.budget_hours and float(project.budget_hours) > 0:
budget_used_pct = round(total_hours / float(project.budget_hours) * 100, 1)
return ProjectTimeReport(
project_id=project.id,
project_name=project.name,
project_color=project.color,
total_hours=total_hours,
entry_count=len(entries),
budget_hours=float(project.budget_hours) if project.budget_hours else None,
budget_used_pct=budget_used_pct,
)
+210
View File
@@ -0,0 +1,210 @@
from datetime import date, timedelta
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response
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.report import (
AbsenceReport,
CompanyDashboard,
EmployeeDashboard,
OvertimeReport,
OvertimeReportDetailed,
TeamDashboard,
TimeReport,
)
from app.services.report_service import report_service
router = APIRouter(tags=["Dashboard & Reports"])
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Dashboard ──────────────────────────────────────────────────────────────────
@router.get("/dashboard/me", response_model=EmployeeDashboard)
async def my_dashboard(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Mitarbeiter-Dashboard: eigene Stunden, Urlaub, Status heute."""
return await report_service.employee_dashboard(current_user, db)
@router.get("/dashboard/team", response_model=TeamDashboard)
async def team_dashboard(
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Team-Dashboard: Anwesenheit, ausstehende Genehmigungen."""
return await report_service.team_dashboard(current_user, db)
@router.get("/dashboard/company", response_model=CompanyDashboard)
async def company_dashboard(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Unternehmens-Dashboard: Gesamtübersicht, Überstunden, kommende Abwesenheiten."""
return await report_service.company_dashboard(current_user, db)
# ── Reports ────────────────────────────────────────────────────────────────────
def _default_date_from() -> date:
today = date.today()
return today.replace(day=1)
def _default_date_to() -> date:
return date.today()
@router.get("/reports/time", response_model=TimeReport)
async def time_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungsbericht (JSON). EMPLOYEE sieht nur eigene Einträge."""
return await report_service.time_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
@router.get("/reports/absences", response_model=AbsenceReport)
async def absence_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Abwesenheitsbericht (JSON). EMPLOYEE sieht nur eigene Abwesenheiten."""
return await report_service.absence_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
@router.get("/reports/overtime", response_model=OvertimeReport)
async def overtime_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Überstundenbericht (JSON). EMPLOYEE sieht nur eigene Daten."""
return await report_service.overtime_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
@router.get("/reports/overtime/detail", response_model=OvertimeReportDetailed)
async def overtime_report_detail(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Erweiterter Überstundenbericht mit Wochen- und Tagesaufschlüsselung."""
return await report_service.overtime_report_detail(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
# ── Export ─────────────────────────────────────────────────────────────────────
@router.get("/reports/time/export")
async def export_time_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
format: str = Query("csv", pattern="^(csv|xlsx|pdf)$"),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungsbericht als CSV, XLSX oder PDF herunterladen."""
report = await report_service.time_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
filename = f"zeiterfassung_{date_from}_{date_to}"
if format == "pdf":
content = report_service.time_report_to_pdf(report)
return Response(content=content, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}.pdf"})
if format == "xlsx":
content = report_service.to_xlsx(report_service._time_rows_to_dicts(report.rows), sheet_name="Zeiterfassung")
return Response(content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}.xlsx"})
content = report_service.to_csv(report_service._time_rows_to_dicts(report.rows))
return Response(content=content, media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
@router.get("/reports/absences/export")
async def export_absence_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
format: str = Query("csv", pattern="^(csv|xlsx|pdf)$"),
db: AsyncSession = Depends(get_db),
):
"""Abwesenheitsbericht als CSV, XLSX oder PDF herunterladen."""
report = await report_service.absence_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
filename = f"abwesenheiten_{date_from}_{date_to}"
if format == "pdf":
content = report_service.absence_report_to_pdf(report)
return Response(content=content, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}.pdf"})
if format == "xlsx":
content = report_service.to_xlsx(report_service._absence_rows_to_dicts(report.rows), sheet_name="Abwesenheiten")
return Response(content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}.xlsx"})
content = report_service.to_csv(report_service._absence_rows_to_dicts(report.rows))
return Response(content=content, media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
@router.get("/reports/overtime/export")
async def export_overtime_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
format: str = Query("csv", pattern="^(csv|xlsx|pdf)$"),
db: AsyncSession = Depends(get_db),
):
"""Überstundenbericht als CSV, XLSX oder PDF herunterladen (Detailansicht)."""
detail = await report_service.overtime_report_detail(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
filename = f"ueberstunden_{date_from}_{date_to}"
if format == "pdf":
content = report_service.overtime_detail_to_pdf(detail)
return Response(content=content, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}.pdf"})
if format == "xlsx":
content = report_service.to_xlsx(report_service._overtime_detail_to_dicts(detail), sheet_name="Überstunden")
return Response(content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}.xlsx"})
content = report_service.to_csv(report_service._overtime_detail_to_dicts(detail))
return Response(content=content, media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
+92
View File
@@ -0,0 +1,92 @@
"""
SMTP-Konfiguration pro Firma.
Nur COMPANY_ADMIN / SUPER_ADMIN darf lesen und schreiben.
"""
import base64
import hashlib
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.smtp_config import SmtpConfig
from app.models.user import User, UserRole
from app.schemas.smtp import SmtpConfigOut, SmtpConfigSave, SmtpTestRequest
from app.services.email_service import email_service
router = APIRouter(prefix="/smtp", tags=["SMTP-Konfiguration"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
def _encrypt(plain: str) -> str:
from app.core.config import settings
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
f = Fernet(base64.urlsafe_b64encode(key))
return f.encrypt(plain.encode()).decode()
async def _get_config(company_id: uuid.UUID, db: AsyncSession) -> SmtpConfig | None:
return await db.scalar(select(SmtpConfig).where(SmtpConfig.company_id == company_id))
@router.get("/config", response_model=SmtpConfigOut | None)
async def get_smtp_config(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await _get_config(current_user.company_id, db)
if not cfg:
return None
return SmtpConfigOut.model_validate(cfg)
@router.post("/config", response_model=SmtpConfigOut)
async def save_smtp_config(
data: SmtpConfigSave,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Erstellt oder überschreibt die SMTP-Konfiguration der Firma."""
cfg = await _get_config(current_user.company_id, db)
if cfg is None:
cfg = SmtpConfig(company_id=current_user.company_id, id=uuid.uuid4())
db.add(cfg)
cfg.host = data.host
cfg.port = data.port
cfg.use_tls = data.use_tls
cfg.use_starttls = data.use_starttls
cfg.username = data.username
cfg.from_email = data.from_email
cfg.from_name = data.from_name
cfg.is_enabled = data.is_enabled
if data.password is not None:
cfg.password_encrypted = _encrypt(data.password)
await db.commit()
await db.refresh(cfg)
return SmtpConfigOut.model_validate(cfg)
@router.post("/test")
async def test_smtp(
data: SmtpTestRequest,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Sendet eine Test-E-Mail mit der aktuellen Konfiguration."""
cfg = await _get_config(current_user.company_id, db)
if not cfg:
raise HTTPException(status_code=404, detail="Keine SMTP-Konfiguration vorhanden.")
try:
await email_service.send_test(cfg, data.to)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"SMTP-Fehler: {exc}")
return {"message": f"Test-E-Mail an {data.to} verschickt."}
+278
View File
@@ -0,0 +1,278 @@
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()
+185
View File
@@ -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")