# 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/.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/.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/_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/.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_.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 (

Mein Feature

{/* Inhalt */}
) } export default MyFeaturePage ``` ### 2. Route registrieren `frontend/src/App.tsx`: ```tsx import MyFeaturePage from './pages/MyFeaturePage' // In der Router-Konfiguration: } /> ``` ### 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 ) 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.