Initial commit – TimeMaster Zeiterfassung & HR-Tool

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
"""Busylight-Integration (Pull-Endpoint + Token-Verwaltung).
- Pull: GET /busylight/users Auth via per-Firma Bearer-Token (SHA-256 in DB)
- Verwaltung: POST/DELETE /companies/me/busylight-token COMPANY_ADMIN/SUPER_ADMIN
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.core.limiter import limiter
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceType
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.user import User, UserRole
from app.schemas.busylight import (
BusylightAbsenceItem,
BusylightTokenRotated,
BusylightTokenStatus,
BusylightUserItem,
BusylightUsersResponse,
)
router = APIRouter(tags=["Busylight"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_pull_bearer = HTTPBearer(auto_error=False)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
# ── Token-Verwaltung (eingeloggter Admin) ────────────────────────────────────
@router.get("/companies/me/busylight-token", response_model=BusylightTokenStatus)
async def get_busylight_token_status(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
return BusylightTokenStatus(
configured=company.busylight_pull_token_hash is not None,
created_at=company.busylight_token_created_at,
)
@router.post("/companies/me/busylight-token/rotate", response_model=BusylightTokenRotated)
async def rotate_busylight_token(
request: Request,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
token = secrets.token_urlsafe(32)
company.busylight_pull_token_hash = _hash_token(token)
company.busylight_token_created_at = datetime.now(timezone.utc)
db.add(AuditLog(
company_id=company.id,
user_id=current_user.id,
action="busylight_token_rotated",
entity_type="company",
entity_id=company.id,
ip=request.client.host if request.client else None,
))
await db.commit()
return BusylightTokenRotated(token=token, created_at=company.busylight_token_created_at)
@router.delete("/companies/me/busylight-token", status_code=204)
async def delete_busylight_token(
request: Request,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
if company.busylight_pull_token_hash is None:
return
company.busylight_pull_token_hash = None
company.busylight_token_created_at = None
db.add(AuditLog(
company_id=company.id,
user_id=current_user.id,
action="busylight_token_revoked",
entity_type="company",
entity_id=company.id,
ip=request.client.host if request.client else None,
))
await db.commit()
# ── Pull-Endpoint (busylight liest hier) ─────────────────────────────────────
async def _company_from_token(
credentials: HTTPAuthorizationCredentials | None,
db: AsyncSession,
) -> Company:
if credentials is None or not credentials.credentials:
raise HTTPException(status_code=401, detail="Missing token")
token_hash = _hash_token(credentials.credentials)
company = await db.scalar(
select(Company).where(Company.busylight_pull_token_hash == token_hash)
)
if company is None:
raise HTTPException(status_code=401, detail="Invalid token")
return company
@router.get("/busylight/users", response_model=BusylightUsersResponse)
@limiter.limit("60/minute")
async def list_users_for_busylight(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_pull_bearer),
db: AsyncSession = Depends(get_db),
):
company = await _company_from_token(credentials, db)
today = date.today()
users = (await db.scalars(
select(User)
.where(
User.company_id == company.id,
User.is_active == True,
User.personnel_number.is_not(None),
)
.order_by(User.last_name, User.first_name)
)).all()
user_ids = [u.id for u in users]
abs_rows = []
if user_ids:
abs_rows = (await db.execute(
select(Absence, AbsenceType)
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
Absence.user_id.in_(user_ids),
Absence.status == AbsenceStatus.APPROVED,
Absence.start_date <= today,
Absence.end_date >= today,
)
)).all()
by_user: dict = {uid: [] for uid in user_ids}
for absence, atype in abs_rows:
by_user[absence.user_id].append(
BusylightAbsenceItem(type=atype.name, category=atype.category.value)
)
items = [
BusylightUserItem(
personnel_number=u.personnel_number,
full_name=u.full_name,
absences_today=by_user[u.id],
)
for u in users
]
return BusylightUsersResponse(date=today, users=items)