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>
This commit is contained in:
@@ -654,3 +654,67 @@ Keine Commits in dieser Session.
|
||||
- backend/tests/conftest.py | 4 +
|
||||
|
||||
---
|
||||
## 2026-05-23 22:00 – 22:35 (34m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
- dd3e069 fix: router db.refresh() nach commit bricht RLS-Kontext
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 16 ++
|
||||
- backend/app/routers/absences.py | 10 -
|
||||
- backend/app/routers/caldav.py | 2 -
|
||||
- backend/app/routers/kiosk.py | 3 -
|
||||
- backend/app/routers/ldap.py | 2 -
|
||||
- backend/app/routers/projects.py | 2 -
|
||||
- backend/app/routers/smtp.py | 1 -
|
||||
- backend/app/routers/time_entries.py | 10 -
|
||||
- backend/migrations/env.py | 4 -
|
||||
- .../migrations/versions/0024_row_level_security.py | 223 +++++----------------
|
||||
- backend/tests/conftest.py | 48 +++++
|
||||
- backend/tests/test_rls.py | 190 ++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-05-23 22:43 – 22:52 (9m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** frontend
|
||||
|
||||
### Commits
|
||||
- eb12280 fix: 8 pre-existing Test-Fehler behoben
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/app/services/absence_service.py | 3 ++-
|
||||
- backend/tests/test_absences.py | 30 +++++++++++++++++++++++++-----
|
||||
- backend/tests/test_reports.py | 2 +-
|
||||
- backend/tests/test_time.py | 25 ++++++++++++++++++++++---
|
||||
|
||||
---
|
||||
## 2026-05-23 23:08 – 23:08 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/app/services/absence_service.py | 3 ++-
|
||||
- backend/tests/test_absences.py | 30 +++++++++++++++++++++++++-----
|
||||
- backend/tests/test_reports.py | 2 +-
|
||||
- backend/tests/test_time.py | 25 ++++++++++++++++++++++---
|
||||
|
||||
---
|
||||
## 2026-05-23 23:12 – 10:21 (11h 09m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/app/services/absence_service.py | 3 ++-
|
||||
- backend/tests/test_absences.py | 30 +++++++++++++++++++++++++-----
|
||||
- backend/tests/test_reports.py | 2 +-
|
||||
- backend/tests/test_time.py | 25 ++++++++++++++++++++++---
|
||||
|
||||
---
|
||||
|
||||
+1375
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
||||
# TimeMaster – Architektur-Dokumentation
|
||||
|
||||
Stand: 2026-05-24
|
||||
|
||||
---
|
||||
|
||||
## Tech-Stack
|
||||
|
||||
| Schicht | Technologie | Version |
|
||||
|---------|-------------|---------|
|
||||
| Backend | Python / FastAPI | 3.12 / 0.115+ |
|
||||
| ORM | SQLAlchemy async | 2.x |
|
||||
| Datenbank | PostgreSQL | 16 |
|
||||
| Cache / Sessions | Redis | 7 |
|
||||
| Frontend | React + TypeScript | 18 / 5 |
|
||||
| CSS-Framework | Tailwind CSS | 3 |
|
||||
| Build-Tool | Vite | 5 |
|
||||
| Prozess-Manager | systemd | – |
|
||||
| Reverse Proxy | nginx | 1.24+ |
|
||||
| Migrationen | Alembic | 1.x |
|
||||
| Authentifizierung | JWT (python-jose) + bcrypt | – |
|
||||
| 2FA | pyotp (TOTP / RFC 6238) | – |
|
||||
| E-Mail | Resend.com HTTP API | – |
|
||||
| Tests | pytest + pytest-asyncio + httpx | – |
|
||||
|
||||
---
|
||||
|
||||
## Verzeichnisstruktur
|
||||
|
||||
```
|
||||
/opt/timemaster/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── config.py # Pydantic Settings (liest .env)
|
||||
│ │ │ ├── database.py # Engine, AsyncSessionLocal, Base
|
||||
│ │ │ ├── dependencies.py # FastAPI Depends: get_current_user, require_role
|
||||
│ │ │ ├── limiter.py # slowapi Rate-Limiter
|
||||
│ │ │ └── security.py # JWT, Passwort-Hash, Token-Utilities
|
||||
│ │ ├── models/
|
||||
│ │ │ ├── company.py # Company, Department
|
||||
│ │ │ ├── user.py # User, UserRole, AuthProvider
|
||||
│ │ │ ├── session.py # Refresh-Token-Sessions
|
||||
│ │ │ ├── password_reset.py # Passwort-Reset-Tokens
|
||||
│ │ │ ├── audit_log.py # AuditLog
|
||||
│ │ │ ├── absence.py # Absence, AbsenceStatus
|
||||
│ │ │ ├── absence_type.py # AbsenceType
|
||||
│ │ │ ├── vacation_balance.py
|
||||
│ │ │ ├── public_holiday.py # Feiertage
|
||||
│ │ │ ├── overtime_balance.py
|
||||
│ │ │ ├── time_entry.py # TimeEntry, WorkSchedule
|
||||
│ │ │ ├── work_schedule.py
|
||||
│ │ │ ├── kiosk_device.py
|
||||
│ │ │ ├── caldav_config.py # CalDAV-Konfig (Firma + User)
|
||||
│ │ │ ├── ldap_config.py
|
||||
│ │ │ ├── smtp_config.py
|
||||
│ │ │ └── project.py # (derzeit nicht aktiv, Migration 0014 entfernt)
|
||||
│ │ ├── schemas/ # Pydantic v2 Ein-/Ausgabe-Schemas
|
||||
│ │ │ ├── auth.py
|
||||
│ │ │ ├── user.py
|
||||
│ │ │ ├── absence.py
|
||||
│ │ │ ├── time_entry.py
|
||||
│ │ │ └── ...
|
||||
│ │ ├── routers/ # FastAPI APIRouter – ein File pro Modul
|
||||
│ │ │ ├── auth.py # /api/v1/auth/*
|
||||
│ │ │ ├── users.py # /api/v1/users/*
|
||||
│ │ │ ├── companies.py # /api/v1/companies/*
|
||||
│ │ │ ├── absences.py # /api/v1/absences/*
|
||||
│ │ │ ├── time_entries.py # /api/v1/time/*
|
||||
│ │ │ ├── kiosk.py # /api/v1/kiosk/*
|
||||
│ │ │ ├── reports.py # /api/v1/reports/*
|
||||
│ │ │ ├── audit.py # /api/v1/audit-logs/
|
||||
│ │ │ ├── caldav.py
|
||||
│ │ │ ├── ldap.py
|
||||
│ │ │ ├── smtp.py
|
||||
│ │ │ └── busylight.py
|
||||
│ │ └── services/ # Business-Logik – kein HTTP-Wissen
|
||||
│ │ ├── auth_service.py
|
||||
│ │ ├── user_service.py
|
||||
│ │ ├── absence_service.py
|
||||
│ │ ├── time_service.py # inkl. ArbZG-Checks
|
||||
│ │ ├── email_service.py
|
||||
│ │ ├── ldap_service.py
|
||||
│ │ └── caldav_service.py
|
||||
│ ├── migrations/
|
||||
│ │ ├── env.py # Alembic-Konfiguration
|
||||
│ │ └── versions/ # 0001–0024 Migrationsdateien
|
||||
│ ├── tests/
|
||||
│ │ ├── conftest.py # pytest-Fixtures (session-scope, RLS-Setup)
|
||||
│ │ ├── test_auth.py
|
||||
│ │ ├── test_users.py
|
||||
│ │ ├── test_absences.py
|
||||
│ │ └── ...
|
||||
│ ├── .env # Umgebungsvariablen (nie committen)
|
||||
│ ├── alembic.ini
|
||||
│ └── requirements.txt
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # Axios-Client-Funktionen pro Ressource
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── Layout.tsx # Haupt-Layout mit Navigation
|
||||
│ │ │ ├── Modal.tsx
|
||||
│ │ │ ├── ProtectedRoute.tsx
|
||||
│ │ │ ├── Spinner.tsx
|
||||
│ │ │ └── absences/ # Abwesenheits-Unterkomponenten
|
||||
│ │ ├── context/ # React Context: AuthContext
|
||||
│ │ ├── hooks/ # Custom Hooks (useAuth, useAbsences, ...)
|
||||
│ │ ├── pages/ # Eine Datei pro Seite/Route
|
||||
│ │ │ ├── LoginPage.tsx
|
||||
│ │ │ ├── DashboardPage.tsx
|
||||
│ │ │ ├── TimeTrackingPage.tsx
|
||||
│ │ │ ├── AbsencesPage.tsx
|
||||
│ │ │ ├── UsersPage.tsx
|
||||
│ │ │ ├── ReportsPage.tsx
|
||||
│ │ │ ├── ProfilePage.tsx
|
||||
│ │ │ ├── CompanySettingsPage.tsx
|
||||
│ │ │ ├── KioskDevicesPage.tsx
|
||||
│ │ │ ├── AuditLogPage.tsx
|
||||
│ │ │ └── ...
|
||||
│ │ ├── types/ # TypeScript-Interfaces
|
||||
│ │ ├── utils/ # Hilfsfunktionen
|
||||
│ │ ├── App.tsx # Router-Konfiguration
|
||||
│ │ └── main.tsx
|
||||
│ ├── dist/ # Build-Output (von nginx ausgeliefert)
|
||||
│ └── package.json
|
||||
├── nginx.conf
|
||||
├── timemaster.service
|
||||
├── setup_server.sh
|
||||
└── update.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Datenbankschema
|
||||
|
||||
### Tabellenübersicht und Beziehungen
|
||||
|
||||
```
|
||||
companies
|
||||
├──< departments (company_id)
|
||||
├──< users (company_id)
|
||||
│ ├──< sessions (user_id) -- Refresh-Token-Sessions
|
||||
│ ├──< password_resets (user_id)
|
||||
│ ├──< time_entries (user_id)
|
||||
│ ├──< absences (user_id)
|
||||
│ │ └──> absence_types (type_id)
|
||||
│ ├──< vacation_balances (user_id)
|
||||
│ └── work_schedule_id → work_schedules
|
||||
├──< work_schedules (company_id)
|
||||
├──< kiosk_devices (company_id)
|
||||
├──< audit_logs (company_id)
|
||||
├──< overtime_balances (company_id)
|
||||
├── ldap_configs
|
||||
├── smtp_configs
|
||||
└── caldav_company_configs
|
||||
```
|
||||
|
||||
### Wichtige Tabellen
|
||||
|
||||
**companies** – Mandanten-Stammdaten
|
||||
- `id` UUID PK
|
||||
- `name`, `slug` (URL-freundlich, unique)
|
||||
- `sick_note_required_after_days` INTEGER DEFAULT 3
|
||||
- `personnel_number_required` BOOLEAN DEFAULT FALSE
|
||||
- `personnel_number_mode` VARCHAR(10) DEFAULT 'manual'
|
||||
- `personnel_number_next` INTEGER DEFAULT 1
|
||||
|
||||
**users**
|
||||
- `id`, `company_id`, `department_id`
|
||||
- `email` unique, `password_hash`, `first_name`, `last_name`
|
||||
- `role` ENUM: SUPER_ADMIN | COMPANY_ADMIN | HR | MANAGER | EMPLOYEE
|
||||
- `auth_provider` ENUM: local | ldap
|
||||
- `personnel_number` VARCHAR(50) – nur Ziffern, partial unique per Firma
|
||||
- `totp_secret`, `totp_enabled` – TOTP 2FA
|
||||
- `kiosk_pin_hash`, `kiosk_qr_token`
|
||||
- `can_manual_time_entry` BOOLEAN DEFAULT FALSE
|
||||
- `is_active` BOOLEAN – kein Hard-Delete
|
||||
- `created_at`
|
||||
|
||||
**sessions** – Refresh-Token-Verwaltung
|
||||
- `id`, `user_id`, `token_hash` (gehashter Refresh-Token)
|
||||
- `expires_at`, `created_at`, `ip_address`, `user_agent`
|
||||
- Bei Rotation: altes Token wird gelöscht, neues wird angelegt
|
||||
|
||||
**absence_types**
|
||||
- `company_id`, `name`, `code`
|
||||
- `affects_vacation` BOOLEAN – zieht vom Urlaubskonto ab
|
||||
- `requires_approval` BOOLEAN
|
||||
- `certificate_after_days` INTEGER NULL – per-Typ-Override für AU-Pflicht
|
||||
- `color` – UI-Darstellung
|
||||
|
||||
**absences**
|
||||
- `user_id`, `type_id`
|
||||
- `start_date`, `end_date`, `half_day_start`, `half_day_end`
|
||||
- `working_days` NUMERIC(5,1)
|
||||
- `status` ENUM: pending | approved | rejected | cancelled
|
||||
- `approved_by`, `substitute_id`
|
||||
- `certificate_required_by` DATE – auto-berechnet bei SICK-Absences
|
||||
- `certificate_received` BOOLEAN DEFAULT FALSE
|
||||
- `meta` JSONB – flexible Zusatzdaten (Weiterbildung, Dienstreise etc.)
|
||||
|
||||
**vacation_balances**
|
||||
- `user_id`, `year`
|
||||
- `base_days`, `special_days`, `carried_over_days`, `used_days`
|
||||
- `comment`
|
||||
|
||||
**time_entries**
|
||||
- `user_id`, `date`
|
||||
- `start_time`, `end_time`, `break_minutes`
|
||||
- `working_minutes` COMPUTED
|
||||
- `status` ENUM: draft | submitted | approved | rejected
|
||||
- `source` ENUM: manual | stamp | import
|
||||
- `arbzg_warning` – ArbZG-Verletzungs-Flag
|
||||
|
||||
**work_schedules**
|
||||
- `company_id`, `name`
|
||||
- `days_per_week`, wöchentliche Soll-Stunden
|
||||
- Für ArbZG-Berechnung und Überstunden-Balance
|
||||
|
||||
**kiosk_devices**
|
||||
- `company_id`, `name`, `location`
|
||||
- `device_token_hash` – aktuell TOKEN-basiert (agent-08: Ed25519-Upgrade geplant)
|
||||
- `is_active` – wird in 0021 durch `status` ENUM ersetzt
|
||||
|
||||
**audit_logs**
|
||||
- `company_id`, `user_id`
|
||||
- `action`, `entity_type`, `entity_id`
|
||||
- `old_value`, `new_value` JSONB
|
||||
- `ip_address`, `created_at`
|
||||
|
||||
**public_holidays** – global (keine RLS), pro Bundesland/Datum
|
||||
|
||||
### Alembic-Migrationskette
|
||||
|
||||
```
|
||||
0001_initial companies, departments, users, sessions, password_resets, audit_logs
|
||||
0002_time_entries time_entries, work_schedules
|
||||
0003_absences absence_types, absences, vacation_balances, public_holidays
|
||||
0004_ldap ldap_configs
|
||||
0005_extensions Erweiterungsfelder (company/user)
|
||||
0006_smtp smtp_configs
|
||||
0007_caldav_and_fixes caldav_company_configs, caldav_user_configs; Bugfixes
|
||||
0008_ldap_tls_verify ldap_configs.tls_verify Spalte
|
||||
0009_absence_correction absences.correction_note
|
||||
0010_public_holidays public_holidays.federal_state
|
||||
0011_caldav_name_format caldav Name-Format-Felder
|
||||
0012_caldav_template CalDAV-Template + users.kuerzel
|
||||
0013_projects projects Tabelle (temporär)
|
||||
0014_remove_projects projects Tabelle entfernt (Feature verschoben)
|
||||
0015_totp users.totp_secret, totp_enabled
|
||||
0016_vacation_special vacation_balances.special_days + Feiertagskalender-Erweiterungen
|
||||
[0017 übersprungen] Nummer wurde nie in die Alembic-Kette aufgenommen
|
||||
0018_kiosk_devices kiosk_devices Tabelle
|
||||
0019_manual_time_entry users.can_manual_time_entry
|
||||
0020_personnel_number users.personnel_number, companies.personnel_number_*
|
||||
0021_kiosk_security TODO (agent-08): Ed25519 Public-Key, Enrollment, Heartbeat
|
||||
0022_sick_note_config companies.sick_note_required_after_days
|
||||
0023_busylight_pull companies.busylight_pull_token
|
||||
0024_row_level_security PostgreSQL RLS auf allen Tabellen
|
||||
```
|
||||
|
||||
Hinweis: Migration 0017 wurde übersprungen und existiert nicht in der Kette. Neuen Migrationen folgen auf 0024.
|
||||
|
||||
---
|
||||
|
||||
## Row Level Security (RLS)
|
||||
|
||||
Seit Migration 0024 setzt TimeMaster PostgreSQL Row Level Security für vollständige Mandanten-Isolation auf Datenbankebene ein. Selbst bei einem Fehler in der Applikationsschicht kann kein User Daten einer anderen Firma sehen.
|
||||
|
||||
### Prinzip
|
||||
|
||||
Jede Tabelle hat vier Policies (SELECT, INSERT, UPDATE, DELETE). Die Policies prüfen zwei PostgreSQL-Session-Variablen:
|
||||
|
||||
```sql
|
||||
-- Bypass-Flag: 'on' = alle Zeilen sichtbar (für unauthentifizierte Endpunkte)
|
||||
app.bypass_rls
|
||||
|
||||
-- Company-ID des eingeloggten Users (UUID als Text)
|
||||
app.company_id
|
||||
```
|
||||
|
||||
### Policy-Typen
|
||||
|
||||
**COMPANY_COL** – Tabellen mit direkter `company_id`-Spalte:
|
||||
`absence_types, audit_logs, caldav_company_configs, departments, kiosk_devices, ldap_configs, overtime_balances, smtp_configs, users, work_schedules`
|
||||
|
||||
```sql
|
||||
(COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on')
|
||||
OR company_id = NULLIF(current_setting('app.company_id', true), '')::uuid
|
||||
```
|
||||
|
||||
**USER_JOIN** – Tabellen ohne direkte `company_id`, verknüpft über `user_id`:
|
||||
`absences, caldav_user_configs, password_resets, sessions, time_entries, vacation_balances`
|
||||
|
||||
```sql
|
||||
(bypass) OR user_id IN (SELECT id FROM users WHERE company_id = ...)
|
||||
```
|
||||
|
||||
**companies** selbst – Policy auf `id` (die Firma darf nur sich selbst sehen):
|
||||
```sql
|
||||
(bypass) OR id = NULLIF(current_setting('app.company_id', true), '')::uuid
|
||||
```
|
||||
|
||||
### Ablauf pro HTTP-Request
|
||||
|
||||
1. `get_db()` öffnet eine AsyncSession, setzt `SET LOCAL app.bypass_rls = 'on'`
|
||||
2. `get_current_user()` Dependency lädt den User (bypass noch aktiv)
|
||||
3. Falls der User kein SUPER_ADMIN ist:
|
||||
- `SET LOCAL app.company_id = '<uuid>'`
|
||||
- `SET LOCAL app.bypass_rls = 'off'`
|
||||
4. Alle folgenden Queries in dieser Transaktion sind automatisch auf die Firma des Users gefiltert
|
||||
5. SUPER_ADMIN behält bypass aktiv – kann alle Firmen sehen
|
||||
|
||||
### expire_on_commit=False
|
||||
|
||||
Die `AsyncSessionLocal` ist mit `expire_on_commit=False` konfiguriert. Das ist zwingend notwendig: Nach einem `db.commit()` würde SQLAlchemy sonst alle Attribute als "expired" markieren und beim nächsten Zugriff einen neuen SELECT auslösen. Da der RLS-Kontext (`SET LOCAL`) transaktionsgebunden ist, wäre dieser zweite SELECT ggf. ohne Kontext – und würde entweder leer zurückkehren oder scheitern.
|
||||
|
||||
**Konsequenz:** `db.refresh(obj)` nach einem `commit()` ist verboten. Stattdessen beim `flush()` vor dem Commit die benötigten Werte sichern.
|
||||
|
||||
---
|
||||
|
||||
## Authentifizierungs-Flow
|
||||
|
||||
### Standard-Login (JWT + Refresh-Token)
|
||||
|
||||
```
|
||||
Client FastAPI PostgreSQL / Redis
|
||||
│ │ │
|
||||
│── POST /api/v1/auth/login ─────>│ │
|
||||
│ { email, password } │── SELECT user WHERE email ────>│
|
||||
│ │<─ User-Objekt ─────────────────│
|
||||
│ │── verify_password() │
|
||||
│ │── INSERT sessions (token_hash)─>│
|
||||
│<── { access_token, refresh_token } │
|
||||
│ (JWT 30min, opaque 30 Tage) │
|
||||
│ │ │
|
||||
│── GET /api/v1/... (Bearer)─────>│ │
|
||||
│ │── decode_access_token() │
|
||||
│ │── db.get(User, user_id) │
|
||||
│ │── SET LOCAL app.company_id │
|
||||
│ │── SET LOCAL app.bypass_rls=off │
|
||||
│<── Response ───────────────────│ │
|
||||
│ │ │
|
||||
│── POST /api/v1/auth/refresh────>│ │
|
||||
│ { refresh_token } │── hash_token(refresh_token) │
|
||||
│ │── SELECT sessions WHERE hash──>│
|
||||
│ │── DELETE old session │
|
||||
│ │── INSERT new session │
|
||||
│<── { neue access_token, refresh_token } │
|
||||
```
|
||||
|
||||
- Access Token: JWT (HS256), Payload: `sub` = user_id (UUID), `exp`
|
||||
- Refresh Token: 64-byte-Zufallsstring, in DB nur als SHA-256-Hash gespeichert
|
||||
- Rotation: Jedes Refresh erzeugt ein neues Token-Paar. Das alte Refresh-Token wird sofort invalidiert.
|
||||
|
||||
### TOTP / 2FA-Flow
|
||||
|
||||
Wenn `user.totp_enabled = True`:
|
||||
|
||||
```
|
||||
POST /auth/login → { totp_required: true, partial_token: "<JWT>" }
|
||||
↓
|
||||
POST /auth/login/totp { partial_token, totp_code }
|
||||
↓ pyotp.TOTP(secret).verify(code, valid_window=1)
|
||||
↓
|
||||
{ access_token, refresh_token } (vollständige Session)
|
||||
```
|
||||
|
||||
TOTP-Setup (Profil-Seite):
|
||||
1. `GET /auth/totp/setup` → `{ secret, qr_code_url }` (base32-Secret, otpauth:// URI)
|
||||
2. User scannt QR in Authenticator-App
|
||||
3. `POST /auth/totp/confirm { code }` – verifiziert ersten Code, setzt `totp_enabled = True`
|
||||
4. `POST /auth/totp/disable { password, code }` – deaktiviert 2FA nach Bestätigung
|
||||
|
||||
### Rollen-Hierarchie
|
||||
|
||||
```
|
||||
SUPER_ADMIN
|
||||
└── COMPANY_ADMIN (Vollzugriff eigene Firma, sieht ALLE User der Firma)
|
||||
├── HR (Personalakten, Reports, Attest-Verwaltung)
|
||||
├── MANAGER (Genehmigungen für eigenes Team)
|
||||
└── EMPLOYEE (Eigene Daten, eigene Anträge)
|
||||
```
|
||||
|
||||
SUPER_ADMIN ist der Plattform-Betreiber und hat RLS-Bypass. COMPANY_ADMIN registriert sich selbst beim `/auth/register`-Endpunkt und legt damit automatisch eine neue Firma an.
|
||||
|
||||
---
|
||||
|
||||
## Datenfluss: Zeiterfassung
|
||||
|
||||
```
|
||||
POST /time/stamp-in
|
||||
→ time_service.stamp_in(user_id, db)
|
||||
→ Prüfe: kein offener Eintrag für heute
|
||||
→ INSERT time_entries(user_id, date, start_time, status='draft')
|
||||
|
||||
POST /time/stamp-out
|
||||
→ time_service.stamp_out(user_id, db)
|
||||
→ Hole offenen Eintrag
|
||||
→ Berechne working_minutes = end - start - break_minutes
|
||||
→ ArbZG-Check:
|
||||
if working_minutes > 480: warn (max 8h; bis 600 möglich mit Warnung)
|
||||
if working_minutes > 360 and break_minutes < 30: warn (Pause fehlt ab 6h)
|
||||
if working_minutes > 540 and break_minutes < 45: warn (Pause fehlt ab 9h)
|
||||
→ UPDATE time_entries(end_time, working_minutes, arbzg_warning)
|
||||
|
||||
PATCH /time/entries/{id}/approve [MANAGER/HR/ADMIN]
|
||||
→ UPDATE status = 'approved'
|
||||
→ INSERT audit_log
|
||||
|
||||
PATCH /time/entries/{id}/reject [MANAGER/HR/ADMIN]
|
||||
→ UPDATE status = 'rejected', rejection_reason
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Datenfluss: Abwesenheiten
|
||||
|
||||
```
|
||||
POST /absences (Antrag stellen)
|
||||
→ absence_service.create(data, current_user, db)
|
||||
→ Berechne working_days (Wochentage - Feiertage)
|
||||
→ Prüfe Urlaubskonto falls affects_vacation = True
|
||||
→ Auto-calculate certificate_required_by falls AbsenceType.code = 'SICK'
|
||||
(start_date + sick_note_required_after_days - 1)
|
||||
→ INSERT absences(status='pending')
|
||||
→ INSERT audit_log
|
||||
|
||||
POST /absences/{id}/approve [MANAGER/HR/ADMIN]
|
||||
→ UPDATE absences.status = 'approved', approved_by = current_user.id
|
||||
→ Falls affects_vacation: UPDATE vacation_balances.used_days += working_days
|
||||
→ INSERT audit_log
|
||||
|
||||
POST /absences/quick-sick [EMPLOYEE]
|
||||
→ Holt ersten aktiven SICK-AbsenceType der Firma
|
||||
→ Legt Absence mit status='approved' an (keine Genehmigung nötig)
|
||||
→ Berechnet certificate_required_by
|
||||
|
||||
PATCH /absences/{id}/certificate [HR/ADMIN]
|
||||
→ UPDATE absences.certificate_received = True
|
||||
→ INSERT audit_log
|
||||
|
||||
GET /absences/sick-stats?user_id=&ref_date=
|
||||
→ Berechnet Bradford-Faktor: S² × D
|
||||
S = Episoden (Krankheitsepisoden im rollenden 12-Monats-Fenster)
|
||||
D = Tage (Gesamtkranktage im selben Fenster)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mandanten-Isolation
|
||||
|
||||
Mandanten-Isolation ist in TimeMaster dreischichtig implementiert:
|
||||
|
||||
1. **Applikationsschicht**: Jeder Endpunkt, der Daten schreibt oder liest, wird über `CurrentUser` Dependency abgesichert. Beim Anlegen von Objekten wird immer `company_id = current_user.company_id` gesetzt. Beim Lesen filtert der Service aktiv nach `company_id`.
|
||||
|
||||
2. **Datenbankschicht (RLS)**: PostgreSQL Row Level Security (Migration 0024) verhindert Datenzugriff über Firmengrenzen auf DB-Ebene. Auch direkte psql-Verbindungen mit dem App-User werden gefiltert (FORCE ROW LEVEL SECURITY).
|
||||
|
||||
3. **Session-Kontext**: `SET LOCAL app.company_id` gilt nur für die aktuelle Transaktion. Jede neue Anfrage beginnt mit einem frischen Kontext.
|
||||
|
||||
SUPER_ADMIN ist die einzige Rolle mit `bypass_rls = 'on'` und kann alle Firmen sehen. Diese Rolle ist ausschließlich für den Plattform-Betreiber gedacht.
|
||||
@@ -0,0 +1,429 @@
|
||||
# TimeMaster – Deployment-Guide
|
||||
|
||||
Stand: 2026-05-24
|
||||
|
||||
---
|
||||
|
||||
## Infrastruktur-Übersicht
|
||||
|
||||
| Server | IP | Rolle |
|
||||
|--------|----|-------|
|
||||
| Primary | 192.168.1.137 | Produktion, Tests, Primär-DB |
|
||||
| Secondary | 192.168.1.164 | Replikat / Fallback |
|
||||
|
||||
Beide Server laufen Ubuntu 22.04 oder 24.04 LTS (amd64). Kein Docker in Phase 1 – alle Dienste laufen nativ als systemd-Units.
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Auf jedem Server müssen folgende Pakete installiert sein:
|
||||
|
||||
```bash
|
||||
apt-get install -y \
|
||||
python3 python3-venv python3-dev python3-pip \
|
||||
postgresql postgresql-contrib \
|
||||
redis-server \
|
||||
nginx \
|
||||
git curl build-essential libpq-dev
|
||||
```
|
||||
|
||||
Node.js 20 für den Frontend-Build wird nur auf der Entwicklungsmaschine benötigt, nicht auf den Servern. Das Frontend wird lokal gebaut und als statisches `dist/`-Verzeichnis per rsync übertragen.
|
||||
|
||||
---
|
||||
|
||||
## Erstes Setup (einmalig)
|
||||
|
||||
Das Setup-Skript `setup_server.sh` übernimmt alle Schritte vollautomatisch:
|
||||
|
||||
```bash
|
||||
# Auf dem Server als root
|
||||
bash /opt/timemaster/setup_server.sh
|
||||
```
|
||||
|
||||
### Was das Skript tut
|
||||
|
||||
**Schritt 1 – System-Pakete:**
|
||||
`apt-get update && apt-get upgrade -y` sowie alle oben genannten Abhängigkeiten.
|
||||
|
||||
**Schritt 2 – PostgreSQL:**
|
||||
|
||||
```sql
|
||||
CREATE ROLE timemaster LOGIN PASSWORD 'timemaster_secret_change_me';
|
||||
CREATE DATABASE timemaster_db OWNER timemaster;
|
||||
CREATE DATABASE timemaster_test OWNER timemaster; -- für pytest
|
||||
GRANT ALL PRIVILEGES ON DATABASE timemaster_db TO timemaster;
|
||||
```
|
||||
|
||||
Das Passwort in Produktion sofort nach Setup ändern (siehe `.env`).
|
||||
|
||||
**Schritt 3 – Redis:**
|
||||
`systemctl enable redis-server && systemctl start redis-server`
|
||||
|
||||
**Schritt 4 – Python venv:**
|
||||
```bash
|
||||
cd /opt/timemaster/backend
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**Schritt 5 – Alembic-Migrationen:**
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
Führt alle Migrationen von 0001 bis zur aktuellen Kopfversion aus.
|
||||
|
||||
**Schritt 6 – nginx:**
|
||||
Legt `/etc/nginx/sites-available/timemaster` an, verlinkt nach `sites-enabled` und startet nginx.
|
||||
|
||||
### Manuelles Setup der .env-Datei
|
||||
|
||||
Vor dem ersten Start muss `/opt/timemaster/backend/.env` angelegt werden:
|
||||
|
||||
```bash
|
||||
cp /opt/timemaster/backend/.env.example /opt/timemaster/backend/.env
|
||||
nano /opt/timemaster/backend/.env
|
||||
```
|
||||
|
||||
Pflichtfelder in Produktion (siehe nächster Abschnitt).
|
||||
|
||||
### systemd-Service aktivieren
|
||||
|
||||
```bash
|
||||
cp /opt/timemaster/timemaster.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable timemaster
|
||||
systemctl start timemaster
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Umgebungsvariablen (.env)
|
||||
|
||||
Datei: `/opt/timemaster/backend/.env`
|
||||
|
||||
```bash
|
||||
# === App ===
|
||||
APP_NAME=TimeMaster
|
||||
APP_ENV=production # production | development
|
||||
SECRET_KEY=<min. 32 zufällige Zeichen – openssl rand -hex 32>
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||
|
||||
# === Datenbank ===
|
||||
DATABASE_URL=postgresql+asyncpg://timemaster:<passwort>@localhost:5432/timemaster_db
|
||||
|
||||
# === Redis ===
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# === JWT ===
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
# === E-Mail (Resend.com) ===
|
||||
RESEND_API_KEY=re_<key>
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
EMAIL_FROM_NAME=TimeMaster
|
||||
|
||||
# === Erster Super-Admin (wird beim ersten Start angelegt) ===
|
||||
FIRST_SUPERADMIN_EMAIL=admin@yourdomain.com
|
||||
FIRST_SUPERADMIN_PASSWORD=<starkes-passwort>
|
||||
```
|
||||
|
||||
Pflicht in Produktion:
|
||||
- `SECRET_KEY` muss einmalig und zufällig sein (min. 32 Zeichen). Die Applikation verweigert den Start mit dem Default-Wert `change-me-in-production`.
|
||||
- `DATABASE_URL` mit dem echten Passwort des `timemaster`-Datenbankusers.
|
||||
- `RESEND_API_KEY` für ausgehende E-Mails (Einladungen, Passwort-Reset, Willkommensmails).
|
||||
|
||||
---
|
||||
|
||||
## Systemd-Service
|
||||
|
||||
Datei: `/etc/systemd/system/timemaster.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=TimeMaster FastAPI Backend
|
||||
After=network.target postgresql.service redis.service
|
||||
Requires=postgresql.service redis.service
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/opt/timemaster/backend
|
||||
EnvironmentFile=/opt/timemaster/backend/.env
|
||||
ExecStart=/opt/timemaster/backend/venv/bin/uvicorn app.main:app \
|
||||
--host 127.0.0.1 \
|
||||
--port 8000 \
|
||||
--workers 4 \
|
||||
--log-level info \
|
||||
--access-log
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=timemaster
|
||||
|
||||
# Sicherheitsoptionen
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/timemaster/backend
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Uvicorn läuft mit 4 Workern auf Port 8000, nur auf localhost. nginx leitet eingehende Anfragen weiter.
|
||||
|
||||
Service-Befehle:
|
||||
```bash
|
||||
systemctl start timemaster
|
||||
systemctl stop timemaster
|
||||
systemctl restart timemaster
|
||||
systemctl status timemaster
|
||||
journalctl -u timemaster -f # Live-Logs
|
||||
journalctl -u timemaster -n 100 # Letzte 100 Zeilen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## nginx-Konfiguration
|
||||
|
||||
Datei: `/etc/nginx/sites-available/timemaster` (symlink nach `sites-enabled`)
|
||||
|
||||
```nginx
|
||||
# HTTP → HTTPS Redirect
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# HSTS (nach agent-08 verpflichtend)
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
|
||||
client_max_body_size 20M;
|
||||
|
||||
# API Backend
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# FastAPI Swagger Docs (nur in dev-Deployments!)
|
||||
location /docs {
|
||||
proxy_pass http://127.0.0.1:8000/docs;
|
||||
}
|
||||
location /openapi.json {
|
||||
proxy_pass http://127.0.0.1:8000/openapi.json;
|
||||
}
|
||||
|
||||
# React Frontend (SPA – alle Routen über index.html)
|
||||
location / {
|
||||
root /opt/timemaster/frontend/dist;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
}
|
||||
|
||||
# Statische Backend-Uploads
|
||||
location /static/ {
|
||||
alias /opt/timemaster/backend/static/;
|
||||
expires 7d;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
TLS-Zertifikat mit Let's Encrypt:
|
||||
```bash
|
||||
apt-get install certbot python3-certbot-nginx
|
||||
certbot --nginx -d yourdomain.com -d www.yourdomain.com
|
||||
```
|
||||
|
||||
Nach nginx-Konfigurationsänderungen:
|
||||
```bash
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment-Workflow (reguläre Updates)
|
||||
|
||||
Der gesamte Deployment-Prozess ist in `update.sh` automatisiert.
|
||||
|
||||
### Vollständiges Deployment
|
||||
|
||||
```bash
|
||||
cd /home/sysops/Dokumente/Scripte/timemaster
|
||||
./update.sh
|
||||
```
|
||||
|
||||
Führt alle Schritte für beide Server durch:
|
||||
1. Tests auf Server 137 (pytest -x -q)
|
||||
2. Frontend lokal bauen (npm run build)
|
||||
3. `git pull --ff-only origin main` auf Server(n)
|
||||
4. Alembic: `alembic upgrade head`
|
||||
5. Service: `systemctl restart timemaster`
|
||||
6. Health-Check: `curl http://localhost:8000/health` (3 Versuche)
|
||||
7. Frontend-Dist per rsync übertragen
|
||||
|
||||
### Optionen
|
||||
|
||||
```bash
|
||||
./update.sh --no-tests # Tests überspringen (schneller, nur im Notfall)
|
||||
./update.sh --no-frontend # Frontend-Build überspringen (nur Backend-Änderungen)
|
||||
./update.sh --server 137 # Nur Primary
|
||||
./update.sh --server 164 # Nur Secondary
|
||||
./update.sh --dry-run # Alle Befehle zeigen, nichts ausführen
|
||||
```
|
||||
|
||||
### Manueller Deploy (ohne update.sh)
|
||||
|
||||
```bash
|
||||
# 1. Tests
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -x -q"
|
||||
|
||||
# 2. Frontend lokal bauen
|
||||
cd /home/sysops/Dokumente/Scripte/timemaster/frontend
|
||||
npm run build
|
||||
|
||||
# 3. Code auf Server synchronisieren
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster && git pull --ff-only origin main"
|
||||
|
||||
# 4. Migrationen
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic upgrade head"
|
||||
|
||||
# 5. Service neustarten
|
||||
ssh root@192.168.1.137 "systemctl restart timemaster"
|
||||
|
||||
# 6. Frontend-Dist synchronisieren
|
||||
rsync -avz --delete \
|
||||
/home/sysops/Dokumente/Scripte/timemaster/frontend/dist/ \
|
||||
root@192.168.1.137:/opt/timemaster/frontend/dist/
|
||||
|
||||
# 7. Logs prüfen
|
||||
ssh root@192.168.1.137 "journalctl -u timemaster -n 50"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring und Logs
|
||||
|
||||
### Applikations-Logs
|
||||
|
||||
```bash
|
||||
# Live-Stream
|
||||
ssh root@192.168.1.137 "journalctl -u timemaster -f"
|
||||
|
||||
# Letzte 100 Zeilen
|
||||
ssh root@192.168.1.137 "journalctl -u timemaster -n 100 --no-pager"
|
||||
|
||||
# Fehler der letzten Stunde
|
||||
ssh root@192.168.1.137 "journalctl -u timemaster --since='1 hour ago' -p err"
|
||||
```
|
||||
|
||||
### Health-Endpoint
|
||||
|
||||
```bash
|
||||
curl https://yourdomain.com/health
|
||||
# → { "status": "ok", "database": "ok", "redis": "ok" }
|
||||
```
|
||||
|
||||
### Service-Status
|
||||
|
||||
```bash
|
||||
ssh root@192.168.1.137 "systemctl status timemaster"
|
||||
ssh root@192.168.1.137 "systemctl status nginx"
|
||||
ssh root@192.168.1.137 "systemctl status postgresql"
|
||||
ssh root@192.168.1.137 "systemctl status redis-server"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup-Strategie
|
||||
|
||||
### PostgreSQL-Dump
|
||||
|
||||
```bash
|
||||
# Vollständiger Dump (täglich per cron)
|
||||
ssh root@192.168.1.137 "pg_dump -U timemaster timemaster_db | gzip > /opt/backups/timemaster_$(date +%Y%m%d).sql.gz"
|
||||
|
||||
# Wiederherstellung
|
||||
ssh root@192.168.1.137 "gunzip -c /opt/backups/timemaster_20260524.sql.gz | psql -U timemaster timemaster_db"
|
||||
```
|
||||
|
||||
Empfehlung: täglicher pg_dump-Cron + Upload auf externen S3-kompatiblen Speicher. Backup 30 Tage aufbewahren.
|
||||
|
||||
### Frontend-Dist
|
||||
|
||||
Das `dist/`-Verzeichnis kann jederzeit aus dem lokalen Build reproduziert werden und muss nicht separat gesichert werden.
|
||||
|
||||
---
|
||||
|
||||
## Rollback-Verfahren
|
||||
|
||||
### Code-Rollback
|
||||
|
||||
```bash
|
||||
# Auf Server: bestimmten Commit auschecken
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster && git log --oneline -10"
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster && git checkout <commit-hash>"
|
||||
ssh root@192.168.1.137 "systemctl restart timemaster"
|
||||
```
|
||||
|
||||
### Alembic-Rollback
|
||||
|
||||
```bash
|
||||
# Eine Version zurück
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic downgrade -1"
|
||||
|
||||
# Auf bestimmte Version zurück
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic downgrade 0023"
|
||||
```
|
||||
|
||||
Achtung: Datenverlust möglich, wenn die `downgrade()`-Funktion Spalten löscht. Vor dem Downgrade immer einen pg_dump anlegen.
|
||||
|
||||
### Alembic-Diagnosebefehle
|
||||
|
||||
```bash
|
||||
# Aktuelle Migration-Version
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic current"
|
||||
|
||||
# Migrationsverlauf
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic history --verbose"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zwei-Server-Setup
|
||||
|
||||
Server 137 ist der Primary. Server 164 ist das Fallback/Replikat.
|
||||
|
||||
Beide Server beziehen den Code über `git pull` aus demselben Gitea-Repository (`gitea.perlbach24.de/scripte/timemaster.git`). Jeder Server hat seine eigene PostgreSQL-Instanz. Es gibt keine automatische Replikation zwischen den Datenbanken – bei Failover muss manuell ein pg_dump vom Primary wiederhergestellt werden.
|
||||
|
||||
Update-Reihenfolge:
|
||||
1. Tests immer nur auf Server 137 ausführen (update.sh-Default)
|
||||
2. Migration zuerst auf 137, dann auf 164
|
||||
3. Service-Restart auf 137, dann auf 164
|
||||
|
||||
Bei divergentem Datenbankzustand zwischen den Servern: Server 164 aus dem Dump von Server 137 wiederherstellen.
|
||||
@@ -0,0 +1,531 @@
|
||||
# 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.
|
||||
@@ -429,3 +429,25 @@ Keine Commits in dieser Session.
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-05-23 22:38 – 22:42 (3m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 16 ++
|
||||
- backend/app/routers/absences.py | 10 -
|
||||
- backend/app/routers/caldav.py | 2 -
|
||||
- backend/app/routers/kiosk.py | 3 -
|
||||
- backend/app/routers/ldap.py | 2 -
|
||||
- backend/app/routers/projects.py | 2 -
|
||||
- backend/app/routers/smtp.py | 1 -
|
||||
- backend/app/routers/time_entries.py | 10 -
|
||||
- backend/migrations/env.py | 4 -
|
||||
- .../migrations/versions/0024_row_level_security.py | 223 +++++----------------
|
||||
- backend/tests/conftest.py | 48 +++++
|
||||
- backend/tests/test_rls.py | 190 ++++++++++++++++++
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user