Initial commit – TimeMaster Zeiterfassung & HR-Tool
Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer), Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst. Migrations 0001–0023 deployed auf 192.168.1.137 + .164. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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: Mo–Fr 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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 (Mo–Fr, 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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user