Initial commit – TimeMaster Zeiterfassung & HR-Tool

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
View File
View File
+54
View File
@@ -0,0 +1,54 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import model_validator
from functools import lru_cache
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# App
app_name: str = "TimeMaster"
app_env: str = "development"
secret_key: str = "change-me-in-production"
frontend_url: str = "http://localhost:5173"
allowed_hosts: list[str] = []
# Database
database_url: str = "postgresql+asyncpg://timemaster:secret@localhost:5432/timemaster_db"
# Redis
redis_url: str = "redis://localhost:6379/0"
# JWT
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 30
algorithm: str = "HS256"
# Email
resend_api_key: str = ""
email_from: str = "noreply@timemaster.app"
email_from_name: str = "TimeMaster"
# First superadmin
first_superadmin_email: str = ""
first_superadmin_password: str = ""
@model_validator(mode='after')
def validate_secret_key(self):
if self.app_env == 'production' and self.secret_key == 'change-me-in-production':
raise ValueError('SECRET_KEY must be changed in production! Set SECRET_KEY env variable.')
if len(self.secret_key) < 32:
raise ValueError('SECRET_KEY must be at least 32 characters long.')
return self
@property
def is_production(self) -> bool:
return self.app_env == "production"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+33
View File
@@ -0,0 +1,33 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(
settings.database_url,
echo=settings.app_env == "development",
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+63
View File
@@ -0,0 +1,63 @@
from typing import Annotated
from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.user import User, UserRole
bearer_scheme = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(credentials.credentials)
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = await db.get(User, UUID(user_id))
if user is None or not user.is_active:
raise credentials_exception
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def require_role(*roles: UserRole):
"""Dependency factory: require_role(UserRole.MANAGER, UserRole.COMPANY_ADMIN)"""
async def checker(current_user: CurrentUser) -> User:
if current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions",
)
return current_user
return Depends(checker)
def require_same_company(target_company_id: UUID, current_user: User) -> None:
"""Raise 403 if user tries to access another company's data."""
if (
current_user.role != UserRole.SUPER_ADMIN
and current_user.company_id != target_company_id
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access to this resource is not allowed",
)
+4
View File
@@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+81
View File
@@ -0,0 +1,81 @@
from datetime import datetime, timedelta, timezone
from typing import Any
import secrets
import hashlib
import bcrypt
from jose import JWTError, jwt
from app.core.config import settings
# ── Password ────────────────────────────────────────────────────────────────
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
def hash_token(token: str) -> str:
"""SHA-256 hash for storing tokens (refresh, invite, reset) in DB."""
return hashlib.sha256(token.encode()).hexdigest()
# ── JWT ─────────────────────────────────────────────────────────────────────
def create_access_token(subject: str, extra: dict[str, Any] | None = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
payload = {"sub": subject, "exp": expire, "type": "access"}
if extra:
payload.update(extra)
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token() -> tuple[str, str]:
"""Returns (raw_token, hashed_token). Store hash in DB, send raw to client."""
raw = secrets.token_urlsafe(64)
return raw, hash_token(raw)
def decode_access_token(token: str) -> dict[str, Any]:
"""Raises JWTError on invalid/expired token."""
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("type") != "access":
raise JWTError("Invalid token type")
return payload
# ── Partial token (TOTP pending) ─────────────────────────────────────────────
def create_partial_token(user_id: str) -> str:
"""Short-lived token issued after password-OK but before TOTP verification. Valid 5 min."""
expire = datetime.now(timezone.utc) + timedelta(minutes=5)
payload = {"sub": user_id, "exp": expire, "type": "partial"}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_partial_token(token: str) -> str:
"""Returns user_id (sub). Raises JWTError on invalid/expired/wrong-type token."""
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("type") != "partial":
raise JWTError("Invalid token type")
return payload["sub"]
# ── One-time tokens ──────────────────────────────────────────────────────────
def generate_invite_token() -> tuple[str, str]:
"""Returns (raw, hashed). Invite valid for 7 days."""
raw = secrets.token_urlsafe(32)
return raw, hash_token(raw)
def generate_reset_token() -> tuple[str, str]:
"""Returns (raw, hashed). Reset valid for 1 hour."""
raw = secrets.token_urlsafe(32)
return raw, hash_token(raw)
+80
View File
@@ -0,0 +1,80 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from app.core.config import settings
from app.core.database import engine, Base
from app.core.limiter import limiter
from app.routers import auth, users, companies
from app.routers import time_entries, absences, reports, ldap, smtp, caldav
from app.routers import import_kimai
from app.routers import kiosk
from app.routers import busylight
from app.routers import audit
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Tabellen anlegen falls noch nicht vorhanden (Alembic übernimmt das in Prod)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown
await engine.dispose()
app = FastAPI(
title=settings.app_name,
version="0.1.0",
docs_url="/docs" if not settings.is_production else None,
redoc_url="/redoc" if not settings.is_production else None,
lifespan=lifespan,
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# ── Middleware ────────────────────────────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_url],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# TODO (M-07): TrustedHostMiddleware set ALLOWED_HOSTS env variable (comma-separated) in production.
# Example: ALLOWED_HOSTS=timemaster.example.com,www.timemaster.example.com
# The placeholder "yourdomain.com" has been replaced with a config-driven approach.
if settings.is_production and settings.allowed_hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.allowed_hosts)
# ── Routers ───────────────────────────────────────────────────────────────────
API_PREFIX = "/api/v1"
app.include_router(auth.router, prefix=API_PREFIX)
app.include_router(users.router, prefix=API_PREFIX)
app.include_router(companies.router, prefix=API_PREFIX)
app.include_router(time_entries.router, prefix=API_PREFIX)
app.include_router(absences.router, prefix=API_PREFIX)
app.include_router(reports.router, prefix=API_PREFIX)
app.include_router(ldap.router, prefix=API_PREFIX)
app.include_router(smtp.router, prefix=API_PREFIX)
app.include_router(caldav.router, prefix=API_PREFIX)
app.include_router(import_kimai.router, prefix=API_PREFIX)
app.include_router(kiosk.router, prefix=API_PREFIX)
app.include_router(busylight.router, prefix=API_PREFIX)
app.include_router(audit.router, prefix=API_PREFIX)
# ── Health ────────────────────────────────────────────────────────────────────
@app.get("/health", tags=["System"])
async def health():
return {"status": "ok", "app": settings.app_name, "env": settings.app_env}
+38
View File
@@ -0,0 +1,38 @@
from app.models.company import Company
from app.models.department import Department
from app.models.user import User, UserRole
from app.models.session import Session
from app.models.password_reset import PasswordReset
from app.models.audit_log import AuditLog
from app.models.work_schedule import WorkSchedule
from app.models.time_entry import TimeEntry, EntryStatus, EntrySource
from app.models.absence_type import AbsenceType
from app.models.absence import Absence, AbsenceStatus
from app.models.vacation_balance import VacationBalance
from app.models.overtime_balance import OvertimeBalance
from app.models.public_holiday import PublicHoliday
from app.models.smtp_config import SmtpConfig
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
from app.models.kiosk_device import KioskDevice, KioskAuthMethod
__all__ = [
"Company",
"Department",
"User",
"UserRole",
"Session",
"PasswordReset",
"AuditLog",
"WorkSchedule",
"TimeEntry",
"EntryStatus",
"EntrySource",
"AbsenceType",
"Absence",
"AbsenceStatus",
"VacationBalance",
"OvertimeBalance",
"PublicHoliday",
"KioskDevice",
"KioskAuthMethod",
]
+87
View File
@@ -0,0 +1,87 @@
import uuid
import enum
from datetime import date, datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.absence_type import AbsenceType
class AbsenceStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
CANCELLED = "cancelled"
class Absence(Base):
__tablename__ = "absences"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
type_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("absence_types.id", ondelete="RESTRICT"), nullable=False
)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
half_day_start: Mapped[bool] = mapped_column(Boolean, default=False)
half_day_end: Mapped[bool] = mapped_column(Boolean, default=False)
working_days: Mapped[float] = mapped_column(Numeric(5, 1), default=0)
status: Mapped[AbsenceStatus] = mapped_column(
Enum(AbsenceStatus, name="absencestatus", values_callable=lambda x: [e.value for e in x]),
nullable=False, default=AbsenceStatus.PENDING,
)
approved_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
substitute_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
note: Mapped[str | None] = mapped_column(Text)
rejection_reason: Mapped[str | None] = mapped_column(Text)
correction_note: Mapped[str | None] = mapped_column(Text)
# Zusatzinformationen (Weiterbildung, Dienstreise, etc.)
# Struktur je Kategorie:
# training: {"course_name": str, "provider": str, "location": str}
# business_trip: {"destination": str, "purpose": str}
meta: Mapped[dict | None] = mapped_column(JSONB)
# Krankheit: Arbeitsunfähigkeitsbescheinigung
certificate_required_by: Mapped[date | None] = mapped_column(Date)
certificate_received_at: Mapped[date | None] = mapped_column(Date)
# CalDAV-Sync
caldav_uid: Mapped[str | None] = mapped_column(String(255))
caldav_user_etag: Mapped[str | None] = mapped_column(Text)
caldav_company_etag: Mapped[str | None] = mapped_column(Text)
caldav_last_error: Mapped[str | None] = mapped_column(Text)
caldav_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(
"User", primaryjoin="Absence.user_id == User.id",
foreign_keys="[Absence.user_id]", lazy="noload",
)
absence_type: Mapped["AbsenceType"] = relationship("AbsenceType", lazy="noload")
approver: Mapped["User | None"] = relationship(
"User", primaryjoin="Absence.approved_by == User.id",
foreign_keys="[Absence.approved_by]", lazy="noload",
)
substitute: Mapped["User | None"] = relationship(
"User", primaryjoin="Absence.substitute_id == User.id",
foreign_keys="[Absence.substitute_id]", lazy="noload",
)
def __repr__(self) -> str:
return f"<Absence {self.user_id} {self.start_date}{self.end_date} [{self.status}]>"
+49
View File
@@ -0,0 +1,49 @@
import enum
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Enum, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
class AbsenceCategory(str, enum.Enum):
VACATION = "vacation"
SICK = "sick"
OVERTIME_COMP = "overtime_comp"
TRAINING = "training"
BUSINESS_TRIP = "business_trip"
OTHER = "other"
class AbsenceType(Base):
__tablename__ = "absence_types"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
color: Mapped[str] = mapped_column(String(7), default="#3B82F6")
category: Mapped[AbsenceCategory] = mapped_column(
Enum(AbsenceCategory, name="absencecategory", values_callable=lambda x: [e.value for e in x]),
nullable=False, default=AbsenceCategory.OTHER,
)
requires_approval: Mapped[bool] = mapped_column(Boolean, default=True)
deducts_vacation: Mapped[bool] = mapped_column(Boolean, default=False)
affects_overtime_balance: Mapped[bool] = mapped_column(Boolean, default=False)
requires_certificate: Mapped[bool] = mapped_column(Boolean, default=False)
certificate_after_days: Mapped[int] = mapped_column(Integer, default=3)
is_paid: Mapped[bool] = mapped_column(Boolean, default=True)
max_days_per_year: Mapped[int | None] = mapped_column(Integer)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
company: Mapped["Company"] = relationship("Company", lazy="noload")
def __repr__(self) -> str:
return f"<AbsenceType {self.name} [{self.category}]>"
+23
View File
@@ -0,0 +1,23 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="SET NULL"), index=True)
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), index=True)
action: Mapped[str] = mapped_column(String(100), nullable=False)
entity_type: Mapped[str | None] = mapped_column(String(100))
entity_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
old_value: Mapped[dict | None] = mapped_column(JSONB)
new_value: Mapped[dict | None] = mapped_column(JSONB)
ip: Mapped[str | None] = mapped_column(String(45))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
+64
View File
@@ -0,0 +1,64 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func # noqa: F401
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
from app.models.user import User
class CaldavCompanyConfig(Base):
"""Zentraler Firmenkalender alle genehmigten Abwesenheiten landen hier."""
__tablename__ = "caldav_company_configs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False, unique=True, index=True,
)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
principal_url: Mapped[str] = mapped_column(Text, nullable=False)
calendar_url: Mapped[str | None] = mapped_column(Text)
username: Mapped[str] = mapped_column(String(255), nullable=False)
password_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
calendar_display_name: Mapped[str] = mapped_column(String(255), default="")
verify_ssl: Mapped[bool] = mapped_column(Boolean, default=True)
name_template: Mapped[str] = mapped_column(Text, default="$vorname $nachname $typ")
last_error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
company: Mapped["Company"] = relationship("Company", lazy="noload")
class CaldavUserConfig(Base):
"""Persönlicher Kalender des Mitarbeiters."""
__tablename__ = "caldav_user_configs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, unique=True, index=True,
)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
principal_url: Mapped[str] = mapped_column(Text, nullable=False)
calendar_url: Mapped[str | None] = mapped_column(Text)
username: Mapped[str] = mapped_column(String(255), nullable=False)
password_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
calendar_display_name: Mapped[str] = mapped_column(String(255), default="")
verify_ssl: Mapped[bool] = mapped_column(Boolean, default=True)
last_error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship("User", lazy="noload")
+52
View File
@@ -0,0 +1,52 @@
import enum
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.department import Department
class PersonnelNumberMode(str, enum.Enum):
MANUAL = "manual"
AUTO = "auto"
class Company(Base):
__tablename__ = "companies"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
plan: Mapped[str] = mapped_column(String(50), default="trial")
logo_url: Mapped[str | None] = mapped_column(Text)
country: Mapped[str] = mapped_column(String(10), default="DE")
state: Mapped[str | None] = mapped_column(String(10))
settings: Mapped[dict] = mapped_column(JSONB, default=dict)
# Personalnummern-Konfiguration
personnel_number_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
personnel_number_mode: Mapped[str] = mapped_column(String(10), nullable=False, default=PersonnelNumberMode.MANUAL.value)
personnel_number_next: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# Krankmeldungs-Konfiguration: Default-Schwelle für AU-Pflicht (in Tagen).
# Pro AbsenceType via certificate_after_days überschreibbar.
sick_note_required_after_days: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
# Busylight-Pull: SHA-256-Hash des per-Firma-Tokens (Klartext nie in DB).
busylight_pull_token_hash: Mapped[str | None] = mapped_column(String(64), unique=True)
busylight_token_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Relationships
users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload")
departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload")
def __repr__(self) -> str:
return f"<Company {self.name}>"
+40
View File
@@ -0,0 +1,40 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
from app.models.user import User
class Department(Base):
__tablename__ = "departments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
manager_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL", use_alter=True, name="fk_dep_manager"))
# Relationships
company: Mapped["Company"] = relationship("Company", back_populates="departments")
members: Mapped[list["User"]] = relationship(
"User",
primaryjoin="User.department_id == Department.id",
foreign_keys="[User.department_id]",
back_populates="department",
lazy="noload",
)
manager: Mapped["User | None"] = relationship(
"User",
primaryjoin="Department.manager_id == User.id",
foreign_keys="[Department.manager_id]",
lazy="noload",
)
def __repr__(self) -> str:
return f"<Department {self.name}>"
+41
View File
@@ -0,0 +1,41 @@
import uuid
import enum
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
class KioskAuthMethod(str, enum.Enum):
PIN = "pin"
NFC = "nfc"
QR = "qr"
LIST = "list" # Mitarbeiter-Liste
class KioskDevice(Base):
__tablename__ = "kiosk_devices"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
location: Mapped[str | None] = mapped_column(String(255))
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
company: Mapped["Company"] = relationship("Company", lazy="noload")
def __repr__(self) -> str:
return f"<KioskDevice {self.name} ({self.company_id})>"
+60
View File
@@ -0,0 +1,60 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
class LdapConfig(Base):
__tablename__ = "ldap_configs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False, unique=True, index=True
)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# Server
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, default=389)
use_ssl: Mapped[bool] = mapped_column(Boolean, default=False)
use_tls: Mapped[bool] = mapped_column(Boolean, default=False)
tls_verify: Mapped[bool] = mapped_column(Boolean, default=False)
# Bind credentials
bind_dn: Mapped[str] = mapped_column(Text, nullable=False)
bind_password_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
# Search
base_dn: Mapped[str] = mapped_column(Text, nullable=False)
user_search_filter: Mapped[str] = mapped_column(
String(512), nullable=False, default="(objectClass=person)"
)
# Attribute mapping
attr_email: Mapped[str] = mapped_column(String(100), default="mail")
attr_firstname: Mapped[str] = mapped_column(String(100), default="givenName")
attr_lastname: Mapped[str] = mapped_column(String(100), default="sn")
attr_username: Mapped[str] = mapped_column(String(100), default="sAMAccountName")
attr_department: Mapped[str | None] = mapped_column(String(100))
attr_personnel_number: Mapped[str | None] = mapped_column(String(100))
# Sync state
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
company: Mapped["Company"] = relationship("Company", lazy="noload")
def __repr__(self) -> str:
return f"<LdapConfig {self.host} company={self.company_id}>"
+50
View File
@@ -0,0 +1,50 @@
import uuid
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Numeric, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.company import Company
class OvertimeBalance(Base):
"""Kumuliertes Überstundenguthaben pro Mitarbeiter.
total_hours = Summe aller genehmigten Überstunden aus time_entries
taken_hours = bereits als Freizeitausgleich genommene Stunden
available = total_hours - taken_hours
"""
__tablename__ = "overtime_balances"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, unique=True, index=True,
)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True
)
total_hours: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal("0"))
taken_hours: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal("0"))
last_calculated: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship("User", lazy="noload")
company: Mapped["Company"] = relationship("Company", lazy="noload")
@property
def available_hours(self) -> Decimal:
return self.total_hours - self.taken_hours
def __repr__(self) -> str:
return f"<OvertimeBalance user={self.user_id} total={self.total_hours}h taken={self.taken_hours}h>"
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class PasswordReset(Base):
__tablename__ = "password_resets"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship("User")
+27
View File
@@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Project(Base):
__tablename__ = "projects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
color: Mapped[str] = mapped_column(String(7), default="#3B82F6") # Tailwind blue-500
budget_hours: Mapped[float | None] = mapped_column(Numeric(8, 2))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def __repr__(self) -> str:
return f"<Project {self.name} ({self.company_id})>"
+26
View File
@@ -0,0 +1,26 @@
import uuid
from datetime import date
from sqlalchemy import Boolean, Date, Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class PublicHoliday(Base):
__tablename__ = "public_holidays"
__table_args__ = (
UniqueConstraint("country", "state", "date", name="uq_public_holiday"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
country: Mapped[str] = mapped_column(String(10), nullable=False)
state: Mapped[str | None] = mapped_column(String(10)) # z.B. "BY", "NW" für Bundesländer
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
year: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
is_high_rate: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
def __repr__(self) -> str:
return f"<PublicHoliday {self.name} {self.date}>"
+30
View File
@@ -0,0 +1,30 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import INET, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class Session(Base):
__tablename__ = "sessions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
refresh_token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
device: Mapped[str | None] = mapped_column(String(255))
ip: Mapped[str | None] = mapped_column(String(45)) # supports IPv6
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Relationships
user: Mapped["User"] = relationship("User", back_populates="sessions")
def __repr__(self) -> str:
return f"<Session user={self.user_id} expires={self.expires_at}>"
+32
View File
@@ -0,0 +1,32 @@
"""
SMTP-Konfiguration pro Firma.
Passwort wird Fernet-verschlüsselt gespeichert (gleiche Methode wie ldap_service).
"""
import uuid
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class SmtpConfig(Base):
__tablename__ = "smtp_configs"
__table_args__ = (UniqueConstraint("company_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, default=587, nullable=False)
use_tls: Mapped[bool] = mapped_column(Boolean, default=False) # SMTPS port 465
use_starttls: Mapped[bool] = mapped_column(Boolean, default=True) # STARTTLS port 587
username: Mapped[str | None] = mapped_column(String(255))
password_encrypted: Mapped[str | None] = mapped_column(Text)
from_email: Mapped[str] = mapped_column(String(255), nullable=False)
from_name: Mapped[str] = mapped_column(String(255), default="TimeMaster", nullable=False)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+81
View File
@@ -0,0 +1,81 @@
import uuid
import enum
from datetime import date, datetime, time
from typing import TYPE_CHECKING
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Integer, String, Text, Time, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class EntryStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class EntrySource(str, enum.Enum):
WEB = "web"
KIOSK = "kiosk"
API = "api"
MANUAL = "manual"
class TimeEntry(Base):
__tablename__ = "time_entries"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
start_time: Mapped[time] = mapped_column(Time(timezone=False), nullable=False)
end_time: Mapped[time | None] = mapped_column(Time(timezone=False))
break_minutes: Mapped[int] = mapped_column(Integer, default=0)
break_start: Mapped[time | None] = mapped_column(Time(timezone=False)) # Aktive Pause tracken
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
note: Mapped[str | None] = mapped_column(Text)
status: Mapped[EntryStatus] = mapped_column(
Enum(EntryStatus, name="entrystatus", values_callable=lambda x: [e.value for e in x]), nullable=False, default=EntryStatus.PENDING
)
source: Mapped[EntrySource] = mapped_column(
Enum(EntrySource, name="entrysource", values_callable=lambda x: [e.value for e in x]), nullable=False, default=EntrySource.WEB
)
approved_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
correction_note: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(
"User", primaryjoin="TimeEntry.user_id == User.id",
foreign_keys="[TimeEntry.user_id]", lazy="noload"
)
approver: Mapped["User | None"] = relationship(
"User", primaryjoin="TimeEntry.approved_by == User.id",
foreign_keys="[TimeEntry.approved_by]", lazy="noload"
)
@property
def worked_minutes(self) -> int | None:
"""Gearbeitete Minuten (ohne Pausen), None wenn noch offen."""
if self.end_time is None:
return None
start_total = self.start_time.hour * 60 + self.start_time.minute
end_total = self.end_time.hour * 60 + self.end_time.minute
if end_total <= start_total:
end_total += 24 * 60 # overnight shift
return max(0, end_total - start_total - self.break_minutes)
@property
def worked_hours(self) -> float | None:
mins = self.worked_minutes
return round(mins / 60, 2) if mins is not None else None
def __repr__(self) -> str:
return f"<TimeEntry {self.user_id} {self.date} {self.start_time}-{self.end_time}>"
+94
View File
@@ -0,0 +1,94 @@
import uuid
import enum
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
from app.models.department import Department
from app.models.session import Session
class UserRole(str, enum.Enum):
SUPER_ADMIN = "SUPER_ADMIN"
COMPANY_ADMIN = "COMPANY_ADMIN"
HR = "HR"
MANAGER = "MANAGER"
EMPLOYEE = "EMPLOYEE"
class AuthProvider(str, enum.Enum):
LOCAL = "local"
LDAP = "ldap"
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"))
department_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("departments.id", ondelete="SET NULL"))
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[UserRole] = mapped_column(Enum(UserRole), nullable=False, default=UserRole.EMPLOYEE)
auth_provider: Mapped[AuthProvider] = mapped_column(
Enum(AuthProvider, name="authprovider", values_callable=lambda x: [e.value for e in x]),
nullable=False, default=AuthProvider.LOCAL,
)
ldap_dn: Mapped[str | None] = mapped_column(Text)
work_schedule_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("work_schedules.id", ondelete="SET NULL")
)
# Kiosk auth
kiosk_pin_hash: Mapped[str | None] = mapped_column(Text)
kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True)
# Kalender-Kürzel (vom Manager setzbar, für CalDAV-Template $kuerzel)
kuerzel: Mapped[str | None] = mapped_column(String(20))
# Personalnummer (numerisch, eindeutig pro Firma; bleibt nach Deaktivierung reserviert)
personnel_number: Mapped[str | None] = mapped_column(String(50))
# TOTP / 2FA
totp_secret: Mapped[str | None] = mapped_column(String(64))
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Permissions
can_manual_time_entry: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Account state
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
invite_token_hash: Mapped[str | None] = mapped_column(Text)
invite_expires: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Relationships
company: Mapped["Company"] = relationship("Company", back_populates="users")
department: Mapped["Department | None"] = relationship(
"Department",
primaryjoin="User.department_id == Department.id",
foreign_keys="[User.department_id]",
back_populates="members",
)
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user", cascade="all, delete-orphan", lazy="noload")
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
def is_admin_or_above(self) -> bool:
return self.role in (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
def __repr__(self) -> str:
return f"<User {self.email} [{self.role}]>"
+39
View File
@@ -0,0 +1,39 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class VacationBalance(Base):
__tablename__ = "vacation_balances"
__table_args__ = (UniqueConstraint("user_id", "year", name="uq_vacation_balance_user_year"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
year: Mapped[int] = mapped_column(Integer, nullable=False)
entitled_days: Mapped[int] = mapped_column(Integer, default=30) # Grundurlaub
special_days: Mapped[int] = mapped_column(Integer, default=0) # Sondertage
carried_over: Mapped[int] = mapped_column(Integer, default=0) # Resturlaub aus Vorjahr
used_days: Mapped[int] = mapped_column(Integer, default=0) # Verbraucht
user: Mapped["User"] = relationship("User", lazy="noload")
@property
def total_days(self) -> int:
return self.entitled_days + self.special_days + self.carried_over
@property
def remaining_days(self) -> int:
return self.total_days - self.used_days
def __repr__(self) -> str:
return f"<VacationBalance {self.user_id} {self.year}: {self.remaining_days} left>"
+45
View File
@@ -0,0 +1,45 @@
import uuid
from datetime import date
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
class WorkSchedule(Base):
__tablename__ = "work_schedules"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
mon_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
tue_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
wed_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
thu_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
fri_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
sat_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("0.00"))
sun_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("0.00"))
valid_from: Mapped[date] = mapped_column(Date, nullable=False)
company: Mapped["Company"] = relationship("Company", lazy="noload")
@property
def weekly_hours(self) -> Decimal:
return self.mon_h + self.tue_h + self.wed_h + self.thu_h + self.fri_h + self.sat_h + self.sun_h
def hours_for_weekday(self, weekday: int) -> Decimal:
"""weekday: 0=Mon, 1=Tue, ..., 6=Sun"""
mapping = [self.mon_h, self.tue_h, self.wed_h, self.thu_h, self.fri_h, self.sat_h, self.sun_h]
return mapping[weekday]
def __repr__(self) -> str:
return f"<WorkSchedule {self.name}>"
View File
+368
View File
@@ -0,0 +1,368 @@
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.absence import AbsenceStatus
from app.models.user import User, UserRole
from app.models.overtime_balance import OvertimeBalance
from app.schemas.absence import (
AbsenceCreate,
AbsenceListResponse,
AbsenceOut,
AbsenceReject,
AbsenceUpdate,
AbsenceTypeCreate,
AbsenceTypeOut,
AbsenceTypeUpdate,
CalendarEntry,
CertificateMarkIn,
OvertimeBalanceOut,
PublicHolidayCreate,
PublicHolidayOut,
QuickSickIn,
SickStatsOut,
VacationBalanceOut,
VacationBalanceUpdate,
)
from app.services.absence_service import absence_service
from app.models.company import Company
from sqlalchemy import select
from datetime import date
router = APIRouter(tags=["Abwesenheiten"])
def _carryover_expiry(company: Company, year: int) -> tuple[date | None, bool]:
"""Verfallsdatum für Resturlaub berechnen.
Gibt (expires_at, is_expired) zurück. None wenn kein Verfall konfiguriert."""
s = company.settings or {}
month = s.get("carryover_expires_month")
day = s.get("carryover_expires_day")
if not month or not day:
return None, False
try:
expires_at = date(year, int(month), int(day))
return expires_at, date.today() > expires_at
except ValueError:
return None, False
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Absence Types ─────────────────────────────────────────────────────────────
@router.get("/absence-types/", response_model=list[AbsenceTypeOut])
async def list_absence_types(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
types = await absence_service.list_types(current_user.company_id, db)
return [AbsenceTypeOut.model_validate(t) for t in types]
@router.post("/absence-types/", response_model=AbsenceTypeOut, status_code=201)
async def create_absence_type(
data: AbsenceTypeCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
at = await absence_service.create_type(current_user.company_id, data, db)
await db.commit()
await db.refresh(at)
return AbsenceTypeOut.model_validate(at)
@router.patch("/absence-types/{type_id}", response_model=AbsenceTypeOut)
async def update_absence_type(
type_id: UUID,
data: AbsenceTypeUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
at = await absence_service.update_type(type_id, current_user.company_id, data, db)
await db.commit()
await db.refresh(at)
return AbsenceTypeOut.model_validate(at)
# ── Public Holidays ───────────────────────────────────────────────────────────
@router.get("/public-holidays/", response_model=list[PublicHolidayOut])
async def list_public_holidays(
current_user: CurrentUser,
year: int = Query(...),
country: str = Query("DE"),
state: str | None = Query(None),
db: AsyncSession = Depends(get_db),
):
holidays = await absence_service.list_holidays(year, country, state, db)
return [PublicHolidayOut.model_validate(h) for h in holidays]
@router.post("/public-holidays/", response_model=PublicHolidayOut, status_code=201)
async def create_public_holiday(
data: PublicHolidayCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
holiday = await absence_service.create_holiday(data, db)
await db.commit()
await db.refresh(holiday)
return PublicHolidayOut.model_validate(holiday)
# ── Absences ──────────────────────────────────────────────────────────────────
@router.get("/absences/calendar", response_model=list[CalendarEntry])
async def get_calendar(
current_user: CurrentUser,
year: int = Query(...),
month: int | None = Query(None, ge=1, le=12),
db: AsyncSession = Depends(get_db),
):
"""Team-Kalender: alle Abwesenheiten im Zeitraum."""
entries = await absence_service.get_calendar(current_user.company_id, year, month, db)
return [CalendarEntry(**e) for e in entries]
@router.get("/absences/balance", response_model=VacationBalanceOut)
async def get_own_balance(
current_user: CurrentUser,
year: int = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Eigenes Urlaubskonto."""
balance = await absence_service.get_balance(current_user.id, year, db)
pending = await absence_service.get_pending_days(current_user.id, year, db)
company = await db.get(Company, current_user.company_id)
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
@router.get("/absences/balance/{user_id}", response_model=VacationBalanceOut)
async def get_balance_for_user(
user_id: UUID,
year: int = Query(...),
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Urlaubskonto eines Mitarbeiters (MANAGER/HR/ADMIN)."""
target_user = await db.get(User, user_id)
if target_user is None or target_user.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
balance = await absence_service.get_balance(user_id, year, db)
pending = await absence_service.get_pending_days(user_id, year, db)
company = await db.get(Company, current_user.company_id)
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
@router.post("/absences/quick-sick", response_model=AbsenceOut, status_code=201)
async def quick_sick(
data: QuickSickIn,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Sofort-Krankmeldung (auto-approved). Nutzt den ersten aktiven SICK-Typ der Firma."""
absence, _ = await absence_service.quick_sick(
data.start_date, data.end_date, current_user, db
)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.get("/absences/sick-stats", response_model=list[SickStatsOut])
async def get_sick_stats(
current_user: User = require_role(*_manager_roles),
user_id: UUID | None = Query(None),
ref_date: date | None = Query(None, description="Stichtag, default heute"),
db: AsyncSession = Depends(get_db),
):
"""Krankheitsstatistik (rolling 12 Monate ab ref_date) inkl. Bradford-Faktor."""
stats = await absence_service.get_sick_stats(
company_id=current_user.company_id,
current_user=current_user,
ref_date=ref_date or date.today(),
db=db,
user_id=user_id,
)
return [SickStatsOut(**s) for s in stats]
@router.get("/absences/overtime-balance", response_model=OvertimeBalanceOut)
async def get_overtime_balance(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Eigenes Überstunden-Konto."""
bal = await db.scalar(
select(OvertimeBalance).where(OvertimeBalance.user_id == current_user.id)
)
if bal is None:
return OvertimeBalanceOut(total_hours=0, taken_hours=0, available_hours=0)
return OvertimeBalanceOut(
total_hours=float(bal.total_hours),
taken_hours=float(bal.taken_hours),
available_hours=float(bal.available_hours),
)
@router.get("/absences/", response_model=AbsenceListResponse)
async def list_absences(
current_user: CurrentUser,
user_id: UUID | None = Query(None),
type_id: UUID | None = Query(None),
status: AbsenceStatus | None = Query(None),
year: int | None = Query(None),
db: AsyncSession = Depends(get_db),
):
total, absences = await absence_service.list_absences(
current_user.company_id, current_user, db,
user_id=user_id, type_id=type_id, status=status, year=year,
)
return AbsenceListResponse(total=total, items=[AbsenceOut.model_validate(a) for a in absences])
@router.post("/absences/", response_model=AbsenceOut, status_code=201)
async def create_absence(
data: AbsenceCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Abwesenheitsantrag stellen. HR/Admin kann for_user_id setzen um für andere anzulegen."""
acting_user = current_user
if data.for_user_id and data.for_user_id != current_user.id:
if current_user.role not in _manager_roles:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Keine Berechtigung, Abwesenheiten für andere anzulegen.")
target = await db.get(User, data.for_user_id)
if not target or target.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
acting_user = target
absence, warnings = await absence_service.create_absence(data, acting_user, db)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.patch("/absences/{absence_id}", response_model=AbsenceOut)
async def update_absence(
absence_id: UUID,
data: AbsenceUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Ausstehenden Antrag bearbeiten (Mitarbeiter: eigene; Manager: alle der Company)."""
absence = await absence_service.update_absence(absence_id, data, current_user, db)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.get("/absences/{absence_id}", response_model=AbsenceOut)
async def get_absence(
absence_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
absence = await absence_service.get_by_id(absence_id, current_user, db)
return AbsenceOut.model_validate(absence)
@router.delete("/absences/{absence_id}", status_code=204)
async def cancel_absence(
absence_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Eigenen ausstehenden Antrag stornieren."""
await absence_service.cancel_absence(absence_id, current_user, db)
await db.commit()
@router.post("/absences/{absence_id}/approve", response_model=AbsenceOut)
async def approve_absence(
absence_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
absence = await absence_service.approve_absence(absence_id, current_user, db)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.post("/absences/{absence_id}/reject", response_model=AbsenceOut)
async def reject_absence(
absence_id: UUID,
data: AbsenceReject,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
absence = await absence_service.reject_absence(absence_id, data, current_user, db)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.patch("/absences/{absence_id}/certificate", response_model=AbsenceOut)
async def mark_certificate_received(
absence_id: UUID,
data: CertificateMarkIn,
current_user: User = require_role(UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
"""HR/Admin markiert die AU-Bescheinigung als eingegangen."""
absence = await absence_service.mark_certificate_received(
absence_id, data.received_at, current_user, db
)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
# ── Urlaubskonto bearbeiten (HR/Admin) ────────────────────────────────────────
@router.patch("/absences/balance/{user_id}", response_model=VacationBalanceOut)
async def update_balance(
user_id: UUID,
data: VacationBalanceUpdate,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
year: int = Query(...),
):
"""Grundurlaub, Sondertage und Resturlaub eines Mitarbeiters anpassen."""
from app.models.vacation_balance import VacationBalance
target = await db.get(User, user_id)
if target is None or target.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(404, "Mitarbeiter nicht gefunden")
balance = await absence_service.get_balance(user_id, year, db)
for field, value in data.model_dump(exclude_unset=True).items():
setattr(balance, field, value)
await db.commit()
await db.refresh(balance)
pending = await absence_service.get_pending_days(user_id, year, db)
company = await db.get(Company, current_user.company_id)
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
+119
View File
@@ -0,0 +1,119 @@
"""AuditLog-Endpoint nur für COMPANY_ADMIN und SUPER_ADMIN, company-isoliert."""
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.audit_log import AuditLog
from app.models.user import User, UserRole
from app.schemas.audit_log import AuditLogEntry, AuditLogListResponse
router = APIRouter(tags=["Audit Log"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
@router.get("/audit-logs", response_model=AuditLogListResponse)
async def list_audit_logs(
user_id: UUID | None = Query(None),
action: str | None = Query(None),
entity_type: str | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
base_filter = [AuditLog.company_id == current_user.company_id]
if current_user.role == UserRole.SUPER_ADMIN:
base_filter = [] # SUPER_ADMIN sieht alle Firmen
if user_id:
base_filter.append(AuditLog.user_id == user_id)
if action:
base_filter.append(AuditLog.action.ilike(f"%{action}%"))
if entity_type:
base_filter.append(AuditLog.entity_type == entity_type)
if date_from:
base_filter.append(AuditLog.created_at >= date_from)
if date_to:
base_filter.append(AuditLog.created_at <= date_to)
count_q = select(func.count()).select_from(AuditLog).where(*base_filter)
total = await db.scalar(count_q) or 0
rows_q = (
select(AuditLog, User.first_name, User.last_name)
.outerjoin(User, AuditLog.user_id == User.id)
.where(*base_filter)
.order_by(AuditLog.created_at.desc())
.limit(limit)
.offset(offset)
)
rows = (await db.execute(rows_q)).all()
items = [
AuditLogEntry(
id=log.id,
user_id=log.user_id,
user_name=f"{first} {last}".strip() if first or last else None,
action=log.action,
entity_type=log.entity_type,
entity_id=log.entity_id,
old_value=log.old_value,
new_value=log.new_value,
ip_address=log.ip,
created_at=log.created_at,
)
for log, first, last in rows
]
return AuditLogListResponse(total=total, items=items)
@router.get("/audit-logs/actions", response_model=list[str])
async def list_audit_actions(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle vorhandenen Action-Werte für Filter-Dropdown."""
filter_cond = (
[] if current_user.role == UserRole.SUPER_ADMIN
else [AuditLog.company_id == current_user.company_id]
)
q = (
select(AuditLog.action)
.where(*filter_cond)
.distinct()
.order_by(AuditLog.action)
)
result = await db.execute(q)
return [r for (r,) in result.all()]
@router.get("/audit-logs/entity-types", response_model=list[str])
async def list_entity_types(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle vorhandenen Entity-Typen für Filter-Dropdown."""
filter_cond = (
[AuditLog.entity_type.isnot(None)]
if current_user.role == UserRole.SUPER_ADMIN
else [AuditLog.company_id == current_user.company_id, AuditLog.entity_type.isnot(None)]
)
q = (
select(AuditLog.entity_type)
.where(*filter_cond)
.distinct()
.order_by(AuditLog.entity_type)
)
result = await db.execute(q)
return [r for (r,) in result.all()]
+211
View File
@@ -0,0 +1,211 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser
from app.core.limiter import limiter
from app.core.security import hash_password, verify_password
from app.schemas.auth import (
LoginRequest,
MessageResponse,
PasswordResetConfirm,
PasswordResetRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
TotpConfirmRequest,
TotpDisableRequest,
TotpLoginRequest,
TotpSetupResponse,
)
from app.schemas.user import InviteAccept, UserOut
from app.services.auth_service import auth_service
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/register", response_model=TokenResponse, status_code=201)
@limiter.limit("3/hour")
async def register(request: Request, data: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Create a new company + admin account."""
return await auth_service.register(data, db)
@router.post("/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def login(request: Request, data: LoginRequest, db: AsyncSession = Depends(get_db)):
return await auth_service.login(data, db, request)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
return await auth_service.refresh(data.refresh_token, db)
@router.post("/logout", response_model=MessageResponse)
async def logout(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
await auth_service.logout(data.refresh_token, db)
return MessageResponse(message="Logged out successfully")
@router.post("/password-reset", response_model=MessageResponse)
@limiter.limit("3/hour")
async def request_password_reset(request: Request, data: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
result = await auth_service.request_password_reset(data.email, db)
if result == "ldap":
from fastapi import HTTPException
raise HTTPException(
status_code=400,
detail="Dein Konto wird über LDAP verwaltet. Bitte setze dein Passwort direkt beim LDAP-Administrator zurück.",
)
return MessageResponse(message="Falls diese E-Mail-Adresse registriert ist, wurde ein Reset-Link verschickt.")
@router.post("/password-reset/confirm", response_model=MessageResponse)
@limiter.limit("5/hour")
async def confirm_password_reset(request: Request, data: PasswordResetConfirm, db: AsyncSession = Depends(get_db)):
await auth_service.confirm_password_reset(data.token, data.new_password, db)
return MessageResponse(message="Password updated successfully")
@router.post("/invite/accept", response_model=UserOut)
@limiter.limit("10/hour")
async def accept_invite(request: Request, data: InviteAccept, db: AsyncSession = Depends(get_db)):
from app.services.user_service import user_service
user = await user_service.accept_invite(data, db)
return UserOut.model_validate(user)
@router.get("/me", response_model=UserOut)
async def me(current_user: CurrentUser):
return UserOut.model_validate(current_user)
@router.post("/change-password", response_model=MessageResponse)
async def change_password(
data: ChangePasswordRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Passwort ändern (eingeloggter User, benötigt aktuelles Passwort)."""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Aktuelles Passwort ist falsch")
import re
if not re.search(r'[A-Z]', data.new_password) or not re.search(r'[0-9]', data.new_password):
raise HTTPException(
status_code=400,
detail="Neues Passwort muss mindestens 1 Großbuchstaben und 1 Zahl enthalten"
)
current_user.password_hash = hash_password(data.new_password)
await db.commit()
return MessageResponse(message="Passwort erfolgreich geändert")
# ── TOTP / 2FA ────────────────────────────────────────────────────────────────
@router.post("/totp/setup", response_model=TotpSetupResponse)
async def totp_setup(current_user: CurrentUser):
"""Generiert ein neues TOTP-Secret und gibt die otpauth-URI zurück. Noch nicht aktiviert."""
import pyotp
secret = pyotp.random_base32()
issuer = "TimeMaster"
label = current_user.email
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=issuer)
# Secret temporär im User speichern (noch nicht totp_enabled)
current_user.totp_secret = secret
# Hinweis: DB-Commit passiert NICHT hier erst nach verify in /totp/confirm
# Damit das Secret nicht verloren geht, sofort speichern
return TotpSetupResponse(secret=secret, otpauth_uri=uri)
@router.post("/totp/setup/save", response_model=MessageResponse)
async def totp_setup_save(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Speichert das generierte Secret temporär (ohne Aktivierung)."""
import pyotp
if not current_user.totp_secret:
secret = pyotp.random_base32()
current_user.totp_secret = secret
await db.commit()
return MessageResponse(message="Secret gespeichert")
@router.post("/totp/confirm", response_model=MessageResponse)
async def totp_confirm(
data: TotpConfirmRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Bestätigt den ersten TOTP-Code und aktiviert 2FA."""
import pyotp
if not current_user.totp_secret:
raise HTTPException(400, "Kein TOTP-Secret vorhanden. Zuerst /totp/setup aufrufen.")
totp = pyotp.TOTP(current_user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
current_user.totp_enabled = True
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung aktiviert")
@router.post("/totp/disable", response_model=MessageResponse)
async def totp_disable(
data: TotpDisableRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Deaktiviert TOTP. Benötigt aktuelles Passwort + gültigen TOTP-Code."""
import pyotp
if not verify_password(data.password, current_user.password_hash or ""):
raise HTTPException(400, "Passwort falsch")
if not current_user.totp_enabled or not current_user.totp_secret:
raise HTTPException(400, "2FA ist nicht aktiv")
totp = pyotp.TOTP(current_user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger TOTP-Code")
current_user.totp_enabled = False
current_user.totp_secret = None
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung deaktiviert")
@router.post("/totp/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def totp_login(
request: Request,
data: TotpLoginRequest,
db: AsyncSession = Depends(get_db),
):
"""Zweiter Login-Schritt: partial_token + TOTP-Code → volle Tokens."""
import pyotp
from uuid import UUID
from app.core.security import decode_partial_token
from app.models.user import User
from jose import JWTError
try:
user_id = decode_partial_token(data.partial_token)
except JWTError:
raise HTTPException(401, "Ungültiger oder abgelaufener Token")
user = await db.get(User, UUID(user_id))
if not user or not user.is_active:
raise HTTPException(401, "Benutzer nicht gefunden")
if not user.totp_enabled or not user.totp_secret:
raise HTTPException(400, "2FA nicht aktiv")
totp = pyotp.TOTP(user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
from datetime import datetime, timezone
user.last_login = datetime.now(timezone.utc)
return await auth_service._create_session(user, db, request=request)
+168
View File
@@ -0,0 +1,168 @@
"""Busylight-Integration (Pull-Endpoint + Token-Verwaltung).
- Pull: GET /busylight/users Auth via per-Firma Bearer-Token (SHA-256 in DB)
- Verwaltung: POST/DELETE /companies/me/busylight-token COMPANY_ADMIN/SUPER_ADMIN
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.core.limiter import limiter
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceType
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.user import User, UserRole
from app.schemas.busylight import (
BusylightAbsenceItem,
BusylightTokenRotated,
BusylightTokenStatus,
BusylightUserItem,
BusylightUsersResponse,
)
router = APIRouter(tags=["Busylight"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_pull_bearer = HTTPBearer(auto_error=False)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
# ── Token-Verwaltung (eingeloggter Admin) ────────────────────────────────────
@router.get("/companies/me/busylight-token", response_model=BusylightTokenStatus)
async def get_busylight_token_status(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
return BusylightTokenStatus(
configured=company.busylight_pull_token_hash is not None,
created_at=company.busylight_token_created_at,
)
@router.post("/companies/me/busylight-token/rotate", response_model=BusylightTokenRotated)
async def rotate_busylight_token(
request: Request,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
token = secrets.token_urlsafe(32)
company.busylight_pull_token_hash = _hash_token(token)
company.busylight_token_created_at = datetime.now(timezone.utc)
db.add(AuditLog(
company_id=company.id,
user_id=current_user.id,
action="busylight_token_rotated",
entity_type="company",
entity_id=company.id,
ip=request.client.host if request.client else None,
))
await db.commit()
return BusylightTokenRotated(token=token, created_at=company.busylight_token_created_at)
@router.delete("/companies/me/busylight-token", status_code=204)
async def delete_busylight_token(
request: Request,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
if company.busylight_pull_token_hash is None:
return
company.busylight_pull_token_hash = None
company.busylight_token_created_at = None
db.add(AuditLog(
company_id=company.id,
user_id=current_user.id,
action="busylight_token_revoked",
entity_type="company",
entity_id=company.id,
ip=request.client.host if request.client else None,
))
await db.commit()
# ── Pull-Endpoint (busylight liest hier) ─────────────────────────────────────
async def _company_from_token(
credentials: HTTPAuthorizationCredentials | None,
db: AsyncSession,
) -> Company:
if credentials is None or not credentials.credentials:
raise HTTPException(status_code=401, detail="Missing token")
token_hash = _hash_token(credentials.credentials)
company = await db.scalar(
select(Company).where(Company.busylight_pull_token_hash == token_hash)
)
if company is None:
raise HTTPException(status_code=401, detail="Invalid token")
return company
@router.get("/busylight/users", response_model=BusylightUsersResponse)
@limiter.limit("60/minute")
async def list_users_for_busylight(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_pull_bearer),
db: AsyncSession = Depends(get_db),
):
company = await _company_from_token(credentials, db)
today = date.today()
users = (await db.scalars(
select(User)
.where(
User.company_id == company.id,
User.is_active == True,
User.personnel_number.is_not(None),
)
.order_by(User.last_name, User.first_name)
)).all()
user_ids = [u.id for u in users]
abs_rows = []
if user_ids:
abs_rows = (await db.execute(
select(Absence, AbsenceType)
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
Absence.user_id.in_(user_ids),
Absence.status == AbsenceStatus.APPROVED,
Absence.start_date <= today,
Absence.end_date >= today,
)
)).all()
by_user: dict = {uid: [] for uid in user_ids}
for absence, atype in abs_rows:
by_user[absence.user_id].append(
BusylightAbsenceItem(type=atype.name, category=atype.category.value)
)
items = [
BusylightUserItem(
personnel_number=u.personnel_number,
full_name=u.full_name,
absences_today=by_user[u.id],
)
for u in users
]
return BusylightUsersResponse(date=today, users=items)
+143
View File
@@ -0,0 +1,143 @@
"""
CalDAV-Konfiguration und manueller Sync-Trigger.
Firmenkalender: nur COMPANY_ADMIN / SUPER_ADMIN
Persönlicher Kalender: jeder eingeloggte Nutzer für sich selbst
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
from app.models.user import User, UserRole
from app.schemas.caldav import (
CaldavCompanyConfigOut,
CaldavCompanyConfigSave,
CaldavUserConfigOut,
CaldavUserConfigSave,
ResyncResult,
)
from app.services.caldav_service import caldav_service, encrypt_password
router = APIRouter(prefix="/caldav", tags=["CalDAV"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Firmenkalender ─────────────────────────────────────────────────────────────
@router.get("/company/config", response_model=CaldavCompanyConfigOut | None)
async def get_company_config(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_company_config(current_user.company_id, db)
return CaldavCompanyConfigOut.model_validate(cfg) if cfg else None
@router.post("/company/config", response_model=CaldavCompanyConfigOut)
async def save_company_config(
data: CaldavCompanyConfigSave,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_company_config(current_user.company_id, db)
if cfg is None:
cfg = CaldavCompanyConfig(company_id=current_user.company_id, id=uuid.uuid4())
db.add(cfg)
cfg.enabled = data.enabled
cfg.principal_url = data.principal_url
cfg.calendar_url = data.calendar_url
cfg.username = data.username
cfg.calendar_display_name = data.calendar_display_name
cfg.verify_ssl = data.verify_ssl
if data.password:
cfg.password_encrypted = encrypt_password(data.password)
elif not cfg.password_encrypted:
raise HTTPException(status_code=400, detail="Passwort wird beim ersten Speichern benötigt.")
await db.commit()
await db.refresh(cfg)
return CaldavCompanyConfigOut.model_validate(cfg)
@router.post("/company/test")
async def test_company_config(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_company_config(current_user.company_id, db)
if not cfg:
raise HTTPException(status_code=404, detail="Keine Firmen-CalDAV-Konfiguration vorhanden.")
result = await caldav_service.test_config(cfg)
if not result["ok"]:
raise HTTPException(status_code=502, detail=result["error"])
return {"message": "Verbindung erfolgreich.", "http_status": result.get("status")}
@router.post("/company/resync", response_model=ResyncResult)
async def resync_all(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle genehmigten Abwesenheiten neu in den Firmenkalender synchronisieren."""
result = await caldav_service.resync_all_approved(current_user.company_id, db)
await db.commit()
return ResyncResult(**result)
# ── Persönlicher Kalender ──────────────────────────────────────────────────────
@router.get("/user/config", response_model=CaldavUserConfigOut | None)
async def get_user_config(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_user_config(current_user.id, db)
return CaldavUserConfigOut.model_validate(cfg) if cfg else None
@router.post("/user/config", response_model=CaldavUserConfigOut)
async def save_user_config(
data: CaldavUserConfigSave,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_user_config(current_user.id, db)
if cfg is None:
cfg = CaldavUserConfig(user_id=current_user.id, id=uuid.uuid4())
db.add(cfg)
cfg.enabled = data.enabled
cfg.principal_url = data.principal_url
cfg.calendar_url = data.calendar_url
cfg.username = data.username
cfg.calendar_display_name = data.calendar_display_name
cfg.verify_ssl = data.verify_ssl
if data.password:
cfg.password_encrypted = encrypt_password(data.password)
elif not cfg.password_encrypted:
raise HTTPException(status_code=400, detail="Passwort wird beim ersten Speichern benötigt.")
await db.commit()
await db.refresh(cfg)
return CaldavUserConfigOut.model_validate(cfg)
@router.post("/user/test")
async def test_user_config(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_user_config(current_user.id, db)
if not cfg:
raise HTTPException(status_code=404, detail="Keine persönliche CalDAV-Konfiguration vorhanden.")
result = await caldav_service.test_config(cfg)
if not result["ok"]:
raise HTTPException(status_code=502, detail=result["error"])
return {"message": "Verbindung erfolgreich.", "http_status": result.get("status")}
+91
View File
@@ -0,0 +1,91 @@
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models import Company
from app.models.department import Department
from app.models.user import User, UserRole
from app.schemas.company import (
CompanyOut,
CompanyUpdate,
DepartmentCreate,
DepartmentOut,
DepartmentUpdate,
)
router = APIRouter(prefix="/companies", tags=["Companies"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
@router.get("/me", response_model=CompanyOut)
async def get_my_company(current_user: CurrentUser, db: AsyncSession = Depends(get_db)):
company = await db.get(Company, current_user.company_id)
return CompanyOut.model_validate(company)
@router.patch("/me", response_model=CompanyOut)
async def update_my_company(
data: CompanyUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
for field, value in data.model_dump(exclude_none=True).items():
setattr(company, field, value)
return CompanyOut.model_validate(company)
# ── Departments ──────────────────────────────────────────────────────────────
@router.get("/me/departments", response_model=list[DepartmentOut])
async def list_departments(current_user: CurrentUser, db: AsyncSession = Depends(get_db)):
depts = await db.scalars(
select(Department).where(Department.company_id == current_user.company_id)
)
return [DepartmentOut.model_validate(d) for d in depts.all()]
@router.post("/me/departments", response_model=DepartmentOut, status_code=201)
async def create_department(
data: DepartmentCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
dept = Department(company_id=current_user.company_id, **data.model_dump())
db.add(dept)
await db.flush()
return DepartmentOut.model_validate(dept)
@router.patch("/me/departments/{dept_id}", response_model=DepartmentOut)
async def update_department(
dept_id: UUID,
data: DepartmentUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
dept = await db.get(Department, dept_id)
if not dept or dept.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Department not found")
for field, value in data.model_dump(exclude_none=True).items():
setattr(dept, field, value)
return DepartmentOut.model_validate(dept)
@router.delete("/me/departments/{dept_id}", status_code=204)
async def delete_department(
dept_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
dept = await db.get(Department, dept_id)
if not dept or dept.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Department not found")
await db.delete(dept)
+87
View File
@@ -0,0 +1,87 @@
"""Router: Kimai CSV Import (nur HR / Admin)."""
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db, require_role
from app.models.user import User, UserRole
from app.services.kimai_import_service import (
ImportPreviewEntry,
ImportResult,
preview_kimai_import,
run_kimai_import,
)
router = APIRouter(prefix="/import", tags=["import"])
_allowed_roles = [UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN]
class ImportPreviewResponse(BaseModel):
preview: list[ImportPreviewEntry]
time_count: int
absence_count: int
skip_count: int
errors: list[str]
class ImportRunResponse(BaseModel):
time_imported: int
absence_imported: int
skipped: int
errors: list[str]
@router.post("/kimai/preview", response_model=ImportPreviewResponse)
async def kimai_preview(
user_id: Annotated[str, Form()],
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_allowed_roles),
db: AsyncSession = Depends(get_db),
):
"""Vorschau des Kimai-Imports (keine DB-Änderungen)."""
try:
target_id = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige user_id")
content = await file.read()
result: ImportResult = await preview_kimai_import(content, target_id, db)
time_count = sum(1 for p in result.preview if p.kind == "time" and not p.skipped)
abs_count = sum(1 for p in result.preview if p.kind == "absence" and not p.skipped)
return ImportPreviewResponse(
preview=result.preview,
time_count=time_count,
absence_count=abs_count,
skip_count=result.skipped,
errors=result.errors,
)
@router.post("/kimai/run", response_model=ImportRunResponse)
async def kimai_run(
user_id: Annotated[str, Form()],
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_allowed_roles),
db: AsyncSession = Depends(get_db),
):
"""Führt den Kimai-Import durch (schreibt in DB)."""
try:
target_id = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige user_id")
content = await file.read()
result: ImportResult = await run_kimai_import(content, target_id, current_user.id, db)
return ImportRunResponse(
time_imported=result.time_imported,
absence_imported=result.absence_imported,
skipped=result.skipped,
errors=result.errors,
)
+102
View File
@@ -0,0 +1,102 @@
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.user import User, UserRole
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceCreated, KioskDeviceOut, KioskDeviceUpdate
from app.services.kiosk_service import kiosk_service
router = APIRouter(prefix="/kiosk", tags=["Kiosk"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Geräteverwaltung (COMPANY_ADMIN) ──────────────────────────────────────────
@router.get("/devices", response_model=list[KioskDeviceOut])
async def list_devices(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle registrierten Kiosk-Geräte der Firma auflisten."""
return await kiosk_service.list_devices(current_user.company_id, db)
@router.post("/devices", response_model=KioskDeviceCreated, status_code=201)
async def create_device(
data: KioskDeviceCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Neues Kiosk-Gerät registrieren. Token wird nur einmalig zurückgegeben."""
device, raw_token = await kiosk_service.create_device(current_user.company_id, data, db)
await db.commit()
await db.refresh(device)
return KioskDeviceCreated(
**KioskDeviceOut.model_validate(device).model_dump(),
token=raw_token,
)
@router.get("/devices/{device_id}", response_model=KioskDeviceOut)
async def get_device(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
return await kiosk_service.get_device(device_id, current_user.company_id, db)
@router.patch("/devices/{device_id}", response_model=KioskDeviceOut)
async def update_device(
device_id: UUID,
data: KioskDeviceUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
device = await kiosk_service.update_device(device_id, current_user.company_id, data, db)
await db.commit()
await db.refresh(device)
return KioskDeviceOut.model_validate(device)
@router.post("/devices/{device_id}/rotate-token", response_model=KioskDeviceCreated)
async def rotate_token(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Token rotieren das alte Token wird sofort ungültig."""
device, raw_token = await kiosk_service.rotate_token(device_id, current_user.company_id, db)
await db.commit()
await db.refresh(device)
return KioskDeviceCreated(
**KioskDeviceOut.model_validate(device).model_dump(),
token=raw_token,
)
@router.delete("/devices/{device_id}", status_code=204)
async def delete_device(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
await kiosk_service.delete_device(device_id, current_user.company_id, db)
await db.commit()
# ── Kiosk-Auth (Gerät authentifiziert sich per Token) ─────────────────────────
@router.get("/me", response_model=KioskDeviceOut)
async def kiosk_me(
x_kiosk_token: str = Header(..., alias="X-Kiosk-Token", min_length=32, max_length=128),
db: AsyncSession = Depends(get_db),
):
"""Kiosk-Gerät prüft seine eigene Identität / aktualisiert last_seen_at."""
device = await kiosk_service.authenticate_device(x_kiosk_token, db)
await db.commit()
return KioskDeviceOut.model_validate(device)
+139
View File
@@ -0,0 +1,139 @@
"""LDAP configuration and sync endpoints.
All endpoints require COMPANY_ADMIN or SUPER_ADMIN role.
"""
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.ldap_config import LdapConfig
from app.models.user import User, UserRole
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
from app.schemas.ldap import (
LdapConfigCreate,
LdapConfigOut,
LdapConfigUpdate,
LdapSyncRequest,
LdapSyncResult,
LdapTestResult,
LdapUserPreview,
)
from app.services.ldap_service import decrypt_password, encrypt_password, ldap_service
router = APIRouter(prefix="/ldap", tags=["LDAP"])
@router.get("/config", response_model=LdapConfigOut | None)
async def get_ldap_config(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
return await ldap_service.get_config(current_user.company_id, db)
@router.post("/config", response_model=LdapConfigOut)
async def create_ldap_config(
data: LdapConfigCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
existing = await ldap_service.get_config(current_user.company_id, db)
if existing:
# Update instead of duplicate
return await _apply_update(existing, data.model_dump(), db)
cfg = LdapConfig(
company_id=current_user.company_id,
enabled=data.enabled,
host=data.host,
port=data.port,
use_ssl=data.use_ssl,
use_tls=data.use_tls,
bind_dn=data.bind_dn,
bind_password_encrypted=encrypt_password(data.bind_password),
base_dn=data.base_dn,
user_search_filter=data.user_search_filter,
attr_email=data.attr_email,
attr_firstname=data.attr_firstname,
attr_lastname=data.attr_lastname,
attr_username=data.attr_username,
attr_department=data.attr_department,
)
db.add(cfg)
await db.commit()
await db.refresh(cfg)
return cfg
@router.patch("/config", response_model=LdapConfigOut)
async def update_ldap_config(
data: LdapConfigUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
return await _apply_update(cfg, data.model_dump(exclude_none=True), db)
@router.post("/test", response_model=LdapTestResult)
async def test_ldap_connection(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
result = ldap_service.test_connection(cfg)
return LdapTestResult(success=result.success, message=result.message)
@router.get("/preview", response_model=list[LdapUserPreview])
async def preview_ldap_users(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Returns first 50 users found in LDAP (for preview before sync)."""
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
raw_users = ldap_service.search_users(cfg)
previews = []
for u in raw_users[:50]:
previews.append(LdapUserPreview(
dn=u.get("dn", ""),
email=str(u.get(cfg.attr_email, "") or "").lower(),
first_name=str(u.get(cfg.attr_firstname, "") or ""),
last_name=str(u.get(cfg.attr_lastname, "") or ""),
department=str(u.get(cfg.attr_department, "") or "") if cfg.attr_department else None,
))
return previews
@router.post("/sync", response_model=LdapSyncResult)
async def sync_ldap_users(
data: LdapSyncRequest,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
result = await ldap_service.sync_users(cfg, db, default_role=data.default_role)
return LdapSyncResult(
created=result.created,
updated=result.updated,
deactivated=result.deactivated,
errors=result.errors,
)
# ── Helpers ───────────────────────────────────────────────────────────────────
async def _apply_update(cfg: LdapConfig, updates: dict, db: AsyncSession) -> LdapConfig:
for field, value in updates.items():
if field == "bind_password" and value:
cfg.bind_password_encrypted = encrypt_password(value)
elif hasattr(cfg, field):
setattr(cfg, field, value)
await db.commit()
await db.refresh(cfg)
return cfg
+206
View File
@@ -0,0 +1,206 @@
from datetime import date
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.project import Project
from app.models.time_entry import TimeEntry, EntryStatus
from app.models.user import User, UserRole
from app.schemas.project import (
ProjectCreate,
ProjectListResponse,
ProjectOut,
ProjectTimeReport,
ProjectUpdate,
)
router = APIRouter(prefix="/projects", tags=["Projekte"])
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
def _assert_company(project: Project, company_id: UUID) -> None:
if project.company_id != company_id:
raise HTTPException(404, "Projekt nicht gefunden")
@router.get("", response_model=ProjectListResponse)
async def list_projects(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
include_inactive: bool = Query(False),
):
stmt = select(Project).where(Project.company_id == current_user.company_id)
if not include_inactive:
stmt = stmt.where(Project.is_active == True)
stmt = stmt.order_by(Project.name)
result = await db.scalars(stmt)
items = list(result.all())
return ProjectListResponse(total=len(items), items=items)
@router.post("", response_model=ProjectOut, status_code=201)
async def create_project(
data: ProjectCreate,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
project = Project(
company_id=current_user.company_id,
name=data.name,
description=data.description,
color=data.color,
budget_hours=data.budget_hours,
)
db.add(project)
await db.commit()
await db.refresh(project)
return project
@router.get("/report/summary", response_model=list[ProjectTimeReport])
async def projects_summary(
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
date_from: date | None = Query(None),
date_to: date | None = Query(None),
):
projects = list((await db.scalars(
select(Project).where(Project.company_id == current_user.company_id, Project.is_active == True)
)).all())
result = []
for project in projects:
stmt = (
select(TimeEntry)
.join(TimeEntry.user)
.where(
TimeEntry.project_id == project.id,
User.company_id == current_user.company_id,
TimeEntry.status == EntryStatus.APPROVED,
TimeEntry.end_time.is_not(None),
)
)
if date_from:
stmt = stmt.where(TimeEntry.date >= date_from)
if date_to:
stmt = stmt.where(TimeEntry.date <= date_to)
entries = list((await db.scalars(stmt)).all())
total_minutes = sum(e.worked_minutes or 0 for e in entries)
total_hours = round(total_minutes / 60, 2)
budget_used_pct = None
if project.budget_hours and float(project.budget_hours) > 0:
budget_used_pct = round(total_hours / float(project.budget_hours) * 100, 1)
result.append(ProjectTimeReport(
project_id=project.id,
project_name=project.name,
project_color=project.color,
total_hours=total_hours,
entry_count=len(entries),
budget_hours=float(project.budget_hours) if project.budget_hours else None,
budget_used_pct=budget_used_pct,
))
result.sort(key=lambda x: x.total_hours, reverse=True)
return result
@router.get("/{project_id}", response_model=ProjectOut)
async def get_project(
project_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Projekt nicht gefunden")
_assert_company(project, current_user.company_id)
return project
@router.patch("/{project_id}", response_model=ProjectOut)
async def update_project(
project_id: UUID,
data: ProjectUpdate,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Projekt nicht gefunden")
_assert_company(project, current_user.company_id)
for field, value in data.model_dump(exclude_unset=True).items():
setattr(project, field, value)
await db.commit()
await db.refresh(project)
return project
@router.delete("/{project_id}", status_code=204)
async def delete_project(
project_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Projekt nicht gefunden")
_assert_company(project, current_user.company_id)
project.is_active = False
await db.commit()
@router.get("/{project_id}/report", response_model=ProjectTimeReport)
async def project_time_report(
project_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
date_from: date | None = Query(None),
date_to: date | None = Query(None),
):
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Projekt nicht gefunden")
_assert_company(project, current_user.company_id)
stmt = (
select(TimeEntry)
.join(TimeEntry.user)
.where(
TimeEntry.project_id == project_id,
User.company_id == current_user.company_id,
TimeEntry.status == EntryStatus.APPROVED,
TimeEntry.end_time.is_not(None),
)
)
if date_from:
stmt = stmt.where(TimeEntry.date >= date_from)
if date_to:
stmt = stmt.where(TimeEntry.date <= date_to)
entries = list((await db.scalars(stmt)).all())
total_minutes = sum(e.worked_minutes or 0 for e in entries)
total_hours = round(total_minutes / 60, 2)
budget_used_pct = None
if project.budget_hours and float(project.budget_hours) > 0:
budget_used_pct = round(total_hours / float(project.budget_hours) * 100, 1)
return ProjectTimeReport(
project_id=project.id,
project_name=project.name,
project_color=project.color,
total_hours=total_hours,
entry_count=len(entries),
budget_hours=float(project.budget_hours) if project.budget_hours else None,
budget_used_pct=budget_used_pct,
)
+210
View File
@@ -0,0 +1,210 @@
from datetime import date, timedelta
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.user import User, UserRole
from app.schemas.report import (
AbsenceReport,
CompanyDashboard,
EmployeeDashboard,
OvertimeReport,
OvertimeReportDetailed,
TeamDashboard,
TimeReport,
)
from app.services.report_service import report_service
router = APIRouter(tags=["Dashboard & Reports"])
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Dashboard ──────────────────────────────────────────────────────────────────
@router.get("/dashboard/me", response_model=EmployeeDashboard)
async def my_dashboard(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Mitarbeiter-Dashboard: eigene Stunden, Urlaub, Status heute."""
return await report_service.employee_dashboard(current_user, db)
@router.get("/dashboard/team", response_model=TeamDashboard)
async def team_dashboard(
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Team-Dashboard: Anwesenheit, ausstehende Genehmigungen."""
return await report_service.team_dashboard(current_user, db)
@router.get("/dashboard/company", response_model=CompanyDashboard)
async def company_dashboard(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Unternehmens-Dashboard: Gesamtübersicht, Überstunden, kommende Abwesenheiten."""
return await report_service.company_dashboard(current_user, db)
# ── Reports ────────────────────────────────────────────────────────────────────
def _default_date_from() -> date:
today = date.today()
return today.replace(day=1)
def _default_date_to() -> date:
return date.today()
@router.get("/reports/time", response_model=TimeReport)
async def time_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungsbericht (JSON). EMPLOYEE sieht nur eigene Einträge."""
return await report_service.time_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
@router.get("/reports/absences", response_model=AbsenceReport)
async def absence_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Abwesenheitsbericht (JSON). EMPLOYEE sieht nur eigene Abwesenheiten."""
return await report_service.absence_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
@router.get("/reports/overtime", response_model=OvertimeReport)
async def overtime_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Überstundenbericht (JSON). EMPLOYEE sieht nur eigene Daten."""
return await report_service.overtime_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
@router.get("/reports/overtime/detail", response_model=OvertimeReportDetailed)
async def overtime_report_detail(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Erweiterter Überstundenbericht mit Wochen- und Tagesaufschlüsselung."""
return await report_service.overtime_report_detail(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
# ── Export ─────────────────────────────────────────────────────────────────────
@router.get("/reports/time/export")
async def export_time_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
format: str = Query("csv", pattern="^(csv|xlsx|pdf)$"),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungsbericht als CSV, XLSX oder PDF herunterladen."""
report = await report_service.time_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
filename = f"zeiterfassung_{date_from}_{date_to}"
if format == "pdf":
content = report_service.time_report_to_pdf(report)
return Response(content=content, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}.pdf"})
if format == "xlsx":
content = report_service.to_xlsx(report_service._time_rows_to_dicts(report.rows), sheet_name="Zeiterfassung")
return Response(content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}.xlsx"})
content = report_service.to_csv(report_service._time_rows_to_dicts(report.rows))
return Response(content=content, media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
@router.get("/reports/absences/export")
async def export_absence_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
format: str = Query("csv", pattern="^(csv|xlsx|pdf)$"),
db: AsyncSession = Depends(get_db),
):
"""Abwesenheitsbericht als CSV, XLSX oder PDF herunterladen."""
report = await report_service.absence_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
filename = f"abwesenheiten_{date_from}_{date_to}"
if format == "pdf":
content = report_service.absence_report_to_pdf(report)
return Response(content=content, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}.pdf"})
if format == "xlsx":
content = report_service.to_xlsx(report_service._absence_rows_to_dicts(report.rows), sheet_name="Abwesenheiten")
return Response(content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}.xlsx"})
content = report_service.to_csv(report_service._absence_rows_to_dicts(report.rows))
return Response(content=content, media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
@router.get("/reports/overtime/export")
async def export_overtime_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
format: str = Query("csv", pattern="^(csv|xlsx|pdf)$"),
db: AsyncSession = Depends(get_db),
):
"""Überstundenbericht als CSV, XLSX oder PDF herunterladen (Detailansicht)."""
detail = await report_service.overtime_report_detail(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
filename = f"ueberstunden_{date_from}_{date_to}"
if format == "pdf":
content = report_service.overtime_detail_to_pdf(detail)
return Response(content=content, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}.pdf"})
if format == "xlsx":
content = report_service.to_xlsx(report_service._overtime_detail_to_dicts(detail), sheet_name="Überstunden")
return Response(content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}.xlsx"})
content = report_service.to_csv(report_service._overtime_detail_to_dicts(detail))
return Response(content=content, media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
+92
View File
@@ -0,0 +1,92 @@
"""
SMTP-Konfiguration pro Firma.
Nur COMPANY_ADMIN / SUPER_ADMIN darf lesen und schreiben.
"""
import base64
import hashlib
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.smtp_config import SmtpConfig
from app.models.user import User, UserRole
from app.schemas.smtp import SmtpConfigOut, SmtpConfigSave, SmtpTestRequest
from app.services.email_service import email_service
router = APIRouter(prefix="/smtp", tags=["SMTP-Konfiguration"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
def _encrypt(plain: str) -> str:
from app.core.config import settings
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
f = Fernet(base64.urlsafe_b64encode(key))
return f.encrypt(plain.encode()).decode()
async def _get_config(company_id: uuid.UUID, db: AsyncSession) -> SmtpConfig | None:
return await db.scalar(select(SmtpConfig).where(SmtpConfig.company_id == company_id))
@router.get("/config", response_model=SmtpConfigOut | None)
async def get_smtp_config(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await _get_config(current_user.company_id, db)
if not cfg:
return None
return SmtpConfigOut.model_validate(cfg)
@router.post("/config", response_model=SmtpConfigOut)
async def save_smtp_config(
data: SmtpConfigSave,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Erstellt oder überschreibt die SMTP-Konfiguration der Firma."""
cfg = await _get_config(current_user.company_id, db)
if cfg is None:
cfg = SmtpConfig(company_id=current_user.company_id, id=uuid.uuid4())
db.add(cfg)
cfg.host = data.host
cfg.port = data.port
cfg.use_tls = data.use_tls
cfg.use_starttls = data.use_starttls
cfg.username = data.username
cfg.from_email = data.from_email
cfg.from_name = data.from_name
cfg.is_enabled = data.is_enabled
if data.password is not None:
cfg.password_encrypted = _encrypt(data.password)
await db.commit()
await db.refresh(cfg)
return SmtpConfigOut.model_validate(cfg)
@router.post("/test")
async def test_smtp(
data: SmtpTestRequest,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Sendet eine Test-E-Mail mit der aktuellen Konfiguration."""
cfg = await _get_config(current_user.company_id, db)
if not cfg:
raise HTTPException(status_code=404, detail="Keine SMTP-Konfiguration vorhanden.")
try:
await email_service.send_test(cfg, data.to)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"SMTP-Fehler: {exc}")
return {"message": f"Test-E-Mail an {data.to} verschickt."}
+278
View File
@@ -0,0 +1,278 @@
from datetime import date
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.time_entry import EntryStatus
from app.models.user import User, UserRole
from app.schemas.time_entry import (
BalanceResponse,
ManualEntryCreate,
RejectRequest,
StampInRequest,
StampOutRequest,
TimeEntryListResponse,
TimeEntryOut,
TimeEntryUpdate,
TimeEntryWithWarnings,
WorkScheduleCreate,
WorkScheduleOut,
)
from app.services.time_service import time_service
router = APIRouter(prefix="/time", tags=["Zeiterfassung"])
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Stempeluhr ────────────────────────────────────────────────────────────────
@router.post("/stamp-in", response_model=TimeEntryWithWarnings, status_code=201)
async def stamp_in(
data: StampInRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Einstempeln startet einen neuen Zeiterfassungseintrag."""
entry, warnings = await time_service.stamp_in(current_user, data, db)
await db.commit()
await db.refresh(entry)
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.post("/stamp-out", response_model=TimeEntryWithWarnings)
async def stamp_out(
data: StampOutRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Ausstempeln schließt den offenen Zeiterfassungseintrag."""
entry, warnings = await time_service.stamp_out(current_user, data.note, db)
await db.commit()
await db.refresh(entry)
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.post("/break-start", response_model=TimeEntryOut)
async def break_start(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Pause beginnen."""
entry = await time_service.break_start(current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.post("/break-end", response_model=TimeEntryOut)
async def break_end(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Pause beenden."""
entry = await time_service.break_end(current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
# ── Heute ─────────────────────────────────────────────────────────────────────
@router.get("/today", response_model=list[TimeEntryOut])
async def get_today(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Alle Einträge des heutigen Tages für den aktuellen Benutzer."""
entries = await time_service.get_today(current_user, db)
return [TimeEntryOut.model_validate(e) for e in entries]
# ── Einträge ──────────────────────────────────────────────────────────────────
@router.get("/entries", response_model=TimeEntryListResponse)
async def list_entries(
current_user: CurrentUser,
user_id: UUID | None = Query(None),
date_from: date | None = Query(None),
date_to: date | None = Query(None),
status: EntryStatus | None = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
total, entries = await time_service.list_entries(
current_user.company_id, current_user, db,
user_id=user_id, date_from=date_from, date_to=date_to,
status=status, skip=skip, limit=limit,
)
return TimeEntryListResponse(total=total, items=[TimeEntryOut.model_validate(e) for e in entries])
@router.post("/entries", response_model=TimeEntryWithWarnings, status_code=201)
async def create_manual_entry(
data: ManualEntryCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Manuellen Zeiterfassungseintrag anlegen."""
entry, warnings = await time_service.create_manual(data, current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.patch("/entries/{entry_id}", response_model=TimeEntryOut)
async def update_entry(
entry_id: UUID,
data: TimeEntryUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag korrigieren."""
entry = await time_service.update_entry(entry_id, data, current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.post("/entries/{entry_id}/approve", response_model=TimeEntryOut)
async def approve_entry(
entry_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag genehmigen."""
entry = await time_service.approve_entry(entry_id, current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.post("/entries/{entry_id}/reject", response_model=TimeEntryOut)
async def reject_entry(
entry_id: UUID,
data: RejectRequest,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag ablehnen."""
entry = await time_service.reject_entry(entry_id, current_user, data.rejection_note, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.delete("/entries/{entry_id}", status_code=204)
async def delete_entry(
entry_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Zeiteintrag löschen. Mitarbeiter: nur eigene offene/ausstehende Einträge. Manager: alle außer genehmigten (außer HR/Admin)."""
await time_service.delete_entry(entry_id, current_user, db)
await db.commit()
# ── Überstundenkonto ──────────────────────────────────────────────────────────
@router.get("/balance/me", response_model=BalanceResponse)
async def get_own_balance(
current_user: CurrentUser,
period_start: date | None = Query(None),
period_end: date | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Eigenes Überstundenkonto."""
return await time_service.get_balance(current_user.id, current_user, db, period_start, period_end)
@router.get("/balance/{user_id}", response_model=BalanceResponse)
async def get_balance(
user_id: UUID,
current_user: CurrentUser,
period_start: date | None = Query(None),
period_end: date | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Überstundenkonto für einen Benutzer."""
if user_id != current_user.id:
if current_user.role == UserRole.EMPLOYEE:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
target_user = await db.get(User, user_id)
if target_user is None or target_user.company_id != current_user.company_id:
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
return await time_service.get_balance(user_id, current_user, db, period_start, period_end)
# ── Arbeitspläne ──────────────────────────────────────────────────────────────
@router.get("/schedules", response_model=list[WorkScheduleOut])
async def list_schedules(
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
schedules = await time_service.list_work_schedules(current_user.company_id, db)
return [WorkScheduleOut.model_validate(s) for s in schedules]
@router.post("/schedules", response_model=WorkScheduleOut, status_code=201)
async def create_schedule(
data: WorkScheduleCreate,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
schedule = await time_service.create_work_schedule(current_user.company_id, data, db)
await db.commit()
await db.refresh(schedule)
return WorkScheduleOut.model_validate(schedule)
@router.patch("/schedules/{schedule_id}", response_model=WorkScheduleOut)
async def update_schedule(
schedule_id: UUID,
data: WorkScheduleCreate,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select as sa_select
from app.models.work_schedule import WorkSchedule
schedule = await db.scalar(
sa_select(WorkSchedule).where(
WorkSchedule.id == schedule_id,
WorkSchedule.company_id == current_user.company_id,
)
)
if not schedule:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden")
for field, value in data.model_dump().items():
setattr(schedule, field, value)
await db.commit()
await db.refresh(schedule)
return WorkScheduleOut.model_validate(schedule)
@router.delete("/schedules/{schedule_id}", status_code=204)
async def delete_schedule(
schedule_id: UUID,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select as sa_select
from app.models.work_schedule import WorkSchedule
schedule = await db.scalar(
sa_select(WorkSchedule).where(
WorkSchedule.id == schedule_id,
WorkSchedule.company_id == current_user.company_id,
)
)
if not schedule:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden")
await db.delete(schedule)
await db.commit()
+185
View File
@@ -0,0 +1,185 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, File, Query, UploadFile
from fastapi.responses import PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.user import User, UserRole
from app.schemas.auth import MessageResponse
from app.schemas.user import (
InviteRequest,
NextPersonnelNumberResponse,
SetKioskPinRequest,
UserImportResult,
UserImportRowResult,
UserListResponse,
UserOut,
UserUpdate,
)
from app.services import user_import_service
from app.services.user_service import user_service
router = APIRouter(prefix="/users", tags=["Users"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_hr_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN, UserRole.HR, UserRole.MANAGER)
@router.get("/", response_model=UserListResponse)
async def list_users(
current_user: User = require_role(*_hr_roles),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
active_only: bool = Query(True),
search: str | None = Query(None, max_length=100),
db: AsyncSession = Depends(get_db),
):
total, users = await user_service.list_users(
current_user.company_id, db, skip, limit, active_only, search,
)
return UserListResponse(total=total, items=[UserOut.model_validate(u) for u in users])
@router.post("/invite", response_model=UserOut, status_code=201)
async def invite_user(
data: InviteRequest,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.invite(data, current_user.company_id, current_user, db)
return UserOut.model_validate(user)
@router.get("/me", response_model=UserOut)
async def get_me(current_user: CurrentUser):
return UserOut.model_validate(current_user)
@router.get("/next-personnel-number", response_model=NextPersonnelNumberResponse)
async def next_personnel_number(
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
"""Schlägt die nächste freie Personalnummer vor (ohne den Counter zu erhöhen)."""
suggestion = await user_service.next_personnel_suggestion(current_user.company_id, db)
return NextPersonnelNumberResponse(next=suggestion)
@router.get("/by-personnel/{number}", response_model=UserOut)
async def get_user_by_personnel(
number: str,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.get_by_personnel_number(number, current_user.company_id, db)
return UserOut.model_validate(user)
@router.get("/import-template.csv", response_class=PlainTextResponse)
async def import_template(
current_user: User = require_role(*_admin_roles),
):
csv_text = user_import_service.build_template_csv()
return PlainTextResponse(
content=csv_text,
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="user-import-template.csv"'},
)
@router.post("/import/preview", response_model=UserImportResult)
async def user_import_preview(
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
content = await file.read()
result = await user_import_service.preview_csv(content, current_user.company_id, current_user, db)
return _to_import_result_schema(result)
@router.post("/import/apply", response_model=UserImportResult)
async def user_import_apply(
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
content = await file.read()
result = await user_import_service.apply_csv(content, current_user.company_id, current_user, db)
return _to_import_result_schema(result)
def _to_import_result_schema(result) -> UserImportResult:
return UserImportResult(
total_rows=result.total_rows,
created=result.created,
reactivated=result.reactivated,
errors=result.errors,
items=[
UserImportRowResult(
row=i.row, email=i.email, personnel_number=i.personnel_number,
action=i.action, message=i.message,
)
for i in result.items
],
)
@router.get("/{user_id}", response_model=UserOut)
async def get_user(
user_id: UUID,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.get_by_id(user_id, current_user.company_id, db)
return UserOut.model_validate(user)
@router.patch("/{user_id}", response_model=UserOut)
async def update_user(
user_id: UUID,
data: UserUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.update(user_id, data, current_user, db)
return UserOut.model_validate(user)
@router.post("/{user_id}/deactivate", response_model=UserOut)
async def deactivate_user(
user_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.deactivate(user_id, current_user, db)
return UserOut.model_validate(user)
@router.post("/{user_id}/reactivate", response_model=UserOut)
async def reactivate_user(
user_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.reactivate(user_id, current_user, db)
return UserOut.model_validate(user)
@router.post("/{user_id}/kiosk-pin", response_model=MessageResponse)
async def set_kiosk_pin(
user_id: UUID,
data: SetKioskPinRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
# Users can set their own PIN; admins can set for any user in company
if user_id != current_user.id and not current_user.is_admin_or_above():
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Not allowed")
user = await user_service.get_by_id(user_id, current_user.company_id, db)
await user_service.set_kiosk_pin(user, data.pin, db)
return MessageResponse(message="Kiosk PIN updated")
View File
+210
View File
@@ -0,0 +1,210 @@
import uuid
from datetime import date, datetime
from pydantic import BaseModel, Field
from app.models.absence import AbsenceStatus
from app.models.absence_type import AbsenceCategory
# ── AbsenceType ───────────────────────────────────────────────────────────────
class AbsenceTypeOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
color: str
category: AbsenceCategory
requires_approval: bool
deducts_vacation: bool
affects_overtime_balance: bool
requires_certificate: bool
certificate_after_days: int
is_paid: bool
max_days_per_year: int | None
is_active: bool
class AbsenceTypeCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
color: str = Field("#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
category: AbsenceCategory = AbsenceCategory.OTHER
requires_approval: bool = True
deducts_vacation: bool = False
affects_overtime_balance: bool = False
requires_certificate: bool = False
certificate_after_days: int = Field(3, ge=0, le=365)
is_paid: bool = True
max_days_per_year: int | None = Field(None, ge=1)
class AbsenceTypeUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
category: AbsenceCategory | None = None
requires_approval: bool | None = None
deducts_vacation: bool | None = None
affects_overtime_balance: bool | None = None
requires_certificate: bool | None = None
certificate_after_days: int | None = Field(None, ge=0, le=365)
is_paid: bool | None = None
max_days_per_year: int | None = Field(None, ge=1)
is_active: bool | None = None
# ── Absence ───────────────────────────────────────────────────────────────────
class AbsenceOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
type_id: uuid.UUID
start_date: date
end_date: date
half_day_start: bool
half_day_end: bool
working_days: float
status: AbsenceStatus
approved_by: uuid.UUID | None
substitute_id: uuid.UUID | None
note: str | None
correction_note: str | None
rejection_reason: str | None
certificate_required_by: date | None = None
certificate_received_at: date | None = None
created_at: datetime
class AbsenceCreate(BaseModel):
type_id: uuid.UUID
start_date: date
end_date: date
half_day_start: bool = False
half_day_end: bool = False
substitute_id: uuid.UUID | None = None
note: str | None = None
for_user_id: uuid.UUID | None = None # HR/Admin: Abwesenheit für anderen Mitarbeiter anlegen
def model_post_init(self, __context) -> None:
if self.end_date < self.start_date:
raise ValueError("end_date must be >= start_date")
class AbsenceUpdate(BaseModel):
type_id: uuid.UUID | None = None
start_date: date | None = None
end_date: date | None = None
half_day_start: bool | None = None
half_day_end: bool | None = None
substitute_id: uuid.UUID | None = None
note: str | None = None
correction_note: str | None = None # Pflicht bei Änderung genehmigter Anträge (Mitarbeiter)
def model_post_init(self, __context) -> None:
if self.start_date and self.end_date and self.end_date < self.start_date:
raise ValueError("end_date must be >= start_date")
class AbsenceReject(BaseModel):
rejection_reason: str = Field(min_length=1)
class AbsenceListResponse(BaseModel):
total: int
items: list[AbsenceOut]
# ── Krankmeldung ──────────────────────────────────────────────────────────────
class QuickSickIn(BaseModel):
start_date: date
end_date: date
def model_post_init(self, __context) -> None:
if self.end_date < self.start_date:
raise ValueError("end_date must be >= start_date")
class CertificateMarkIn(BaseModel):
received_at: date | None = None # default = heute
class SickStatsOut(BaseModel):
user_id: uuid.UUID
user_name: str
personnel_number: str | None = None
episodes: int
total_days: float
bradford_factor: float
certificates_overdue: int
# ── VacationBalance ───────────────────────────────────────────────────────────
class VacationBalanceOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
year: int
entitled_days: int
special_days: int = 0
carried_over: int
used_days: int
total_days: int
remaining_days: int
pending_days: float = 0
# Resturlaub-Verfall (wird zur Laufzeit befüllt, nicht in DB)
carried_over_expires_at: date | None = None
carried_over_expired: bool = False
class VacationBalanceUpdate(BaseModel):
entitled_days: int | None = Field(None, ge=0, le=365)
special_days: int | None = Field(None, ge=0, le=365)
carried_over: int | None = Field(None, ge=0, le=365)
# ── PublicHoliday ─────────────────────────────────────────────────────────────
class PublicHolidayOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
country: str
state: str | None
date: date
name: str
year: int
class PublicHolidayCreate(BaseModel):
country: str = Field("DE", min_length=2, max_length=10)
state: str | None = Field(None, max_length=10)
date: date
name: str = Field(min_length=1, max_length=255)
# ── OvertimeBalance ───────────────────────────────────────────────────────────
class OvertimeBalanceOut(BaseModel):
total_hours: float
taken_hours: float
available_hours: float
# ── Calendar ──────────────────────────────────────────────────────────────────
class CalendarEntry(BaseModel):
user_id: uuid.UUID
user_name: str
absence_id: uuid.UUID
type_name: str
type_color: str
start_date: date
end_date: date
status: AbsenceStatus
working_days: float
+24
View File
@@ -0,0 +1,24 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class AuditLogEntry(BaseModel):
id: UUID
user_id: UUID | None
user_name: str | None
action: str
entity_type: str | None
entity_id: UUID | None
old_value: dict | None
new_value: dict | None
ip_address: str | None
created_at: datetime
model_config = {"from_attributes": True}
class AuditLogListResponse(BaseModel):
total: int
items: list[AuditLogEntry]
+81
View File
@@ -0,0 +1,81 @@
from pydantic import BaseModel, EmailStr, Field, model_validator
class RegisterRequest(BaseModel):
company_name: str = Field(min_length=2, max_length=255)
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(min_length=1, max_length=100)
email: EmailStr
password: str = Field(min_length=8, max_length=128)
@model_validator(mode="after")
def password_strength(self):
pw = self.password
if not any(c.isupper() for c in pw):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in pw):
raise ValueError("Password must contain at least one digit")
return self
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class PasswordResetRequest(BaseModel):
email: EmailStr
class PasswordResetConfirm(BaseModel):
token: str
new_password: str = Field(min_length=8, max_length=128)
@model_validator(mode="after")
def password_strength(self):
pw = self.new_password
if not any(c.isupper() for c in pw):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in pw):
raise ValueError("Password must contain at least one digit")
return self
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
totp_required: bool = False
partial_token: str | None = None
class TotpSetupResponse(BaseModel):
secret: str # base32 secret for manual entry
otpauth_uri: str # otpauth://totp/... für QR-Code
class TotpConfirmRequest(BaseModel):
code: str = Field(min_length=6, max_length=6)
class TotpLoginRequest(BaseModel):
partial_token: str
code: str = Field(min_length=6, max_length=6)
class TotpDisableRequest(BaseModel):
password: str
code: str = Field(min_length=6, max_length=6)
class AccessTokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class MessageResponse(BaseModel):
message: str
+29
View File
@@ -0,0 +1,29 @@
from datetime import date, datetime
from pydantic import BaseModel, Field
class BusylightTokenStatus(BaseModel):
configured: bool
created_at: datetime | None = None
class BusylightTokenRotated(BaseModel):
token: str = Field(..., description="Klartext-Token, wird nur einmal angezeigt.")
created_at: datetime
class BusylightAbsenceItem(BaseModel):
type: str
category: str
class BusylightUserItem(BaseModel):
personnel_number: str
full_name: str
absences_today: list[BusylightAbsenceItem]
class BusylightUsersResponse(BaseModel):
date: date
users: list[BusylightUserItem]
+59
View File
@@ -0,0 +1,59 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class CaldavCompanyConfigOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
enabled: bool
principal_url: str
calendar_url: str | None
username: str
calendar_display_name: str
verify_ssl: bool
name_template: str
last_error: str | None
updated_at: datetime
class CaldavCompanyConfigSave(BaseModel):
enabled: bool = False
principal_url: str = Field(min_length=1)
calendar_url: str | None = None
username: str = Field(min_length=1, max_length=255)
password: str | None = None # leer = unverändert
calendar_display_name: str = ""
verify_ssl: bool = True
name_template: str = "$vorname $nachname $typ"
class CaldavUserConfigOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
enabled: bool
principal_url: str
calendar_url: str | None
username: str
calendar_display_name: str
verify_ssl: bool
last_error: str | None
updated_at: datetime
class CaldavUserConfigSave(BaseModel):
enabled: bool = False
principal_url: str = Field(min_length=1)
calendar_url: str | None = None
username: str = Field(min_length=1, max_length=255)
password: str | None = None
calendar_display_name: str = ""
verify_ssl: bool = True
class ResyncResult(BaseModel):
synced: int
failed: int
total: int
+51
View File
@@ -0,0 +1,51 @@
import uuid
from typing import Literal
from pydantic import BaseModel, Field
PersonnelNumberModeT = Literal["manual", "auto"]
class CompanyOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
name: str
slug: str
plan: str
logo_url: str | None
country: str
state: str | None
settings: dict
personnel_number_required: bool = False
personnel_number_mode: PersonnelNumberModeT = "manual"
personnel_number_next: int = 1
class CompanyUpdate(BaseModel):
name: str | None = Field(None, min_length=2, max_length=255)
state: str | None = Field(None, max_length=10)
settings: dict | None = None
personnel_number_required: bool | None = None
personnel_number_mode: PersonnelNumberModeT | None = None
personnel_number_next: int | None = Field(None, ge=1)
class DepartmentOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
manager_id: uuid.UUID | None
class DepartmentCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
manager_id: uuid.UUID | None = None
class DepartmentUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
manager_id: uuid.UUID | None = None
+48
View File
@@ -0,0 +1,48 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class KioskDeviceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
location: str | None = Field(None, max_length=255)
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
return v.strip()
class KioskDeviceUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
location: str | None = Field(None, max_length=255)
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str | None) -> str | None:
if v is not None:
if not v.strip():
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
return v.strip()
return v
is_active: bool | None = None
class KioskDeviceOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
location: str | None
is_active: bool
last_seen_at: datetime | None
created_at: datetime
class KioskDeviceCreated(KioskDeviceOut):
"""Wird nur einmalig bei Erstellung zurückgegeben enthält den Klartext-Token."""
token: str
+93
View File
@@ -0,0 +1,93 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.user import UserRole
class LdapConfigCreate(BaseModel):
host: str = Field(min_length=1, max_length=255)
port: int = Field(default=389, ge=1, le=65535)
use_ssl: bool = False
use_tls: bool = False
tls_verify: bool = False
bind_dn: str = Field(min_length=1)
bind_password: str = Field(min_length=1)
base_dn: str = Field(min_length=1)
user_search_filter: str = Field(default="(objectClass=person)", min_length=1, max_length=512)
attr_email: str = Field(default="mail", min_length=1, max_length=100)
attr_firstname: str = Field(default="givenName", min_length=1, max_length=100)
attr_lastname: str = Field(default="sn", min_length=1, max_length=100)
attr_username: str = Field(default="sAMAccountName", min_length=1, max_length=100)
attr_department: str | None = Field(default=None, max_length=100)
attr_personnel_number: str | None = Field(default=None, max_length=100)
enabled: bool = False
class LdapConfigUpdate(BaseModel):
host: str | None = Field(default=None, min_length=1, max_length=255)
port: int | None = Field(default=None, ge=1, le=65535)
use_ssl: bool | None = None
use_tls: bool | None = None
tls_verify: bool | None = None
bind_dn: str | None = None
bind_password: str | None = None
base_dn: str | None = None
user_search_filter: str | None = Field(default=None, max_length=512)
attr_email: str | None = Field(default=None, max_length=100)
attr_firstname: str | None = Field(default=None, max_length=100)
attr_lastname: str | None = Field(default=None, max_length=100)
attr_username: str | None = Field(default=None, max_length=100)
attr_department: str | None = Field(default=None, max_length=100)
attr_personnel_number: str | None = Field(default=None, max_length=100)
enabled: bool | None = None
class LdapConfigOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
enabled: bool
host: str
port: int
use_ssl: bool
use_tls: bool
tls_verify: bool
bind_dn: str
base_dn: str
user_search_filter: str
attr_email: str
attr_firstname: str
attr_lastname: str
attr_username: str
attr_department: str | None
attr_personnel_number: str | None = None
last_sync_at: datetime | None
created_at: datetime
updated_at: datetime
class LdapTestResult(BaseModel):
success: bool
message: str
class LdapSyncRequest(BaseModel):
default_role: UserRole = UserRole.EMPLOYEE
class LdapSyncResult(BaseModel):
created: int
updated: int
deactivated: int
errors: list[str]
class LdapUserPreview(BaseModel):
dn: str
email: str
first_name: str
last_name: str
department: str | None = None
+48
View File
@@ -0,0 +1,48 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class ProjectOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
description: str | None
color: str
budget_hours: float | None
is_active: bool
created_at: datetime
updated_at: datetime
class ProjectCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str | None = None
color: str = Field("#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
budget_hours: float | None = Field(None, ge=0.1, le=99999)
class ProjectUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
budget_hours: float | None = Field(None, ge=0.1, le=99999)
is_active: bool | None = None
class ProjectListResponse(BaseModel):
total: int
items: list[ProjectOut]
class ProjectTimeReport(BaseModel):
project_id: uuid.UUID
project_name: str
project_color: str
total_hours: float
entry_count: int
budget_hours: float | None
budget_used_pct: float | None # None wenn kein Budget
+190
View File
@@ -0,0 +1,190 @@
import uuid
from datetime import date, time
from pydantic import BaseModel
# ── Employee Dashboard ─────────────────────────────────────────────────────────
class EmployeeDashboard(BaseModel):
today_open: bool
today_start: time | None
today_hours_so_far: float | None
week_hours_worked: float
week_hours_expected: float
week_overtime: float
vacation_remaining_days: int | None
vacation_used_days: int
vacation_entitled_days: int
pending_absences: int
overtime_balance_hours: float | None # verfügbares Überstundenguthaben
schedule_name: str | None # zugewiesener Arbeitsplan
# ── Team Dashboard ─────────────────────────────────────────────────────────────
class TeamMemberStatus(BaseModel):
user_id: uuid.UUID
user_name: str
department: str | None
status: str # "present" | "on_leave" | "absent"
absence_type: str | None
time_in: time | None
hours_today: float | None
class TeamDashboard(BaseModel):
present_count: int
on_leave_count: int
absent_count: int
pending_time_approvals: int
pending_absence_approvals: int
members: list[TeamMemberStatus]
# ── Company Dashboard ──────────────────────────────────────────────────────────
class UpcomingAbsence(BaseModel):
user_id: uuid.UUID
user_name: str
absence_type: str
start_date: date
end_date: date
working_days: float
class CompanyDashboard(BaseModel):
total_employees: int
active_today: int
attendance_rate: float
month_hours_worked: float
month_hours_expected: float
month_overtime: float
pending_time_approvals: int
pending_absence_approvals: int
upcoming_absences: list[UpcomingAbsence]
# ── Time Report ────────────────────────────────────────────────────────────────
class HoursBreakdown(BaseModel):
"""Stunden-Aufteilung nach §3b EStG für den Steuerberater."""
normal_hours: float # MoFr, 0620 Uhr, kein Feiertag
night_25_hours: float # 2024 + 0406 Uhr (25% Zuschlag)
night_40_hours: float # 0004 Uhr (40% Zuschlag)
sunday_hours: float # Sonntag 0024 Uhr (50% Zuschlag)
holiday_125_hours: float # gesetzl. Feiertag (125% Zuschlag)
holiday_150_hours: float # besondere Feiertage 25.12, 26.12, 01.05 etc. (150%)
holiday_name: str | None # Name des Feiertags falls zutreffend
class TimeReportRow(BaseModel):
date: date
user_id: uuid.UUID
user_name: str
personnel_number: str | None = None
department: str | None
start_time: time
end_time: time | None
break_minutes: int
worked_hours: float | None
status: str
source: str
note: str | None
breakdown: HoursBreakdown | None = None # None wenn kein Bundesland konfiguriert
class TimeReport(BaseModel):
date_from: date
date_to: date
total_rows: int
total_hours: float
rows: list[TimeReportRow]
# ── Absence Report ─────────────────────────────────────────────────────────────
class AbsenceReportRow(BaseModel):
user_id: uuid.UUID
user_name: str
personnel_number: str | None = None
department: str | None
absence_type: str
start_date: date
end_date: date
working_days: float
status: str
note: str | None
class AbsenceReport(BaseModel):
date_from: date
date_to: date
total_rows: int
total_days: float
rows: list[AbsenceReportRow]
# ── Overtime Report ────────────────────────────────────────────────────────────
class OvertimeReportRow(BaseModel):
user_id: uuid.UUID
user_name: str
personnel_number: str | None = None
department: str | None
hours_worked: float
hours_expected: float
overtime_hours: float
class OvertimeReport(BaseModel):
date_from: date
date_to: date
total_employees: int
total_overtime: float
rows: list[OvertimeReportRow]
# ── Overtime Detail Report (Option A: Inline-Expand) ──────────────────────────
class DayEntry(BaseModel):
"""Einzelner Zeiteintrag innerhalb eines Tages (mehrere möglich)."""
start_time: time
end_time: time
break_minutes: int
hours_worked: float
status: str
arbzg_warnings: list[str] = []
breakdown: HoursBreakdown | None = None
class OvertimeDay(BaseModel):
date: date
weekday: str # "Mo", "Di", …
hours_worked: float # Summe aller Einträge des Tages
hours_expected: float
overtime: float
entries: list[DayEntry] = [] # leer = kein Eintrag an dem Tag
class OvertimeWeek(BaseModel):
week_nr: int
week_start: date
week_end: date
hours_worked: float
hours_expected: float
overtime: float
days: list[OvertimeDay]
class OvertimeReportRowDetailed(OvertimeReportRow):
weeks: list[OvertimeWeek] = []
arbzg_violation_days: int = 0 # Tage > 10h
special_hours_total: HoursBreakdown | None = None
class OvertimeReportDetailed(BaseModel):
date_from: date
date_to: date
total_employees: int
total_overtime: float
rows: list[OvertimeReportRowDetailed]
+34
View File
@@ -0,0 +1,34 @@
import uuid
from pydantic import BaseModel, Field
class SmtpConfigOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
host: str
port: int
use_tls: bool
use_starttls: bool
username: str | None
from_email: str
from_name: str
is_enabled: bool
# password_encrypted wird nie zurückgegeben
class SmtpConfigSave(BaseModel):
host: str = Field(min_length=1, max_length=255)
port: int = Field(default=587, ge=1, le=65535)
use_tls: bool = False
use_starttls: bool = True
username: str | None = Field(None, max_length=255)
password: str | None = None # Klartext wird serverseitig verschlüsselt
from_email: str = Field(min_length=5, max_length=255)
from_name: str = Field(default="TimeMaster", min_length=1, max_length=255)
is_enabled: bool = True
class SmtpTestRequest(BaseModel):
to: str = Field(min_length=5, max_length=255)
+111
View File
@@ -0,0 +1,111 @@
import uuid
from datetime import date, datetime, time
from decimal import Decimal
from pydantic import BaseModel, Field
from app.models.time_entry import EntrySource, EntryStatus
class TimeEntryOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
date: date
start_time: time
end_time: time | None
break_minutes: int
break_start: time | None
project_id: uuid.UUID | None
note: str | None
status: EntryStatus
source: EntrySource
approved_by: uuid.UUID | None
correction_note: str | None
worked_hours: float | None
created_at: datetime
updated_at: datetime
class TimeEntryWithWarnings(BaseModel):
entry: TimeEntryOut
warnings: list[str] = []
class StampInRequest(BaseModel):
source: EntrySource = EntrySource.WEB
project_id: uuid.UUID | None = None
note: str | None = None
class StampOutRequest(BaseModel):
note: str | None = None
class ManualEntryCreate(BaseModel):
user_id: uuid.UUID | None = None # MANAGER/HR können für andere setzen
date: date
start_time: time
end_time: time
break_minutes: int = Field(0, ge=0, le=600)
project_id: uuid.UUID | None = None
note: str | None = None
source: EntrySource = EntrySource.MANUAL
class TimeEntryUpdate(BaseModel):
start_time: time | None = None
end_time: time | None = None
break_minutes: int | None = Field(None, ge=0, le=600)
project_id: uuid.UUID | None = None
note: str | None = None
correction_note: str | None = None
class RejectRequest(BaseModel):
rejection_note: str | None = None
class TimeEntryListResponse(BaseModel):
total: int
items: list[TimeEntryOut]
class WorkScheduleOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
mon_h: Decimal
tue_h: Decimal
wed_h: Decimal
thu_h: Decimal
fri_h: Decimal
sat_h: Decimal
sun_h: Decimal
valid_from: date
class WorkScheduleCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
mon_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
tue_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
wed_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
thu_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
fri_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
sat_h: Decimal = Field(Decimal("0.00"), ge=0, le=24)
sun_h: Decimal = Field(Decimal("0.00"), ge=0, le=24)
valid_from: date
class BalanceResponse(BaseModel):
user_id: uuid.UUID
period_start: date
period_end: date
total_hours_worked: float
expected_hours: float
overtime_hours: float
approved_entries: int
pending_entries: int
+112
View File
@@ -0,0 +1,112 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, model_validator
from app.models.user import AuthProvider, UserRole
PERSONNEL_NUMBER_PATTERN = r"^[0-9]+$"
class UserOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID | None
department_id: uuid.UUID | None
email: str
first_name: str
last_name: str
full_name: str
role: UserRole
auth_provider: AuthProvider
is_active: bool
last_login: datetime | None
created_at: datetime
kuerzel: str | None = None
personnel_number: str | None = None
can_manual_time_entry: bool = False
class UserUpdate(BaseModel):
first_name: str | None = Field(None, min_length=1, max_length=100)
last_name: str | None = Field(None, min_length=1, max_length=100)
department_id: uuid.UUID | None = None
role: UserRole | None = None
work_schedule_id: uuid.UUID | None = None
kuerzel: str | None = Field(None, max_length=20)
personnel_number: str | None = Field(None, max_length=50, pattern=PERSONNEL_NUMBER_PATTERN)
can_manual_time_entry: bool | None = None
is_active: bool | None = None
class InviteRequest(BaseModel):
email: EmailStr
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(min_length=1, max_length=100)
role: UserRole = UserRole.EMPLOYEE
department_id: uuid.UUID | None = None
personnel_number: str | None = Field(None, max_length=50, pattern=PERSONNEL_NUMBER_PATTERN)
# Wenn gesetzt → User wird sofort aktiv (kein Invite-E-Mail nötig)
initial_password: str | None = Field(None, min_length=8, max_length=128)
@model_validator(mode="after")
def password_strength(self):
pw = self.initial_password
if pw is None:
return self
if not any(c.isupper() for c in pw):
raise ValueError("initial_password must contain at least one uppercase letter")
if not any(c.isdigit() for c in pw):
raise ValueError("initial_password must contain at least one digit")
return self
class InviteAccept(BaseModel):
token: str
password: str = Field(min_length=8, max_length=128)
@model_validator(mode="after")
def password_strength(self):
pw = self.password
if not any(c.isupper() for c in pw):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in pw):
raise ValueError("Password must contain at least one digit")
return self
class UserListResponse(BaseModel):
total: int
items: list[UserOut]
class SetKioskPinRequest(BaseModel):
pin: str = Field(min_length=4, max_length=6, pattern=r"^\d+$")
class NextPersonnelNumberResponse(BaseModel):
next: str
class UserImportRowError(BaseModel):
row: int
email: str | None = None
message: str
class UserImportRowResult(BaseModel):
row: int
email: str
personnel_number: str | None = None
action: str # "created" | "reactivated" | "skipped" | "error"
message: str | None = None
class UserImportResult(BaseModel):
total_rows: int
created: int
reactivated: int
errors: int
items: list[UserImportRowResult]
View File
+772
View File
@@ -0,0 +1,772 @@
import asyncio
from datetime import date, timedelta
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from decimal import Decimal
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceCategory, AbsenceType
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.overtime_balance import OvertimeBalance
from app.models.public_holiday import PublicHoliday
from app.models.user import User, UserRole
from app.models.vacation_balance import VacationBalance
from app.models.work_schedule import WorkSchedule
from app.schemas.absence import AbsenceCreate, AbsenceReject, AbsenceTypeCreate, AbsenceTypeUpdate
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
class AbsenceService:
# ── AbsenceTypes ─────────────────────────────────────────────────────────
async def list_types(self, company_id: UUID, db: AsyncSession) -> list[AbsenceType]:
result = await db.scalars(
select(AbsenceType)
.where(AbsenceType.company_id == company_id, AbsenceType.is_active == True)
.order_by(AbsenceType.name)
)
return list(result.all())
async def create_type(
self, company_id: UUID, data: AbsenceTypeCreate, db: AsyncSession
) -> AbsenceType:
at = AbsenceType(company_id=company_id, **data.model_dump())
db.add(at)
await db.flush()
return at
async def update_type(
self, type_id: UUID, company_id: UUID, data: AbsenceTypeUpdate, db: AsyncSession
) -> AbsenceType:
at = await self._get_type_or_404(type_id, company_id, db)
for field, value in data.model_dump(exclude_none=True).items():
setattr(at, field, value)
return at
async def create_defaults_for_company(self, company_id: UUID, db: AsyncSession) -> None:
"""Standard-Abwesenheitstypen + Standard-Arbeitsplan für ein neues Unternehmen anlegen."""
defaults = [
{
"name": "Urlaub", "color": "#3B82F6", "category": AbsenceCategory.VACATION,
"requires_approval": True, "deducts_vacation": True, "is_paid": True,
},
{
"name": "Krankheit", "color": "#EF4444", "category": AbsenceCategory.SICK,
"requires_approval": False, "deducts_vacation": False, "is_paid": True,
"requires_certificate": True, "certificate_after_days": 3,
},
{
"name": "Freizeitausgleich", "color": "#F59E0B", "category": AbsenceCategory.OVERTIME_COMP,
"requires_approval": True, "deducts_vacation": False,
"affects_overtime_balance": True, "is_paid": True,
},
{
"name": "Weiterbildung", "color": "#8B5CF6", "category": AbsenceCategory.TRAINING,
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
"max_days_per_year": 5,
},
{
"name": "Dienstreise", "color": "#06B6D4", "category": AbsenceCategory.BUSINESS_TRIP,
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
},
{
"name": "Homeoffice", "color": "#10B981", "category": AbsenceCategory.OTHER,
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
},
{
"name": "Sonderurlaub", "color": "#84CC16", "category": AbsenceCategory.VACATION,
"requires_approval": True, "deducts_vacation": True, "is_paid": True,
},
]
for d in defaults:
db.add(AbsenceType(company_id=company_id, **d))
# Standard-Arbeitsplan: MoFr 8h
schedule = WorkSchedule(
company_id=company_id,
name="Vollzeit (40h)",
valid_from=date.today(),
)
db.add(schedule)
await db.flush()
# ── Absences ──────────────────────────────────────────────────────────────
async def list_absences(
self,
company_id: UUID,
current_user: User,
db: AsyncSession,
user_id: UUID | None = None,
type_id: UUID | None = None,
status: AbsenceStatus | None = None,
year: int | None = None,
) -> tuple[int, list[Absence]]:
q = (
select(Absence)
.join(User, Absence.user_id == User.id)
.where(User.company_id == company_id)
)
if current_user.role == UserRole.EMPLOYEE:
q = q.where(Absence.user_id == current_user.id)
elif user_id:
q = q.where(Absence.user_id == user_id)
if type_id:
q = q.where(Absence.type_id == type_id)
if status:
q = q.where(Absence.status == status)
if year:
q = q.where(Absence.start_date >= date(year, 1, 1), Absence.end_date <= date(year, 12, 31))
total = await db.scalar(select(func.count()).select_from(q.subquery())) or 0
result = await db.scalars(q.order_by(Absence.start_date.desc()))
return total, list(result.all())
async def get_by_id(self, absence_id: UUID, current_user: User, db: AsyncSession) -> Absence:
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
if absence.user_id != current_user.id and current_user.role == UserRole.EMPLOYEE:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
return absence
async def create_absence(
self,
data: AbsenceCreate,
current_user: User,
db: AsyncSession,
) -> tuple[Absence, list[str]]:
# AbsenceType validieren
absence_type = await self._get_type_or_404(data.type_id, current_user.company_id, db)
# Arbeitstage berechnen
holidays = await self._get_holiday_dates(
current_user.company_id, data.start_date.year, db
)
working_days = self._calc_working_days(
data.start_date, data.end_date, holidays,
data.half_day_start, data.half_day_end
)
if working_days <= 0:
raise HTTPException(status_code=400, detail="Keine Arbeitstage im ausgewählten Zeitraum.")
# Urlaubskonto prüfen wenn Urlaub abgezogen werden soll
warnings: list[str] = []
if absence_type.deducts_vacation:
balance = await self._get_or_create_balance(current_user.id, data.start_date.year, db)
if balance.remaining_days < working_days:
warnings.append(
f"Urlaubskonto reicht möglicherweise nicht aus: "
f"{balance.remaining_days} Tage verfügbar, {working_days} Tage beantragt."
)
# Überschneidung mit eigenen Abwesenheiten prüfen
overlap = await db.scalar(
select(Absence).where(
and_(
Absence.user_id == current_user.id,
Absence.status != AbsenceStatus.CANCELLED,
Absence.status != AbsenceStatus.REJECTED,
Absence.start_date <= data.end_date,
Absence.end_date >= data.start_date,
)
)
)
if overlap:
warnings.append("Überschneidung mit bestehender Abwesenheit im selben Zeitraum.")
status = AbsenceStatus.PENDING if absence_type.requires_approval else AbsenceStatus.APPROVED
approved_by = None if absence_type.requires_approval else current_user.id
# Krankmeldung: AU-Pflicht-Datum automatisch berechnen.
# Reihenfolge: AbsenceType.certificate_after_days (override) → Company default.
certificate_required_by: date | None = None
if absence_type.category == AbsenceCategory.SICK and absence_type.requires_certificate:
company = await db.get(Company, current_user.company_id)
company_default = company.sick_note_required_after_days if company else 3
threshold = absence_type.certificate_after_days or company_default
certificate_required_by = data.start_date + timedelta(days=threshold)
absence = Absence(
user_id=current_user.id,
type_id=data.type_id,
start_date=data.start_date,
end_date=data.end_date,
half_day_start=data.half_day_start,
half_day_end=data.half_day_end,
working_days=working_days,
status=status,
approved_by=approved_by,
substitute_id=data.substitute_id,
note=data.note,
certificate_required_by=certificate_required_by,
)
db.add(absence)
await db.flush()
# Bei automatischer Genehmigung Konto abziehen
if not absence_type.requires_approval and absence_type.deducts_vacation:
await self._deduct_vacation(current_user.id, data.start_date.year, int(working_days), db)
return absence, warnings
async def update_absence(
self, absence_id: UUID, data: "AbsenceUpdate", current_user: User, db: AsyncSession
) -> Absence:
from app.schemas.absence import AbsenceUpdate
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
# Mitarbeiter: nur eigene; Manager: gleiche Company
if current_user.role == UserRole.EMPLOYEE:
if absence.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
else:
owner = await db.get(User, absence.user_id)
if owner is None or owner.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
is_manager = current_user.role in _manager_roles
if absence.status not in (AbsenceStatus.PENDING, AbsenceStatus.APPROVED):
raise HTTPException(status_code=409, detail="Nur ausstehende oder genehmigte Anträge können bearbeitet werden.")
if absence.status == AbsenceStatus.APPROVED and not is_manager:
# Mitarbeiter stellt Änderungswunsch → Begründung Pflicht, Status zurück auf pending
if not data.correction_note or not data.correction_note.strip():
raise HTTPException(status_code=422, detail="Änderungsgrund ist bei genehmigten Anträgen Pflicht.")
if data.type_id is not None:
await self._get_type_or_404(data.type_id, current_user.company_id, db)
absence.type_id = data.type_id
if data.start_date is not None:
absence.start_date = data.start_date
if data.end_date is not None:
absence.end_date = data.end_date
if data.half_day_start is not None:
absence.half_day_start = data.half_day_start
if data.half_day_end is not None:
absence.half_day_end = data.half_day_end
if data.substitute_id is not None:
absence.substitute_id = data.substitute_id
if data.note is not None:
absence.note = data.note
if data.correction_note is not None:
absence.correction_note = data.correction_note.strip() or None
# Genehmigter Antrag: Mitarbeiter-Änderung → zurück auf pending (erneute Genehmigung)
was_approved = absence.status == AbsenceStatus.APPROVED
if was_approved and not is_manager:
absence.status = AbsenceStatus.PENDING
absence.approved_by = None
# Arbeitstage neu berechnen
holiday_dates = await self._get_holiday_dates(current_user.company_id, absence.start_date.year, db)
absence.working_days = Decimal(str(
self._calc_working_days(absence.start_date, absence.end_date,
holiday_dates, absence.half_day_start, absence.half_day_end)
))
# Audit-Log
action = "absence_change_request" if (was_approved and not is_manager) else "absence_updated"
db.add(AuditLog(
user_id=current_user.id,
action=action,
entity_type="absence",
entity_id=absence.id,
old_value={"status": "approved" if was_approved else "pending"},
new_value={
"status": absence.status.value,
"start_date": str(absence.start_date),
"end_date": str(absence.end_date),
"working_days": float(absence.working_days),
"correction_note": absence.correction_note,
},
))
return absence
async def cancel_absence(
self, absence_id: UUID, current_user: User, db: AsyncSession
) -> Absence:
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
if absence.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Nur eigene Anträge können storniert werden.")
if absence.status != AbsenceStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können gelöscht werden.")
absence.status = AbsenceStatus.CANCELLED
# Audit-Log (DSGVO)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_cancelled",
entity_type="absence",
entity_id=absence.id,
old_value={"status": "pending"},
new_value={
"status": "cancelled",
"cancelled_by": str(current_user.id),
"absence_user_id": str(absence.user_id),
"start_date": str(absence.start_date),
"end_date": str(absence.end_date),
"working_days": float(absence.working_days),
},
))
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_removed(absence, db))
return absence
async def approve_absence(
self, absence_id: UUID, current_user: User, db: AsyncSession
) -> Absence:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
requester = await db.get(User, absence.user_id)
if requester is None or requester.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
if absence.user_id == current_user.id:
raise HTTPException(
status_code=409,
detail="Eigene Abwesenheitsanträge können nicht selbst genehmigt werden."
)
if absence.status != AbsenceStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können genehmigt werden.")
absence.status = AbsenceStatus.APPROVED
absence.approved_by = current_user.id
absence_type = await db.get(AbsenceType, absence.type_id)
# Urlaubskonto abziehen wenn nötig
if absence_type and absence_type.deducts_vacation:
await self._deduct_vacation(absence.user_id, absence.start_date.year, int(absence.working_days), db)
# Überstundenkonto abziehen wenn Freizeitausgleich
if absence_type and absence_type.affects_overtime_balance:
await self._deduct_overtime(absence.user_id, absence.working_days, db)
# Audit-Log (DSGVO)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_approved",
entity_type="absence",
entity_id=absence.id,
old_value={"status": "pending"},
new_value={
"status": "approved",
"approved_by": str(current_user.id),
"approved_by_name": current_user.full_name,
"absence_user_id": str(absence.user_id),
"start_date": str(absence.start_date),
"end_date": str(absence.end_date),
"working_days": float(absence.working_days),
},
))
# CalDAV-Sync (fire & forget Fehler blockieren nicht die Genehmigung)
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_approved(absence, db))
return absence
async def reject_absence(
self, absence_id: UUID, data: AbsenceReject, current_user: User, db: AsyncSession
) -> Absence:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
requester = await db.get(User, absence.user_id)
if requester is None or requester.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
if absence.status != AbsenceStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können abgelehnt werden.")
absence.status = AbsenceStatus.REJECTED
absence.approved_by = current_user.id
absence.rejection_reason = data.rejection_reason
# Audit-Log (DSGVO)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_rejected",
entity_type="absence",
entity_id=absence.id,
old_value={"status": "pending"},
new_value={
"status": "rejected",
"rejection_reason": absence.rejection_reason,
"rejected_by": str(current_user.id),
"rejected_by_name": current_user.full_name,
"absence_user_id": str(absence.user_id),
"start_date": str(absence.start_date),
"end_date": str(absence.end_date),
"working_days": float(absence.working_days),
},
))
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_removed(absence, db))
return absence
async def get_calendar(
self,
company_id: UUID,
year: int,
month: int | None,
db: AsyncSession,
) -> list[dict]:
q = (
select(Absence, User, AbsenceType)
.join(User, Absence.user_id == User.id)
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
User.company_id == company_id,
Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.APPROVED]),
)
)
if month:
start = date(year, month, 1)
end = date(year, month, 28) + timedelta(days=4)
end = end.replace(day=1) - timedelta(days=1)
q = q.where(Absence.start_date <= end, Absence.end_date >= start)
else:
q = q.where(
Absence.start_date >= date(year, 1, 1),
Absence.end_date <= date(year, 12, 31),
)
result = await db.execute(q.order_by(Absence.start_date))
rows = result.all()
calendar = []
for absence, user, atype in rows:
calendar.append({
"user_id": user.id,
"user_name": user.full_name,
"absence_id": absence.id,
"type_name": atype.name,
"type_color": atype.color,
"start_date": absence.start_date,
"end_date": absence.end_date,
"status": absence.status,
"working_days": absence.working_days,
})
return calendar
# ── Urlaubskonto ──────────────────────────────────────────────────────────
async def get_balance(self, user_id: UUID, year: int, db: AsyncSession) -> VacationBalance:
return await self._get_or_create_balance(user_id, year, db)
async def get_pending_days(self, user_id: UUID, year: int, db: AsyncSession) -> float:
"""Summe der Arbeitstage aus ausstehenden Anträgen die Urlaub abziehen."""
q = (
select(func.sum(Absence.working_days))
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
Absence.user_id == user_id,
Absence.status == AbsenceStatus.PENDING,
AbsenceType.deducts_vacation.is_(True),
func.extract("year", Absence.start_date) == year,
)
)
result = await db.scalar(q)
return float(result or 0)
# ── Feiertage ─────────────────────────────────────────────────────────────
async def list_holidays(
self, year: int, country: str, state: str | None, db: AsyncSession
) -> list[PublicHoliday]:
q = select(PublicHoliday).where(
PublicHoliday.year == year, PublicHoliday.country == country
)
if state:
q = q.where(or_(PublicHoliday.state == state, PublicHoliday.state.is_(None)))
result = await db.scalars(q.order_by(PublicHoliday.date))
return list(result.all())
async def create_holiday(self, data, db: AsyncSession) -> PublicHoliday:
holiday = PublicHoliday(
country=data.country,
state=data.state,
date=data.date,
name=data.name,
year=data.date.year,
)
db.add(holiday)
await db.flush()
return holiday
# ── Helpers ───────────────────────────────────────────────────────────────
async def _get_type_or_404(
self, type_id: UUID, company_id: UUID, db: AsyncSession
) -> AbsenceType:
at = await db.get(AbsenceType, type_id)
if at is None or at.company_id != company_id:
raise HTTPException(status_code=404, detail="Abwesenheitstyp nicht gefunden.")
return at
async def _get_or_create_balance(
self, user_id: UUID, year: int, db: AsyncSession
) -> VacationBalance:
balance = await db.scalar(
select(VacationBalance).where(
VacationBalance.user_id == user_id, VacationBalance.year == year
)
)
if balance is None:
# Automatischer Übertrag: Resturlaub aus dem Vorjahr übernehmen
prev = await db.scalar(
select(VacationBalance).where(
VacationBalance.user_id == user_id, VacationBalance.year == year - 1
)
)
carried = max(0, prev.remaining_days) if prev else 0
entitled = prev.entitled_days if prev else 30
balance = VacationBalance(
user_id=user_id,
year=year,
entitled_days=entitled,
carried_over=carried,
)
db.add(balance)
await db.flush()
return balance
async def _deduct_vacation(
self, user_id: UUID, year: int, days: int, db: AsyncSession
) -> None:
balance = await self._get_or_create_balance(user_id, year, db)
balance.used_days += days
async def _deduct_overtime(
self, user_id: UUID, working_days: float, db: AsyncSession
) -> None:
"""Zieht working_days × tägliche Stunden vom Überstundenkonto ab."""
# Stunden/Tag aus Arbeitsplan ermitteln (Fallback: 8h)
user = await db.get(User, user_id)
daily_hours = Decimal("8.00")
if user and user.work_schedule_id:
schedule = await db.get(WorkSchedule, user.work_schedule_id)
if schedule:
working_days_in_week = sum(
1 for h in [schedule.mon_h, schedule.tue_h, schedule.wed_h,
schedule.thu_h, schedule.fri_h, schedule.sat_h, schedule.sun_h]
if h > 0
)
if working_days_in_week > 0:
daily_hours = schedule.weekly_hours / Decimal(working_days_in_week)
hours_to_deduct = Decimal(str(working_days)) * daily_hours
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
if ob is None:
# Erstelle Eintrag mit 0 Überstunden — taken_hours kann negativ werden
company_id = user.company_id if user else None
if not company_id:
return
ob = OvertimeBalance(user_id=user_id, company_id=company_id)
db.add(ob)
await db.flush()
ob.taken_hours += hours_to_deduct
async def _get_holiday_dates(
self, company_id: UUID, year: int, db: AsyncSession
) -> set[date]:
"""Feiertage für die Company-Country holen."""
from app.models.company import Company
from sqlalchemy import or_
company = await db.get(Company, company_id)
country = company.country if company else "DE"
state = company.state if company else None
q = select(PublicHoliday.date).where(
PublicHoliday.year == year,
PublicHoliday.country == country,
)
if state:
q = q.where(or_(PublicHoliday.state == state, PublicHoliday.state.is_(None)))
result = await db.scalars(q)
return set(result.all())
@staticmethod
def _calc_working_days(
start: date,
end: date,
holidays: set[date],
half_day_start: bool,
half_day_end: bool,
) -> float:
count = 0.0
current = start
while current <= end:
if current.weekday() < 5 and current not in holidays:
count += 1.0
current += timedelta(days=1)
# Halbtage abziehen
if half_day_start and start.weekday() < 5 and start not in holidays:
count -= 0.5
if half_day_end and end.weekday() < 5 and end not in holidays and end != start:
count -= 0.5
return max(0.0, count)
# ── Krankmeldung ──────────────────────────────────────────────────────────
async def quick_sick(
self,
start: date,
end: date,
current_user: User,
db: AsyncSession,
) -> tuple[Absence, list[str]]:
"""Sofort-Krankmeldung: nutzt den ersten aktiven SICK-Typ der Firma."""
sick_type = await db.scalar(
select(AbsenceType)
.where(
AbsenceType.company_id == current_user.company_id,
AbsenceType.category == AbsenceCategory.SICK,
AbsenceType.is_active == True,
)
.order_by(AbsenceType.name)
.limit(1)
)
if sick_type is None:
raise HTTPException(status_code=404, detail="Kein aktiver Krankheits-Typ konfiguriert.")
if end < start:
raise HTTPException(status_code=400, detail="Enddatum darf nicht vor dem Startdatum liegen.")
create_data = AbsenceCreate(
type_id=sick_type.id,
start_date=start,
end_date=end,
)
return await self.create_absence(create_data, current_user, db)
async def mark_certificate_received(
self,
absence_id: UUID,
received_at: date | None,
current_user: User,
db: AsyncSession,
) -> Absence:
"""HR/Admin: AU-Bescheinigung als eingegangen markieren."""
if current_user.role not in (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Nur HR/Admin darf den Attest-Eingang markieren.")
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
owner = await db.get(User, absence.user_id)
if owner is None or owner.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
absence_type = await db.get(AbsenceType, absence.type_id)
if absence_type is None or absence_type.category != AbsenceCategory.SICK:
raise HTTPException(status_code=409, detail="Nur für Krankmeldungen verfügbar.")
old_value = str(absence.certificate_received_at) if absence.certificate_received_at else None
absence.certificate_received_at = received_at or date.today()
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_certificate_received",
entity_type="absence",
entity_id=absence.id,
old_value={"certificate_received_at": old_value},
new_value={
"certificate_received_at": str(absence.certificate_received_at),
"absence_user_id": str(absence.user_id),
"marked_by": str(current_user.id),
"marked_by_name": current_user.full_name,
},
))
return absence
async def get_sick_stats(
self,
company_id: UUID,
current_user: User,
ref_date: date,
db: AsyncSession,
user_id: UUID | None = None,
) -> list[dict]:
"""Krankheitsstatistik für rolling 12 Monate ab ref_date.
Bradford-Faktor: S² × D mit S = Anzahl Episoden, D = Summe Kranktage.
"""
window_start = ref_date - timedelta(days=365)
q = (
select(Absence, User)
.join(User, Absence.user_id == User.id)
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
User.company_id == company_id,
AbsenceType.category == AbsenceCategory.SICK,
Absence.status == AbsenceStatus.APPROVED,
Absence.start_date <= ref_date,
Absence.end_date >= window_start,
)
.order_by(User.last_name, User.first_name, Absence.start_date)
)
if user_id:
q = q.where(Absence.user_id == user_id)
# MANAGER sieht nur sein Department
if current_user.role == UserRole.MANAGER and current_user.department_id:
q = q.where(User.department_id == current_user.department_id)
result = await db.execute(q)
rows = result.all()
by_user: dict[UUID, dict] = {}
for absence, user in rows:
entry = by_user.setdefault(user.id, {
"user_id": user.id,
"user_name": user.full_name,
"personnel_number": user.personnel_number,
"episodes": 0,
"total_days": 0.0,
"certificates_overdue": 0,
})
entry["episodes"] += 1
entry["total_days"] += float(absence.working_days or 0)
if (
absence.certificate_required_by
and absence.certificate_required_by < ref_date
and absence.certificate_received_at is None
):
entry["certificates_overdue"] += 1
for entry in by_user.values():
entry["bradford_factor"] = float(entry["episodes"]) ** 2 * entry["total_days"]
return list(by_user.values())
absence_service = AbsenceService()
+211
View File
@@ -0,0 +1,211 @@
import re
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import (
create_access_token,
create_refresh_token,
generate_invite_token,
generate_reset_token,
hash_password,
hash_token,
verify_password,
)
from app.models import Company, PasswordReset, Session, User, UserRole
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.services.email_service import email_service
def _slugify(name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return slug[:80]
class AuthService:
async def register(self, data: RegisterRequest, db: AsyncSession) -> TokenResponse:
existing = await db.scalar(select(User).where(User.email == data.email))
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
base_slug = _slugify(data.company_name)
slug = base_slug
counter = 1
while await db.scalar(select(Company).where(Company.slug == slug)):
slug = f"{base_slug}-{counter}"
counter += 1
company = Company(name=data.company_name, slug=slug)
db.add(company)
await db.flush()
user = User(
company_id=company.id,
email=data.email,
password_hash=hash_password(data.password),
first_name=data.first_name,
last_name=data.last_name,
role=UserRole.COMPANY_ADMIN,
)
db.add(user)
await db.flush()
from app.services.absence_service import absence_service
await absence_service.create_defaults_for_company(company.id, db)
tokens = await self._create_session(user, db)
await email_service.send_welcome(user, db)
return tokens
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse:
from app.models.user import AuthProvider
from app.services.ldap_service import ldap_service
user = await db.scalar(select(User).where(User.email == data.email))
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated")
if user.auth_provider == AuthProvider.LDAP:
ldap_cfg = await ldap_service.get_config(user.company_id, db)
if not ldap_cfg or not ldap_cfg.enabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="LDAP authentication not available",
)
if not ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
else:
if not user.password_hash or not verify_password(data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
# TOTP: wenn aktiviert → partial token zurückgeben statt vollem Login
if user.totp_enabled:
from app.core.security import create_partial_token
from app.schemas.auth import TokenResponse
partial = create_partial_token(str(user.id))
return TokenResponse(
access_token="",
refresh_token="",
totp_required=True,
partial_token=partial,
)
user.last_login = datetime.now(timezone.utc)
return await self._create_session(user, db, request=request)
async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse:
token_hash = hash_token(raw_token)
session = await db.scalar(
select(Session).where(Session.refresh_token_hash == token_hash)
)
if not session or session.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
user = await db.get(User, session.user_id)
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found or inactive")
await db.delete(session)
return await self._create_session(user, db)
async def logout(self, raw_token: str, db: AsyncSession) -> None:
token_hash = hash_token(raw_token)
session = await db.scalar(
select(Session).where(Session.refresh_token_hash == token_hash)
)
if session:
await db.delete(session)
async def request_password_reset(self, email: str, db: AsyncSession) -> str | None:
"""
Gibt None zurück (lokale User) oder 'ldap' wenn der User LDAP-Auth nutzt.
Die aufrufende Route entscheidet, was dem Client mitgeteilt wird.
"""
from app.models.user import AuthProvider
user = await db.scalar(select(User).where(User.email == email))
if not user:
return None # Security: kein Hinweis ob E-Mail existiert
if user.auth_provider == AuthProvider.LDAP:
return "ldap"
old_resets = await db.scalars(
select(PasswordReset).where(
PasswordReset.user_id == user.id,
PasswordReset.used_at.is_(None),
)
)
for r in old_resets:
await db.delete(r)
raw, hashed = generate_reset_token()
reset = PasswordReset(
user_id=user.id,
token_hash=hashed,
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
db.add(reset)
await email_service.send_password_reset(user, raw, db)
return None
async def confirm_password_reset(self, token: str, new_password: str, db: AsyncSession) -> None:
token_hash = hash_token(token)
reset = await db.scalar(
select(PasswordReset).where(
PasswordReset.token_hash == token_hash,
PasswordReset.used_at.is_(None),
)
)
if not reset or reset.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
user = await db.get(User, reset.user_id)
if not user:
raise HTTPException(status_code=400, detail="User not found")
user.password_hash = hash_password(new_password)
reset.used_at = datetime.now(timezone.utc)
sessions = await db.scalars(select(Session).where(Session.user_id == user.id))
for s in sessions:
await db.delete(s)
async def _create_session(
self,
user: User,
db: AsyncSession,
request: Request | None = None,
) -> TokenResponse:
raw_refresh, hashed_refresh = create_refresh_token()
session = Session(
user_id=user.id,
refresh_token_hash=hashed_refresh,
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days),
device=request.headers.get("User-Agent", "")[:255] if request else None,
ip=request.client.host if request and request.client else None,
)
db.add(session)
access_token = create_access_token(
str(user.id),
extra={"role": user.role, "company_id": str(user.company_id)},
)
return TokenResponse(access_token=access_token, refresh_token=raw_refresh)
auth_service = AuthService()
+307
View File
@@ -0,0 +1,307 @@
"""
CalDAV-Sync für Abwesenheiten.
Logik:
approve → VEVENT in persönlichem Kalender (CaldavUserConfig) +
VEVENT in Firmenkalender (CaldavCompanyConfig)
reject / cancel → DELETE aus beiden Kalendern
Verwendet httpx für die HTTP-Kommunikation und icalendar für iCal-Erzeugung.
Passwörter werden Fernet-verschlüsselt gespeichert (gleiche Methode wie SMTP/LDAP).
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import logging
import uuid
from datetime import date, timedelta, timezone, datetime
from typing import Union
import httpx
from icalendar import Calendar, Event
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.absence import Absence
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
log = logging.getLogger(__name__)
# ── Crypto (shared with SMTP/LDAP) ────────────────────────────────────────────
def _fernet():
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_password(plain: str) -> str:
return _fernet().encrypt(plain.encode()).decode()
def decrypt_password(encrypted: str) -> str:
return _fernet().decrypt(encrypted.encode()).decode()
# ── iCal builder ──────────────────────────────────────────────────────────────
def _build_ical(uid: str, summary: str, start: date, end: date, description: str = "") -> bytes:
"""Erzeugt einen VCALENDAR-Blob für ein ganztägiges Ereignis."""
cal = Calendar()
cal.add("prodid", "-//TimeMaster//DE")
cal.add("version", "2.0")
ev = Event()
ev.add("uid", f"{uid}@timemaster")
ev.add("dtstart", start)
ev.add("dtend", end + timedelta(days=1)) # DTEND ist exklusiv
ev.add("summary", summary)
if description:
ev.add("description", description)
ev.add("status", "CONFIRMED")
ev.add("transp", "TRANSPARENT") # zeigt keine Verfügbarkeit als blockiert
ev.add("dtstamp", datetime.now(timezone.utc))
cal.add_component(ev)
return cal.to_ical()
# ── Kalender-Titel formatieren ────────────────────────────────────────────────
def _format_summary(user: "User", absence_type: str, name_template: str) -> str:
"""
Ersetzt Platzhalter im name_template:
$vorname → vollständiger Vorname
$nachname → vollständiger Nachname
$vorname_short → erster Buchstabe Vorname
$nachname_middle → erste 3 Buchstaben Nachname
$kuerzel → manuell gesetztes Kürzel (Fallback: Initialen)
$personalnummer → Personalnummer (leer wenn nicht gesetzt)
$typ → Abwesenheitsart
"""
kuerzel = user.kuerzel if user.kuerzel else (user.first_name[:1] + user.last_name[:1]).upper()
result = name_template
result = result.replace("$vorname_short", user.first_name[:1])
result = result.replace("$nachname_middle", user.last_name[:3])
result = result.replace("$vorname", user.first_name)
result = result.replace("$nachname", user.last_name)
result = result.replace("$kuerzel", kuerzel)
result = result.replace("$personalnummer", user.personnel_number or "")
result = result.replace("$typ", absence_type)
return result
# ── HTTP helpers ───────────────────────────────────────────────────────────────
def _event_url(calendar_url: str, uid: str) -> str:
return calendar_url.rstrip("/") + f"/{uid}.ics"
async def _http_put(
calendar_url: str, username: str, password: str, uid: str,
ical: bytes, verify_ssl: bool,
) -> str:
"""PUT event. Returns ETag (empty string if server doesn't send one)."""
url = _event_url(calendar_url, uid)
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
resp = await client.put(
url, content=ical,
headers={"Content-Type": "text/calendar; charset=utf-8"},
auth=(username, password),
)
resp.raise_for_status()
return resp.headers.get("ETag", "")
async def _http_delete(
calendar_url: str, username: str, password: str, uid: str, verify_ssl: bool,
) -> None:
url = _event_url(calendar_url, uid)
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
resp = await client.delete(url, auth=(username, password))
if resp.status_code not in (200, 204, 404):
resp.raise_for_status()
async def _http_propfind(
calendar_url: str, username: str, password: str, verify_ssl: bool,
) -> int:
"""Einfacher Verbindungstest via PROPFIND Depth:0. Gibt HTTP-Status zurück."""
body = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
async with httpx.AsyncClient(verify=verify_ssl, timeout=10) as client:
resp = await client.request(
"PROPFIND", calendar_url.rstrip("/") + "/",
content=body,
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"},
auth=(username, password),
)
return resp.status_code
# ── Service ───────────────────────────────────────────────────────────────────
class CalDavService:
# ── Config laden ──────────────────────────────────────────────────────────
async def get_company_config(
self, company_id: uuid.UUID, db: AsyncSession
) -> CaldavCompanyConfig | None:
return await db.scalar(
select(CaldavCompanyConfig).where(CaldavCompanyConfig.company_id == company_id)
)
async def get_user_config(
self, user_id: uuid.UUID, db: AsyncSession
) -> CaldavUserConfig | None:
return await db.scalar(
select(CaldavUserConfig).where(CaldavUserConfig.user_id == user_id)
)
# ── Sync-Operationen ──────────────────────────────────────────────────────
async def sync_approved(self, absence: Absence, db: AsyncSession) -> None:
"""Wird nach Genehmigung gerufen: Event in beide Kalender einpflegen."""
# User und AbsenceType laden (für VEVENT-Titel)
from app.models.user import User
from app.models.absence_type import AbsenceType
user = await db.get(User, absence.user_id)
atype = await db.get(AbsenceType, absence.type_id)
if not user or not atype:
return
if absence.caldav_uid is None:
absence.caldav_uid = str(uuid.uuid4())
description = absence.note or ""
# Persönlicher Kalender: nur Abwesenheitsart, kein Name
personal_ical = _build_ical(
absence.caldav_uid, atype.name,
absence.start_date, absence.end_date, description,
)
user_cfg = await self.get_user_config(user.id, db)
if user_cfg and user_cfg.enabled and user_cfg.calendar_url:
try:
pw = decrypt_password(user_cfg.password_encrypted)
etag = await _http_put(
user_cfg.calendar_url, user_cfg.username, pw,
absence.caldav_uid, personal_ical, user_cfg.verify_ssl,
)
absence.caldav_user_etag = etag
except Exception as exc:
absence.caldav_last_error = f"User-Kalender: {exc}"
log.warning("CalDAV user sync failed for absence %s: %s", absence.id, exc)
# Firmenkalender: Titelformat per Konfiguration
company_cfg = await self.get_company_config(user.company_id, db)
if company_cfg and company_cfg.enabled and company_cfg.calendar_url:
company_summary = _format_summary(user, atype.name, company_cfg.name_template)
company_ical = _build_ical(
absence.caldav_uid, company_summary,
absence.start_date, absence.end_date, description,
)
try:
pw = decrypt_password(company_cfg.password_encrypted)
etag = await _http_put(
company_cfg.calendar_url, company_cfg.username, pw,
absence.caldav_uid, company_ical, company_cfg.verify_ssl,
)
absence.caldav_company_etag = etag
except Exception as exc:
err = f"Firmen-Kalender: {exc}"
absence.caldav_last_error = (
(absence.caldav_last_error + " | " + err) if absence.caldav_last_error else err
)
log.warning("CalDAV company sync failed for absence %s: %s", absence.id, exc)
absence.caldav_synced_at = datetime.now(timezone.utc)
async def sync_removed(self, absence: Absence, db: AsyncSession) -> None:
"""Wird nach Ablehnung/Stornierung gerufen: Event aus Kalendern löschen."""
if not absence.caldav_uid:
return
from app.models.user import User
user = await db.get(User, absence.user_id)
if not user:
return
# Persönlicher Kalender
user_cfg = await self.get_user_config(user.id, db)
if user_cfg and user_cfg.enabled and user_cfg.calendar_url:
try:
pw = decrypt_password(user_cfg.password_encrypted)
await _http_delete(
user_cfg.calendar_url, user_cfg.username, pw,
absence.caldav_uid, user_cfg.verify_ssl,
)
absence.caldav_user_etag = None
except Exception as exc:
log.warning("CalDAV user delete failed for absence %s: %s", absence.id, exc)
# Firmenkalender
company_cfg = await self.get_company_config(user.company_id, db)
if company_cfg and company_cfg.enabled and company_cfg.calendar_url:
try:
pw = decrypt_password(company_cfg.password_encrypted)
await _http_delete(
company_cfg.calendar_url, company_cfg.username, pw,
absence.caldav_uid, company_cfg.verify_ssl,
)
absence.caldav_company_etag = None
except Exception as exc:
log.warning("CalDAV company delete failed for absence %s: %s", absence.id, exc)
absence.caldav_last_error = None
absence.caldav_synced_at = datetime.now(timezone.utc)
async def resync_all_approved(self, company_id: uuid.UUID, db: AsyncSession) -> dict:
"""Alle genehmigten Abwesenheiten der Firma neu synchronisieren."""
from app.models.absence import AbsenceStatus
from app.models.user import User
result = await db.scalars(
select(Absence)
.join(Absence.user)
.where(
Absence.status == AbsenceStatus.APPROVED,
User.company_id == company_id,
)
)
absences = list(result.all())
ok = 0
failed = 0
for absence in absences:
try:
await self.sync_approved(absence, db)
ok += 1
except Exception as exc:
failed += 1
log.error("Resync failed for absence %s: %s", absence.id, exc)
return {"synced": ok, "failed": failed, "total": len(absences)}
# ── Verbindungstest ───────────────────────────────────────────────────────
async def test_config(
self, cfg: Union[CaldavCompanyConfig, CaldavUserConfig]
) -> dict:
if not cfg.calendar_url:
return {"ok": False, "error": "Keine Kalender-URL konfiguriert."}
try:
pw = decrypt_password(cfg.password_encrypted)
status = await _http_propfind(cfg.calendar_url, cfg.username, pw, cfg.verify_ssl)
if status in (200, 207):
return {"ok": True, "status": status}
return {"ok": False, "error": f"Server antwortete mit HTTP {status}"}
except Exception as exc:
return {"ok": False, "error": str(exc)}
caldav_service = CalDavService()
+154
View File
@@ -0,0 +1,154 @@
"""
E-Mail-Versand via SMTP (smtplib + asyncio.to_thread).
Konfiguration pro Firma in smtp_configs. Kein externer Mail-Dienst nötig.
"""
import asyncio
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.smtp_config import SmtpConfig
if TYPE_CHECKING:
from app.models.user import User
def _html_wrapper(title: str, body: str) -> str:
return f"""
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><title>{title}</title>
<style>
body {{ font-family: system-ui, sans-serif; background: #f4f4f4; margin: 0; padding: 40px 20px; }}
.card {{ background: white; border-radius: 12px; padding: 40px; max-width: 520px; margin: 0 auto; }}
.logo {{ font-size: 22px; font-weight: 600; color: #2563EB; margin-bottom: 32px; }}
h1 {{ font-size: 20px; font-weight: 600; color: #111; margin: 0 0 12px; }}
p {{ font-size: 15px; color: #555; line-height: 1.6; margin: 0 0 16px; }}
.btn {{ display: inline-block; padding: 12px 28px; background: #2563EB; color: white !important;
text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 15px; margin: 8px 0 24px; }}
.footer {{ font-size: 12px; color: #999; margin-top: 32px; padding-top: 16px; border-top: 1px solid #eee; }}
</style></head>
<body><div class="card">
<div class="logo">⏱ {settings.app_name}</div>
{body}
<div class="footer">Diese E-Mail wurde automatisch von {settings.app_name} gesendet.</div>
</div></body></html>
"""
def _decrypt_password(encrypted: str) -> str:
"""Fernet-Entschlüsselung (gleiche Implementierung wie ldap_service)."""
import base64
import hashlib
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
f = Fernet(base64.urlsafe_b64encode(key))
return f.decrypt(encrypted.encode()).decode()
def _smtp_send_sync(cfg: SmtpConfig, to: str, subject: str, html: str) -> None:
"""Synchroner SMTP-Versand wird via asyncio.to_thread() aufgerufen."""
password = _decrypt_password(cfg.password_encrypted) if cfg.password_encrypted else None
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{cfg.from_name} <{cfg.from_email}>"
msg["To"] = to
msg.attach(MIMEText(html, "html", "utf-8"))
if cfg.use_tls:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(cfg.host, cfg.port, context=context) as smtp:
if cfg.username and password:
smtp.login(cfg.username, password)
smtp.send_message(msg)
else:
with smtplib.SMTP(cfg.host, cfg.port) as smtp:
if cfg.use_starttls:
smtp.starttls(context=ssl.create_default_context())
if cfg.username and password:
smtp.login(cfg.username, password)
smtp.send_message(msg)
class EmailService:
async def _load_smtp(self, company_id: UUID, db: AsyncSession) -> SmtpConfig | None:
return await db.scalar(
select(SmtpConfig).where(
SmtpConfig.company_id == company_id,
SmtpConfig.is_enabled == True,
)
)
async def _send(self, to: str, subject: str, html: str, cfg: SmtpConfig | None) -> None:
if not cfg:
print(f"\n{'='*60}")
print(f"EMAIL (kein SMTP konfiguriert nicht versendet)")
print(f" To: {to}")
print(f" Subject: {subject}")
print(f"{'='*60}\n")
return
try:
await asyncio.to_thread(_smtp_send_sync, cfg, to, subject, html)
except Exception as exc:
print(f"SMTP Fehler: {exc}")
async def send_welcome(self, user: "User", db: AsyncSession) -> None:
cfg = await self._load_smtp(user.company_id, db)
body = f"""
<h1>Willkommen bei {settings.app_name}, {user.first_name}!</h1>
<p>Dein Firmen-Account wurde erfolgreich erstellt. Du kannst dich ab sofort anmelden.</p>
<a href="{settings.frontend_url}/login" class="btn">Zum Login</a>
"""
await self._send(user.email, f"Willkommen bei {settings.app_name}", _html_wrapper("Willkommen", body), cfg)
async def send_invite(self, user: "User", invited_by: "User", raw_token: str, db: AsyncSession) -> None:
cfg = await self._load_smtp(user.company_id, db)
invite_url = f"{settings.frontend_url}/invite/accept?token={raw_token}"
body = f"""
<h1>Du wurdest eingeladen!</h1>
<p><strong>{invited_by.full_name}</strong> hat dich zu <strong>{settings.app_name}</strong> eingeladen.</p>
<p>Klicke auf den Button, um dein Konto zu aktivieren und ein Passwort festzulegen.<br>
Der Link ist <strong>7 Tage</strong> gültig.</p>
<a href="{invite_url}" class="btn">Einladung annehmen</a>
<p style="font-size:13px;color:#999;">Oder kopiere diesen Link: {invite_url}</p>
"""
await self._send(
user.email,
f"{invited_by.full_name} hat dich zu {settings.app_name} eingeladen",
_html_wrapper("Einladung", body),
cfg,
)
async def send_password_reset(self, user: "User", raw_token: str, db: AsyncSession) -> None:
cfg = await self._load_smtp(user.company_id, db)
reset_url = f"{settings.frontend_url}/auth/reset-password?token={raw_token}"
body = f"""
<h1>Passwort zurücksetzen</h1>
<p>Hallo {user.first_name},</p>
<p>du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.<br>
Klicke auf den Button der Link ist <strong>1 Stunde</strong> gültig.</p>
<a href="{reset_url}" class="btn">Passwort zurücksetzen</a>
<p>Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>
"""
await self._send(user.email, "Passwort zurücksetzen", _html_wrapper("Passwort zurücksetzen", body), cfg)
async def send_test(self, cfg: SmtpConfig, to: str) -> None:
"""Test-E-Mail direkt mit übergebenem Konfigurationsobjekt."""
body = f"""
<h1>SMTP-Test erfolgreich!</h1>
<p>Deine SMTP-Konfiguration für <strong>{settings.app_name}</strong> funktioniert korrekt.</p>
<p>Server: {cfg.host}:{cfg.port}</p>
"""
await self._send(to, f"{settings.app_name} SMTP-Test", _html_wrapper("SMTP-Test", body), cfg)
email_service = EmailService()
+174
View File
@@ -0,0 +1,174 @@
"""Feiertags-Service: berechnet und befüllt deutsche Feiertage per Bundesland."""
from __future__ import annotations
import uuid
from datetime import date, timedelta
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.public_holiday import PublicHoliday
# ---------------------------------------------------------------------------
# Osterformel (Gauß/Anonymous)
# ---------------------------------------------------------------------------
def _easter(year: int) -> date:
"""Berechnet Ostersonntag nach der anonymen Gregorianischen Osterformel."""
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
# ---------------------------------------------------------------------------
# Feiertage berechnen
# ---------------------------------------------------------------------------
def _holidays_for_state(year: int, state: str) -> list[tuple[date, str, bool]]:
"""
Gibt Liste von (date, name, is_high_rate) für das Bundesland zurück.
is_high_rate = True → 150% Zuschlag nach §3b EStG
"""
easter = _easter(year)
holidays: list[tuple[date, str, bool]] = []
def add(d: date, name: str, high: bool = False) -> None:
holidays.append((d, name, high))
# ── Bundesweit gültige Feiertage ────────────────────────────────────────
add(date(year, 1, 1), "Neujahr")
add(easter - timedelta(days=2), "Karfreitag")
add(easter, "Ostersonntag")
add(easter + timedelta(days=1), "Ostermontag")
add(date(year, 5, 1), "Tag der Arbeit", high=True)
add(easter + timedelta(days=39), "Christi Himmelfahrt")
add(easter + timedelta(days=49), "Pfingstsonntag")
add(easter + timedelta(days=50), "Pfingstmontag")
add(date(year, 10, 3), "Tag der Deutschen Einheit")
add(date(year, 12, 25), "1. Weihnachtstag", high=True)
add(date(year, 12, 26), "2. Weihnachtstag", high=True)
# ── Heilige Drei Könige: BY, BW, ST ────────────────────────────────────
if state in ("BY", "BW", "ST"):
add(date(year, 1, 6), "Heilige Drei Könige")
# ── Frauentag: BE (ab 2019) ─────────────────────────────────────────────
if state == "BE" and year >= 2019:
add(date(year, 3, 8), "Internationaler Frauentag")
# ── Gründonnerstag: BY (nur Schulen) nicht als gesetzlicher Feiertag
# ── Fronleichnam: BW, BY, HE, NW, RP, SL (+ Teile ST, TH) ─────────────
if state in ("BW", "BY", "HE", "NW", "RP", "SL"):
add(easter + timedelta(days=60), "Fronleichnam")
# ── Mariä Himmelfahrt: BY (kath. Gemeinden), SL ─────────────────────────
if state in ("BY", "SL"):
add(date(year, 8, 15), "Mariä Himmelfahrt")
# ── Weltkindertag: TH (ab 2019) ─────────────────────────────────────────
if state == "TH" and year >= 2019:
add(date(year, 9, 20), "Weltkindertag")
# ── Reformationstag: BB, HB, HH, MV, NI, SH, SN, ST, TH ───────────────
if state in ("BB", "HB", "HH", "MV", "NI", "SH", "SN", "ST", "TH"):
add(date(year, 10, 31), "Reformationstag")
# ── Allerheiligen: BW, BY, NW, RP, SL ───────────────────────────────────
if state in ("BW", "BY", "NW", "RP", "SL"):
add(date(year, 11, 1), "Allerheiligen")
# ── Buß- und Bettag: SN ──────────────────────────────────────────────────
if state == "SN":
# Mittwoch vor dem 23. November
nov23 = date(year, 11, 23)
bbt = nov23 - timedelta(days=(nov23.weekday() + 3) % 7 + 1)
if bbt.weekday() != 2:
# Fallback: letzter Mittwoch vor 23.11.
bbt = nov23 - timedelta(days=(nov23.weekday() - 2) % 7 + 7)
add(bbt, "Buß- und Bettag")
return holidays
# ---------------------------------------------------------------------------
# DB-Funktionen
# ---------------------------------------------------------------------------
async def ensure_holidays_for_year(year: int, state: str, db: AsyncSession) -> int:
"""
Stellt sicher dass Feiertage für (year, state) in der DB vorhanden sind.
Löscht ggf. alte Einträge und schreibt neu.
Gibt Anzahl geschriebener Einträge zurück.
"""
# Löschen falls schon vorhanden (refresh)
await db.execute(
delete(PublicHoliday).where(
PublicHoliday.country == "DE",
PublicHoliday.state == state,
PublicHoliday.year == year,
)
)
holidays = _holidays_for_state(year, state)
for d, name, high in holidays:
db.add(PublicHoliday(
id=uuid.uuid4(),
country="DE",
state=state,
date=d,
name=name,
year=year,
is_high_rate=high,
))
await db.flush()
return len(holidays)
async def get_holidays_set(
date_from: date,
date_to: date,
state: str,
db: AsyncSession,
) -> dict[date, tuple[str, bool]]:
"""
Gibt dict {date: (name, is_high_rate)} für den Zeitraum zurück.
Befüllt fehlende Jahre automatisch.
"""
years = set(range(date_from.year, date_to.year + 1))
# Prüfen welche Jahre schon in der DB sind
existing_years_q = await db.execute(
select(PublicHoliday.year).where(
PublicHoliday.country == "DE",
PublicHoliday.state == state,
).distinct()
)
existing_years = {r[0] for r in existing_years_q.all()}
for year in years - existing_years:
await ensure_holidays_for_year(year, state, db)
result_q = await db.execute(
select(PublicHoliday).where(
PublicHoliday.country == "DE",
PublicHoliday.state == state,
PublicHoliday.date >= date_from,
PublicHoliday.date <= date_to,
)
)
return {h.date: (h.name, h.is_high_rate) for h in result_q.scalars().all()}
@@ -0,0 +1,364 @@
"""Kimai CSV Import Service parst Kimai-Export und erzeugt TimeEntries + Absences."""
from __future__ import annotations
import csv
import io
import uuid
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceType
from app.models.time_entry import EntrySource, EntryStatus, TimeEntry
from app.models.user import User
# ---------------------------------------------------------------------------
# Datenstrukturen für die Vorschau
# ---------------------------------------------------------------------------
@dataclass
class KimaiRow:
date: date
start: time
end: time
duration_sec: int
projekt: str
taetigkeit: str
beschreibung: str
@dataclass
class ImportPreviewEntry:
kind: str # "time" | "absence"
date_from: str
date_to: str
start: str | None
end: str | None
break_minutes: int
worked_hours: float | None
absence_type: str | None
note: str | None
skipped: bool = False
skip_reason: str | None = None
@dataclass
class ImportResult:
preview: list[ImportPreviewEntry] = field(default_factory=list)
time_imported: int = 0
absence_imported: int = 0
skipped: int = 0
errors: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _parse_time(s: str) -> time:
parts = s.strip().split(":")
return time(int(parts[0]), int(parts[1]))
def _parse_date(s: str) -> date:
return datetime.strptime(s.strip(), "%Y-%m-%d").date()
def _gross_minutes(start: time, end: time) -> int:
return (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute)
def _break_minutes(row: KimaiRow) -> int:
gross = _gross_minutes(row.start, row.end)
net = row.duration_sec // 60
return max(0, gross - net)
def _worked_hours(row: KimaiRow) -> float:
net_min = row.duration_sec / 60
return round(net_min / 60, 2)
def _is_vacation_row(row: KimaiRow) -> bool:
return row.projekt.strip().lower() == "urlaub"
def _note(row: KimaiRow) -> str | None:
"""
Notiz aus Beschreibung; falls leer, Tätigkeit außer 'Reguläre Arbeitszeit'
(das ist der Standard und braucht keine eigene Notiz).
"""
desc = row.beschreibung.strip()
if desc:
return desc
taet = row.taetigkeit.strip()
if taet and taet.lower() != "reguläre arbeitszeit":
return taet
return None
def _absence_type_name(row: KimaiRow) -> str:
"""Ermittelt Abwesenheitstyp aus Beschreibung."""
desc = row.beschreibung.strip().lower()
if "sonderurlaub" in desc:
return "Sonderurlaub"
return "Urlaub"
def _group_vacation_rows(rows: list[KimaiRow]) -> list[tuple[date, date, str, str]]:
"""
Gruppiert aufeinanderfolgende Urlaubszeilen (gleicher Typ) zu Abwesenheitsblöcken.
Gibt Liste von (start_date, end_date, abs_type_name, note) zurück.
"""
if not rows:
return []
rows_sorted = sorted(rows, key=lambda r: r.date)
groups: list[tuple[date, date, str, str]] = []
cur_start = rows_sorted[0].date
cur_end = rows_sorted[0].date
cur_type = _absence_type_name(rows_sorted[0])
cur_note = rows_sorted[0].beschreibung.strip()
for row in rows_sorted[1:]:
t = _absence_type_name(row)
# Aufeinanderfolgend = max. 3 Tage Abstand (Wochenende überbrücken)
gap = (row.date - cur_end).days
if t == cur_type and gap <= 3:
cur_end = row.date
if row.beschreibung.strip() and row.beschreibung.strip() not in cur_note:
cur_note = (cur_note + " / " + row.beschreibung.strip()).strip(" /")
else:
groups.append((cur_start, cur_end, cur_type, cur_note))
cur_start = row.date
cur_end = row.date
cur_type = t
cur_note = row.beschreibung.strip()
groups.append((cur_start, cur_end, cur_type, cur_note))
return groups
# ---------------------------------------------------------------------------
# CSV-Parser
# ---------------------------------------------------------------------------
def parse_kimai_csv(content: bytes) -> tuple[list[KimaiRow], list[str]]:
"""Parst Kimai-CSV-Bytes, gibt (rows, errors) zurück."""
rows: list[KimaiRow] = []
errors: list[str] = []
text = content.decode("utf-8-sig") # BOM-safe
reader = csv.DictReader(io.StringIO(text))
for i, row in enumerate(reader, start=2):
try:
rows.append(KimaiRow(
date=_parse_date(row["Datum"]),
start=_parse_time(row["Von"]),
end=_parse_time(row["Bis"]),
duration_sec=int(row["Dauer"]),
projekt=row.get("Projekt", ""),
taetigkeit=row.get("Tätigkeit", ""),
beschreibung=row.get("Beschreibung", ""),
))
except Exception as e:
errors.append(f"Zeile {i}: {e}")
return rows, errors
# ---------------------------------------------------------------------------
# Preview (keine DB-Änderungen)
# ---------------------------------------------------------------------------
async def preview_kimai_import(
content: bytes,
target_user_id: uuid.UUID,
db: AsyncSession,
) -> ImportResult:
result = ImportResult()
rows, parse_errors = parse_kimai_csv(content)
result.errors.extend(parse_errors)
# Bestehende Zeiteinträge: Duplikat-Prüfung auf (date, start_time, end_time)
existing_q = await db.execute(
select(TimeEntry.date, TimeEntry.start_time, TimeEntry.end_time)
.where(TimeEntry.user_id == target_user_id)
)
existing_slots: set[tuple] = {(r.date, r.start_time, r.end_time) for r in existing_q}
# Abwesenheitstypen laden
types_q = await db.execute(select(AbsenceType))
abs_types: dict[str, AbsenceType] = {t.name: t for t in types_q.scalars().all()}
time_rows = [r for r in rows if not _is_vacation_row(r)]
vac_rows = [r for r in rows if _is_vacation_row(r)]
# Zeiteinträge
seen_slots: set[tuple] = set()
for row in time_rows:
slot = (row.date, row.start, row.end)
skip = slot in existing_slots or slot in seen_slots
if not skip:
seen_slots.add(slot)
brk = _break_minutes(row)
result.preview.append(ImportPreviewEntry(
kind="time",
date_from=row.date.isoformat(),
date_to=row.date.isoformat(),
start=row.start.strftime("%H:%M"),
end=row.end.strftime("%H:%M"),
break_minutes=brk,
worked_hours=_worked_hours(row),
absence_type=None,
note=_note(row),
skipped=skip,
skip_reason="Bereits vorhanden (gleiche Zeit)" if skip else None,
))
# Bestehende Abwesenheiten für Duplikat-Prüfung
existing_abs_q = await db.execute(
select(AbsenceType.id).where(AbsenceType.id.in_([t.id for t in abs_types.values()]))
)
from app.models.absence import Absence as AbsenceModel
existing_abs_q2 = await db.execute(
select(AbsenceModel.start_date, AbsenceModel.end_date, AbsenceModel.type_id)
.where(AbsenceModel.user_id == target_user_id)
)
existing_absences: set[tuple] = {(r.start_date, r.end_date, r.type_id) for r in existing_abs_q2}
# Urlaubsblöcke
for start, end, type_name, note in _group_vacation_rows(vac_rows):
t = abs_types.get(type_name)
already_exists = t is not None and (start, end, t.id) in existing_absences
skip = t is None or already_exists
result.preview.append(ImportPreviewEntry(
kind="absence",
date_from=start.isoformat(),
date_to=end.isoformat(),
start=None,
end=None,
break_minutes=0,
worked_hours=None,
absence_type=type_name,
note=note or None,
skipped=skip,
skip_reason=(
f"Abwesenheitstyp '{type_name}' nicht gefunden" if t is None
else "Bereits vorhanden" if already_exists
else None
),
))
result.skipped = sum(1 for p in result.preview if p.skipped)
return result
# ---------------------------------------------------------------------------
# Eigentlicher Import (mit DB-Änderungen)
# ---------------------------------------------------------------------------
async def run_kimai_import(
content: bytes,
target_user_id: uuid.UUID,
approver_id: uuid.UUID,
db: AsyncSession,
) -> ImportResult:
result = ImportResult()
rows, parse_errors = parse_kimai_csv(content)
result.errors.extend(parse_errors)
# User + Company laden
user_q = await db.execute(select(User).where(User.id == target_user_id))
user = user_q.scalar_one_or_none()
if not user:
result.errors.append("Ziel-User nicht gefunden.")
return result
# Bestehende Zeiteinträge: Duplikat-Prüfung auf (date, start_time, end_time)
existing_q = await db.execute(
select(TimeEntry.date, TimeEntry.start_time, TimeEntry.end_time)
.where(TimeEntry.user_id == target_user_id)
)
existing_slots: set[tuple] = {(r.date, r.start_time, r.end_time) for r in existing_q}
# Abwesenheitstypen
types_q = await db.execute(
select(AbsenceType).where(AbsenceType.company_id == user.company_id)
)
abs_types: dict[str, AbsenceType] = {t.name: t for t in types_q.scalars().all()}
time_rows = [r for r in rows if not _is_vacation_row(r)]
vac_rows = [r for r in rows if _is_vacation_row(r)]
# ---- Zeiteinträge ----
seen_slots: set[tuple] = set()
for row in time_rows:
slot = (row.date, row.start, row.end)
if slot in existing_slots or slot in seen_slots:
result.skipped += 1
continue
seen_slots.add(slot)
brk = _break_minutes(row)
entry = TimeEntry(
id=uuid.uuid4(),
user_id=target_user_id,
date=row.date,
start_time=row.start,
end_time=row.end,
break_minutes=brk,
status=EntryStatus.APPROVED,
source=EntrySource.API,
approved_by=approver_id,
note=_note(row),
)
db.add(entry)
result.time_imported += 1
# Bestehende Abwesenheiten für Duplikat-Prüfung
existing_abs_q = await db.execute(
select(Absence.start_date, Absence.end_date, Absence.type_id)
.where(Absence.user_id == target_user_id)
)
existing_absences: set[tuple] = {(r.start_date, r.end_date, r.type_id) for r in existing_abs_q}
# ---- Urlaubsblöcke ----
for start, end, type_name, note in _group_vacation_rows(vac_rows):
t = abs_types.get(type_name)
if not t:
result.errors.append(f"Abwesenheitstyp '{type_name}' nicht gefunden übersprungen.")
result.skipped += 1
continue
if (start, end, t.id) in existing_absences:
result.skipped += 1
continue
# Arbeitstage zählen (MoFr, keine Feiertage)
working_days = sum(
1 for n in range((end - start).days + 1)
if (start + timedelta(days=n)).weekday() < 5
)
absence = Absence(
id=uuid.uuid4(),
user_id=target_user_id,
type_id=t.id,
start_date=start,
end_date=end,
working_days=working_days,
status=AbsenceStatus.APPROVED,
approved_by=approver_id,
note=note or None,
)
db.add(absence)
result.absence_imported += 1
await db.commit()
return result
+87
View File
@@ -0,0 +1,87 @@
import secrets
from datetime import datetime, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_token
from app.models.kiosk_device import KioskDevice
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceUpdate
class KioskService:
async def list_devices(self, company_id: UUID, db: AsyncSession) -> list[KioskDevice]:
result = await db.scalars(
select(KioskDevice)
.where(KioskDevice.company_id == company_id)
.order_by(KioskDevice.created_at.desc())
)
return list(result.all())
async def create_device(
self, company_id: UUID, data: KioskDeviceCreate, db: AsyncSession
) -> tuple[KioskDevice, str]:
"""Gerät anlegen. Gibt (device, raw_token) zurück raw_token nur einmalig."""
raw_token = secrets.token_urlsafe(48)
device = KioskDevice(
company_id=company_id,
name=data.name,
location=data.location,
token_hash=hash_token(raw_token),
)
db.add(device)
await db.flush()
return device, raw_token
async def get_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> KioskDevice:
device = await db.scalar(
select(KioskDevice).where(
KioskDevice.id == device_id,
KioskDevice.company_id == company_id,
)
)
if device is None:
raise HTTPException(status_code=404, detail="Gerät nicht gefunden.")
return device
async def update_device(
self, device_id: UUID, company_id: UUID, data: KioskDeviceUpdate, db: AsyncSession
) -> KioskDevice:
device = await self.get_device(device_id, company_id, db)
changes = data.model_dump(exclude_none=True)
for field, value in changes.items():
setattr(device, field, value)
return device
async def rotate_token(
self, device_id: UUID, company_id: UUID, db: AsyncSession
) -> tuple[KioskDevice, str]:
"""Token rotieren altes Token wird sofort ungültig."""
device = await self.get_device(device_id, company_id, db)
raw_token = secrets.token_urlsafe(48)
device.token_hash = hash_token(raw_token)
return device, raw_token
async def delete_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> None:
device = await self.get_device(device_id, company_id, db)
await db.delete(device)
async def authenticate_device(self, raw_token: str, db: AsyncSession) -> KioskDevice:
"""Gerät per Token authentifizieren (für Kiosk-Endpoints)."""
token_hash = hash_token(raw_token)
device = await db.scalar(
select(KioskDevice).where(
KioskDevice.token_hash == token_hash,
KioskDevice.is_active.is_(True),
)
)
if device is None:
raise HTTPException(status_code=401, detail="Ungültiges oder deaktiviertes Gerät.")
device.last_seen_at = datetime.now(timezone.utc)
return device
kiosk_service = KioskService()
+332
View File
@@ -0,0 +1,332 @@
"""LDAP integration service.
Supports ActiveDirectory and OpenLDAP via ldap3 (pure Python).
Bind passwords are stored Fernet-encrypted using the app SECRET_KEY.
"""
import base64
import hashlib
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.ldap_config import LdapConfig
from app.models.user import AuthProvider, User, UserRole
logger = logging.getLogger(__name__)
def _fernet():
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_password(plain: str) -> str:
return _fernet().encrypt(plain.encode()).decode()
def decrypt_password(encrypted: str) -> str:
return _fernet().decrypt(encrypted.encode()).decode()
@dataclass
class ConnectionResult:
success: bool
message: str
@dataclass
class SyncResult:
created: int
updated: int
deactivated: int
errors: list[str]
def _get_attr(entry_attrs: dict, attr_name: str) -> str:
"""Safely extract a string attribute value from ldap3 entry attributes."""
val = entry_attrs.get(attr_name)
if not val:
return ""
if isinstance(val, list):
return str(val[0]) if val else ""
return str(val)
class LdapService:
def _build_server(self, config: LdapConfig):
from ldap3 import Server, Tls
import ssl
tls = None
if config.use_tls:
if config.tls_verify:
tls = Tls(validate=ssl.CERT_REQUIRED)
else:
logger.warning(
"LDAP TLS certificate validation is DISABLED for host %s (company_id=%s). "
"Set tls_verify=True in production to prevent MITM attacks.",
config.host,
config.company_id,
)
tls = Tls(validate=ssl.CERT_NONE)
return Server(
config.host,
port=config.port,
use_ssl=config.use_ssl,
tls=tls,
get_info="ALL",
connect_timeout=5,
)
def _bind_connection(self, config: LdapConfig, dn: str | None = None, password: str | None = None):
"""Return an authenticated ldap3 Connection (simple bind)."""
from ldap3 import Connection, SIMPLE, SYNC
bind_dn = dn or config.bind_dn
bind_pw = password or decrypt_password(config.bind_password_encrypted)
server = self._build_server(config)
conn = Connection(
server,
user=bind_dn,
password=bind_pw,
authentication=SIMPLE,
client_strategy=SYNC,
auto_bind="NO_TLS",
raise_exceptions=False,
)
if not conn.bind():
return None
return conn
# ── Public API ────────────────────────────────────────────────────────────
async def get_config(self, company_id: uuid.UUID, db: AsyncSession) -> LdapConfig | None:
return await db.scalar(
select(LdapConfig).where(LdapConfig.company_id == company_id)
)
async def get_config_or_404(self, company_id: uuid.UUID, db: AsyncSession) -> LdapConfig:
cfg = await self.get_config(company_id, db)
if not cfg:
raise HTTPException(status_code=404, detail="LDAP configuration not found")
return cfg
def test_connection(self, config: LdapConfig) -> ConnectionResult:
try:
conn = self._bind_connection(config)
if conn is None:
return ConnectionResult(success=False, message="Bind fehlgeschlagen DN oder Passwort falsch")
conn.unbind()
return ConnectionResult(success=True, message="Verbindung erfolgreich")
except Exception as exc:
return ConnectionResult(success=False, message=str(exc))
def search_users(self, config: LdapConfig) -> list[dict[str, Any]]:
"""Return raw list of user dicts from LDAP directory."""
from ldap3 import SUBTREE
conn = self._bind_connection(config)
if conn is None:
raise HTTPException(status_code=502, detail="LDAP bind fehlgeschlagen")
attrs = [
config.attr_email,
config.attr_firstname,
config.attr_lastname,
config.attr_username,
]
if config.attr_department:
attrs.append(config.attr_department)
conn.search(
search_base=config.base_dn,
search_filter=config.user_search_filter,
search_scope=SUBTREE,
attributes=attrs,
)
results = []
for entry in conn.entries:
raw = {a: entry[a].value for a in attrs if a in entry}
raw["dn"] = entry.entry_dn
results.append(raw)
conn.unbind()
return results
async def sync_users(
self,
config: LdapConfig,
db: AsyncSession,
default_role: UserRole = UserRole.EMPLOYEE,
) -> SyncResult:
"""Sync LDAP users into the local database."""
from ldap3 import SUBTREE
conn = self._bind_connection(config)
if conn is None:
raise HTTPException(status_code=502, detail="LDAP bind fehlgeschlagen")
attrs = [
config.attr_email,
config.attr_firstname,
config.attr_lastname,
]
if config.attr_department:
attrs.append(config.attr_department)
if config.attr_personnel_number:
attrs.append(config.attr_personnel_number)
conn.search(
search_base=config.base_dn,
search_filter=config.user_search_filter,
search_scope=SUBTREE,
attributes=attrs,
)
entries = list(conn.entries)
conn.unbind()
result = SyncResult(created=0, updated=0, deactivated=0, errors=[])
ldap_emails: set[str] = set()
for entry in entries:
try:
email = _get_attr(entry, config.attr_email).lower().strip()
if not email:
continue
ldap_emails.add(email)
first = _get_attr(entry, config.attr_firstname) or "?"
last = _get_attr(entry, config.attr_lastname) or "?"
dn = entry.entry_dn
ldap_personnel = (
_get_attr(entry, config.attr_personnel_number).strip()
if config.attr_personnel_number else ""
)
# nur Ziffern akzeptieren (Format-Vorgabe)
if ldap_personnel and not ldap_personnel.isdigit():
logger.warning(
"LDAP personnel_number for %s contains non-digits (%r), skipping mapping.",
email, ldap_personnel,
)
ldap_personnel = ""
existing = await db.scalar(select(User).where(User.email == email))
if existing:
existing.first_name = first
existing.last_name = last
existing.ldap_dn = dn
existing.auth_provider = AuthProvider.LDAP
existing.is_active = True
if ldap_personnel and existing.personnel_number != ldap_personnel:
await self._apply_personnel_from_ldap(
existing, ldap_personnel, db, result,
)
result.updated += 1
else:
user = User(
company_id=config.company_id,
email=email,
first_name=first,
last_name=last,
password_hash=None,
auth_provider=AuthProvider.LDAP,
ldap_dn=dn,
role=default_role,
is_active=True,
)
if ldap_personnel:
await self._apply_personnel_from_ldap(
user, ldap_personnel, db, result, company_id=config.company_id,
)
db.add(user)
result.created += 1
except Exception as exc:
result.errors.append(str(exc))
# Deactivate LDAP users no longer in directory
existing_ldap = await db.scalars(
select(User).where(
User.company_id == config.company_id,
User.auth_provider == AuthProvider.LDAP,
User.is_active.is_(True),
)
)
for user in existing_ldap:
if user.email not in ldap_emails:
user.is_active = False
result.deactivated += 1
config.last_sync_at = datetime.now(timezone.utc)
await db.commit()
return result
async def _apply_personnel_from_ldap(
self,
user: User,
ldap_value: str,
db: AsyncSession,
result: SyncResult,
company_id: uuid.UUID | None = None,
) -> None:
"""Apply personnel_number from LDAP, but skip on conflict (no override of reserved numbers)."""
cid = company_id or user.company_id
# Konflikt mit anderem User in derselben Firma?
conflict = await db.scalar(
select(User.id).where(
User.company_id == cid,
User.personnel_number == ldap_value,
User.id != user.id,
)
)
if conflict is not None:
msg = (
f"LDAP-Personalnummer {ldap_value!r} für {user.email} kollidiert mit "
f"vergebener Nummer Wert verworfen."
)
logger.warning(msg)
result.errors.append(msg)
return
user.personnel_number = ldap_value
def authenticate_ldap(self, config: LdapConfig, email: str, password: str) -> bool:
"""Authenticate a user by finding their DN and attempting a bind."""
from ldap3 import SUBTREE
from ldap3.utils.conv import escape_filter_chars
conn = self._bind_connection(config)
if conn is None:
return False
safe_email = escape_filter_chars(email)
conn.search(
search_base=config.base_dn,
search_filter=f"({config.attr_email}={safe_email})",
search_scope=SUBTREE,
attributes=[config.attr_email],
)
if not conn.entries:
conn.unbind()
return False
user_dn = conn.entries[0].entry_dn
conn.unbind()
# Try binding as the found user
user_conn = self._bind_connection(config, dn=user_dn, password=password)
if user_conn is None:
return False
user_conn.unbind()
return True
ldap_service = LdapService()
File diff suppressed because it is too large Load Diff
+524
View File
@@ -0,0 +1,524 @@
from datetime import date, datetime, time, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
from app.models.time_entry import EntrySource, EntryStatus, TimeEntry
from app.models.user import User, UserRole
from app.models.work_schedule import WorkSchedule
from app.schemas.time_entry import (
BalanceResponse,
ManualEntryCreate,
StampInRequest,
TimeEntryUpdate,
)
def _check_arbzg(start: time, end: time, break_minutes: int) -> list[str]:
"""ArbZG §3 und §4 Prüfung. Gibt Warnungen zurück, blockiert nicht."""
start_mins = start.hour * 60 + start.minute
end_mins = end.hour * 60 + end.minute
if end_mins <= start_mins:
end_mins += 24 * 60 # Nachtschicht
total_mins = end_mins - start_mins
worked_mins = total_mins - break_minutes
worked_hours = worked_mins / 60
warnings: list[str] = []
if worked_hours > 10:
warnings.append(
f"Maximale Arbeitszeit von 10 Stunden überschritten "
f"({worked_hours:.1f}h gearbeitet) ArbZG §3"
)
if total_mins >= 9 * 60 and break_minutes < 45:
warnings.append(
"Bei mehr als 9h Anwesenheit sind mind. 45 min Pause vorgeschrieben ArbZG §4"
)
elif total_mins >= 6 * 60 and break_minutes < 30:
warnings.append(
"Bei mehr als 6h Anwesenheit sind mind. 30 min Pause vorgeschrieben ArbZG §4"
)
return warnings
def _check_rest_period(prev_end: time | None, prev_date: date | None,
new_start: time, new_date: date) -> list[str]:
"""Mindestruhezeit 11h zwischen Schichten ArbZG §5.
Nur relevant bei Schichtwechsel über Tagesgrenzen, nicht bei mehrfachen
Stempelungen am gleichen Tag (z.B. Korrektur oder Pause).
"""
if prev_end is None or prev_date is None:
return []
# Gleicher Tag → kein Schichtwechsel, §5 nicht anwendbar
if prev_date == new_date:
return []
prev_end_dt = datetime.combine(prev_date, prev_end, tzinfo=None)
new_start_dt = datetime.combine(new_date, new_start, tzinfo=None)
rest_hours = (new_start_dt - prev_end_dt).total_seconds() / 3600
# Nur warnen wenn tatsächlich weniger als 11h Ruhe zwischen zwei verschiedenen Tagen
if 0 < rest_hours < 11:
return [
f"Mindestruhezeit von 11h unterschritten "
f"({rest_hours:.1f}h seit letzter Schicht) ArbZG §5"
]
return []
class TimeService:
# ── Stempeluhr ────────────────────────────────────────────────────────────
async def stamp_in(
self,
user: User,
data: StampInRequest,
db: AsyncSession,
) -> tuple[TimeEntry, list[str]]:
today = datetime.now(timezone.utc).date()
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
# Offenen Eintrag für heute prüfen
open_entry = await self._get_open_entry(user.id, db)
if open_entry is not None:
raise HTTPException(status_code=409, detail="Bereits eingestempelt. Bitte zuerst ausstempeln.")
# Letzten abgeschlossenen Eintrag für Ruhezeit-Check holen
last_entry = await db.scalar(
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.end_time.isnot(None))
.order_by(TimeEntry.date.desc(), TimeEntry.end_time.desc())
.limit(1)
)
warnings = _check_rest_period(
last_entry.end_time if last_entry else None,
last_entry.date if last_entry else None,
now_time,
today,
)
entry = TimeEntry(
user_id=user.id,
date=today,
start_time=now_time,
break_minutes=0,
source=data.source,
project_id=data.project_id,
note=data.note,
status=EntryStatus.PENDING,
)
db.add(entry)
await db.flush()
return entry, warnings
async def stamp_out(
self,
user: User,
note: str | None,
db: AsyncSession,
) -> tuple[TimeEntry, list[str]]:
entry = await self._get_open_entry(user.id, db)
if entry is None:
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
# Aktive Pause beenden falls vergessen
if entry.break_start is not None:
extra_break = self._calc_break_minutes(entry.break_start, now_time)
entry.break_minutes += extra_break
entry.break_start = None
entry.end_time = now_time
entry.updated_at = datetime.now(timezone.utc)
if note:
entry.note = note
warnings = _check_arbzg(entry.start_time, entry.end_time, entry.break_minutes)
return entry, warnings
async def break_start(self, user: User, db: AsyncSession) -> TimeEntry:
entry = await self._get_open_entry(user.id, db)
if entry is None:
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
if entry.break_start is not None:
raise HTTPException(status_code=409, detail="Pause bereits aktiv.")
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
entry.break_start = now_time
entry.updated_at = datetime.now(timezone.utc)
return entry
async def break_end(self, user: User, db: AsyncSession) -> TimeEntry:
entry = await self._get_open_entry(user.id, db)
if entry is None:
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
if entry.break_start is None:
raise HTTPException(status_code=409, detail="Keine aktive Pause.")
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
extra = self._calc_break_minutes(entry.break_start, now_time)
entry.break_minutes += extra
entry.break_start = None
entry.updated_at = datetime.now(timezone.utc)
return entry
# ── Einträge ──────────────────────────────────────────────────────────────
async def get_today(self, user: User, db: AsyncSession) -> list[TimeEntry]:
today = datetime.now(timezone.utc).date()
result = await db.scalars(
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date == today)
.order_by(TimeEntry.start_time)
)
return list(result.all())
async def list_entries(
self,
company_id: UUID,
current_user: User,
db: AsyncSession,
user_id: UUID | None = None,
date_from: date | None = None,
date_to: date | None = None,
status: EntryStatus | None = None,
skip: int = 0,
limit: int = 50,
) -> tuple[int, list[TimeEntry]]:
# Basis: nur Einträge der eigenen Company
# Subquery: JOIN user für company_id Filter
q = (
select(TimeEntry)
.join(User, TimeEntry.user_id == User.id)
.where(User.company_id == company_id)
)
# EMPLOYEE sieht nur eigene Einträge
if current_user.role == UserRole.EMPLOYEE:
q = q.where(TimeEntry.user_id == current_user.id)
elif user_id:
q = q.where(TimeEntry.user_id == user_id)
if date_from:
q = q.where(TimeEntry.date >= date_from)
if date_to:
q = q.where(TimeEntry.date <= date_to)
if status:
q = q.where(TimeEntry.status == status)
total = await db.scalar(select(func.count()).select_from(q.subquery()))
entries = await db.scalars(q.order_by(TimeEntry.date.desc(), TimeEntry.start_time.desc()).offset(skip).limit(limit))
return total or 0, list(entries.all())
async def create_manual(
self,
data: ManualEntryCreate,
current_user: User,
db: AsyncSession,
) -> tuple[TimeEntry, list[str]]:
target_user_id = current_user.id
# Employees need explicit permission to create manual entries
_elevated = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
if current_user.role == UserRole.EMPLOYEE and not current_user.can_manual_time_entry:
raise HTTPException(status_code=403, detail="Manuelle Zeiterfassung ist für Ihr Konto nicht freigeschaltet.")
if data.user_id and data.user_id != current_user.id:
if current_user.role not in _elevated:
raise HTTPException(status_code=403, detail="Keine Berechtigung für andere Benutzer.")
target = await db.get(User, data.user_id)
if target is None or target.company_id != current_user.company_id:
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
target_user_id = data.user_id
entry = TimeEntry(
user_id=target_user_id,
date=data.date,
start_time=data.start_time,
end_time=data.end_time,
break_minutes=data.break_minutes,
project_id=data.project_id,
note=data.note,
source=data.source,
status=EntryStatus.PENDING,
)
db.add(entry)
await db.flush()
warnings = _check_arbzg(data.start_time, data.end_time, data.break_minutes)
return entry, warnings
async def update_entry(
self,
entry_id: UUID,
data: TimeEntryUpdate,
current_user: User,
db: AsyncSession,
) -> TimeEntry:
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
entry = await self._get_entry_or_404(entry_id, db)
await self._assert_access(entry, current_user, db)
if entry.status == EntryStatus.APPROVED:
if current_user.role not in _manager_roles:
raise HTTPException(status_code=403, detail="Genehmigte Einträge können nur von Vorgesetzten geändert werden.")
if not data.correction_note:
raise HTTPException(status_code=422, detail="Änderungsgrund (correction_note) ist bei genehmigten Einträgen Pflicht.")
# Vorherigen Zustand für AuditLog sichern
old_snapshot = {
"started_at": entry.started_at.isoformat() if entry.started_at else None,
"ended_at": entry.ended_at.isoformat() if entry.ended_at else None,
"break_minutes": entry.break_minutes,
"note": entry.note,
"correction_note": entry.correction_note,
}
changes = data.model_dump(exclude_none=True)
for field, value in changes.items():
setattr(entry, field, value)
entry.updated_at = datetime.now(timezone.utc)
if entry.status == EntryStatus.APPROVED:
new_snapshot = {
"started_at": entry.started_at.isoformat() if entry.started_at else None,
"ended_at": entry.ended_at.isoformat() if entry.ended_at else None,
"break_minutes": entry.break_minutes,
"note": entry.note,
"correction_note": entry.correction_note,
}
user_obj = await db.get(User, entry.user_id)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="time_entry_approved_edit",
entity_type="time_entry",
entity_id=entry.id,
old_value=old_snapshot,
new_value={**new_snapshot, "changed_by": str(current_user.id),
"target_user": str(entry.user_id),
"target_user_name": user_obj.full_name if user_obj else None},
))
return entry
async def approve_entry(
self,
entry_id: UUID,
current_user: User,
db: AsyncSession,
) -> TimeEntry:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Genehmigen.")
entry = await self._get_entry_or_404(entry_id, db)
# Cross-Tenant-Schutz
entry_user = await db.get(User, entry.user_id)
if entry_user is None or entry_user.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
# Self-Approval-Schutz (L-03)
if entry.user_id == current_user.id:
raise HTTPException(
status_code=409,
detail="Eigene Zeiteinträge können nicht selbst genehmigt werden."
)
if entry.status != EntryStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können genehmigt werden.")
entry.status = EntryStatus.APPROVED
entry.approved_by = current_user.id
entry.updated_at = datetime.now(timezone.utc)
return entry
async def reject_entry(
self,
entry_id: UUID,
current_user: User,
correction_note: str | None,
db: AsyncSession,
) -> TimeEntry:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Ablehnen.")
entry = await self._get_entry_or_404(entry_id, db)
if entry.status != EntryStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können abgelehnt werden.")
entry.status = EntryStatus.REJECTED
entry.approved_by = current_user.id
if correction_note:
entry.correction_note = correction_note
entry.updated_at = datetime.now(timezone.utc)
return entry
async def delete_entry(
self,
entry_id: UUID,
current_user: User,
db: AsyncSession,
) -> None:
entry = await self._get_entry_or_404(entry_id, db)
await self._assert_access(entry, current_user, db)
# Genehmigte Einträge dürfen nur von HR/Admin gelöscht werden
if entry.status == EntryStatus.APPROVED:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(
status_code=403,
detail="Genehmigte Einträge können nur von Vorgesetzten gelöscht werden."
)
await db.delete(entry)
async def get_balance(
self,
user_id: UUID,
current_user: User,
db: AsyncSession,
period_start: date | None = None,
period_end: date | None = None,
) -> BalanceResponse:
# Zugriff prüfen
if user_id != current_user.id and current_user.role == UserRole.EMPLOYEE:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
today = datetime.now(timezone.utc).date()
if period_start is None:
period_start = today.replace(day=1)
if period_end is None:
period_end = today
# Genehmigte Einträge summieren
approved_entries = await db.scalars(
select(TimeEntry).where(
and_(
TimeEntry.user_id == user_id,
TimeEntry.date >= period_start,
TimeEntry.date <= period_end,
TimeEntry.status == EntryStatus.APPROVED,
TimeEntry.end_time.isnot(None),
)
)
)
approved_list = list(approved_entries.all())
total_worked = sum(e.worked_hours or 0.0 for e in approved_list)
# Ausstehende Einträge zählen
pending_count = await db.scalar(
select(func.count(TimeEntry.id)).where(
and_(
TimeEntry.user_id == user_id,
TimeEntry.date >= period_start,
TimeEntry.date <= period_end,
TimeEntry.status == EntryStatus.PENDING,
)
)
) or 0
# Soll-Stunden aus Arbeitsplan ermitteln (neuester gültiger Plan)
schedule = await db.scalar(
select(WorkSchedule)
.join(User, WorkSchedule.company_id == User.company_id)
.where(
User.id == user_id,
WorkSchedule.valid_from <= period_start,
)
.order_by(WorkSchedule.valid_from.desc())
.limit(1)
)
expected = self._calc_expected_hours(period_start, period_end, schedule)
return BalanceResponse(
user_id=user_id,
period_start=period_start,
period_end=period_end,
total_hours_worked=round(total_worked, 2),
expected_hours=round(expected, 2),
overtime_hours=round(total_worked - expected, 2),
approved_entries=len(approved_list),
pending_entries=pending_count,
)
# ── Arbeitspläne ──────────────────────────────────────────────────────────
async def create_work_schedule(
self,
company_id: UUID,
data,
db: AsyncSession,
) -> WorkSchedule:
schedule = WorkSchedule(company_id=company_id, **data.model_dump())
db.add(schedule)
await db.flush()
return schedule
async def list_work_schedules(self, company_id: UUID, db: AsyncSession) -> list[WorkSchedule]:
result = await db.scalars(
select(WorkSchedule)
.where(WorkSchedule.company_id == company_id)
.order_by(WorkSchedule.valid_from.desc())
)
return list(result.all())
# ── Helpers ───────────────────────────────────────────────────────────────
async def _get_open_entry(self, user_id: UUID, db: AsyncSession) -> TimeEntry | None:
return await db.scalar(
select(TimeEntry).where(
TimeEntry.user_id == user_id,
TimeEntry.end_time.is_(None),
).order_by(TimeEntry.date.desc(), TimeEntry.start_time.desc()).limit(1)
)
async def _get_entry_or_404(self, entry_id: UUID, db: AsyncSession) -> TimeEntry:
entry = await db.get(TimeEntry, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="Zeiterfassungseintrag nicht gefunden.")
return entry
async def _assert_access(self, entry: TimeEntry, user: User, db: AsyncSession) -> None:
if entry.user_id != user.id and user.role not in (
UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN
):
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
entry_user = await db.get(User, entry.user_id)
if entry_user is None or entry_user.company_id != user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
@staticmethod
def _calc_break_minutes(start: time, end: time) -> int:
s = start.hour * 60 + start.minute
e = end.hour * 60 + end.minute
if e < s:
e += 24 * 60
return max(0, e - s)
@staticmethod
def _calc_expected_hours(period_start: date, period_end: date, schedule: WorkSchedule | None) -> float:
"""Soll-Stunden für den Zeitraum berechnen."""
from datetime import timedelta
total = 0.0
current = period_start
while current <= period_end:
wd = current.weekday() # 0=Mon
if schedule:
total += float(schedule.hours_for_weekday(wd))
else:
# Fallback: 8h Mo-Fr
if wd < 5:
total += 8.0
current += timedelta(days=1)
return total
time_service = TimeService()
+308
View File
@@ -0,0 +1,308 @@
"""User CSV Bulk Import validates, creates new users or reactivates deactivated ones."""
from __future__ import annotations
import csv
import io
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import generate_invite_token, hash_password
from app.models.company import Company, PersonnelNumberMode
from app.models.user import User, UserRole
from app.services.email_service import email_service
from app.services.user_service import user_service
REQUIRED_HEADERS = ["email", "first_name", "last_name"]
OPTIONAL_HEADERS = ["role", "personnel_number", "kuerzel"]
TEMPLATE_HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
PERSONNEL_RE = re.compile(r"^[0-9]+$")
VALID_ROLES = {r.value for r in UserRole if r != UserRole.SUPER_ADMIN}
# ── Datenstrukturen ──────────────────────────────────────────────────────────
@dataclass
class ImportRowResult:
row: int
email: str
personnel_number: str | None
action: str # created | reactivated | error
message: str | None = None
@dataclass
class ImportResult:
total_rows: int
created: int
reactivated: int
errors: int
items: list[ImportRowResult]
# ── CSV-Parsing ──────────────────────────────────────────────────────────────
def build_template_csv() -> str:
"""CSV template returned via /users/import-template.csv."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(TEMPLATE_HEADERS)
writer.writerow([
"max@firma.de", "Max", "Mustermann",
"EMPLOYEE", "0042", "MM",
])
return buf.getvalue()
def _normalize(value: str | None) -> str:
return (value or "").strip()
def _parse_csv(content: bytes) -> tuple[list[dict[str, str]], list[str]]:
"""Parse CSV bytes (BOM-safe). Returns (rows, header_errors)."""
text = content.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
if reader.fieldnames is None:
return [], ["CSV ist leer oder kein gültiger Header gefunden."]
headers = [h.strip() for h in reader.fieldnames]
missing = [h for h in REQUIRED_HEADERS if h not in headers]
if missing:
return [], [f"Pflicht-Spalten fehlen: {', '.join(missing)}"]
rows = list(reader)
return rows, []
# ── Import-Kern (Preview & Apply gemeinsam) ──────────────────────────────────
async def _process_import(
*,
content: bytes,
company_id: UUID,
invited_by: User,
db: AsyncSession,
apply: bool,
) -> ImportResult:
"""Process CSV bulk import. apply=False = validation only (no DB writes, rolled back)."""
rows, header_errors = _parse_csv(content)
items: list[ImportRowResult] = []
if header_errors:
for msg in header_errors:
items.append(ImportRowResult(
row=0, email="", personnel_number=None, action="error", message=msg,
))
return ImportResult(total_rows=0, created=0, reactivated=0, errors=len(items), items=items)
company = await db.get(Company, company_id)
if company is None:
items.append(ImportRowResult(
row=0, email="", personnel_number=None, action="error", message="Firma nicht gefunden.",
))
return ImportResult(total_rows=0, created=0, reactivated=0, errors=1, items=items)
seen_emails_in_csv: set[str] = set()
used_personnel_in_csv: set[str] = set()
created = 0
reactivated = 0
errors = 0
for idx, raw in enumerate(rows, start=2): # CSV row numbers start at 2 (after header)
email = _normalize(raw.get("email")).lower()
first_name = _normalize(raw.get("first_name"))
last_name = _normalize(raw.get("last_name"))
role_str = _normalize(raw.get("role")) or UserRole.EMPLOYEE.value
personnel_number = _normalize(raw.get("personnel_number")) or None
kuerzel = _normalize(raw.get("kuerzel")) or None
# Validation
if not email or not EMAIL_RE.match(email):
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Ungültige E-Mail-Adresse.",
))
errors += 1
continue
if not first_name or not last_name:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Vor- und Nachname sind Pflicht.",
))
errors += 1
continue
if role_str not in VALID_ROLES:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message=f"Ungültige Rolle: {role_str}",
))
errors += 1
continue
if personnel_number is not None and not PERSONNEL_RE.match(personnel_number):
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Personalnummer darf nur Ziffern enthalten.",
))
errors += 1
continue
# Doppelte Mail im Import → Fehler
if email in seen_emails_in_csv:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="E-Mail kommt im Import mehrfach vor.",
))
errors += 1
continue
seen_emails_in_csv.add(email)
# Doppelte Personalnr. im Import → Fehler
if personnel_number and personnel_number in used_personnel_in_csv:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Personalnummer kommt im Import mehrfach vor.",
))
errors += 1
continue
# Personalnr.-Konflikt mit DB?
if personnel_number:
taken = await db.scalar(
select(User.id).where(
User.company_id == company_id,
User.personnel_number == personnel_number,
)
)
if taken is not None:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Personalnummer ist bereits vergeben.",
))
errors += 1
continue
# Auto-Vergabe wenn leer (auch im Manuell-Modus laut Anforderung)
if not personnel_number:
personnel_number = await user_service._next_personnel_number(company_id, db)
# E-Mail-Konflikt prüfen (auch deaktivierte User in derselben Firma)
existing_user = await db.scalar(
select(User).where(User.email == email)
)
if existing_user is not None and existing_user.is_active:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="E-Mail bereits aktiv vergeben.",
))
errors += 1
continue
if existing_user is not None and not existing_user.is_active:
# Reaktivieren
if existing_user.company_id != company_id:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="E-Mail existiert in anderer Firma.",
))
errors += 1
continue
existing_user.first_name = first_name
existing_user.last_name = last_name
existing_user.role = UserRole(role_str)
if kuerzel:
existing_user.kuerzel = kuerzel
# Personalnr.: behalten, falls schon vorhanden (Reservierung), sonst setzen
if not existing_user.personnel_number:
existing_user.personnel_number = personnel_number
else:
personnel_number = existing_user.personnel_number
existing_user.is_active = True
used_personnel_in_csv.add(personnel_number)
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="reactivated",
))
reactivated += 1
continue
# Neuanlage: Invite-Token generieren, User inaktiv (warten auf Annahme)
raw_token, token_hash = generate_invite_token()
new_user = User(
company_id=company_id,
email=email,
first_name=first_name,
last_name=last_name,
role=UserRole(role_str),
kuerzel=kuerzel,
personnel_number=personnel_number,
password_hash=hash_password(raw_token),
invite_token_hash=token_hash,
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
is_active=False,
)
db.add(new_user)
await db.flush()
used_personnel_in_csv.add(personnel_number)
if apply:
try:
await email_service.send_invite(new_user, invited_by, raw_token, db)
except Exception as e: # noqa: BLE001
# Mail-Fehler darf Import nicht abbrechen, wird aber gemeldet
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="created",
message=f"Anlage OK, aber Einladungs-Mail fehlgeschlagen: {e}",
))
created += 1
continue
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="created",
))
created += 1
return ImportResult(
total_rows=len(rows),
created=created,
reactivated=reactivated,
errors=errors,
items=items,
)
async def preview_csv(
content: bytes, company_id: UUID, invited_by: User, db: AsyncSession,
) -> ImportResult:
"""Validiert CSV ohne DB-Schreibvorgänge (Rollback am Ende)."""
result = await _process_import(
content=content,
company_id=company_id,
invited_by=invited_by,
db=db,
apply=False,
)
await db.rollback()
return result
async def apply_csv(
content: bytes, company_id: UUID, invited_by: User, db: AsyncSession,
) -> ImportResult:
"""Führt Import durch und committet."""
result = await _process_import(
content=content,
company_id=company_id,
invited_by=invited_by,
db=db,
apply=True,
)
await db.commit()
return result
+310
View File
@@ -0,0 +1,310 @@
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import (
generate_invite_token,
hash_password,
hash_token,
verify_password,
)
from app.models import User, UserRole
from app.models.audit_log import AuditLog
from app.models.company import Company, PersonnelNumberMode
from app.schemas.user import InviteAccept, InviteRequest, UserUpdate
from app.services.email_service import email_service
PERSONNEL_NUMBER_MIN_DIGITS = 4
class UserService:
# ── Personalnummer-Helpers ────────────────────────────────────────────────
async def _get_company(self, company_id: UUID, db: AsyncSession) -> Company:
company = await db.get(Company, company_id)
if not company:
raise HTTPException(status_code=404, detail="Company not found")
return company
@staticmethod
def _format_personnel_number(value: int) -> str:
return str(value).zfill(PERSONNEL_NUMBER_MIN_DIGITS)
async def _next_personnel_number(self, company_id: UUID, db: AsyncSession) -> str:
"""Atomic increment + return next personnel number for the company.
Uses UPDATE ... RETURNING to avoid race conditions with parallel inserts.
Skips numbers that are already taken (e.g. manual override) by retrying.
"""
for _ in range(50): # safety bound, in practice 1-2 iterations max
result = await db.execute(
text(
"UPDATE companies "
"SET personnel_number_next = personnel_number_next + 1 "
"WHERE id = :cid "
"RETURNING personnel_number_next - 1 AS used"
),
{"cid": company_id},
)
row = result.first()
if row is None:
raise HTTPException(status_code=404, detail="Company not found")
candidate = self._format_personnel_number(int(row.used))
existing = await db.scalar(
select(User.id).where(
User.company_id == company_id,
User.personnel_number == candidate,
)
)
if existing is None:
return candidate
raise HTTPException(status_code=500, detail="Could not allocate personnel number")
async def next_personnel_suggestion(self, company_id: UUID, db: AsyncSession) -> str:
"""Preview next personnel number without consuming the counter."""
company = await self._get_company(company_id, db)
candidate_int = company.personnel_number_next
while True:
candidate = self._format_personnel_number(candidate_int)
taken = await db.scalar(
select(User.id).where(
User.company_id == company_id,
User.personnel_number == candidate,
)
)
if taken is None:
return candidate
candidate_int += 1
async def _check_personnel_unique(
self,
company_id: UUID,
number: str,
db: AsyncSession,
exclude_user_id: UUID | None = None,
) -> None:
"""Raise 409 if personnel number is already taken (incl. deactivated/reserved)."""
q = select(User.id).where(
User.company_id == company_id,
User.personnel_number == number,
)
if exclude_user_id is not None:
q = q.where(User.id != exclude_user_id)
existing = await db.scalar(q)
if existing is not None:
raise HTTPException(
status_code=409,
detail=f"Personalnummer '{number}' ist bereits vergeben (auch reservierte Nummern bleiben belegt).",
)
async def get_by_personnel_number(
self, number: str, company_id: UUID, db: AsyncSession
) -> User:
user = await db.scalar(
select(User).where(
User.company_id == company_id,
User.personnel_number == number,
)
)
if user is None:
raise HTTPException(status_code=404, detail="Personalnummer nicht gefunden")
return user
# ── Invite ────────────────────────────────────────────────────────────────
async def invite(
self,
data: InviteRequest,
company_id: UUID,
invited_by: User,
db: AsyncSession,
) -> User:
existing = await db.scalar(select(User).where(User.email == data.email))
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
company = await self._get_company(company_id, db)
personnel_number = data.personnel_number
if personnel_number:
await self._check_personnel_unique(company_id, personnel_number, db)
else:
if company.personnel_number_mode == PersonnelNumberMode.AUTO.value:
personnel_number = await self._next_personnel_number(company_id, db)
elif company.personnel_number_required:
raise HTTPException(
status_code=400,
detail="Personalnummer ist in dieser Firma Pflicht.",
)
if data.initial_password:
# Direktanlage mit Passwort sofort aktiv, kein E-Mail-Invite
user = User(
company_id=company_id,
email=data.email,
first_name=data.first_name,
last_name=data.last_name,
role=data.role,
department_id=data.department_id,
personnel_number=personnel_number,
password_hash=hash_password(data.initial_password),
is_active=True,
)
db.add(user)
await db.flush()
db.add(AuditLog(
company_id=company_id,
user_id=invited_by.id,
action="user_created",
entity_type="user",
entity_id=user.id,
new_value={"email": user.email, "role": user.role.value, "direct": True},
))
else:
raw_token, token_hash = generate_invite_token()
user = User(
company_id=company_id,
email=data.email,
first_name=data.first_name,
last_name=data.last_name,
role=data.role,
department_id=data.department_id,
personnel_number=personnel_number,
password_hash=hash_password(raw_token), # Temp overwritten on accept
invite_token_hash=token_hash,
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
is_active=False,
)
db.add(user)
await db.flush()
db.add(AuditLog(
company_id=company_id,
user_id=invited_by.id,
action="user_invited",
entity_type="user",
entity_id=user.id,
new_value={"email": user.email, "role": user.role.value},
))
await email_service.send_invite(user, invited_by, raw_token, db)
return user
async def accept_invite(self, data: InviteAccept, db: AsyncSession) -> User:
token_hash = hash_token(data.token)
user = await db.scalar(
select(User).where(User.invite_token_hash == token_hash)
)
if not user:
raise HTTPException(status_code=400, detail="Invalid invite token")
if user.invite_expires and user.invite_expires < datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="Invite token expired")
user.password_hash = hash_password(data.password)
user.invite_token_hash = None
user.invite_expires = None
user.is_active = True
return user
# ── Listing ───────────────────────────────────────────────────────────────
async def list_users(
self,
company_id: UUID,
db: AsyncSession,
skip: int = 0,
limit: int = 50,
active_only: bool = True,
search: str | None = None,
) -> tuple[int, list[User]]:
q = select(User).where(User.company_id == company_id)
if active_only:
q = q.where(User.is_active == True)
if search:
pattern = f"%{search.strip()}%"
q = q.where(
or_(
User.email.ilike(pattern),
User.first_name.ilike(pattern),
User.last_name.ilike(pattern),
User.personnel_number.ilike(pattern),
)
)
total = await db.scalar(select(func.count()).select_from(q.subquery()))
users = await db.scalars(q.offset(skip).limit(limit))
return total, list(users.all())
async def get_by_id(self, user_id: UUID, company_id: UUID, db: AsyncSession) -> User:
user = await db.get(User, user_id)
if not user or user.company_id != company_id:
raise HTTPException(status_code=404, detail="User not found")
return user
# ── Update / De/Reactivate ────────────────────────────────────────────────
async def update(
self,
user_id: UUID,
data: UserUpdate,
current_user: User,
db: AsyncSession,
) -> User:
user = await self.get_by_id(user_id, current_user.company_id, db)
changes = data.model_dump(exclude_unset=True)
old_personnel = user.personnel_number
if "personnel_number" in changes:
new_value = changes["personnel_number"]
if new_value:
await self._check_personnel_unique(
current_user.company_id, new_value, db, exclude_user_id=user.id
)
elif user.personnel_number is not None:
# Explizites Löschen erlauben? Plan sagt Reservierung wir verbieten Clear.
raise HTTPException(
status_code=400,
detail="Personalnummer kann nicht gelöscht werden (Reservierung).",
)
for field, value in changes.items():
setattr(user, field, value)
if "personnel_number" in changes and changes["personnel_number"] != old_personnel:
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="user_personnel_number_changed",
entity_type="user",
entity_id=user.id,
old_value={"personnel_number": old_personnel},
new_value={"personnel_number": user.personnel_number},
))
return user
async def deactivate(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
user = await self.get_by_id(user_id, current_user.company_id, db)
user.is_active = False
return user
async def reactivate(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
user = await self.get_by_id(user_id, current_user.company_id, db)
user.is_active = True
return user
# ── Kiosk ─────────────────────────────────────────────────────────────────
async def set_kiosk_pin(self, user: User, pin: str, db: AsyncSession) -> None:
user.kiosk_pin_hash = hash_password(pin)
async def verify_kiosk_pin(self, user: User, pin: str) -> bool:
if not user.kiosk_pin_hash:
return False
return verify_password(pin, user.kiosk_pin_hash)
user_service = UserService()