Files
timemaster/docs/development.md
T
patrick ada1b51f33 docs: vollständige Projektdokumentation hinzugefügt
- docs/api.md: komplette API-Referenz (1375 Zeilen, alle Endpunkte)
- docs/architecture.md: Tech-Stack, DB-Schema, RLS-Architektur, Auth-Flow
- docs/deployment.md: Setup, nginx, systemd, update.sh, Backup/Rollback
- docs/development.md: Dev-Setup, Test-Workflow, Code-Konventionen, Fallstricke

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:29:44 +02:00

17 KiB
Raw Blame History

TimeMaster Developer-Guide

Stand: 2026-05-24


Entwicklungsumgebung aufsetzen

Voraussetzungen (lokal)

  • Python 3.12+
  • Node.js 20+ und npm
  • SSH-Zugang zu root@192.168.1.137 (Tests und Datenbank laufen dort)
  • Git-Zugang zu gitea.perlbach24.de/scripte/timemaster.git

Lokale PostgreSQL oder Redis-Instanz ist nicht erforderlich. Alle Backend-Operationen laufen auf dem Entwicklungsserver via SSH.

Repository klonen

git clone git@gitea.perlbach24.de:scripte/timemaster.git
cd timemaster

Frontend lokal einrichten

cd frontend
npm install
cp .env.example .env.local   # falls vorhanden, sonst:
# Inhalt von .env.local:
# VITE_API_URL=http://192.168.1.137:8000
npm run dev   # Vite Dev-Server auf localhost:5173

Das Frontend spricht dann direkt gegen den Backend-Server 137. Für Produktions-Builds:

npm run build   # Output in frontend/dist/

Backend: kein lokales Setup nötig

Backend-Dateien werden bearbeitet und per update.sh auf den Server synchronisiert. Tests laufen remote (siehe Abschnitt Tests).


Tests ausführen

Tests laufen ausschließlich auf root@192.168.1.137. Nie lokal die Tests benötigen eine PostgreSQL-Instanz mit der timemaster_test-Datenbank sowie Redis.

# Alle Tests
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -v"

# Schnell (bricht beim ersten Fehler ab)
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -x -q"

# Einzelne Testdatei
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -v tests/test_absences.py"

# Einzelner Test
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -v tests/test_absences.py::test_quick_sick"

pytest-Konfiguration

pytest.ini / pyproject.toml enthält:

[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = session

Alle Fixtures in conftest.py haben scope="session" und loop_scope="session". Das ist zwingend erforderlich für pytest-asyncio 1.x mit asyncpg ein einzelner Event-Loop wird für die gesamte Test-Session geteilt.

Wichtige Fixtures in conftest.py

setup_db      # scope=session, autouse=True
              # Legt Test-DB-Schema neu an (DROP+CREATE SCHEMA public)
              # Wendet alle RLS-Policies an (identisch zu Migration 0024)

db_session    # scope=session
              # Gemeinsame AsyncSession für alle Tests

client        # scope=session
              # httpx AsyncClient mit ASGI-Transport (kein echter HTTP-Stack)
              # Override get_db → nutzt db_session
              # Rate-Limiter wird deaktiviert

registered_user  # scope=session
                 # Legt einmalig eine Firma "Test GmbH" + Admin-User an
                 # Gibt { tokens, user } zurück

RLS in Tests

Die Test-DB-Session setzt bypass_rls = 'on' alle Test-Queries sind damit nicht durch RLS eingeschränkt. Das entspricht dem Verhalten von unauthentifizierten Routen im Produktivbetrieb. Tests, die RLS-Verhalten prüfen wollen, müssen app.company_id explizit setzen.


Neuen API-Endpunkt anlegen

Schritt 1: Schema (Pydantic)

Datei: backend/app/schemas/<modul>.py

from pydantic import BaseModel, UUID4
from datetime import datetime

class MyResourceCreate(BaseModel):
    name: str
    company_id: UUID4  # bei manuellen Creates; bei auth-geschützten Endpunkten aus current_user

class MyResourceOut(BaseModel):
    id: UUID4
    name: str
    created_at: datetime

    model_config = {"from_attributes": True}

Pydantic v2: model_validate(obj) statt .from_orm(obj). from_attributes = True aktiviert ORM-Mode.

Schritt 2: Model (SQLAlchemy)

Datei: backend/app/models/<modul>.py

import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base

class MyResource(Base):
    __tablename__ = "my_resources"

    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"))
    name: Mapped[str] = mapped_column(String(255), nullable=False)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

Model in backend/app/models/__init__.py importieren.

Schritt 3: Service

Datei: backend/app/services/<modul>_service.py

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import HTTPException
from app.models.my_resource import MyResource
from app.schemas.my_module import MyResourceCreate

class MyService:
    async def create(self, data: MyResourceCreate, company_id: UUID, db: AsyncSession) -> MyResource:
        obj = MyResource(company_id=company_id, name=data.name)
        db.add(obj)
        await db.flush()   # flush statt commit  commit macht get_db() nach yield
        return obj

    async def get_all(self, company_id: UUID, db: AsyncSession) -> list[MyResource]:
        result = await db.execute(
            select(MyResource).where(MyResource.company_id == company_id)
        )
        return result.scalars().all()

my_service = MyService()

Regel: Kein db.commit() in Services. get_db() committed nach yield. Nur db.flush() in Services, wenn Werte (z.B. generierte IDs) vor dem finalen Commit benötigt werden.

Schritt 4: Router

Datei: backend/app/routers/<modul>.py

from fastapi import APIRouter, Depends
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 UserRole
from app.schemas.my_module import MyResourceCreate, MyResourceOut
from app.services.my_service import my_service

router = APIRouter(prefix="/my-resources", tags=["MyResources"])

@router.get("/", response_model=list[MyResourceOut])
async def list_resources(
    current_user: CurrentUser,
    db: AsyncSession = Depends(get_db),
):
    return await my_service.get_all(current_user.company_id, db)

@router.post("/", response_model=MyResourceOut, status_code=201)
async def create_resource(
    data: MyResourceCreate,
    current_user: CurrentUser = Depends(require_role(UserRole.COMPANY_ADMIN, UserRole.HR)),
    db: AsyncSession = Depends(get_db),
):
    return await my_service.create(data, current_user.company_id, db)

Router in backend/app/main.py registrieren:

from app.routers import my_module
app.include_router(my_module.router, prefix=API_PREFIX)

Schritt 5: Tests

Datei: backend/tests/test_<modul>.py

import pytest
from httpx import AsyncClient

@pytest.mark.asyncio
async def test_create_resource(client: AsyncClient, registered_user):
    headers = {"Authorization": f"Bearer {registered_user['tokens']['access_token']}"}
    resp = await client.post("/api/v1/my-resources/", json={"name": "Test"}, headers=headers)
    assert resp.status_code == 201
    data = resp.json()
    assert data["name"] == "Test"
    assert "id" in data

Neue Alembic-Migration anlegen

ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && \
  alembic revision --autogenerate -m 'add_my_feature'"

Die generierte Datei in backend/migrations/versions/ erscheint auf dem Server. Sie muss dann lokal per git pull geholt werden (oder die Datei manuell kopieren).

Wichtig: Autogenerate erkennt nicht alles zuverlässig. Immer die generierte Datei prüfen, bevor sie eingecheckt wird. Besonders:

  • Partial Unique Indexes (müssen manuell mit postgresql_where Klausel ergänzt werden)
  • CHECK Constraints
  • RLS-Policies (nie autogeneriert immer manuell, wie in 0024 gezeigt)
  • Enum-Änderungen

Migrationsdatei-Namenskonvention: XXXX_kurzbeschreibung.py (fortlaufend, nächste: 0025_...).

Migration testen:

ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic upgrade head"
# Rollback prüfen:
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic downgrade -1"
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic upgrade head"

Neue Frontend-Seite anlegen

1. Page-Komponente

Datei: frontend/src/pages/MyFeaturePage.tsx

import React, { useEffect, useState } from 'react'
import Layout from '../components/Layout'

const MyFeaturePage: React.FC = () => {
  return (
    <Layout>
      <div className="p-6">
        <h1 className="text-2xl font-bold mb-4">Mein Feature</h1>
        {/* Inhalt */}
      </div>
    </Layout>
  )
}

export default MyFeaturePage

2. Route registrieren

frontend/src/App.tsx:

import MyFeaturePage from './pages/MyFeaturePage'

// In der Router-Konfiguration:
<Route path="/my-feature" element={
  <ProtectedRoute roles={['COMPANY_ADMIN', 'HR']}>
    <MyFeaturePage />
  </ProtectedRoute>
} />

3. Navigation in Layout.tsx

frontend/src/components/Layout.tsx Navigation-Links in der Sidebar oder im Settings-Dropdown ergänzen. Sichtbarkeit über currentUser.role steuern.

4. API-Calls

frontend/src/api/myFeature.ts:

import axios from './client'   // konfigurierter Axios-Client mit Bearer-Token

export const getMyResources = () =>
  axios.get('/api/v1/my-resources/').then(r => r.data)

export const createMyResource = (data: { name: string }) =>
  axios.post('/api/v1/my-resources/', data).then(r => r.data)

Code-Konventionen

Backend

Async überall: Alle DB-Operationen nutzen AsyncSession. Kein Mischen von sync/async SQLAlchemy.

# Korrekt
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()

# Falsch
user = db.query(User).filter_by(email=email).first()  # sync API

Fehler als HTTPException:

raise HTTPException(status_code=404, detail="User not found")
raise HTTPException(status_code=403, detail="Insufficient permissions")

Kein raise ValueError() oder return None aus Routers. Services dürfen HTTPException werfen, wenn der Fehler inhärent HTTP-natur hat (z.B. Duplikat-Fehler bei eindeutigem Feld).

Tokens nie im Klartext in DB:

from app.core.security import hash_token
db_token = hash_token(raw_token)   # SHA-256

Kein Hard-Delete bei Users:

user.is_active = False   # Deaktivierung
# Nicht: await db.delete(user)

Pydantic v2:

# Serialisierung
out = MyResourceOut.model_validate(orm_obj)

# Nicht mehr:
out = MyResourceOut.from_orm(orm_obj)   # Pydantic v1 Syntax

AuditLog schreiben bei sensitiven Aktionen:

from app.models.audit_log import AuditLog
audit = AuditLog(
    company_id=current_user.company_id,
    user_id=current_user.id,
    action="user.role_changed",
    entity_type="user",
    entity_id=str(target_user.id),
    old_value={"role": old_role},
    new_value={"role": new_role},
    ip_address=request.client.host,
)
db.add(audit)

Frontend

  • TypeScript strict keine any-Typen
  • Tailwind CSS für Styling keine CSS-Module oder Styled Components
  • Formular-Validierung inline oder mit React Hook Form
  • Axios-Client aus api/client.ts verwenden (handhabt Token-Refresh automatisch)
  • Fehlermeldungen aus error.response?.data?.detail extrahieren

ArbZG-Regeln (Arbeitszeitgesetz)

Implementiert in backend/app/services/time_service.py:

Bedingung Regel Reaktion
Arbeitszeit > 8h (480 min) Maximalarbeitszeit überschritten Warnung
Arbeitszeit > 10h (600 min) Absolutes Maximum Warnung + Flag
Arbeitszeit >= 6h, Pause < 30 min Pflichtpause ab 6h fehlt Warnung
Arbeitszeit >= 9h, Pause < 45 min Pflichtpause ab 9h fehlt Warnung
Zeit zwischen Schichtende und nächstem Beginn < 11h Ruhezeit unterschritten Warnung

Warnungen werden in time_entries.arbzg_warning gespeichert. Es gibt kein hartes Blockieren Manager und HR sehen die Warnungen und können reagieren.


RLS-Entwicklungshinweise

db.refresh() nach commit() ist verboten

# FALSCH  führt zu Fehler oder leerem Ergebnis wegen RLS-Kontext-Verlust
await db.commit()
await db.refresh(obj)   # zweiter SELECT außerhalb Transaktionskontext

# RICHTIG  Werte vor commit() sichern mit flush()
await db.flush()        # schreibt in DB, commit noch ausstehend
obj_id = obj.id         # ID jetzt verfügbar (durch flush zugewiesen)
# Werte, die nach commit() gebraucht werden, vor dem commit() lesen
await db.commit()
# obj.id ist immer noch verfügbar wegen expire_on_commit=False

expire_on_commit=False

Die Session-Factory ist mit expire_on_commit=False konfiguriert. Das bedeutet: Attribute werden nach einem commit() nicht als "expired" markiert. Bereits geladene Werte bleiben im Python-Objekt erhalten. Ein erneuter SELECT (durch Attributzugriff nach commit) findet nicht statt.

SET LOCAL gilt nur für die aktuelle Transaktion

# In get_db():
await session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
yield session
# Nach yield: commit → Transaktion endet → SET LOCAL wird zurückgesetzt

Bei neuen Requests beginnt immer eine frische Transaktion mit bypass_rls = 'on'. get_current_user() setzt dann ggf. bypass_rls = 'off' und company_id.

SUPER_ADMIN und company_id = None

SUPER_ADMIN hat company_id = None. Für diesen User bleibt bypass_rls = 'on'. Services müssen prüfen, ob ein SUPER_ADMIN-Request eine company_id als Query-Parameter übergibt, wenn er firmenbezogene Daten abfragt.


Git-Workflow

Branch-Strategie

Alle Änderungen landen auf main. Kein Feature-Branch-Workflow in Phase 1.

# Vor jeder Änderung: aktuellen Stand holen
git pull origin main

# Änderungen committen
git add -p   # interaktiv (oder git add <datei>)
git commit -m "feat: kurze Beschreibung was geändert wurde"

# Auf Gitea pushen
git push origin main

Commit-Messages folgen Conventional Commits:

  • feat: neues Feature
  • fix: Bugfix
  • refactor: Code-Umstrukturierung ohne Verhaltensänderung
  • test: Testzusätze/-korrekturen
  • chore: Wartungsarbeiten (Dependencies, Konfiguration)

Deployment nach Push

./update.sh   # deployt auf beide Server

Bekannte Fallstricke

Saturday/Sunday in Tests

Abwesenheits-Berechnungen überspringen Wochenenden. Wenn ein Test-Datum auf ein Wochenende fällt, ist working_days = 0. Testdaten immer mit Wochentagen (Mo-Fr) anlegen oder dynamisch berechnen:

from datetime import date, timedelta

def next_monday():
    today = date.today()
    days_ahead = 7 - today.weekday()  # 0=Monday
    return today + timedelta(days=days_ahead % 7 or 7)

Self-Approval

Ein User kann seinen eigenen Abwesenheitsantrag nicht genehmigen. Der absence_service prüft approved_by != user_id. In Tests muss ein separater User mit MANAGER/HR-Rolle für Genehmigungen angelegt werden.

RLS-Kontext nach commit()

Wie oben beschrieben: db.refresh(obj) nach db.commit() schlägt fehl oder liefert leere Ergebnisse, weil der RLS-Kontext (SET LOCAL) transaktionsgebunden ist und nach dem commit() zurückgesetzt wurde. Statt dessen:

  1. db.flush() vor dem commit() für sofortige ID-Zuweisung
  2. Benötigte Attribute vor dem commit() lesen
  3. expire_on_commit=False stellt sicher, dass bereits geladene Attribute erhalten bleiben

LDAP-Authentifizierung und Tests

LDAP-Tests benötigen einen erreichbaren LDAP-Server oder müssen gemockt werden. Standardmäßig sind LDAP-Tests in der Test-Suite mit pytest.mark.skip oder durch fehlende Konfig deaktiviert.

Alembic-Kette und Migration 0017

Migration 0017 existiert nicht die Nummer wurde übersprungen. Die Alembic-Kette ist: ...0016 → 0018 → 0019 → 0020 → 0022 → 0023 → 0024. Neue Migrationen chained auf 0024.

Bradford-Faktor Berechnung

Der Bradford-Faktor (× D) nutzt ein rollendes 12-Monats-Fenster ab ref_date. ref_date ist optional und defaulted auf date.today(). Bei Tests immer ein explizites ref_date übergeben, da sich sonst der Test-Zeitraum mit der Zeit verschiebt.

Rate-Limiter in Tests

limiter.enabled = False wird in conftest.py gesetzt. Falls ein neuer Router Rate-Limiting hinzufügt, ist das in Tests automatisch deaktiviert. In manuellen Integration-Tests gegen den echten Server gelten die Limits.

Passwort-Validierung

Min. 8 Zeichen, 1 Großbuchstabe, 1 Ziffer. Diese Regel ist in security.py implementiert und gilt für Register, Invite-Accept und Change-Password. Schwache Test-Passwörter wie password123 scheitern mindestens Password1 oder Secret123 verwenden.