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,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,
|
||||
})
|
||||
@@ -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()]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"})
|
||||
@@ -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."}
|
||||
@@ -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()
|
||||
@@ -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