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

532 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```bash
git clone git@gitea.perlbach24.de:scripte/timemaster.git
cd timemaster
```
### Frontend lokal einrichten
```bash
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:
```bash
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.
```bash
# 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:
```ini
[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
```python
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`
```python
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`
```python
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`
```python
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`
```python
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:
```python
from app.routers import my_module
app.include_router(my_module.router, prefix=API_PREFIX)
```
### Schritt 5: Tests
Datei: `backend/tests/test_<modul>.py`
```python
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
```bash
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:
```bash
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`
```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`:
```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`:
```typescript
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.
```python
# 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:**
```python
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:**
```python
from app.core.security import hash_token
db_token = hash_token(raw_token) # SHA-256
```
**Kein Hard-Delete bei Users:**
```python
user.is_active = False # Deaktivierung
# Nicht: await db.delete(user)
```
**Pydantic v2:**
```python
# Serialisierung
out = MyResourceOut.model_validate(orm_obj)
# Nicht mehr:
out = MyResourceOut.from_orm(orm_obj) # Pydantic v1 Syntax
```
**AuditLog schreiben bei sensitiven Aktionen:**
```python
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
```python
# 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
```python
# 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.
```bash
# 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
```bash
./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:
```python
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 (`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.