Initial commit – TimeMaster Zeiterfassung & HR-Tool

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
View File
+54
View File
@@ -0,0 +1,54 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import model_validator
from functools import lru_cache
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# App
app_name: str = "TimeMaster"
app_env: str = "development"
secret_key: str = "change-me-in-production"
frontend_url: str = "http://localhost:5173"
allowed_hosts: list[str] = []
# Database
database_url: str = "postgresql+asyncpg://timemaster:secret@localhost:5432/timemaster_db"
# Redis
redis_url: str = "redis://localhost:6379/0"
# JWT
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 30
algorithm: str = "HS256"
# Email
resend_api_key: str = ""
email_from: str = "noreply@timemaster.app"
email_from_name: str = "TimeMaster"
# First superadmin
first_superadmin_email: str = ""
first_superadmin_password: str = ""
@model_validator(mode='after')
def validate_secret_key(self):
if self.app_env == 'production' and self.secret_key == 'change-me-in-production':
raise ValueError('SECRET_KEY must be changed in production! Set SECRET_KEY env variable.')
if len(self.secret_key) < 32:
raise ValueError('SECRET_KEY must be at least 32 characters long.')
return self
@property
def is_production(self) -> bool:
return self.app_env == "production"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+33
View File
@@ -0,0 +1,33 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(
settings.database_url,
echo=settings.app_env == "development",
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+63
View File
@@ -0,0 +1,63 @@
from typing import Annotated
from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.user import User, UserRole
bearer_scheme = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(credentials.credentials)
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = await db.get(User, UUID(user_id))
if user is None or not user.is_active:
raise credentials_exception
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def require_role(*roles: UserRole):
"""Dependency factory: require_role(UserRole.MANAGER, UserRole.COMPANY_ADMIN)"""
async def checker(current_user: CurrentUser) -> User:
if current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions",
)
return current_user
return Depends(checker)
def require_same_company(target_company_id: UUID, current_user: User) -> None:
"""Raise 403 if user tries to access another company's data."""
if (
current_user.role != UserRole.SUPER_ADMIN
and current_user.company_id != target_company_id
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access to this resource is not allowed",
)
+4
View File
@@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+81
View File
@@ -0,0 +1,81 @@
from datetime import datetime, timedelta, timezone
from typing import Any
import secrets
import hashlib
import bcrypt
from jose import JWTError, jwt
from app.core.config import settings
# ── Password ────────────────────────────────────────────────────────────────
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
def hash_token(token: str) -> str:
"""SHA-256 hash for storing tokens (refresh, invite, reset) in DB."""
return hashlib.sha256(token.encode()).hexdigest()
# ── JWT ─────────────────────────────────────────────────────────────────────
def create_access_token(subject: str, extra: dict[str, Any] | None = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
payload = {"sub": subject, "exp": expire, "type": "access"}
if extra:
payload.update(extra)
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token() -> tuple[str, str]:
"""Returns (raw_token, hashed_token). Store hash in DB, send raw to client."""
raw = secrets.token_urlsafe(64)
return raw, hash_token(raw)
def decode_access_token(token: str) -> dict[str, Any]:
"""Raises JWTError on invalid/expired token."""
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("type") != "access":
raise JWTError("Invalid token type")
return payload
# ── Partial token (TOTP pending) ─────────────────────────────────────────────
def create_partial_token(user_id: str) -> str:
"""Short-lived token issued after password-OK but before TOTP verification. Valid 5 min."""
expire = datetime.now(timezone.utc) + timedelta(minutes=5)
payload = {"sub": user_id, "exp": expire, "type": "partial"}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_partial_token(token: str) -> str:
"""Returns user_id (sub). Raises JWTError on invalid/expired/wrong-type token."""
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("type") != "partial":
raise JWTError("Invalid token type")
return payload["sub"]
# ── One-time tokens ──────────────────────────────────────────────────────────
def generate_invite_token() -> tuple[str, str]:
"""Returns (raw, hashed). Invite valid for 7 days."""
raw = secrets.token_urlsafe(32)
return raw, hash_token(raw)
def generate_reset_token() -> tuple[str, str]:
"""Returns (raw, hashed). Reset valid for 1 hour."""
raw = secrets.token_urlsafe(32)
return raw, hash_token(raw)