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