Initial commit – TimeMaster Zeiterfassung & HR-Tool

Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer),
Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst.
Migrations 0001–0023 deployed auf 192.168.1.137 + .164.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
View File
+772
View File
@@ -0,0 +1,772 @@
import asyncio
from datetime import date, timedelta
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from decimal import Decimal
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceCategory, AbsenceType
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.overtime_balance import OvertimeBalance
from app.models.public_holiday import PublicHoliday
from app.models.user import User, UserRole
from app.models.vacation_balance import VacationBalance
from app.models.work_schedule import WorkSchedule
from app.schemas.absence import AbsenceCreate, AbsenceReject, AbsenceTypeCreate, AbsenceTypeUpdate
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
class AbsenceService:
# ── AbsenceTypes ─────────────────────────────────────────────────────────
async def list_types(self, company_id: UUID, db: AsyncSession) -> list[AbsenceType]:
result = await db.scalars(
select(AbsenceType)
.where(AbsenceType.company_id == company_id, AbsenceType.is_active == True)
.order_by(AbsenceType.name)
)
return list(result.all())
async def create_type(
self, company_id: UUID, data: AbsenceTypeCreate, db: AsyncSession
) -> AbsenceType:
at = AbsenceType(company_id=company_id, **data.model_dump())
db.add(at)
await db.flush()
return at
async def update_type(
self, type_id: UUID, company_id: UUID, data: AbsenceTypeUpdate, db: AsyncSession
) -> AbsenceType:
at = await self._get_type_or_404(type_id, company_id, db)
for field, value in data.model_dump(exclude_none=True).items():
setattr(at, field, value)
return at
async def create_defaults_for_company(self, company_id: UUID, db: AsyncSession) -> None:
"""Standard-Abwesenheitstypen + Standard-Arbeitsplan für ein neues Unternehmen anlegen."""
defaults = [
{
"name": "Urlaub", "color": "#3B82F6", "category": AbsenceCategory.VACATION,
"requires_approval": True, "deducts_vacation": True, "is_paid": True,
},
{
"name": "Krankheit", "color": "#EF4444", "category": AbsenceCategory.SICK,
"requires_approval": False, "deducts_vacation": False, "is_paid": True,
"requires_certificate": True, "certificate_after_days": 3,
},
{
"name": "Freizeitausgleich", "color": "#F59E0B", "category": AbsenceCategory.OVERTIME_COMP,
"requires_approval": True, "deducts_vacation": False,
"affects_overtime_balance": True, "is_paid": True,
},
{
"name": "Weiterbildung", "color": "#8B5CF6", "category": AbsenceCategory.TRAINING,
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
"max_days_per_year": 5,
},
{
"name": "Dienstreise", "color": "#06B6D4", "category": AbsenceCategory.BUSINESS_TRIP,
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
},
{
"name": "Homeoffice", "color": "#10B981", "category": AbsenceCategory.OTHER,
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
},
{
"name": "Sonderurlaub", "color": "#84CC16", "category": AbsenceCategory.VACATION,
"requires_approval": True, "deducts_vacation": True, "is_paid": True,
},
]
for d in defaults:
db.add(AbsenceType(company_id=company_id, **d))
# Standard-Arbeitsplan: MoFr 8h
schedule = WorkSchedule(
company_id=company_id,
name="Vollzeit (40h)",
valid_from=date.today(),
)
db.add(schedule)
await db.flush()
# ── Absences ──────────────────────────────────────────────────────────────
async def list_absences(
self,
company_id: UUID,
current_user: User,
db: AsyncSession,
user_id: UUID | None = None,
type_id: UUID | None = None,
status: AbsenceStatus | None = None,
year: int | None = None,
) -> tuple[int, list[Absence]]:
q = (
select(Absence)
.join(User, Absence.user_id == User.id)
.where(User.company_id == company_id)
)
if current_user.role == UserRole.EMPLOYEE:
q = q.where(Absence.user_id == current_user.id)
elif user_id:
q = q.where(Absence.user_id == user_id)
if type_id:
q = q.where(Absence.type_id == type_id)
if status:
q = q.where(Absence.status == status)
if year:
q = q.where(Absence.start_date >= date(year, 1, 1), Absence.end_date <= date(year, 12, 31))
total = await db.scalar(select(func.count()).select_from(q.subquery())) or 0
result = await db.scalars(q.order_by(Absence.start_date.desc()))
return total, list(result.all())
async def get_by_id(self, absence_id: UUID, current_user: User, db: AsyncSession) -> Absence:
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
if absence.user_id != current_user.id and current_user.role == UserRole.EMPLOYEE:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
return absence
async def create_absence(
self,
data: AbsenceCreate,
current_user: User,
db: AsyncSession,
) -> tuple[Absence, list[str]]:
# AbsenceType validieren
absence_type = await self._get_type_or_404(data.type_id, current_user.company_id, db)
# Arbeitstage berechnen
holidays = await self._get_holiday_dates(
current_user.company_id, data.start_date.year, db
)
working_days = self._calc_working_days(
data.start_date, data.end_date, holidays,
data.half_day_start, data.half_day_end
)
if working_days <= 0:
raise HTTPException(status_code=400, detail="Keine Arbeitstage im ausgewählten Zeitraum.")
# Urlaubskonto prüfen wenn Urlaub abgezogen werden soll
warnings: list[str] = []
if absence_type.deducts_vacation:
balance = await self._get_or_create_balance(current_user.id, data.start_date.year, db)
if balance.remaining_days < working_days:
warnings.append(
f"Urlaubskonto reicht möglicherweise nicht aus: "
f"{balance.remaining_days} Tage verfügbar, {working_days} Tage beantragt."
)
# Überschneidung mit eigenen Abwesenheiten prüfen
overlap = await db.scalar(
select(Absence).where(
and_(
Absence.user_id == current_user.id,
Absence.status != AbsenceStatus.CANCELLED,
Absence.status != AbsenceStatus.REJECTED,
Absence.start_date <= data.end_date,
Absence.end_date >= data.start_date,
)
)
)
if overlap:
warnings.append("Überschneidung mit bestehender Abwesenheit im selben Zeitraum.")
status = AbsenceStatus.PENDING if absence_type.requires_approval else AbsenceStatus.APPROVED
approved_by = None if absence_type.requires_approval else current_user.id
# Krankmeldung: AU-Pflicht-Datum automatisch berechnen.
# Reihenfolge: AbsenceType.certificate_after_days (override) → Company default.
certificate_required_by: date | None = None
if absence_type.category == AbsenceCategory.SICK and absence_type.requires_certificate:
company = await db.get(Company, current_user.company_id)
company_default = company.sick_note_required_after_days if company else 3
threshold = absence_type.certificate_after_days or company_default
certificate_required_by = data.start_date + timedelta(days=threshold)
absence = Absence(
user_id=current_user.id,
type_id=data.type_id,
start_date=data.start_date,
end_date=data.end_date,
half_day_start=data.half_day_start,
half_day_end=data.half_day_end,
working_days=working_days,
status=status,
approved_by=approved_by,
substitute_id=data.substitute_id,
note=data.note,
certificate_required_by=certificate_required_by,
)
db.add(absence)
await db.flush()
# Bei automatischer Genehmigung Konto abziehen
if not absence_type.requires_approval and absence_type.deducts_vacation:
await self._deduct_vacation(current_user.id, data.start_date.year, int(working_days), db)
return absence, warnings
async def update_absence(
self, absence_id: UUID, data: "AbsenceUpdate", current_user: User, db: AsyncSession
) -> Absence:
from app.schemas.absence import AbsenceUpdate
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
# Mitarbeiter: nur eigene; Manager: gleiche Company
if current_user.role == UserRole.EMPLOYEE:
if absence.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
else:
owner = await db.get(User, absence.user_id)
if owner is None or owner.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
is_manager = current_user.role in _manager_roles
if absence.status not in (AbsenceStatus.PENDING, AbsenceStatus.APPROVED):
raise HTTPException(status_code=409, detail="Nur ausstehende oder genehmigte Anträge können bearbeitet werden.")
if absence.status == AbsenceStatus.APPROVED and not is_manager:
# Mitarbeiter stellt Änderungswunsch → Begründung Pflicht, Status zurück auf pending
if not data.correction_note or not data.correction_note.strip():
raise HTTPException(status_code=422, detail="Änderungsgrund ist bei genehmigten Anträgen Pflicht.")
if data.type_id is not None:
await self._get_type_or_404(data.type_id, current_user.company_id, db)
absence.type_id = data.type_id
if data.start_date is not None:
absence.start_date = data.start_date
if data.end_date is not None:
absence.end_date = data.end_date
if data.half_day_start is not None:
absence.half_day_start = data.half_day_start
if data.half_day_end is not None:
absence.half_day_end = data.half_day_end
if data.substitute_id is not None:
absence.substitute_id = data.substitute_id
if data.note is not None:
absence.note = data.note
if data.correction_note is not None:
absence.correction_note = data.correction_note.strip() or None
# Genehmigter Antrag: Mitarbeiter-Änderung → zurück auf pending (erneute Genehmigung)
was_approved = absence.status == AbsenceStatus.APPROVED
if was_approved and not is_manager:
absence.status = AbsenceStatus.PENDING
absence.approved_by = None
# Arbeitstage neu berechnen
holiday_dates = await self._get_holiday_dates(current_user.company_id, absence.start_date.year, db)
absence.working_days = Decimal(str(
self._calc_working_days(absence.start_date, absence.end_date,
holiday_dates, absence.half_day_start, absence.half_day_end)
))
# Audit-Log
action = "absence_change_request" if (was_approved and not is_manager) else "absence_updated"
db.add(AuditLog(
user_id=current_user.id,
action=action,
entity_type="absence",
entity_id=absence.id,
old_value={"status": "approved" if was_approved else "pending"},
new_value={
"status": absence.status.value,
"start_date": str(absence.start_date),
"end_date": str(absence.end_date),
"working_days": float(absence.working_days),
"correction_note": absence.correction_note,
},
))
return absence
async def cancel_absence(
self, absence_id: UUID, current_user: User, db: AsyncSession
) -> Absence:
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
if absence.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Nur eigene Anträge können storniert werden.")
if absence.status != AbsenceStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können gelöscht werden.")
absence.status = AbsenceStatus.CANCELLED
# Audit-Log (DSGVO)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_cancelled",
entity_type="absence",
entity_id=absence.id,
old_value={"status": "pending"},
new_value={
"status": "cancelled",
"cancelled_by": str(current_user.id),
"absence_user_id": str(absence.user_id),
"start_date": str(absence.start_date),
"end_date": str(absence.end_date),
"working_days": float(absence.working_days),
},
))
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_removed(absence, db))
return absence
async def approve_absence(
self, absence_id: UUID, current_user: User, db: AsyncSession
) -> Absence:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
requester = await db.get(User, absence.user_id)
if requester is None or requester.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
if absence.user_id == current_user.id:
raise HTTPException(
status_code=409,
detail="Eigene Abwesenheitsanträge können nicht selbst genehmigt werden."
)
if absence.status != AbsenceStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können genehmigt werden.")
absence.status = AbsenceStatus.APPROVED
absence.approved_by = current_user.id
absence_type = await db.get(AbsenceType, absence.type_id)
# Urlaubskonto abziehen wenn nötig
if absence_type and absence_type.deducts_vacation:
await self._deduct_vacation(absence.user_id, absence.start_date.year, int(absence.working_days), db)
# Überstundenkonto abziehen wenn Freizeitausgleich
if absence_type and absence_type.affects_overtime_balance:
await self._deduct_overtime(absence.user_id, absence.working_days, db)
# Audit-Log (DSGVO)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_approved",
entity_type="absence",
entity_id=absence.id,
old_value={"status": "pending"},
new_value={
"status": "approved",
"approved_by": str(current_user.id),
"approved_by_name": current_user.full_name,
"absence_user_id": str(absence.user_id),
"start_date": str(absence.start_date),
"end_date": str(absence.end_date),
"working_days": float(absence.working_days),
},
))
# CalDAV-Sync (fire & forget Fehler blockieren nicht die Genehmigung)
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_approved(absence, db))
return absence
async def reject_absence(
self, absence_id: UUID, data: AbsenceReject, current_user: User, db: AsyncSession
) -> Absence:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
requester = await db.get(User, absence.user_id)
if requester is None or requester.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
if absence.status != AbsenceStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können abgelehnt werden.")
absence.status = AbsenceStatus.REJECTED
absence.approved_by = current_user.id
absence.rejection_reason = data.rejection_reason
# Audit-Log (DSGVO)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_rejected",
entity_type="absence",
entity_id=absence.id,
old_value={"status": "pending"},
new_value={
"status": "rejected",
"rejection_reason": absence.rejection_reason,
"rejected_by": str(current_user.id),
"rejected_by_name": current_user.full_name,
"absence_user_id": str(absence.user_id),
"start_date": str(absence.start_date),
"end_date": str(absence.end_date),
"working_days": float(absence.working_days),
},
))
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_removed(absence, db))
return absence
async def get_calendar(
self,
company_id: UUID,
year: int,
month: int | None,
db: AsyncSession,
) -> list[dict]:
q = (
select(Absence, User, AbsenceType)
.join(User, Absence.user_id == User.id)
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
User.company_id == company_id,
Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.APPROVED]),
)
)
if month:
start = date(year, month, 1)
end = date(year, month, 28) + timedelta(days=4)
end = end.replace(day=1) - timedelta(days=1)
q = q.where(Absence.start_date <= end, Absence.end_date >= start)
else:
q = q.where(
Absence.start_date >= date(year, 1, 1),
Absence.end_date <= date(year, 12, 31),
)
result = await db.execute(q.order_by(Absence.start_date))
rows = result.all()
calendar = []
for absence, user, atype in rows:
calendar.append({
"user_id": user.id,
"user_name": user.full_name,
"absence_id": absence.id,
"type_name": atype.name,
"type_color": atype.color,
"start_date": absence.start_date,
"end_date": absence.end_date,
"status": absence.status,
"working_days": absence.working_days,
})
return calendar
# ── Urlaubskonto ──────────────────────────────────────────────────────────
async def get_balance(self, user_id: UUID, year: int, db: AsyncSession) -> VacationBalance:
return await self._get_or_create_balance(user_id, year, db)
async def get_pending_days(self, user_id: UUID, year: int, db: AsyncSession) -> float:
"""Summe der Arbeitstage aus ausstehenden Anträgen die Urlaub abziehen."""
q = (
select(func.sum(Absence.working_days))
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
Absence.user_id == user_id,
Absence.status == AbsenceStatus.PENDING,
AbsenceType.deducts_vacation.is_(True),
func.extract("year", Absence.start_date) == year,
)
)
result = await db.scalar(q)
return float(result or 0)
# ── Feiertage ─────────────────────────────────────────────────────────────
async def list_holidays(
self, year: int, country: str, state: str | None, db: AsyncSession
) -> list[PublicHoliday]:
q = select(PublicHoliday).where(
PublicHoliday.year == year, PublicHoliday.country == country
)
if state:
q = q.where(or_(PublicHoliday.state == state, PublicHoliday.state.is_(None)))
result = await db.scalars(q.order_by(PublicHoliday.date))
return list(result.all())
async def create_holiday(self, data, db: AsyncSession) -> PublicHoliday:
holiday = PublicHoliday(
country=data.country,
state=data.state,
date=data.date,
name=data.name,
year=data.date.year,
)
db.add(holiday)
await db.flush()
return holiday
# ── Helpers ───────────────────────────────────────────────────────────────
async def _get_type_or_404(
self, type_id: UUID, company_id: UUID, db: AsyncSession
) -> AbsenceType:
at = await db.get(AbsenceType, type_id)
if at is None or at.company_id != company_id:
raise HTTPException(status_code=404, detail="Abwesenheitstyp nicht gefunden.")
return at
async def _get_or_create_balance(
self, user_id: UUID, year: int, db: AsyncSession
) -> VacationBalance:
balance = await db.scalar(
select(VacationBalance).where(
VacationBalance.user_id == user_id, VacationBalance.year == year
)
)
if balance is None:
# Automatischer Übertrag: Resturlaub aus dem Vorjahr übernehmen
prev = await db.scalar(
select(VacationBalance).where(
VacationBalance.user_id == user_id, VacationBalance.year == year - 1
)
)
carried = max(0, prev.remaining_days) if prev else 0
entitled = prev.entitled_days if prev else 30
balance = VacationBalance(
user_id=user_id,
year=year,
entitled_days=entitled,
carried_over=carried,
)
db.add(balance)
await db.flush()
return balance
async def _deduct_vacation(
self, user_id: UUID, year: int, days: int, db: AsyncSession
) -> None:
balance = await self._get_or_create_balance(user_id, year, db)
balance.used_days += days
async def _deduct_overtime(
self, user_id: UUID, working_days: float, db: AsyncSession
) -> None:
"""Zieht working_days × tägliche Stunden vom Überstundenkonto ab."""
# Stunden/Tag aus Arbeitsplan ermitteln (Fallback: 8h)
user = await db.get(User, user_id)
daily_hours = Decimal("8.00")
if user and user.work_schedule_id:
schedule = await db.get(WorkSchedule, user.work_schedule_id)
if schedule:
working_days_in_week = sum(
1 for h in [schedule.mon_h, schedule.tue_h, schedule.wed_h,
schedule.thu_h, schedule.fri_h, schedule.sat_h, schedule.sun_h]
if h > 0
)
if working_days_in_week > 0:
daily_hours = schedule.weekly_hours / Decimal(working_days_in_week)
hours_to_deduct = Decimal(str(working_days)) * daily_hours
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
if ob is None:
# Erstelle Eintrag mit 0 Überstunden — taken_hours kann negativ werden
company_id = user.company_id if user else None
if not company_id:
return
ob = OvertimeBalance(user_id=user_id, company_id=company_id)
db.add(ob)
await db.flush()
ob.taken_hours += hours_to_deduct
async def _get_holiday_dates(
self, company_id: UUID, year: int, db: AsyncSession
) -> set[date]:
"""Feiertage für die Company-Country holen."""
from app.models.company import Company
from sqlalchemy import or_
company = await db.get(Company, company_id)
country = company.country if company else "DE"
state = company.state if company else None
q = select(PublicHoliday.date).where(
PublicHoliday.year == year,
PublicHoliday.country == country,
)
if state:
q = q.where(or_(PublicHoliday.state == state, PublicHoliday.state.is_(None)))
result = await db.scalars(q)
return set(result.all())
@staticmethod
def _calc_working_days(
start: date,
end: date,
holidays: set[date],
half_day_start: bool,
half_day_end: bool,
) -> float:
count = 0.0
current = start
while current <= end:
if current.weekday() < 5 and current not in holidays:
count += 1.0
current += timedelta(days=1)
# Halbtage abziehen
if half_day_start and start.weekday() < 5 and start not in holidays:
count -= 0.5
if half_day_end and end.weekday() < 5 and end not in holidays and end != start:
count -= 0.5
return max(0.0, count)
# ── Krankmeldung ──────────────────────────────────────────────────────────
async def quick_sick(
self,
start: date,
end: date,
current_user: User,
db: AsyncSession,
) -> tuple[Absence, list[str]]:
"""Sofort-Krankmeldung: nutzt den ersten aktiven SICK-Typ der Firma."""
sick_type = await db.scalar(
select(AbsenceType)
.where(
AbsenceType.company_id == current_user.company_id,
AbsenceType.category == AbsenceCategory.SICK,
AbsenceType.is_active == True,
)
.order_by(AbsenceType.name)
.limit(1)
)
if sick_type is None:
raise HTTPException(status_code=404, detail="Kein aktiver Krankheits-Typ konfiguriert.")
if end < start:
raise HTTPException(status_code=400, detail="Enddatum darf nicht vor dem Startdatum liegen.")
create_data = AbsenceCreate(
type_id=sick_type.id,
start_date=start,
end_date=end,
)
return await self.create_absence(create_data, current_user, db)
async def mark_certificate_received(
self,
absence_id: UUID,
received_at: date | None,
current_user: User,
db: AsyncSession,
) -> Absence:
"""HR/Admin: AU-Bescheinigung als eingegangen markieren."""
if current_user.role not in (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Nur HR/Admin darf den Attest-Eingang markieren.")
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
owner = await db.get(User, absence.user_id)
if owner is None or owner.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
absence_type = await db.get(AbsenceType, absence.type_id)
if absence_type is None or absence_type.category != AbsenceCategory.SICK:
raise HTTPException(status_code=409, detail="Nur für Krankmeldungen verfügbar.")
old_value = str(absence.certificate_received_at) if absence.certificate_received_at else None
absence.certificate_received_at = received_at or date.today()
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_certificate_received",
entity_type="absence",
entity_id=absence.id,
old_value={"certificate_received_at": old_value},
new_value={
"certificate_received_at": str(absence.certificate_received_at),
"absence_user_id": str(absence.user_id),
"marked_by": str(current_user.id),
"marked_by_name": current_user.full_name,
},
))
return absence
async def get_sick_stats(
self,
company_id: UUID,
current_user: User,
ref_date: date,
db: AsyncSession,
user_id: UUID | None = None,
) -> list[dict]:
"""Krankheitsstatistik für rolling 12 Monate ab ref_date.
Bradford-Faktor: S² × D mit S = Anzahl Episoden, D = Summe Kranktage.
"""
window_start = ref_date - timedelta(days=365)
q = (
select(Absence, User)
.join(User, Absence.user_id == User.id)
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
User.company_id == company_id,
AbsenceType.category == AbsenceCategory.SICK,
Absence.status == AbsenceStatus.APPROVED,
Absence.start_date <= ref_date,
Absence.end_date >= window_start,
)
.order_by(User.last_name, User.first_name, Absence.start_date)
)
if user_id:
q = q.where(Absence.user_id == user_id)
# MANAGER sieht nur sein Department
if current_user.role == UserRole.MANAGER and current_user.department_id:
q = q.where(User.department_id == current_user.department_id)
result = await db.execute(q)
rows = result.all()
by_user: dict[UUID, dict] = {}
for absence, user in rows:
entry = by_user.setdefault(user.id, {
"user_id": user.id,
"user_name": user.full_name,
"personnel_number": user.personnel_number,
"episodes": 0,
"total_days": 0.0,
"certificates_overdue": 0,
})
entry["episodes"] += 1
entry["total_days"] += float(absence.working_days or 0)
if (
absence.certificate_required_by
and absence.certificate_required_by < ref_date
and absence.certificate_received_at is None
):
entry["certificates_overdue"] += 1
for entry in by_user.values():
entry["bradford_factor"] = float(entry["episodes"]) ** 2 * entry["total_days"]
return list(by_user.values())
absence_service = AbsenceService()
+211
View File
@@ -0,0 +1,211 @@
import re
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import (
create_access_token,
create_refresh_token,
generate_invite_token,
generate_reset_token,
hash_password,
hash_token,
verify_password,
)
from app.models import Company, PasswordReset, Session, User, UserRole
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.services.email_service import email_service
def _slugify(name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return slug[:80]
class AuthService:
async def register(self, data: RegisterRequest, db: AsyncSession) -> TokenResponse:
existing = await db.scalar(select(User).where(User.email == data.email))
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
base_slug = _slugify(data.company_name)
slug = base_slug
counter = 1
while await db.scalar(select(Company).where(Company.slug == slug)):
slug = f"{base_slug}-{counter}"
counter += 1
company = Company(name=data.company_name, slug=slug)
db.add(company)
await db.flush()
user = User(
company_id=company.id,
email=data.email,
password_hash=hash_password(data.password),
first_name=data.first_name,
last_name=data.last_name,
role=UserRole.COMPANY_ADMIN,
)
db.add(user)
await db.flush()
from app.services.absence_service import absence_service
await absence_service.create_defaults_for_company(company.id, db)
tokens = await self._create_session(user, db)
await email_service.send_welcome(user, db)
return tokens
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse:
from app.models.user import AuthProvider
from app.services.ldap_service import ldap_service
user = await db.scalar(select(User).where(User.email == data.email))
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated")
if user.auth_provider == AuthProvider.LDAP:
ldap_cfg = await ldap_service.get_config(user.company_id, db)
if not ldap_cfg or not ldap_cfg.enabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="LDAP authentication not available",
)
if not ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
else:
if not user.password_hash or not verify_password(data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
# TOTP: wenn aktiviert → partial token zurückgeben statt vollem Login
if user.totp_enabled:
from app.core.security import create_partial_token
from app.schemas.auth import TokenResponse
partial = create_partial_token(str(user.id))
return TokenResponse(
access_token="",
refresh_token="",
totp_required=True,
partial_token=partial,
)
user.last_login = datetime.now(timezone.utc)
return await self._create_session(user, db, request=request)
async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse:
token_hash = hash_token(raw_token)
session = await db.scalar(
select(Session).where(Session.refresh_token_hash == token_hash)
)
if not session or session.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
user = await db.get(User, session.user_id)
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found or inactive")
await db.delete(session)
return await self._create_session(user, db)
async def logout(self, raw_token: str, db: AsyncSession) -> None:
token_hash = hash_token(raw_token)
session = await db.scalar(
select(Session).where(Session.refresh_token_hash == token_hash)
)
if session:
await db.delete(session)
async def request_password_reset(self, email: str, db: AsyncSession) -> str | None:
"""
Gibt None zurück (lokale User) oder 'ldap' wenn der User LDAP-Auth nutzt.
Die aufrufende Route entscheidet, was dem Client mitgeteilt wird.
"""
from app.models.user import AuthProvider
user = await db.scalar(select(User).where(User.email == email))
if not user:
return None # Security: kein Hinweis ob E-Mail existiert
if user.auth_provider == AuthProvider.LDAP:
return "ldap"
old_resets = await db.scalars(
select(PasswordReset).where(
PasswordReset.user_id == user.id,
PasswordReset.used_at.is_(None),
)
)
for r in old_resets:
await db.delete(r)
raw, hashed = generate_reset_token()
reset = PasswordReset(
user_id=user.id,
token_hash=hashed,
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
db.add(reset)
await email_service.send_password_reset(user, raw, db)
return None
async def confirm_password_reset(self, token: str, new_password: str, db: AsyncSession) -> None:
token_hash = hash_token(token)
reset = await db.scalar(
select(PasswordReset).where(
PasswordReset.token_hash == token_hash,
PasswordReset.used_at.is_(None),
)
)
if not reset or reset.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
user = await db.get(User, reset.user_id)
if not user:
raise HTTPException(status_code=400, detail="User not found")
user.password_hash = hash_password(new_password)
reset.used_at = datetime.now(timezone.utc)
sessions = await db.scalars(select(Session).where(Session.user_id == user.id))
for s in sessions:
await db.delete(s)
async def _create_session(
self,
user: User,
db: AsyncSession,
request: Request | None = None,
) -> TokenResponse:
raw_refresh, hashed_refresh = create_refresh_token()
session = Session(
user_id=user.id,
refresh_token_hash=hashed_refresh,
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days),
device=request.headers.get("User-Agent", "")[:255] if request else None,
ip=request.client.host if request and request.client else None,
)
db.add(session)
access_token = create_access_token(
str(user.id),
extra={"role": user.role, "company_id": str(user.company_id)},
)
return TokenResponse(access_token=access_token, refresh_token=raw_refresh)
auth_service = AuthService()
+307
View File
@@ -0,0 +1,307 @@
"""
CalDAV-Sync für Abwesenheiten.
Logik:
approve → VEVENT in persönlichem Kalender (CaldavUserConfig) +
VEVENT in Firmenkalender (CaldavCompanyConfig)
reject / cancel → DELETE aus beiden Kalendern
Verwendet httpx für die HTTP-Kommunikation und icalendar für iCal-Erzeugung.
Passwörter werden Fernet-verschlüsselt gespeichert (gleiche Methode wie SMTP/LDAP).
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import logging
import uuid
from datetime import date, timedelta, timezone, datetime
from typing import Union
import httpx
from icalendar import Calendar, Event
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.absence import Absence
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
log = logging.getLogger(__name__)
# ── Crypto (shared with SMTP/LDAP) ────────────────────────────────────────────
def _fernet():
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_password(plain: str) -> str:
return _fernet().encrypt(plain.encode()).decode()
def decrypt_password(encrypted: str) -> str:
return _fernet().decrypt(encrypted.encode()).decode()
# ── iCal builder ──────────────────────────────────────────────────────────────
def _build_ical(uid: str, summary: str, start: date, end: date, description: str = "") -> bytes:
"""Erzeugt einen VCALENDAR-Blob für ein ganztägiges Ereignis."""
cal = Calendar()
cal.add("prodid", "-//TimeMaster//DE")
cal.add("version", "2.0")
ev = Event()
ev.add("uid", f"{uid}@timemaster")
ev.add("dtstart", start)
ev.add("dtend", end + timedelta(days=1)) # DTEND ist exklusiv
ev.add("summary", summary)
if description:
ev.add("description", description)
ev.add("status", "CONFIRMED")
ev.add("transp", "TRANSPARENT") # zeigt keine Verfügbarkeit als blockiert
ev.add("dtstamp", datetime.now(timezone.utc))
cal.add_component(ev)
return cal.to_ical()
# ── Kalender-Titel formatieren ────────────────────────────────────────────────
def _format_summary(user: "User", absence_type: str, name_template: str) -> str:
"""
Ersetzt Platzhalter im name_template:
$vorname → vollständiger Vorname
$nachname → vollständiger Nachname
$vorname_short → erster Buchstabe Vorname
$nachname_middle → erste 3 Buchstaben Nachname
$kuerzel → manuell gesetztes Kürzel (Fallback: Initialen)
$personalnummer → Personalnummer (leer wenn nicht gesetzt)
$typ → Abwesenheitsart
"""
kuerzel = user.kuerzel if user.kuerzel else (user.first_name[:1] + user.last_name[:1]).upper()
result = name_template
result = result.replace("$vorname_short", user.first_name[:1])
result = result.replace("$nachname_middle", user.last_name[:3])
result = result.replace("$vorname", user.first_name)
result = result.replace("$nachname", user.last_name)
result = result.replace("$kuerzel", kuerzel)
result = result.replace("$personalnummer", user.personnel_number or "")
result = result.replace("$typ", absence_type)
return result
# ── HTTP helpers ───────────────────────────────────────────────────────────────
def _event_url(calendar_url: str, uid: str) -> str:
return calendar_url.rstrip("/") + f"/{uid}.ics"
async def _http_put(
calendar_url: str, username: str, password: str, uid: str,
ical: bytes, verify_ssl: bool,
) -> str:
"""PUT event. Returns ETag (empty string if server doesn't send one)."""
url = _event_url(calendar_url, uid)
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
resp = await client.put(
url, content=ical,
headers={"Content-Type": "text/calendar; charset=utf-8"},
auth=(username, password),
)
resp.raise_for_status()
return resp.headers.get("ETag", "")
async def _http_delete(
calendar_url: str, username: str, password: str, uid: str, verify_ssl: bool,
) -> None:
url = _event_url(calendar_url, uid)
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
resp = await client.delete(url, auth=(username, password))
if resp.status_code not in (200, 204, 404):
resp.raise_for_status()
async def _http_propfind(
calendar_url: str, username: str, password: str, verify_ssl: bool,
) -> int:
"""Einfacher Verbindungstest via PROPFIND Depth:0. Gibt HTTP-Status zurück."""
body = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
async with httpx.AsyncClient(verify=verify_ssl, timeout=10) as client:
resp = await client.request(
"PROPFIND", calendar_url.rstrip("/") + "/",
content=body,
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"},
auth=(username, password),
)
return resp.status_code
# ── Service ───────────────────────────────────────────────────────────────────
class CalDavService:
# ── Config laden ──────────────────────────────────────────────────────────
async def get_company_config(
self, company_id: uuid.UUID, db: AsyncSession
) -> CaldavCompanyConfig | None:
return await db.scalar(
select(CaldavCompanyConfig).where(CaldavCompanyConfig.company_id == company_id)
)
async def get_user_config(
self, user_id: uuid.UUID, db: AsyncSession
) -> CaldavUserConfig | None:
return await db.scalar(
select(CaldavUserConfig).where(CaldavUserConfig.user_id == user_id)
)
# ── Sync-Operationen ──────────────────────────────────────────────────────
async def sync_approved(self, absence: Absence, db: AsyncSession) -> None:
"""Wird nach Genehmigung gerufen: Event in beide Kalender einpflegen."""
# User und AbsenceType laden (für VEVENT-Titel)
from app.models.user import User
from app.models.absence_type import AbsenceType
user = await db.get(User, absence.user_id)
atype = await db.get(AbsenceType, absence.type_id)
if not user or not atype:
return
if absence.caldav_uid is None:
absence.caldav_uid = str(uuid.uuid4())
description = absence.note or ""
# Persönlicher Kalender: nur Abwesenheitsart, kein Name
personal_ical = _build_ical(
absence.caldav_uid, atype.name,
absence.start_date, absence.end_date, description,
)
user_cfg = await self.get_user_config(user.id, db)
if user_cfg and user_cfg.enabled and user_cfg.calendar_url:
try:
pw = decrypt_password(user_cfg.password_encrypted)
etag = await _http_put(
user_cfg.calendar_url, user_cfg.username, pw,
absence.caldav_uid, personal_ical, user_cfg.verify_ssl,
)
absence.caldav_user_etag = etag
except Exception as exc:
absence.caldav_last_error = f"User-Kalender: {exc}"
log.warning("CalDAV user sync failed for absence %s: %s", absence.id, exc)
# Firmenkalender: Titelformat per Konfiguration
company_cfg = await self.get_company_config(user.company_id, db)
if company_cfg and company_cfg.enabled and company_cfg.calendar_url:
company_summary = _format_summary(user, atype.name, company_cfg.name_template)
company_ical = _build_ical(
absence.caldav_uid, company_summary,
absence.start_date, absence.end_date, description,
)
try:
pw = decrypt_password(company_cfg.password_encrypted)
etag = await _http_put(
company_cfg.calendar_url, company_cfg.username, pw,
absence.caldav_uid, company_ical, company_cfg.verify_ssl,
)
absence.caldav_company_etag = etag
except Exception as exc:
err = f"Firmen-Kalender: {exc}"
absence.caldav_last_error = (
(absence.caldav_last_error + " | " + err) if absence.caldav_last_error else err
)
log.warning("CalDAV company sync failed for absence %s: %s", absence.id, exc)
absence.caldav_synced_at = datetime.now(timezone.utc)
async def sync_removed(self, absence: Absence, db: AsyncSession) -> None:
"""Wird nach Ablehnung/Stornierung gerufen: Event aus Kalendern löschen."""
if not absence.caldav_uid:
return
from app.models.user import User
user = await db.get(User, absence.user_id)
if not user:
return
# Persönlicher Kalender
user_cfg = await self.get_user_config(user.id, db)
if user_cfg and user_cfg.enabled and user_cfg.calendar_url:
try:
pw = decrypt_password(user_cfg.password_encrypted)
await _http_delete(
user_cfg.calendar_url, user_cfg.username, pw,
absence.caldav_uid, user_cfg.verify_ssl,
)
absence.caldav_user_etag = None
except Exception as exc:
log.warning("CalDAV user delete failed for absence %s: %s", absence.id, exc)
# Firmenkalender
company_cfg = await self.get_company_config(user.company_id, db)
if company_cfg and company_cfg.enabled and company_cfg.calendar_url:
try:
pw = decrypt_password(company_cfg.password_encrypted)
await _http_delete(
company_cfg.calendar_url, company_cfg.username, pw,
absence.caldav_uid, company_cfg.verify_ssl,
)
absence.caldav_company_etag = None
except Exception as exc:
log.warning("CalDAV company delete failed for absence %s: %s", absence.id, exc)
absence.caldav_last_error = None
absence.caldav_synced_at = datetime.now(timezone.utc)
async def resync_all_approved(self, company_id: uuid.UUID, db: AsyncSession) -> dict:
"""Alle genehmigten Abwesenheiten der Firma neu synchronisieren."""
from app.models.absence import AbsenceStatus
from app.models.user import User
result = await db.scalars(
select(Absence)
.join(Absence.user)
.where(
Absence.status == AbsenceStatus.APPROVED,
User.company_id == company_id,
)
)
absences = list(result.all())
ok = 0
failed = 0
for absence in absences:
try:
await self.sync_approved(absence, db)
ok += 1
except Exception as exc:
failed += 1
log.error("Resync failed for absence %s: %s", absence.id, exc)
return {"synced": ok, "failed": failed, "total": len(absences)}
# ── Verbindungstest ───────────────────────────────────────────────────────
async def test_config(
self, cfg: Union[CaldavCompanyConfig, CaldavUserConfig]
) -> dict:
if not cfg.calendar_url:
return {"ok": False, "error": "Keine Kalender-URL konfiguriert."}
try:
pw = decrypt_password(cfg.password_encrypted)
status = await _http_propfind(cfg.calendar_url, cfg.username, pw, cfg.verify_ssl)
if status in (200, 207):
return {"ok": True, "status": status}
return {"ok": False, "error": f"Server antwortete mit HTTP {status}"}
except Exception as exc:
return {"ok": False, "error": str(exc)}
caldav_service = CalDavService()
+154
View File
@@ -0,0 +1,154 @@
"""
E-Mail-Versand via SMTP (smtplib + asyncio.to_thread).
Konfiguration pro Firma in smtp_configs. Kein externer Mail-Dienst nötig.
"""
import asyncio
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.smtp_config import SmtpConfig
if TYPE_CHECKING:
from app.models.user import User
def _html_wrapper(title: str, body: str) -> str:
return f"""
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><title>{title}</title>
<style>
body {{ font-family: system-ui, sans-serif; background: #f4f4f4; margin: 0; padding: 40px 20px; }}
.card {{ background: white; border-radius: 12px; padding: 40px; max-width: 520px; margin: 0 auto; }}
.logo {{ font-size: 22px; font-weight: 600; color: #2563EB; margin-bottom: 32px; }}
h1 {{ font-size: 20px; font-weight: 600; color: #111; margin: 0 0 12px; }}
p {{ font-size: 15px; color: #555; line-height: 1.6; margin: 0 0 16px; }}
.btn {{ display: inline-block; padding: 12px 28px; background: #2563EB; color: white !important;
text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 15px; margin: 8px 0 24px; }}
.footer {{ font-size: 12px; color: #999; margin-top: 32px; padding-top: 16px; border-top: 1px solid #eee; }}
</style></head>
<body><div class="card">
<div class="logo">⏱ {settings.app_name}</div>
{body}
<div class="footer">Diese E-Mail wurde automatisch von {settings.app_name} gesendet.</div>
</div></body></html>
"""
def _decrypt_password(encrypted: str) -> str:
"""Fernet-Entschlüsselung (gleiche Implementierung wie ldap_service)."""
import base64
import hashlib
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
f = Fernet(base64.urlsafe_b64encode(key))
return f.decrypt(encrypted.encode()).decode()
def _smtp_send_sync(cfg: SmtpConfig, to: str, subject: str, html: str) -> None:
"""Synchroner SMTP-Versand wird via asyncio.to_thread() aufgerufen."""
password = _decrypt_password(cfg.password_encrypted) if cfg.password_encrypted else None
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{cfg.from_name} <{cfg.from_email}>"
msg["To"] = to
msg.attach(MIMEText(html, "html", "utf-8"))
if cfg.use_tls:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(cfg.host, cfg.port, context=context) as smtp:
if cfg.username and password:
smtp.login(cfg.username, password)
smtp.send_message(msg)
else:
with smtplib.SMTP(cfg.host, cfg.port) as smtp:
if cfg.use_starttls:
smtp.starttls(context=ssl.create_default_context())
if cfg.username and password:
smtp.login(cfg.username, password)
smtp.send_message(msg)
class EmailService:
async def _load_smtp(self, company_id: UUID, db: AsyncSession) -> SmtpConfig | None:
return await db.scalar(
select(SmtpConfig).where(
SmtpConfig.company_id == company_id,
SmtpConfig.is_enabled == True,
)
)
async def _send(self, to: str, subject: str, html: str, cfg: SmtpConfig | None) -> None:
if not cfg:
print(f"\n{'='*60}")
print(f"EMAIL (kein SMTP konfiguriert nicht versendet)")
print(f" To: {to}")
print(f" Subject: {subject}")
print(f"{'='*60}\n")
return
try:
await asyncio.to_thread(_smtp_send_sync, cfg, to, subject, html)
except Exception as exc:
print(f"SMTP Fehler: {exc}")
async def send_welcome(self, user: "User", db: AsyncSession) -> None:
cfg = await self._load_smtp(user.company_id, db)
body = f"""
<h1>Willkommen bei {settings.app_name}, {user.first_name}!</h1>
<p>Dein Firmen-Account wurde erfolgreich erstellt. Du kannst dich ab sofort anmelden.</p>
<a href="{settings.frontend_url}/login" class="btn">Zum Login</a>
"""
await self._send(user.email, f"Willkommen bei {settings.app_name}", _html_wrapper("Willkommen", body), cfg)
async def send_invite(self, user: "User", invited_by: "User", raw_token: str, db: AsyncSession) -> None:
cfg = await self._load_smtp(user.company_id, db)
invite_url = f"{settings.frontend_url}/invite/accept?token={raw_token}"
body = f"""
<h1>Du wurdest eingeladen!</h1>
<p><strong>{invited_by.full_name}</strong> hat dich zu <strong>{settings.app_name}</strong> eingeladen.</p>
<p>Klicke auf den Button, um dein Konto zu aktivieren und ein Passwort festzulegen.<br>
Der Link ist <strong>7 Tage</strong> gültig.</p>
<a href="{invite_url}" class="btn">Einladung annehmen</a>
<p style="font-size:13px;color:#999;">Oder kopiere diesen Link: {invite_url}</p>
"""
await self._send(
user.email,
f"{invited_by.full_name} hat dich zu {settings.app_name} eingeladen",
_html_wrapper("Einladung", body),
cfg,
)
async def send_password_reset(self, user: "User", raw_token: str, db: AsyncSession) -> None:
cfg = await self._load_smtp(user.company_id, db)
reset_url = f"{settings.frontend_url}/auth/reset-password?token={raw_token}"
body = f"""
<h1>Passwort zurücksetzen</h1>
<p>Hallo {user.first_name},</p>
<p>du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.<br>
Klicke auf den Button der Link ist <strong>1 Stunde</strong> gültig.</p>
<a href="{reset_url}" class="btn">Passwort zurücksetzen</a>
<p>Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>
"""
await self._send(user.email, "Passwort zurücksetzen", _html_wrapper("Passwort zurücksetzen", body), cfg)
async def send_test(self, cfg: SmtpConfig, to: str) -> None:
"""Test-E-Mail direkt mit übergebenem Konfigurationsobjekt."""
body = f"""
<h1>SMTP-Test erfolgreich!</h1>
<p>Deine SMTP-Konfiguration für <strong>{settings.app_name}</strong> funktioniert korrekt.</p>
<p>Server: {cfg.host}:{cfg.port}</p>
"""
await self._send(to, f"{settings.app_name} SMTP-Test", _html_wrapper("SMTP-Test", body), cfg)
email_service = EmailService()
+174
View File
@@ -0,0 +1,174 @@
"""Feiertags-Service: berechnet und befüllt deutsche Feiertage per Bundesland."""
from __future__ import annotations
import uuid
from datetime import date, timedelta
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.public_holiday import PublicHoliday
# ---------------------------------------------------------------------------
# Osterformel (Gauß/Anonymous)
# ---------------------------------------------------------------------------
def _easter(year: int) -> date:
"""Berechnet Ostersonntag nach der anonymen Gregorianischen Osterformel."""
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
# ---------------------------------------------------------------------------
# Feiertage berechnen
# ---------------------------------------------------------------------------
def _holidays_for_state(year: int, state: str) -> list[tuple[date, str, bool]]:
"""
Gibt Liste von (date, name, is_high_rate) für das Bundesland zurück.
is_high_rate = True → 150% Zuschlag nach §3b EStG
"""
easter = _easter(year)
holidays: list[tuple[date, str, bool]] = []
def add(d: date, name: str, high: bool = False) -> None:
holidays.append((d, name, high))
# ── Bundesweit gültige Feiertage ────────────────────────────────────────
add(date(year, 1, 1), "Neujahr")
add(easter - timedelta(days=2), "Karfreitag")
add(easter, "Ostersonntag")
add(easter + timedelta(days=1), "Ostermontag")
add(date(year, 5, 1), "Tag der Arbeit", high=True)
add(easter + timedelta(days=39), "Christi Himmelfahrt")
add(easter + timedelta(days=49), "Pfingstsonntag")
add(easter + timedelta(days=50), "Pfingstmontag")
add(date(year, 10, 3), "Tag der Deutschen Einheit")
add(date(year, 12, 25), "1. Weihnachtstag", high=True)
add(date(year, 12, 26), "2. Weihnachtstag", high=True)
# ── Heilige Drei Könige: BY, BW, ST ────────────────────────────────────
if state in ("BY", "BW", "ST"):
add(date(year, 1, 6), "Heilige Drei Könige")
# ── Frauentag: BE (ab 2019) ─────────────────────────────────────────────
if state == "BE" and year >= 2019:
add(date(year, 3, 8), "Internationaler Frauentag")
# ── Gründonnerstag: BY (nur Schulen) nicht als gesetzlicher Feiertag
# ── Fronleichnam: BW, BY, HE, NW, RP, SL (+ Teile ST, TH) ─────────────
if state in ("BW", "BY", "HE", "NW", "RP", "SL"):
add(easter + timedelta(days=60), "Fronleichnam")
# ── Mariä Himmelfahrt: BY (kath. Gemeinden), SL ─────────────────────────
if state in ("BY", "SL"):
add(date(year, 8, 15), "Mariä Himmelfahrt")
# ── Weltkindertag: TH (ab 2019) ─────────────────────────────────────────
if state == "TH" and year >= 2019:
add(date(year, 9, 20), "Weltkindertag")
# ── Reformationstag: BB, HB, HH, MV, NI, SH, SN, ST, TH ───────────────
if state in ("BB", "HB", "HH", "MV", "NI", "SH", "SN", "ST", "TH"):
add(date(year, 10, 31), "Reformationstag")
# ── Allerheiligen: BW, BY, NW, RP, SL ───────────────────────────────────
if state in ("BW", "BY", "NW", "RP", "SL"):
add(date(year, 11, 1), "Allerheiligen")
# ── Buß- und Bettag: SN ──────────────────────────────────────────────────
if state == "SN":
# Mittwoch vor dem 23. November
nov23 = date(year, 11, 23)
bbt = nov23 - timedelta(days=(nov23.weekday() + 3) % 7 + 1)
if bbt.weekday() != 2:
# Fallback: letzter Mittwoch vor 23.11.
bbt = nov23 - timedelta(days=(nov23.weekday() - 2) % 7 + 7)
add(bbt, "Buß- und Bettag")
return holidays
# ---------------------------------------------------------------------------
# DB-Funktionen
# ---------------------------------------------------------------------------
async def ensure_holidays_for_year(year: int, state: str, db: AsyncSession) -> int:
"""
Stellt sicher dass Feiertage für (year, state) in der DB vorhanden sind.
Löscht ggf. alte Einträge und schreibt neu.
Gibt Anzahl geschriebener Einträge zurück.
"""
# Löschen falls schon vorhanden (refresh)
await db.execute(
delete(PublicHoliday).where(
PublicHoliday.country == "DE",
PublicHoliday.state == state,
PublicHoliday.year == year,
)
)
holidays = _holidays_for_state(year, state)
for d, name, high in holidays:
db.add(PublicHoliday(
id=uuid.uuid4(),
country="DE",
state=state,
date=d,
name=name,
year=year,
is_high_rate=high,
))
await db.flush()
return len(holidays)
async def get_holidays_set(
date_from: date,
date_to: date,
state: str,
db: AsyncSession,
) -> dict[date, tuple[str, bool]]:
"""
Gibt dict {date: (name, is_high_rate)} für den Zeitraum zurück.
Befüllt fehlende Jahre automatisch.
"""
years = set(range(date_from.year, date_to.year + 1))
# Prüfen welche Jahre schon in der DB sind
existing_years_q = await db.execute(
select(PublicHoliday.year).where(
PublicHoliday.country == "DE",
PublicHoliday.state == state,
).distinct()
)
existing_years = {r[0] for r in existing_years_q.all()}
for year in years - existing_years:
await ensure_holidays_for_year(year, state, db)
result_q = await db.execute(
select(PublicHoliday).where(
PublicHoliday.country == "DE",
PublicHoliday.state == state,
PublicHoliday.date >= date_from,
PublicHoliday.date <= date_to,
)
)
return {h.date: (h.name, h.is_high_rate) for h in result_q.scalars().all()}
@@ -0,0 +1,364 @@
"""Kimai CSV Import Service parst Kimai-Export und erzeugt TimeEntries + Absences."""
from __future__ import annotations
import csv
import io
import uuid
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceType
from app.models.time_entry import EntrySource, EntryStatus, TimeEntry
from app.models.user import User
# ---------------------------------------------------------------------------
# Datenstrukturen für die Vorschau
# ---------------------------------------------------------------------------
@dataclass
class KimaiRow:
date: date
start: time
end: time
duration_sec: int
projekt: str
taetigkeit: str
beschreibung: str
@dataclass
class ImportPreviewEntry:
kind: str # "time" | "absence"
date_from: str
date_to: str
start: str | None
end: str | None
break_minutes: int
worked_hours: float | None
absence_type: str | None
note: str | None
skipped: bool = False
skip_reason: str | None = None
@dataclass
class ImportResult:
preview: list[ImportPreviewEntry] = field(default_factory=list)
time_imported: int = 0
absence_imported: int = 0
skipped: int = 0
errors: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _parse_time(s: str) -> time:
parts = s.strip().split(":")
return time(int(parts[0]), int(parts[1]))
def _parse_date(s: str) -> date:
return datetime.strptime(s.strip(), "%Y-%m-%d").date()
def _gross_minutes(start: time, end: time) -> int:
return (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute)
def _break_minutes(row: KimaiRow) -> int:
gross = _gross_minutes(row.start, row.end)
net = row.duration_sec // 60
return max(0, gross - net)
def _worked_hours(row: KimaiRow) -> float:
net_min = row.duration_sec / 60
return round(net_min / 60, 2)
def _is_vacation_row(row: KimaiRow) -> bool:
return row.projekt.strip().lower() == "urlaub"
def _note(row: KimaiRow) -> str | None:
"""
Notiz aus Beschreibung; falls leer, Tätigkeit außer 'Reguläre Arbeitszeit'
(das ist der Standard und braucht keine eigene Notiz).
"""
desc = row.beschreibung.strip()
if desc:
return desc
taet = row.taetigkeit.strip()
if taet and taet.lower() != "reguläre arbeitszeit":
return taet
return None
def _absence_type_name(row: KimaiRow) -> str:
"""Ermittelt Abwesenheitstyp aus Beschreibung."""
desc = row.beschreibung.strip().lower()
if "sonderurlaub" in desc:
return "Sonderurlaub"
return "Urlaub"
def _group_vacation_rows(rows: list[KimaiRow]) -> list[tuple[date, date, str, str]]:
"""
Gruppiert aufeinanderfolgende Urlaubszeilen (gleicher Typ) zu Abwesenheitsblöcken.
Gibt Liste von (start_date, end_date, abs_type_name, note) zurück.
"""
if not rows:
return []
rows_sorted = sorted(rows, key=lambda r: r.date)
groups: list[tuple[date, date, str, str]] = []
cur_start = rows_sorted[0].date
cur_end = rows_sorted[0].date
cur_type = _absence_type_name(rows_sorted[0])
cur_note = rows_sorted[0].beschreibung.strip()
for row in rows_sorted[1:]:
t = _absence_type_name(row)
# Aufeinanderfolgend = max. 3 Tage Abstand (Wochenende überbrücken)
gap = (row.date - cur_end).days
if t == cur_type and gap <= 3:
cur_end = row.date
if row.beschreibung.strip() and row.beschreibung.strip() not in cur_note:
cur_note = (cur_note + " / " + row.beschreibung.strip()).strip(" /")
else:
groups.append((cur_start, cur_end, cur_type, cur_note))
cur_start = row.date
cur_end = row.date
cur_type = t
cur_note = row.beschreibung.strip()
groups.append((cur_start, cur_end, cur_type, cur_note))
return groups
# ---------------------------------------------------------------------------
# CSV-Parser
# ---------------------------------------------------------------------------
def parse_kimai_csv(content: bytes) -> tuple[list[KimaiRow], list[str]]:
"""Parst Kimai-CSV-Bytes, gibt (rows, errors) zurück."""
rows: list[KimaiRow] = []
errors: list[str] = []
text = content.decode("utf-8-sig") # BOM-safe
reader = csv.DictReader(io.StringIO(text))
for i, row in enumerate(reader, start=2):
try:
rows.append(KimaiRow(
date=_parse_date(row["Datum"]),
start=_parse_time(row["Von"]),
end=_parse_time(row["Bis"]),
duration_sec=int(row["Dauer"]),
projekt=row.get("Projekt", ""),
taetigkeit=row.get("Tätigkeit", ""),
beschreibung=row.get("Beschreibung", ""),
))
except Exception as e:
errors.append(f"Zeile {i}: {e}")
return rows, errors
# ---------------------------------------------------------------------------
# Preview (keine DB-Änderungen)
# ---------------------------------------------------------------------------
async def preview_kimai_import(
content: bytes,
target_user_id: uuid.UUID,
db: AsyncSession,
) -> ImportResult:
result = ImportResult()
rows, parse_errors = parse_kimai_csv(content)
result.errors.extend(parse_errors)
# Bestehende Zeiteinträge: Duplikat-Prüfung auf (date, start_time, end_time)
existing_q = await db.execute(
select(TimeEntry.date, TimeEntry.start_time, TimeEntry.end_time)
.where(TimeEntry.user_id == target_user_id)
)
existing_slots: set[tuple] = {(r.date, r.start_time, r.end_time) for r in existing_q}
# Abwesenheitstypen laden
types_q = await db.execute(select(AbsenceType))
abs_types: dict[str, AbsenceType] = {t.name: t for t in types_q.scalars().all()}
time_rows = [r for r in rows if not _is_vacation_row(r)]
vac_rows = [r for r in rows if _is_vacation_row(r)]
# Zeiteinträge
seen_slots: set[tuple] = set()
for row in time_rows:
slot = (row.date, row.start, row.end)
skip = slot in existing_slots or slot in seen_slots
if not skip:
seen_slots.add(slot)
brk = _break_minutes(row)
result.preview.append(ImportPreviewEntry(
kind="time",
date_from=row.date.isoformat(),
date_to=row.date.isoformat(),
start=row.start.strftime("%H:%M"),
end=row.end.strftime("%H:%M"),
break_minutes=brk,
worked_hours=_worked_hours(row),
absence_type=None,
note=_note(row),
skipped=skip,
skip_reason="Bereits vorhanden (gleiche Zeit)" if skip else None,
))
# Bestehende Abwesenheiten für Duplikat-Prüfung
existing_abs_q = await db.execute(
select(AbsenceType.id).where(AbsenceType.id.in_([t.id for t in abs_types.values()]))
)
from app.models.absence import Absence as AbsenceModel
existing_abs_q2 = await db.execute(
select(AbsenceModel.start_date, AbsenceModel.end_date, AbsenceModel.type_id)
.where(AbsenceModel.user_id == target_user_id)
)
existing_absences: set[tuple] = {(r.start_date, r.end_date, r.type_id) for r in existing_abs_q2}
# Urlaubsblöcke
for start, end, type_name, note in _group_vacation_rows(vac_rows):
t = abs_types.get(type_name)
already_exists = t is not None and (start, end, t.id) in existing_absences
skip = t is None or already_exists
result.preview.append(ImportPreviewEntry(
kind="absence",
date_from=start.isoformat(),
date_to=end.isoformat(),
start=None,
end=None,
break_minutes=0,
worked_hours=None,
absence_type=type_name,
note=note or None,
skipped=skip,
skip_reason=(
f"Abwesenheitstyp '{type_name}' nicht gefunden" if t is None
else "Bereits vorhanden" if already_exists
else None
),
))
result.skipped = sum(1 for p in result.preview if p.skipped)
return result
# ---------------------------------------------------------------------------
# Eigentlicher Import (mit DB-Änderungen)
# ---------------------------------------------------------------------------
async def run_kimai_import(
content: bytes,
target_user_id: uuid.UUID,
approver_id: uuid.UUID,
db: AsyncSession,
) -> ImportResult:
result = ImportResult()
rows, parse_errors = parse_kimai_csv(content)
result.errors.extend(parse_errors)
# User + Company laden
user_q = await db.execute(select(User).where(User.id == target_user_id))
user = user_q.scalar_one_or_none()
if not user:
result.errors.append("Ziel-User nicht gefunden.")
return result
# Bestehende Zeiteinträge: Duplikat-Prüfung auf (date, start_time, end_time)
existing_q = await db.execute(
select(TimeEntry.date, TimeEntry.start_time, TimeEntry.end_time)
.where(TimeEntry.user_id == target_user_id)
)
existing_slots: set[tuple] = {(r.date, r.start_time, r.end_time) for r in existing_q}
# Abwesenheitstypen
types_q = await db.execute(
select(AbsenceType).where(AbsenceType.company_id == user.company_id)
)
abs_types: dict[str, AbsenceType] = {t.name: t for t in types_q.scalars().all()}
time_rows = [r for r in rows if not _is_vacation_row(r)]
vac_rows = [r for r in rows if _is_vacation_row(r)]
# ---- Zeiteinträge ----
seen_slots: set[tuple] = set()
for row in time_rows:
slot = (row.date, row.start, row.end)
if slot in existing_slots or slot in seen_slots:
result.skipped += 1
continue
seen_slots.add(slot)
brk = _break_minutes(row)
entry = TimeEntry(
id=uuid.uuid4(),
user_id=target_user_id,
date=row.date,
start_time=row.start,
end_time=row.end,
break_minutes=brk,
status=EntryStatus.APPROVED,
source=EntrySource.API,
approved_by=approver_id,
note=_note(row),
)
db.add(entry)
result.time_imported += 1
# Bestehende Abwesenheiten für Duplikat-Prüfung
existing_abs_q = await db.execute(
select(Absence.start_date, Absence.end_date, Absence.type_id)
.where(Absence.user_id == target_user_id)
)
existing_absences: set[tuple] = {(r.start_date, r.end_date, r.type_id) for r in existing_abs_q}
# ---- Urlaubsblöcke ----
for start, end, type_name, note in _group_vacation_rows(vac_rows):
t = abs_types.get(type_name)
if not t:
result.errors.append(f"Abwesenheitstyp '{type_name}' nicht gefunden übersprungen.")
result.skipped += 1
continue
if (start, end, t.id) in existing_absences:
result.skipped += 1
continue
# Arbeitstage zählen (MoFr, keine Feiertage)
working_days = sum(
1 for n in range((end - start).days + 1)
if (start + timedelta(days=n)).weekday() < 5
)
absence = Absence(
id=uuid.uuid4(),
user_id=target_user_id,
type_id=t.id,
start_date=start,
end_date=end,
working_days=working_days,
status=AbsenceStatus.APPROVED,
approved_by=approver_id,
note=note or None,
)
db.add(absence)
result.absence_imported += 1
await db.commit()
return result
+87
View File
@@ -0,0 +1,87 @@
import secrets
from datetime import datetime, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_token
from app.models.kiosk_device import KioskDevice
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceUpdate
class KioskService:
async def list_devices(self, company_id: UUID, db: AsyncSession) -> list[KioskDevice]:
result = await db.scalars(
select(KioskDevice)
.where(KioskDevice.company_id == company_id)
.order_by(KioskDevice.created_at.desc())
)
return list(result.all())
async def create_device(
self, company_id: UUID, data: KioskDeviceCreate, db: AsyncSession
) -> tuple[KioskDevice, str]:
"""Gerät anlegen. Gibt (device, raw_token) zurück raw_token nur einmalig."""
raw_token = secrets.token_urlsafe(48)
device = KioskDevice(
company_id=company_id,
name=data.name,
location=data.location,
token_hash=hash_token(raw_token),
)
db.add(device)
await db.flush()
return device, raw_token
async def get_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> KioskDevice:
device = await db.scalar(
select(KioskDevice).where(
KioskDevice.id == device_id,
KioskDevice.company_id == company_id,
)
)
if device is None:
raise HTTPException(status_code=404, detail="Gerät nicht gefunden.")
return device
async def update_device(
self, device_id: UUID, company_id: UUID, data: KioskDeviceUpdate, db: AsyncSession
) -> KioskDevice:
device = await self.get_device(device_id, company_id, db)
changes = data.model_dump(exclude_none=True)
for field, value in changes.items():
setattr(device, field, value)
return device
async def rotate_token(
self, device_id: UUID, company_id: UUID, db: AsyncSession
) -> tuple[KioskDevice, str]:
"""Token rotieren altes Token wird sofort ungültig."""
device = await self.get_device(device_id, company_id, db)
raw_token = secrets.token_urlsafe(48)
device.token_hash = hash_token(raw_token)
return device, raw_token
async def delete_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> None:
device = await self.get_device(device_id, company_id, db)
await db.delete(device)
async def authenticate_device(self, raw_token: str, db: AsyncSession) -> KioskDevice:
"""Gerät per Token authentifizieren (für Kiosk-Endpoints)."""
token_hash = hash_token(raw_token)
device = await db.scalar(
select(KioskDevice).where(
KioskDevice.token_hash == token_hash,
KioskDevice.is_active.is_(True),
)
)
if device is None:
raise HTTPException(status_code=401, detail="Ungültiges oder deaktiviertes Gerät.")
device.last_seen_at = datetime.now(timezone.utc)
return device
kiosk_service = KioskService()
+332
View File
@@ -0,0 +1,332 @@
"""LDAP integration service.
Supports ActiveDirectory and OpenLDAP via ldap3 (pure Python).
Bind passwords are stored Fernet-encrypted using the app SECRET_KEY.
"""
import base64
import hashlib
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.ldap_config import LdapConfig
from app.models.user import AuthProvider, User, UserRole
logger = logging.getLogger(__name__)
def _fernet():
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_password(plain: str) -> str:
return _fernet().encrypt(plain.encode()).decode()
def decrypt_password(encrypted: str) -> str:
return _fernet().decrypt(encrypted.encode()).decode()
@dataclass
class ConnectionResult:
success: bool
message: str
@dataclass
class SyncResult:
created: int
updated: int
deactivated: int
errors: list[str]
def _get_attr(entry_attrs: dict, attr_name: str) -> str:
"""Safely extract a string attribute value from ldap3 entry attributes."""
val = entry_attrs.get(attr_name)
if not val:
return ""
if isinstance(val, list):
return str(val[0]) if val else ""
return str(val)
class LdapService:
def _build_server(self, config: LdapConfig):
from ldap3 import Server, Tls
import ssl
tls = None
if config.use_tls:
if config.tls_verify:
tls = Tls(validate=ssl.CERT_REQUIRED)
else:
logger.warning(
"LDAP TLS certificate validation is DISABLED for host %s (company_id=%s). "
"Set tls_verify=True in production to prevent MITM attacks.",
config.host,
config.company_id,
)
tls = Tls(validate=ssl.CERT_NONE)
return Server(
config.host,
port=config.port,
use_ssl=config.use_ssl,
tls=tls,
get_info="ALL",
connect_timeout=5,
)
def _bind_connection(self, config: LdapConfig, dn: str | None = None, password: str | None = None):
"""Return an authenticated ldap3 Connection (simple bind)."""
from ldap3 import Connection, SIMPLE, SYNC
bind_dn = dn or config.bind_dn
bind_pw = password or decrypt_password(config.bind_password_encrypted)
server = self._build_server(config)
conn = Connection(
server,
user=bind_dn,
password=bind_pw,
authentication=SIMPLE,
client_strategy=SYNC,
auto_bind="NO_TLS",
raise_exceptions=False,
)
if not conn.bind():
return None
return conn
# ── Public API ────────────────────────────────────────────────────────────
async def get_config(self, company_id: uuid.UUID, db: AsyncSession) -> LdapConfig | None:
return await db.scalar(
select(LdapConfig).where(LdapConfig.company_id == company_id)
)
async def get_config_or_404(self, company_id: uuid.UUID, db: AsyncSession) -> LdapConfig:
cfg = await self.get_config(company_id, db)
if not cfg:
raise HTTPException(status_code=404, detail="LDAP configuration not found")
return cfg
def test_connection(self, config: LdapConfig) -> ConnectionResult:
try:
conn = self._bind_connection(config)
if conn is None:
return ConnectionResult(success=False, message="Bind fehlgeschlagen DN oder Passwort falsch")
conn.unbind()
return ConnectionResult(success=True, message="Verbindung erfolgreich")
except Exception as exc:
return ConnectionResult(success=False, message=str(exc))
def search_users(self, config: LdapConfig) -> list[dict[str, Any]]:
"""Return raw list of user dicts from LDAP directory."""
from ldap3 import SUBTREE
conn = self._bind_connection(config)
if conn is None:
raise HTTPException(status_code=502, detail="LDAP bind fehlgeschlagen")
attrs = [
config.attr_email,
config.attr_firstname,
config.attr_lastname,
config.attr_username,
]
if config.attr_department:
attrs.append(config.attr_department)
conn.search(
search_base=config.base_dn,
search_filter=config.user_search_filter,
search_scope=SUBTREE,
attributes=attrs,
)
results = []
for entry in conn.entries:
raw = {a: entry[a].value for a in attrs if a in entry}
raw["dn"] = entry.entry_dn
results.append(raw)
conn.unbind()
return results
async def sync_users(
self,
config: LdapConfig,
db: AsyncSession,
default_role: UserRole = UserRole.EMPLOYEE,
) -> SyncResult:
"""Sync LDAP users into the local database."""
from ldap3 import SUBTREE
conn = self._bind_connection(config)
if conn is None:
raise HTTPException(status_code=502, detail="LDAP bind fehlgeschlagen")
attrs = [
config.attr_email,
config.attr_firstname,
config.attr_lastname,
]
if config.attr_department:
attrs.append(config.attr_department)
if config.attr_personnel_number:
attrs.append(config.attr_personnel_number)
conn.search(
search_base=config.base_dn,
search_filter=config.user_search_filter,
search_scope=SUBTREE,
attributes=attrs,
)
entries = list(conn.entries)
conn.unbind()
result = SyncResult(created=0, updated=0, deactivated=0, errors=[])
ldap_emails: set[str] = set()
for entry in entries:
try:
email = _get_attr(entry, config.attr_email).lower().strip()
if not email:
continue
ldap_emails.add(email)
first = _get_attr(entry, config.attr_firstname) or "?"
last = _get_attr(entry, config.attr_lastname) or "?"
dn = entry.entry_dn
ldap_personnel = (
_get_attr(entry, config.attr_personnel_number).strip()
if config.attr_personnel_number else ""
)
# nur Ziffern akzeptieren (Format-Vorgabe)
if ldap_personnel and not ldap_personnel.isdigit():
logger.warning(
"LDAP personnel_number for %s contains non-digits (%r), skipping mapping.",
email, ldap_personnel,
)
ldap_personnel = ""
existing = await db.scalar(select(User).where(User.email == email))
if existing:
existing.first_name = first
existing.last_name = last
existing.ldap_dn = dn
existing.auth_provider = AuthProvider.LDAP
existing.is_active = True
if ldap_personnel and existing.personnel_number != ldap_personnel:
await self._apply_personnel_from_ldap(
existing, ldap_personnel, db, result,
)
result.updated += 1
else:
user = User(
company_id=config.company_id,
email=email,
first_name=first,
last_name=last,
password_hash=None,
auth_provider=AuthProvider.LDAP,
ldap_dn=dn,
role=default_role,
is_active=True,
)
if ldap_personnel:
await self._apply_personnel_from_ldap(
user, ldap_personnel, db, result, company_id=config.company_id,
)
db.add(user)
result.created += 1
except Exception as exc:
result.errors.append(str(exc))
# Deactivate LDAP users no longer in directory
existing_ldap = await db.scalars(
select(User).where(
User.company_id == config.company_id,
User.auth_provider == AuthProvider.LDAP,
User.is_active.is_(True),
)
)
for user in existing_ldap:
if user.email not in ldap_emails:
user.is_active = False
result.deactivated += 1
config.last_sync_at = datetime.now(timezone.utc)
await db.commit()
return result
async def _apply_personnel_from_ldap(
self,
user: User,
ldap_value: str,
db: AsyncSession,
result: SyncResult,
company_id: uuid.UUID | None = None,
) -> None:
"""Apply personnel_number from LDAP, but skip on conflict (no override of reserved numbers)."""
cid = company_id or user.company_id
# Konflikt mit anderem User in derselben Firma?
conflict = await db.scalar(
select(User.id).where(
User.company_id == cid,
User.personnel_number == ldap_value,
User.id != user.id,
)
)
if conflict is not None:
msg = (
f"LDAP-Personalnummer {ldap_value!r} für {user.email} kollidiert mit "
f"vergebener Nummer Wert verworfen."
)
logger.warning(msg)
result.errors.append(msg)
return
user.personnel_number = ldap_value
def authenticate_ldap(self, config: LdapConfig, email: str, password: str) -> bool:
"""Authenticate a user by finding their DN and attempting a bind."""
from ldap3 import SUBTREE
from ldap3.utils.conv import escape_filter_chars
conn = self._bind_connection(config)
if conn is None:
return False
safe_email = escape_filter_chars(email)
conn.search(
search_base=config.base_dn,
search_filter=f"({config.attr_email}={safe_email})",
search_scope=SUBTREE,
attributes=[config.attr_email],
)
if not conn.entries:
conn.unbind()
return False
user_dn = conn.entries[0].entry_dn
conn.unbind()
# Try binding as the found user
user_conn = self._bind_connection(config, dn=user_dn, password=password)
if user_conn is None:
return False
user_conn.unbind()
return True
ldap_service = LdapService()
File diff suppressed because it is too large Load Diff
+524
View File
@@ -0,0 +1,524 @@
from datetime import date, datetime, time, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
from app.models.time_entry import EntrySource, EntryStatus, TimeEntry
from app.models.user import User, UserRole
from app.models.work_schedule import WorkSchedule
from app.schemas.time_entry import (
BalanceResponse,
ManualEntryCreate,
StampInRequest,
TimeEntryUpdate,
)
def _check_arbzg(start: time, end: time, break_minutes: int) -> list[str]:
"""ArbZG §3 und §4 Prüfung. Gibt Warnungen zurück, blockiert nicht."""
start_mins = start.hour * 60 + start.minute
end_mins = end.hour * 60 + end.minute
if end_mins <= start_mins:
end_mins += 24 * 60 # Nachtschicht
total_mins = end_mins - start_mins
worked_mins = total_mins - break_minutes
worked_hours = worked_mins / 60
warnings: list[str] = []
if worked_hours > 10:
warnings.append(
f"Maximale Arbeitszeit von 10 Stunden überschritten "
f"({worked_hours:.1f}h gearbeitet) ArbZG §3"
)
if total_mins >= 9 * 60 and break_minutes < 45:
warnings.append(
"Bei mehr als 9h Anwesenheit sind mind. 45 min Pause vorgeschrieben ArbZG §4"
)
elif total_mins >= 6 * 60 and break_minutes < 30:
warnings.append(
"Bei mehr als 6h Anwesenheit sind mind. 30 min Pause vorgeschrieben ArbZG §4"
)
return warnings
def _check_rest_period(prev_end: time | None, prev_date: date | None,
new_start: time, new_date: date) -> list[str]:
"""Mindestruhezeit 11h zwischen Schichten ArbZG §5.
Nur relevant bei Schichtwechsel über Tagesgrenzen, nicht bei mehrfachen
Stempelungen am gleichen Tag (z.B. Korrektur oder Pause).
"""
if prev_end is None or prev_date is None:
return []
# Gleicher Tag → kein Schichtwechsel, §5 nicht anwendbar
if prev_date == new_date:
return []
prev_end_dt = datetime.combine(prev_date, prev_end, tzinfo=None)
new_start_dt = datetime.combine(new_date, new_start, tzinfo=None)
rest_hours = (new_start_dt - prev_end_dt).total_seconds() / 3600
# Nur warnen wenn tatsächlich weniger als 11h Ruhe zwischen zwei verschiedenen Tagen
if 0 < rest_hours < 11:
return [
f"Mindestruhezeit von 11h unterschritten "
f"({rest_hours:.1f}h seit letzter Schicht) ArbZG §5"
]
return []
class TimeService:
# ── Stempeluhr ────────────────────────────────────────────────────────────
async def stamp_in(
self,
user: User,
data: StampInRequest,
db: AsyncSession,
) -> tuple[TimeEntry, list[str]]:
today = datetime.now(timezone.utc).date()
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
# Offenen Eintrag für heute prüfen
open_entry = await self._get_open_entry(user.id, db)
if open_entry is not None:
raise HTTPException(status_code=409, detail="Bereits eingestempelt. Bitte zuerst ausstempeln.")
# Letzten abgeschlossenen Eintrag für Ruhezeit-Check holen
last_entry = await db.scalar(
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.end_time.isnot(None))
.order_by(TimeEntry.date.desc(), TimeEntry.end_time.desc())
.limit(1)
)
warnings = _check_rest_period(
last_entry.end_time if last_entry else None,
last_entry.date if last_entry else None,
now_time,
today,
)
entry = TimeEntry(
user_id=user.id,
date=today,
start_time=now_time,
break_minutes=0,
source=data.source,
project_id=data.project_id,
note=data.note,
status=EntryStatus.PENDING,
)
db.add(entry)
await db.flush()
return entry, warnings
async def stamp_out(
self,
user: User,
note: str | None,
db: AsyncSession,
) -> tuple[TimeEntry, list[str]]:
entry = await self._get_open_entry(user.id, db)
if entry is None:
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
# Aktive Pause beenden falls vergessen
if entry.break_start is not None:
extra_break = self._calc_break_minutes(entry.break_start, now_time)
entry.break_minutes += extra_break
entry.break_start = None
entry.end_time = now_time
entry.updated_at = datetime.now(timezone.utc)
if note:
entry.note = note
warnings = _check_arbzg(entry.start_time, entry.end_time, entry.break_minutes)
return entry, warnings
async def break_start(self, user: User, db: AsyncSession) -> TimeEntry:
entry = await self._get_open_entry(user.id, db)
if entry is None:
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
if entry.break_start is not None:
raise HTTPException(status_code=409, detail="Pause bereits aktiv.")
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
entry.break_start = now_time
entry.updated_at = datetime.now(timezone.utc)
return entry
async def break_end(self, user: User, db: AsyncSession) -> TimeEntry:
entry = await self._get_open_entry(user.id, db)
if entry is None:
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
if entry.break_start is None:
raise HTTPException(status_code=409, detail="Keine aktive Pause.")
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
extra = self._calc_break_minutes(entry.break_start, now_time)
entry.break_minutes += extra
entry.break_start = None
entry.updated_at = datetime.now(timezone.utc)
return entry
# ── Einträge ──────────────────────────────────────────────────────────────
async def get_today(self, user: User, db: AsyncSession) -> list[TimeEntry]:
today = datetime.now(timezone.utc).date()
result = await db.scalars(
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date == today)
.order_by(TimeEntry.start_time)
)
return list(result.all())
async def list_entries(
self,
company_id: UUID,
current_user: User,
db: AsyncSession,
user_id: UUID | None = None,
date_from: date | None = None,
date_to: date | None = None,
status: EntryStatus | None = None,
skip: int = 0,
limit: int = 50,
) -> tuple[int, list[TimeEntry]]:
# Basis: nur Einträge der eigenen Company
# Subquery: JOIN user für company_id Filter
q = (
select(TimeEntry)
.join(User, TimeEntry.user_id == User.id)
.where(User.company_id == company_id)
)
# EMPLOYEE sieht nur eigene Einträge
if current_user.role == UserRole.EMPLOYEE:
q = q.where(TimeEntry.user_id == current_user.id)
elif user_id:
q = q.where(TimeEntry.user_id == user_id)
if date_from:
q = q.where(TimeEntry.date >= date_from)
if date_to:
q = q.where(TimeEntry.date <= date_to)
if status:
q = q.where(TimeEntry.status == status)
total = await db.scalar(select(func.count()).select_from(q.subquery()))
entries = await db.scalars(q.order_by(TimeEntry.date.desc(), TimeEntry.start_time.desc()).offset(skip).limit(limit))
return total or 0, list(entries.all())
async def create_manual(
self,
data: ManualEntryCreate,
current_user: User,
db: AsyncSession,
) -> tuple[TimeEntry, list[str]]:
target_user_id = current_user.id
# Employees need explicit permission to create manual entries
_elevated = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
if current_user.role == UserRole.EMPLOYEE and not current_user.can_manual_time_entry:
raise HTTPException(status_code=403, detail="Manuelle Zeiterfassung ist für Ihr Konto nicht freigeschaltet.")
if data.user_id and data.user_id != current_user.id:
if current_user.role not in _elevated:
raise HTTPException(status_code=403, detail="Keine Berechtigung für andere Benutzer.")
target = await db.get(User, data.user_id)
if target is None or target.company_id != current_user.company_id:
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
target_user_id = data.user_id
entry = TimeEntry(
user_id=target_user_id,
date=data.date,
start_time=data.start_time,
end_time=data.end_time,
break_minutes=data.break_minutes,
project_id=data.project_id,
note=data.note,
source=data.source,
status=EntryStatus.PENDING,
)
db.add(entry)
await db.flush()
warnings = _check_arbzg(data.start_time, data.end_time, data.break_minutes)
return entry, warnings
async def update_entry(
self,
entry_id: UUID,
data: TimeEntryUpdate,
current_user: User,
db: AsyncSession,
) -> TimeEntry:
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
entry = await self._get_entry_or_404(entry_id, db)
await self._assert_access(entry, current_user, db)
if entry.status == EntryStatus.APPROVED:
if current_user.role not in _manager_roles:
raise HTTPException(status_code=403, detail="Genehmigte Einträge können nur von Vorgesetzten geändert werden.")
if not data.correction_note:
raise HTTPException(status_code=422, detail="Änderungsgrund (correction_note) ist bei genehmigten Einträgen Pflicht.")
# Vorherigen Zustand für AuditLog sichern
old_snapshot = {
"started_at": entry.started_at.isoformat() if entry.started_at else None,
"ended_at": entry.ended_at.isoformat() if entry.ended_at else None,
"break_minutes": entry.break_minutes,
"note": entry.note,
"correction_note": entry.correction_note,
}
changes = data.model_dump(exclude_none=True)
for field, value in changes.items():
setattr(entry, field, value)
entry.updated_at = datetime.now(timezone.utc)
if entry.status == EntryStatus.APPROVED:
new_snapshot = {
"started_at": entry.started_at.isoformat() if entry.started_at else None,
"ended_at": entry.ended_at.isoformat() if entry.ended_at else None,
"break_minutes": entry.break_minutes,
"note": entry.note,
"correction_note": entry.correction_note,
}
user_obj = await db.get(User, entry.user_id)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="time_entry_approved_edit",
entity_type="time_entry",
entity_id=entry.id,
old_value=old_snapshot,
new_value={**new_snapshot, "changed_by": str(current_user.id),
"target_user": str(entry.user_id),
"target_user_name": user_obj.full_name if user_obj else None},
))
return entry
async def approve_entry(
self,
entry_id: UUID,
current_user: User,
db: AsyncSession,
) -> TimeEntry:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Genehmigen.")
entry = await self._get_entry_or_404(entry_id, db)
# Cross-Tenant-Schutz
entry_user = await db.get(User, entry.user_id)
if entry_user is None or entry_user.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
# Self-Approval-Schutz (L-03)
if entry.user_id == current_user.id:
raise HTTPException(
status_code=409,
detail="Eigene Zeiteinträge können nicht selbst genehmigt werden."
)
if entry.status != EntryStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können genehmigt werden.")
entry.status = EntryStatus.APPROVED
entry.approved_by = current_user.id
entry.updated_at = datetime.now(timezone.utc)
return entry
async def reject_entry(
self,
entry_id: UUID,
current_user: User,
correction_note: str | None,
db: AsyncSession,
) -> TimeEntry:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Ablehnen.")
entry = await self._get_entry_or_404(entry_id, db)
if entry.status != EntryStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können abgelehnt werden.")
entry.status = EntryStatus.REJECTED
entry.approved_by = current_user.id
if correction_note:
entry.correction_note = correction_note
entry.updated_at = datetime.now(timezone.utc)
return entry
async def delete_entry(
self,
entry_id: UUID,
current_user: User,
db: AsyncSession,
) -> None:
entry = await self._get_entry_or_404(entry_id, db)
await self._assert_access(entry, current_user, db)
# Genehmigte Einträge dürfen nur von HR/Admin gelöscht werden
if entry.status == EntryStatus.APPROVED:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(
status_code=403,
detail="Genehmigte Einträge können nur von Vorgesetzten gelöscht werden."
)
await db.delete(entry)
async def get_balance(
self,
user_id: UUID,
current_user: User,
db: AsyncSession,
period_start: date | None = None,
period_end: date | None = None,
) -> BalanceResponse:
# Zugriff prüfen
if user_id != current_user.id and current_user.role == UserRole.EMPLOYEE:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
today = datetime.now(timezone.utc).date()
if period_start is None:
period_start = today.replace(day=1)
if period_end is None:
period_end = today
# Genehmigte Einträge summieren
approved_entries = await db.scalars(
select(TimeEntry).where(
and_(
TimeEntry.user_id == user_id,
TimeEntry.date >= period_start,
TimeEntry.date <= period_end,
TimeEntry.status == EntryStatus.APPROVED,
TimeEntry.end_time.isnot(None),
)
)
)
approved_list = list(approved_entries.all())
total_worked = sum(e.worked_hours or 0.0 for e in approved_list)
# Ausstehende Einträge zählen
pending_count = await db.scalar(
select(func.count(TimeEntry.id)).where(
and_(
TimeEntry.user_id == user_id,
TimeEntry.date >= period_start,
TimeEntry.date <= period_end,
TimeEntry.status == EntryStatus.PENDING,
)
)
) or 0
# Soll-Stunden aus Arbeitsplan ermitteln (neuester gültiger Plan)
schedule = await db.scalar(
select(WorkSchedule)
.join(User, WorkSchedule.company_id == User.company_id)
.where(
User.id == user_id,
WorkSchedule.valid_from <= period_start,
)
.order_by(WorkSchedule.valid_from.desc())
.limit(1)
)
expected = self._calc_expected_hours(period_start, period_end, schedule)
return BalanceResponse(
user_id=user_id,
period_start=period_start,
period_end=period_end,
total_hours_worked=round(total_worked, 2),
expected_hours=round(expected, 2),
overtime_hours=round(total_worked - expected, 2),
approved_entries=len(approved_list),
pending_entries=pending_count,
)
# ── Arbeitspläne ──────────────────────────────────────────────────────────
async def create_work_schedule(
self,
company_id: UUID,
data,
db: AsyncSession,
) -> WorkSchedule:
schedule = WorkSchedule(company_id=company_id, **data.model_dump())
db.add(schedule)
await db.flush()
return schedule
async def list_work_schedules(self, company_id: UUID, db: AsyncSession) -> list[WorkSchedule]:
result = await db.scalars(
select(WorkSchedule)
.where(WorkSchedule.company_id == company_id)
.order_by(WorkSchedule.valid_from.desc())
)
return list(result.all())
# ── Helpers ───────────────────────────────────────────────────────────────
async def _get_open_entry(self, user_id: UUID, db: AsyncSession) -> TimeEntry | None:
return await db.scalar(
select(TimeEntry).where(
TimeEntry.user_id == user_id,
TimeEntry.end_time.is_(None),
).order_by(TimeEntry.date.desc(), TimeEntry.start_time.desc()).limit(1)
)
async def _get_entry_or_404(self, entry_id: UUID, db: AsyncSession) -> TimeEntry:
entry = await db.get(TimeEntry, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="Zeiterfassungseintrag nicht gefunden.")
return entry
async def _assert_access(self, entry: TimeEntry, user: User, db: AsyncSession) -> None:
if entry.user_id != user.id and user.role not in (
UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN
):
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
entry_user = await db.get(User, entry.user_id)
if entry_user is None or entry_user.company_id != user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
@staticmethod
def _calc_break_minutes(start: time, end: time) -> int:
s = start.hour * 60 + start.minute
e = end.hour * 60 + end.minute
if e < s:
e += 24 * 60
return max(0, e - s)
@staticmethod
def _calc_expected_hours(period_start: date, period_end: date, schedule: WorkSchedule | None) -> float:
"""Soll-Stunden für den Zeitraum berechnen."""
from datetime import timedelta
total = 0.0
current = period_start
while current <= period_end:
wd = current.weekday() # 0=Mon
if schedule:
total += float(schedule.hours_for_weekday(wd))
else:
# Fallback: 8h Mo-Fr
if wd < 5:
total += 8.0
current += timedelta(days=1)
return total
time_service = TimeService()
+308
View File
@@ -0,0 +1,308 @@
"""User CSV Bulk Import validates, creates new users or reactivates deactivated ones."""
from __future__ import annotations
import csv
import io
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import generate_invite_token, hash_password
from app.models.company import Company, PersonnelNumberMode
from app.models.user import User, UserRole
from app.services.email_service import email_service
from app.services.user_service import user_service
REQUIRED_HEADERS = ["email", "first_name", "last_name"]
OPTIONAL_HEADERS = ["role", "personnel_number", "kuerzel"]
TEMPLATE_HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
PERSONNEL_RE = re.compile(r"^[0-9]+$")
VALID_ROLES = {r.value for r in UserRole if r != UserRole.SUPER_ADMIN}
# ── Datenstrukturen ──────────────────────────────────────────────────────────
@dataclass
class ImportRowResult:
row: int
email: str
personnel_number: str | None
action: str # created | reactivated | error
message: str | None = None
@dataclass
class ImportResult:
total_rows: int
created: int
reactivated: int
errors: int
items: list[ImportRowResult]
# ── CSV-Parsing ──────────────────────────────────────────────────────────────
def build_template_csv() -> str:
"""CSV template returned via /users/import-template.csv."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(TEMPLATE_HEADERS)
writer.writerow([
"max@firma.de", "Max", "Mustermann",
"EMPLOYEE", "0042", "MM",
])
return buf.getvalue()
def _normalize(value: str | None) -> str:
return (value or "").strip()
def _parse_csv(content: bytes) -> tuple[list[dict[str, str]], list[str]]:
"""Parse CSV bytes (BOM-safe). Returns (rows, header_errors)."""
text = content.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
if reader.fieldnames is None:
return [], ["CSV ist leer oder kein gültiger Header gefunden."]
headers = [h.strip() for h in reader.fieldnames]
missing = [h for h in REQUIRED_HEADERS if h not in headers]
if missing:
return [], [f"Pflicht-Spalten fehlen: {', '.join(missing)}"]
rows = list(reader)
return rows, []
# ── Import-Kern (Preview & Apply gemeinsam) ──────────────────────────────────
async def _process_import(
*,
content: bytes,
company_id: UUID,
invited_by: User,
db: AsyncSession,
apply: bool,
) -> ImportResult:
"""Process CSV bulk import. apply=False = validation only (no DB writes, rolled back)."""
rows, header_errors = _parse_csv(content)
items: list[ImportRowResult] = []
if header_errors:
for msg in header_errors:
items.append(ImportRowResult(
row=0, email="", personnel_number=None, action="error", message=msg,
))
return ImportResult(total_rows=0, created=0, reactivated=0, errors=len(items), items=items)
company = await db.get(Company, company_id)
if company is None:
items.append(ImportRowResult(
row=0, email="", personnel_number=None, action="error", message="Firma nicht gefunden.",
))
return ImportResult(total_rows=0, created=0, reactivated=0, errors=1, items=items)
seen_emails_in_csv: set[str] = set()
used_personnel_in_csv: set[str] = set()
created = 0
reactivated = 0
errors = 0
for idx, raw in enumerate(rows, start=2): # CSV row numbers start at 2 (after header)
email = _normalize(raw.get("email")).lower()
first_name = _normalize(raw.get("first_name"))
last_name = _normalize(raw.get("last_name"))
role_str = _normalize(raw.get("role")) or UserRole.EMPLOYEE.value
personnel_number = _normalize(raw.get("personnel_number")) or None
kuerzel = _normalize(raw.get("kuerzel")) or None
# Validation
if not email or not EMAIL_RE.match(email):
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Ungültige E-Mail-Adresse.",
))
errors += 1
continue
if not first_name or not last_name:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Vor- und Nachname sind Pflicht.",
))
errors += 1
continue
if role_str not in VALID_ROLES:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message=f"Ungültige Rolle: {role_str}",
))
errors += 1
continue
if personnel_number is not None and not PERSONNEL_RE.match(personnel_number):
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Personalnummer darf nur Ziffern enthalten.",
))
errors += 1
continue
# Doppelte Mail im Import → Fehler
if email in seen_emails_in_csv:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="E-Mail kommt im Import mehrfach vor.",
))
errors += 1
continue
seen_emails_in_csv.add(email)
# Doppelte Personalnr. im Import → Fehler
if personnel_number and personnel_number in used_personnel_in_csv:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Personalnummer kommt im Import mehrfach vor.",
))
errors += 1
continue
# Personalnr.-Konflikt mit DB?
if personnel_number:
taken = await db.scalar(
select(User.id).where(
User.company_id == company_id,
User.personnel_number == personnel_number,
)
)
if taken is not None:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Personalnummer ist bereits vergeben.",
))
errors += 1
continue
# Auto-Vergabe wenn leer (auch im Manuell-Modus laut Anforderung)
if not personnel_number:
personnel_number = await user_service._next_personnel_number(company_id, db)
# E-Mail-Konflikt prüfen (auch deaktivierte User in derselben Firma)
existing_user = await db.scalar(
select(User).where(User.email == email)
)
if existing_user is not None and existing_user.is_active:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="E-Mail bereits aktiv vergeben.",
))
errors += 1
continue
if existing_user is not None and not existing_user.is_active:
# Reaktivieren
if existing_user.company_id != company_id:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="E-Mail existiert in anderer Firma.",
))
errors += 1
continue
existing_user.first_name = first_name
existing_user.last_name = last_name
existing_user.role = UserRole(role_str)
if kuerzel:
existing_user.kuerzel = kuerzel
# Personalnr.: behalten, falls schon vorhanden (Reservierung), sonst setzen
if not existing_user.personnel_number:
existing_user.personnel_number = personnel_number
else:
personnel_number = existing_user.personnel_number
existing_user.is_active = True
used_personnel_in_csv.add(personnel_number)
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="reactivated",
))
reactivated += 1
continue
# Neuanlage: Invite-Token generieren, User inaktiv (warten auf Annahme)
raw_token, token_hash = generate_invite_token()
new_user = User(
company_id=company_id,
email=email,
first_name=first_name,
last_name=last_name,
role=UserRole(role_str),
kuerzel=kuerzel,
personnel_number=personnel_number,
password_hash=hash_password(raw_token),
invite_token_hash=token_hash,
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
is_active=False,
)
db.add(new_user)
await db.flush()
used_personnel_in_csv.add(personnel_number)
if apply:
try:
await email_service.send_invite(new_user, invited_by, raw_token, db)
except Exception as e: # noqa: BLE001
# Mail-Fehler darf Import nicht abbrechen, wird aber gemeldet
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="created",
message=f"Anlage OK, aber Einladungs-Mail fehlgeschlagen: {e}",
))
created += 1
continue
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="created",
))
created += 1
return ImportResult(
total_rows=len(rows),
created=created,
reactivated=reactivated,
errors=errors,
items=items,
)
async def preview_csv(
content: bytes, company_id: UUID, invited_by: User, db: AsyncSession,
) -> ImportResult:
"""Validiert CSV ohne DB-Schreibvorgänge (Rollback am Ende)."""
result = await _process_import(
content=content,
company_id=company_id,
invited_by=invited_by,
db=db,
apply=False,
)
await db.rollback()
return result
async def apply_csv(
content: bytes, company_id: UUID, invited_by: User, db: AsyncSession,
) -> ImportResult:
"""Führt Import durch und committet."""
result = await _process_import(
content=content,
company_id=company_id,
invited_by=invited_by,
db=db,
apply=True,
)
await db.commit()
return result
+310
View File
@@ -0,0 +1,310 @@
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import (
generate_invite_token,
hash_password,
hash_token,
verify_password,
)
from app.models import User, UserRole
from app.models.audit_log import AuditLog
from app.models.company import Company, PersonnelNumberMode
from app.schemas.user import InviteAccept, InviteRequest, UserUpdate
from app.services.email_service import email_service
PERSONNEL_NUMBER_MIN_DIGITS = 4
class UserService:
# ── Personalnummer-Helpers ────────────────────────────────────────────────
async def _get_company(self, company_id: UUID, db: AsyncSession) -> Company:
company = await db.get(Company, company_id)
if not company:
raise HTTPException(status_code=404, detail="Company not found")
return company
@staticmethod
def _format_personnel_number(value: int) -> str:
return str(value).zfill(PERSONNEL_NUMBER_MIN_DIGITS)
async def _next_personnel_number(self, company_id: UUID, db: AsyncSession) -> str:
"""Atomic increment + return next personnel number for the company.
Uses UPDATE ... RETURNING to avoid race conditions with parallel inserts.
Skips numbers that are already taken (e.g. manual override) by retrying.
"""
for _ in range(50): # safety bound, in practice 1-2 iterations max
result = await db.execute(
text(
"UPDATE companies "
"SET personnel_number_next = personnel_number_next + 1 "
"WHERE id = :cid "
"RETURNING personnel_number_next - 1 AS used"
),
{"cid": company_id},
)
row = result.first()
if row is None:
raise HTTPException(status_code=404, detail="Company not found")
candidate = self._format_personnel_number(int(row.used))
existing = await db.scalar(
select(User.id).where(
User.company_id == company_id,
User.personnel_number == candidate,
)
)
if existing is None:
return candidate
raise HTTPException(status_code=500, detail="Could not allocate personnel number")
async def next_personnel_suggestion(self, company_id: UUID, db: AsyncSession) -> str:
"""Preview next personnel number without consuming the counter."""
company = await self._get_company(company_id, db)
candidate_int = company.personnel_number_next
while True:
candidate = self._format_personnel_number(candidate_int)
taken = await db.scalar(
select(User.id).where(
User.company_id == company_id,
User.personnel_number == candidate,
)
)
if taken is None:
return candidate
candidate_int += 1
async def _check_personnel_unique(
self,
company_id: UUID,
number: str,
db: AsyncSession,
exclude_user_id: UUID | None = None,
) -> None:
"""Raise 409 if personnel number is already taken (incl. deactivated/reserved)."""
q = select(User.id).where(
User.company_id == company_id,
User.personnel_number == number,
)
if exclude_user_id is not None:
q = q.where(User.id != exclude_user_id)
existing = await db.scalar(q)
if existing is not None:
raise HTTPException(
status_code=409,
detail=f"Personalnummer '{number}' ist bereits vergeben (auch reservierte Nummern bleiben belegt).",
)
async def get_by_personnel_number(
self, number: str, company_id: UUID, db: AsyncSession
) -> User:
user = await db.scalar(
select(User).where(
User.company_id == company_id,
User.personnel_number == number,
)
)
if user is None:
raise HTTPException(status_code=404, detail="Personalnummer nicht gefunden")
return user
# ── Invite ────────────────────────────────────────────────────────────────
async def invite(
self,
data: InviteRequest,
company_id: UUID,
invited_by: User,
db: AsyncSession,
) -> User:
existing = await db.scalar(select(User).where(User.email == data.email))
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
company = await self._get_company(company_id, db)
personnel_number = data.personnel_number
if personnel_number:
await self._check_personnel_unique(company_id, personnel_number, db)
else:
if company.personnel_number_mode == PersonnelNumberMode.AUTO.value:
personnel_number = await self._next_personnel_number(company_id, db)
elif company.personnel_number_required:
raise HTTPException(
status_code=400,
detail="Personalnummer ist in dieser Firma Pflicht.",
)
if data.initial_password:
# Direktanlage mit Passwort sofort aktiv, kein E-Mail-Invite
user = User(
company_id=company_id,
email=data.email,
first_name=data.first_name,
last_name=data.last_name,
role=data.role,
department_id=data.department_id,
personnel_number=personnel_number,
password_hash=hash_password(data.initial_password),
is_active=True,
)
db.add(user)
await db.flush()
db.add(AuditLog(
company_id=company_id,
user_id=invited_by.id,
action="user_created",
entity_type="user",
entity_id=user.id,
new_value={"email": user.email, "role": user.role.value, "direct": True},
))
else:
raw_token, token_hash = generate_invite_token()
user = User(
company_id=company_id,
email=data.email,
first_name=data.first_name,
last_name=data.last_name,
role=data.role,
department_id=data.department_id,
personnel_number=personnel_number,
password_hash=hash_password(raw_token), # Temp overwritten on accept
invite_token_hash=token_hash,
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
is_active=False,
)
db.add(user)
await db.flush()
db.add(AuditLog(
company_id=company_id,
user_id=invited_by.id,
action="user_invited",
entity_type="user",
entity_id=user.id,
new_value={"email": user.email, "role": user.role.value},
))
await email_service.send_invite(user, invited_by, raw_token, db)
return user
async def accept_invite(self, data: InviteAccept, db: AsyncSession) -> User:
token_hash = hash_token(data.token)
user = await db.scalar(
select(User).where(User.invite_token_hash == token_hash)
)
if not user:
raise HTTPException(status_code=400, detail="Invalid invite token")
if user.invite_expires and user.invite_expires < datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="Invite token expired")
user.password_hash = hash_password(data.password)
user.invite_token_hash = None
user.invite_expires = None
user.is_active = True
return user
# ── Listing ───────────────────────────────────────────────────────────────
async def list_users(
self,
company_id: UUID,
db: AsyncSession,
skip: int = 0,
limit: int = 50,
active_only: bool = True,
search: str | None = None,
) -> tuple[int, list[User]]:
q = select(User).where(User.company_id == company_id)
if active_only:
q = q.where(User.is_active == True)
if search:
pattern = f"%{search.strip()}%"
q = q.where(
or_(
User.email.ilike(pattern),
User.first_name.ilike(pattern),
User.last_name.ilike(pattern),
User.personnel_number.ilike(pattern),
)
)
total = await db.scalar(select(func.count()).select_from(q.subquery()))
users = await db.scalars(q.offset(skip).limit(limit))
return total, list(users.all())
async def get_by_id(self, user_id: UUID, company_id: UUID, db: AsyncSession) -> User:
user = await db.get(User, user_id)
if not user or user.company_id != company_id:
raise HTTPException(status_code=404, detail="User not found")
return user
# ── Update / De/Reactivate ────────────────────────────────────────────────
async def update(
self,
user_id: UUID,
data: UserUpdate,
current_user: User,
db: AsyncSession,
) -> User:
user = await self.get_by_id(user_id, current_user.company_id, db)
changes = data.model_dump(exclude_unset=True)
old_personnel = user.personnel_number
if "personnel_number" in changes:
new_value = changes["personnel_number"]
if new_value:
await self._check_personnel_unique(
current_user.company_id, new_value, db, exclude_user_id=user.id
)
elif user.personnel_number is not None:
# Explizites Löschen erlauben? Plan sagt Reservierung wir verbieten Clear.
raise HTTPException(
status_code=400,
detail="Personalnummer kann nicht gelöscht werden (Reservierung).",
)
for field, value in changes.items():
setattr(user, field, value)
if "personnel_number" in changes and changes["personnel_number"] != old_personnel:
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="user_personnel_number_changed",
entity_type="user",
entity_id=user.id,
old_value={"personnel_number": old_personnel},
new_value={"personnel_number": user.personnel_number},
))
return user
async def deactivate(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
user = await self.get_by_id(user_id, current_user.company_id, db)
user.is_active = False
return user
async def reactivate(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
user = await self.get_by_id(user_id, current_user.company_id, db)
user.is_active = True
return user
# ── Kiosk ─────────────────────────────────────────────────────────────────
async def set_kiosk_pin(self, user: User, pin: str, db: AsyncSession) -> None:
user.kiosk_pin_hash = hash_password(pin)
async def verify_kiosk_pin(self, user: User, pin: str) -> bool:
if not user.kiosk_pin_hash:
return False
return verify_password(pin, user.kiosk_pin_hash)
user_service = UserService()