- 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>
17 KiB
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_whereKlausel 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.tsverwenden (handhabt Token-Refresh automatisch) - Fehlermeldungen aus
error.response?.data?.detailextrahieren
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 Featurefix:Bugfixrefactor:Code-Umstrukturierung ohne Verhaltensänderungtest:Testzusätze/-korrekturenchore: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:
db.flush()vor dem commit() für sofortige ID-Zuweisung- Benötigte Attribute vor dem commit() lesen
expire_on_commit=Falsestellt 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 (S² × 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.